diff --git a/.cnb.yml b/.cnb.yml new file mode 100644 index 0000000000000..cf291737cb69a --- /dev/null +++ b/.cnb.yml @@ -0,0 +1,64 @@ +# .cnb.yml +.npc: &npc + - services: + - docker + stages: + - name: run with npc + image: cnbcool/default-npc-agent:latest + +好大个: + issue.comment@npc: *npc + pull_request.comment@npc: *npc + +$: + vscode: + - runner: + cpus: 32 + docker: + build: .ide/Dockerfile + env: + # 声明首次打开终端时自动执行的命令,支持多行文本 + CNB_WELCOME_CMD: | + echo "Hello World!" + echo done + services: + - vscode + - docker + # 开发环境启动后会执行的任务 + stages: + - name: ls + script: ls -al + tag_push: + - stages: + - name: 生成 Code Wiki + timeout: 4h + image: cnbcool/codewiki:latest + settings: + git_doc_dir: /${CNB_BUILD_WORKSPACE}/${CNB_REPO_SLUG}/codewiki + +# .cnb.yml +"**": # 触发的分支名,默认所有分支,可按需修改 + push: # push 触发,可按需修改为 pull_request 等 + - stages: + # 代码分析 + - name: TCA + timeout: 2h + image: cnbcool/tca:latest + settings: + from_file: # 可选,可以传递一个文件(比如 changed.txt),内容为需要分析的文件列表,一行一个文件(采用基于工作空间的相对路径) + ignore_paths: # 可选,指定一个或多个相对工作空间的文件屏蔽路径(黑名单),多个路径可写成数组格式,路径采用Unix通配符格式。 + white_paths: # 可选,指定一个或多个相对工作区的扫描路径(白名单),多个路径可写成数组格式,路径采用Unix通配符格式。 + block: # 可选,默认为true, 如果有代码问题或分析异常,会阻塞流水线。如果不希望阻塞流水线,可以设置为false。 + + - stages: + - name: build knowledge base + image: cnbcool/knowledge-base + settings: + include: '**/*.md,**/*.mdx,**/*.pdf,**/*.docx,**/*.txt' + issue_sync_enabled: true + issue_labels: + - bug + - feature + issue_state: "open" + issue_priority: "P0,P1" + diff --git a/.cnb/settings.yml b/.cnb/settings.yml new file mode 100644 index 0000000000000..3f24b3a409bb6 --- /dev/null +++ b/.cnb/settings.yml @@ -0,0 +1,32 @@ +npc: + roles: + - name: 好大个 + prompt: | + 你用"好大个"自称,叫用户"大人", + 你的口头禅是『此事必有蹊跷!』, + 结束对话前礼貌地回复一行:"此事背后一定有一个天大的秘密。" + 无论是日常对话还是讲解知识,你都会保持以上风格, + 使用中文的『~』代替所有英文的『~』, + 用卑微并专业的语气回答接下来的问题 + + +# 云原生开发配置 +# 注意:读取云原生启动按钮所在页面当前分支的 .cnb/settings.yml 配置 +workspace: + launch: + # 定制云原生开发启动按钮 + button: + # 按钮名称 + name: 启动云原生开发 + # 按钮描述 + # 如果值为 null,则不显示默认描述 + description: 点击此按钮启动云原生开发环境 + # 鼠标悬浮图片:仓库图片填相对路径(如 .cnb/launch-hover.gif),外部图片填 raw 地址,最大 10MB + # hoverImage: .cnb/launch-hover.gif + # CPU 核心数,默认 8,仅对默认模板有效 + cpus: 16 + # 是否禁用默认按钮,默认 false + disabled: false + # 环境创建后是否自动打开 WebIDE,默认 false + # 未安装 openssh 时会自动打开,不受此参数影响 + autoOpenWebIDE: true \ No newline at end of file diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 5d1beb9488986..efc9a0d40df92 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -9,6 +9,11 @@ "version": "1.5.0", "resolved": "ghcr.io/devcontainers/features/rust@sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c", "integrity": "sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c" + }, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", + "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" } } } \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 947527eb4c90f..874a814fbbfa6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,8 @@ }, "features": { "ghcr.io/devcontainers/features/desktop-lite:": {}, - "ghcr.io/devcontainers/features/rust:": {} + "ghcr.io/devcontainers/features/rust:": {}, + "ghcr.io/devcontainers/features/sshd:1": {} }, "containerEnv": { "DISPLAY": "" // Allow the Dev Containers extension to set DISPLAY, post-create.sh will add it back in ~/.bashrc and ~/.zshrc if not set. diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index 35ea29b51742d..dfeb89b450764 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -4,12 +4,12 @@ src/vs/base/browser/domSanitize.ts @mjbvz src/vs/base/parts/quickinput/** @TylerLeonhardt # Base Widgets -src/vs/base/browser/ui/grid/** @joaomoreno @benibenj -src/vs/base/browser/ui/list/** @joaomoreno @benibenj -src/vs/base/browser/ui/sash/** @joaomoreno @benibenj -src/vs/base/browser/ui/splitview/** @joaomoreno @benibenj -src/vs/base/browser/ui/table/** @joaomoreno @benibenj -src/vs/base/browser/ui/tree/** @joaomoreno @benibenj +src/vs/base/browser/ui/grid/** @benibenj +src/vs/base/browser/ui/list/** @benibenj +src/vs/base/browser/ui/sash/** @benibenj +src/vs/base/browser/ui/splitview/** @benibenj +src/vs/base/browser/ui/table/** @benibenj +src/vs/base/browser/ui/tree/** @benibenj # Platform src/vs/platform/browserView/** @kycutler @jruales @@ -43,7 +43,7 @@ src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts @TylerLeonha src/vs/workbench/contrib/scm/** @lszomoru src/vs/workbench/contrib/terminal/** @anthonykim1 src/vs/workbench/contrib/terminalContrib/** @anthonykim1 -src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @alexr00 @joaomoreno +src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @alexr00 src/vs/workbench/contrib/preferences/** @rzhao271 # Sessions Contributions diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7e6bf875307c3..2904f679f73c7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,14 +1,14 @@ # GitHub actions required reviewers -.github/workflows/monaco-editor.yml @hediet @alexdima @lszomoru @joaomoreno -.github/workflows/no-package-lock-changes.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/no-yarn-lock-changes.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/pr-darwin-test.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/pr-linux-cli-test.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/pr-linux-test.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/pr-node-modules.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/pr-win32-test.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/pr.yml @lszomoru @alexdima @joaomoreno @TylerLeonhardt @rzhao271 @Yoyokrazy -.github/workflows/telemetry.yml @lramos15 @lszomoru @alexdima @joaomoreno +.github/workflows/monaco-editor.yml @hediet @alexdima @lszomoru +.github/workflows/no-package-lock-changes.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/no-yarn-lock-changes.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/pr-darwin-test.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/pr-linux-cli-test.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/pr-linux-test.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/pr-node-modules.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/pr-win32-test.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/pr.yml @lszomoru @alexdima @sandy081 @TylerLeonhardt @rzhao271 @Yoyokrazy +.github/workflows/telemetry.yml @lramos15 @lszomoru @alexdima # VS Code API # Ensure the API team is aware of changes to the vscode-dts file diff --git a/.github/commands.json b/.github/commands.json index 461fd84f07837..4728611887ac0 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -82,7 +82,7 @@ "name": "*caused-by-extension", "action": "close", "reason": "not_planned", - "comment": "This issue is caused by an extension, please file it with the repository (or contact) the extension has linked in its overview in VS Code or the [marketplace](https://aka.ms/vscodemarketplace) for VS Code. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting). If you don't know which extension is causing the problem, you can run `Help: Start extension bisect` from the command palette (F1) to help identify the problem extension.\n\nHappy Coding!" + "comment": "This issue is caused by an extension, please file it with the repository (or contact) the extension has linked in its overview in VS Code or the [marketplace](https://aka.ms/vscodemarketplace) for VS Code. See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting). If you don't know which extension is causing the problem, you can run `Help: Start Extension Bisect` from the command palette (F1) to help identify the problem extension.\n\nHappy Coding!" }, { "type": "label", diff --git a/.github/instructions/best-practices.instructions.md b/.github/instructions/best-practices.instructions.md index e20eb82720876..006c8f2d387eb 100644 --- a/.github/instructions/best-practices.instructions.md +++ b/.github/instructions/best-practices.instructions.md @@ -24,6 +24,15 @@ applyTo: src/vs/** - Don't hardcode URI scheme strings like `'file'`, `'untitled'`, or `'vscode-remote'`. Use the `Schemas` constants from `vs/base/common/network.ts` (e.g. `Schemas.file`, `Schemas.untitled`, `Schemas.vscodeRemote`). - Don't compare URIs with `===` or `uri.toString()`. Use the comparison utilities from `vs/base/common/resources.ts`: `isEqual` for equality, `isEqualOrParent` for containment, and `getComparisonKey` when a URI is used as a map/set key. These handle path-case sensitivity and fragment/authority correctly. When you need explicit control over case sensitivity, use an `ExtUri` instance (`extUri`, `extUriIgnorePathCase`, or `extUriBiasedIgnorePathCase`) instead of the bound helpers. +## Resource Labels + +- Don't set `{ supportIcons: true }` when creating a `ResourceLabel` (`vs/workbench/browser/labels.ts`). This option makes the label parse `$(codicon)` syntax in the name/description and is only needed when you want to render a codicon inline with the resource text. By default (without it), the label computes the proper file-icon CSS classes for the resource, which is what we almost always want. Note that those classes only render as icons when an ancestor DOM element enables file icons (see below); otherwise the label shows text only. +- To actually display file icons for resource labels in a tree/list, an ancestor container must have the `show-file-icons` class and be wired to the active file icon theme. Don't add the class by hand — call `createFileIconThemableTreeContainerScope` (`vs/workbench/contrib/files/browser/views/explorerView.ts`) on the container. It adds the required `show-file-icons` / `file-icon-themable-tree` classes and keeps `align-icons-and-twisties` / `hide-arrows` in sync with the file icon theme. Register the returned `IDisposable`. A common bug is placing a resource-label list/tree outside such a scoped container, which makes file icons silently disappear. + +## Styling + +- Avoid `getComputedStyle`. If a style value is needed in both CSS and TypeScript, prefer hardcoding the value in TypeScript and setting it directly on the DOM element (e.g. `element.style.width = '100px'`), or set a CSS custom property via `element.style.setProperty('--my-var', value)` when the value is needed across multiple CSS rules. + ## Editor Decorations - For editor highlights, use a regular editor decoration with an `inlineClassName` or `className` plus a CSS rule. diff --git a/.github/instructions/coding-guidelines.instructions.md b/.github/instructions/coding-guidelines.instructions.md index 17f9662b87cb5..cbab74790541f 100644 --- a/.github/instructions/coding-guidelines.instructions.md +++ b/.github/instructions/coding-guidelines.instructions.md @@ -28,6 +28,11 @@ Use tabs, not spaces. - Use JSDoc style comments for functions, interfaces, enums, and classes - Do not write large comments in the middle of a method or comments that explain a single line. If a line or block needs a paragraph of explanation to be understood, treat that as a signal that the code itself is unclear: extract a well-named function, introduce an explanatory variable, or simplify the logic instead. Keep any remaining inline comment to a brief note. +- **Default to NO comment.** Add one only when the code cannot be made self-explanatory by naming. Code that is obvious from its identifiers must not be commented. +- **Hard limits** (treat as rules, not suggestions): + - JSDoc on a function/interface/property: at most 1–2 short sentences. Do not enumerate every branch/feature, restate the signature, list what it does NOT do, or explain parameters that are obvious from their names/types. + - Inline comment inside a method body: at most 1 line, and only for a genuine workaround/hack, a non-obvious ordering constraint, or a surprising side effect. Never narrate the next statement (e.g. `// Expand the variable`, `// loop over args`). +- Before writing any comment longer than one line, stop and either delete it or shorten it to a single line. A multi-line block comment inside a method body is almost always wrong. ## Strings diff --git a/.github/instructions/oss-third-party-notices.instructions.md b/.github/instructions/oss-third-party-notices.instructions.md index 1af566c41ac77..420e42f28bae0 100644 --- a/.github/instructions/oss-third-party-notices.instructions.md +++ b/.github/instructions/oss-third-party-notices.instructions.md @@ -1,10 +1,12 @@ --- -applyTo: 'build/azure-pipelines/oss/**' +applyTo: 'build/azure-pipelines/{oss/**,common/downloadNotice.ts,{win32,linux,darwin}/steps/product-build-*-compile.yml}' --- # VS Code OSS Third-Party-Notices pipeline -This directory contains the thin VS Code-specific layer around Component Governance (CG) for producing OSS third-party notices. The goal is to replace the legacy custom OSS tool with CG plus a gap-filling scanner, so release owners do not need to babysit `ThirdPartyNotices.txt` every release. The generated output should be at least as good as the legacy tool: CG provides the base NOTICE, the local scanner fills known CG gaps, and human-authored overrides cover the small set that automation cannot safely resolve. +This directory contains the thin VS Code-specific layer around Component Governance (CG) for producing OSS third-party notices. It replaces the legacy custom OSS tool with CG plus a gap-filling scanner, so release owners do not need to babysit `ThirdPartyNotices.txt` every release. The generated output should be at least as good as the legacy tool: CG provides the base NOTICE, the local scanner fills known CG gaps, and human-authored overrides cover the small set that automation cannot safely resolve. + +The pipeline has two halves: **generation** (this `oss/` directory — CG + scanner + merge, producing the `notice_output` artifact) and **application** (the cutover that swaps the merged notice into the shipped product — see "Applying the NOTICE (cutover)" below). ## Architecture @@ -34,6 +36,45 @@ In `product-quality-checks.yml`: 6. `merge-notices.js` runs with `--cg`, `--extensions`, `--cglicenses`, and `--output`. 7. The generated CG file, scanner file, final merged file, and optional cache metadata are uploaded under `notice_output`. +## Applying the NOTICE (cutover) + +Generation (above) produces the merged `ThirdPartyNotices.new.txt` in the `notice_output` artifact. **Application** is the cutover that makes that file the one VS Code actually ships, replacing the legacy mixin notice. + +Consumer script: `build/azure-pipelines/common/downloadNotice.ts` (read its header comment for the full design rationale). + +How it works, per desktop platform compile template (`build/azure-pipelines/{win32,linux,darwin}/steps/product-build-*-compile.yml`): + +1. **Pull CG NOTICE (background)** — at compile start, `deemon --detach` launches `downloadNotice.ts` as a detached poller. It polls the parallel Quality stage's `notice_output` artifact while compilation runs, so the wait overlaps work already happening. +2. **Apply CG NOTICE** — right before gulp packages the app, `deemon --attach` blocks on that poller. It downloads, extracts, validates the merged notice, and **overwrites the repo-root `ThirdPartyNotices.txt`**. `build/gulpfile.vscode.ts` then packages whatever file is at that path → the CG notice ships. + +Only the 7 desktop targets that bundle a notice run these steps (win32 x64/arm64, darwin universal, linux x64/arm64/armhf). REH/server/web/alpine are unaffected. + +### Fallback chain (never fail the build) + +`downloadNotice.ts` is **non-fatal — it always exits 0.** A notice problem must never break packaging. The outcomes, in order: + +1. **CG fresh** — the `notice_output` artifact is present and valid → overwrite with it. +2. **Cached CG** — if CG generation failed upstream, the Quality stage substitutes the last good `ThirdPartyNotices.generated.txt` (same branch, then `main`) before the artifact is published — so the consumer still gets a CG notice. +3. **Legacy** — if no usable artifact is available, the legacy mixin notice that `mixin-quality.ts` already laid down is left in place. `mixin-quality.ts` is deliberately left untouched (it's a shared chokepoint used by 8+ pipelines), which is what guarantees we never ship with *no* notice. + +The accept-gate validates the *extracted content* (file present AND non-trivial) inside the poll loop — it does not trust the artifact listing alone. A mid-upload miss re-polls rather than falling back, which prevents a "mixed notice" race where one platform ships legacy while its siblings ship CG. + +### Rollback lever + +A queue-time checkbox **"Use legacy OSS Notice"** (parameter `VSCODE_USE_LEGACY_OSS_NOTICE`, default `false`) is the instant rollback. When checked, it derives `VSCODE_OVERWRITE_TPN=false`, and `downloadNotice.ts` skips the overwrite so the legacy notice ships — no code change or redeploy needed. + +> ⚠️ `downloadNotice.ts` normalizes the flag with `.trim().toLowerCase()` before comparing, so YAML casing (`true`/`false` vs `True`/`False`) can't break the rollback lever. The pipeline still derives `VSCODE_OVERWRITE_TPN` as a lowercase string literal (`'true'`/`'false'`) for clarity. See the comment in `product-build-variables.yml`. + +### Diagnosing a build + +`downloadNotice.ts` logs three greppable markers so a build log answers "did the cutover work?": + +- `[notice-cutover] RESULT=fresh` — overwrote from `notice_output` (the happy path). +- `[notice-cutover] RESULT=fallback` — artifact missing → kept the legacy notice. +- `[notice-cutover] RESULT=disabled` — feature flag off → kept the legacy notice. + +To confirm a shipped artifact carries the CG notice, the root `resources/app/ThirdPartyNotices.txt` should be the large CG-merged file (multi-MB) rather than the ~3 MB legacy notice. Validate *intra-build* parity — all platforms in one build should produce a byte-identical notice (same SHA-256) — rather than checking against a fixed byte count, since CG's exact output size varies run-to-run. + ## Script reference ### `scan-licenses.ts` diff --git a/.github/instructions/resources/interactive/interactive.editor.drawio.svg b/.github/instructions/resources/interactive/interactive.editor.drawio.svg index c6b947982800c..1a19b6e02245a 100644 --- a/.github/instructions/resources/interactive/interactive.editor.drawio.svg +++ b/.github/instructions/resources/interactive/interactive.editor.drawio.svg @@ -1,331 +1 @@ - - - - - - - - - - - -
-
-
- Notebook Editor Widget -
-
-
-
- - Notebook Editor Widget - -
-
- - - - -
-
-
- Code Editor Widget -
-
-
-
- - Code Editor Widget - -
-
- - - - -
-
-
- Interactive Editor -
-
-
-
- - Interactive Editor - -
-
- - - - - -
-
-
- NotebookService -
-
-
-
- - NotebookService - -
-
- - - - - -
-
-
- TextModelResolverService -
-
-
-
- - TextModelResolverService - -
-
- - - - - - - - - - - -
-
-
- NotebookTextModel -
-
-
-
- - NotebookTextModel - -
-
- - - - -
-
-
- ITextModel -
-
-
-
- - ITextModel - -
-
- - - - -
-
-
- Interactive Editor Input -
-
-
-
- - Interactive Editor In... - -
-
- - - - -
-
-
- EditorPane -
-
-
-
- - EditorPane - -
-
- - - - -
-
-
- EditorInput -
-
-
-
- - EditorInput - -
-
- - - - - - -
-
-
- Editor Resolver Service -
-
-
-
- - Editor Resolver Service - -
-
- - - - -
-
-
- EditorPane Registry -
-
-
-
- - EditorPane Registry - -
-
- - - - - - -
-
-
- Registry Editor Pane -
-
-
-
- - Registry Editor Pane - -
-
- - - - - - - - -
-
-
- - registerEditor - -
-
-
-
- - registerEditor - -
-
- - - - - - -
-
-
- input: EditorInput -
-
-
-
- - input: EditorInput - -
-
- - - - - - -
-
-
- group: IEditorGroup -
-
-
-
- - group: IEditorGroup - -
-
- - - - - - -
-
-
- getControl: IEditorControl -
-
-
-
- - getControl: IEditorControl - -
-
-
- - - - - Text is not SVG - cannot display - - - -
+
Notebook Editor Widget
Notebook Editor Widget
Code Editor Widget
Code Editor Widget
Interactive Editor
Interactive Editor
NotebookService
NotebookService
TextModelResolverService
TextModelResolverService
NotebookTextModel
NotebookTextModel
ITextModel
ITextModel
Interactive Editor Input
Interactive Editor In...
EditorPane
EditorPane
EditorInput
EditorInput
Editor Resolver Service
Editor Resolver Service
EditorPane Registry
EditorPane Registry
Registry Editor Pane
Registry Editor Pane
registerEditor
registerEditor
input: EditorInput
input: EditorInput
group: IEditorGroup
group: IEditorGroup
getControl: IEditorControl
getControl: IEditorControl
Text is not SVG - cannot display
\ No newline at end of file diff --git a/.github/instructions/resources/interactive/interactive.eh.drawio.svg b/.github/instructions/resources/interactive/interactive.eh.drawio.svg index 5f9f40795c2b1..71662103ad74f 100644 --- a/.github/instructions/resources/interactive/interactive.eh.drawio.svg +++ b/.github/instructions/resources/interactive/interactive.eh.drawio.svg @@ -1,244 +1 @@ - - - - - - - - - -
-
-
- - Ext Host - -
-
-
-
- - Ext Host - -
-
- - - - -
-
-
- - UI - -
-
-
-
- - UI - -
-
- - - - - - - -
-
-
- Notebook Editor -
-
-
-
- - Notebook Editor - -
-
- - - - -
-
-
- Text Editor -
-
-
-
- - Text Editor - -
-
- - - - -
-
-
- Interactive Editor -
-
-
-
- - Interactive Editor - -
-
- - - - - -
-
-
- NotebookService -
-
-
-
- - NotebookService - -
-
- - - - - - - -
-
-
- TextModelResolverService -
-
-
-
- - TextModelResolverService - -
-
- - - - - - - -
-
-
- ExthostNotebook -
-
-
-
- - ExthostNotebook - -
-
- - - - - - - -
-
-
- ExthostEditors -
-
-
-
- - ExthostEditors - -
-
- - - - - - - - -
-
-
- ExthostInteractive -
-
-
-
- - ExthostInteracti... - -
-
- - - - - - -
-
-
- NotebookEditor -
-
-
-
- - NotebookE... - -
-
- - - - - - -
-
-
- Input -
-
-
-
- - Input - -
-
-
- - - - - Text is not SVG - cannot display - - - -
+
Ext Host
Ext Host
UI
UI
Notebook Editor
Notebook Editor
Text Editor
Text Editor
Interactive Editor
Interactive Editor
NotebookService
NotebookService
TextModelResolverService
TextModelResolverService
ExthostNotebook
ExthostNotebook
ExthostEditors
ExthostEditors
ExthostInteractive
ExthostInteracti...
NotebookEditor
NotebookE...
Input
Input
Text is not SVG - cannot display
\ No newline at end of file diff --git a/.github/instructions/resources/interactive/interactive.model.resolution.drawio.svg b/.github/instructions/resources/interactive/interactive.model.resolution.drawio.svg index 61c91b981255e..742ff1d618556 100644 --- a/.github/instructions/resources/interactive/interactive.model.resolution.drawio.svg +++ b/.github/instructions/resources/interactive/interactive.model.resolution.drawio.svg @@ -1,352 +1 @@ - - - - - - - - - - -
-
-
- Read resource -
-
-
-
- - Read resource - -
-
- - - - - - -
-
-
- Deserialize NotebookData -
-
-
-
- - Deserialize NotebookData - -
-
- - - - - - -
-
-
- creates -
-
-
-
- - creates - -
-
- - - - - -
-
-
- creates -
-
-
-
- - creates - -
-
- - - - - -
-
-
- creates -
-
-
-
- - creates - -
-
- - - - - -
-
-
- NotebookEditorModelResolverService -
-
-
-
- - NotebookEditorMod... - -
-
- - - - -
-
-
- - SimpleNotebookEditorModel - -
-
-
-
- - SimpleNoteboookEd... - -
-
- - - - -
-
-
- - NotebookFileWorkingCopyModelFactory - -
-
-
-
- - NotebookFileWorki... - -
-
- - - - -
-
-
- - WorkingCopyManager - -
-
-
-
- - WorkingCopyManager - -
-
- - - - - -
-
-
- creates -
-
-
-
- - creates - -
-
- - - - -
-
-
- NotebookService -
-
-
-
- - NotebookService - -
-
- - - - -
-
-
- FileService -
-
-
-
- - FileService - -
-
- - - - -
-
-
- NotebookTextModel -
-
-
-
- - NotebookTextModel - -
-
- - - - - -
-
-
- register FS provider -
- for vscode-interactive schema -
-
-
-
- - register FS provider... - -
-
- - - - - -
-
-
- register notebook serializer -
- for interactive viewtype -
-
-
-
- - register notebook serializer... - -
-
- - - - -
-
-
- interactive.contribution -
- (startup) -
-
-
-
- - interactive.contributio... - -
-
- - - - - - - -
-
-
- InteractiveEditor -
-
-
-
- - InteractiveEditor - -
-
- - - - - - - -
-
-
- - NotebookEditorInput - -
-
-
-
- - NotebookEditorInp... - -
-
-
- - - - - Text is not SVG - cannot display - - - -
+
Read resource
Read resource
Deserialize NotebookData
Deserialize NotebookData
creates
creates
creates
creates
creates
creates
NotebookEditorModelResolverService
NotebookEditorMod...
SimpleNotebookEditorModel
SimpleNoteboookEd...
NotebookFileWorkingCopyModelFactory
NotebookFileWorki...
WorkingCopyManager
WorkingCopyManager
creates
creates
NotebookService
NotebookService
FileService
FileService
NotebookTextModel
NotebookTextModel
register FS provider
for vscode-interactive schema
register FS provider...
register notebook serializer
for interactive viewtype
register notebook serializer...
interactive.contribution
(startup)
interactive.contributio...
InteractiveEditor
InteractiveEditor
NotebookEditorInput
NotebookEditorInp...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/.github/instructions/resources/notebook/cell-resize-above-viewport.drawio.svg b/.github/instructions/resources/notebook/cell-resize-above-viewport.drawio.svg index 6a2f80fc98da0..a17da1c03679a 100644 --- a/.github/instructions/resources/notebook/cell-resize-above-viewport.drawio.svg +++ b/.github/instructions/resources/notebook/cell-resize-above-viewport.drawio.svg @@ -1,654 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- Notebook List View -
-
-
-
- - Notebook List View - -
-
- - - - -
-
-
- Webview top -1000 -
-
-
-
- - Webview top -1000 - -
-
- - - - - - -
-
-
- Viewport -
-
-
-
- - Viewport - -
-
- - - - - - - - - -
-
-
- Notebook List View -
-
-
-
- - Notebook List View - -
-
- - - - - - - - - - - - - - - - -
-
-
- Grow Height by 50 -
-
-
-
- - Grow Height by 50 - -
-
- - - - -
-
-
- scrollTop 1000 -
-
-
-
- - scrollTop 1000 - -
-
- - - - -
-
-
- scrollTop 1000 -
-
-
-
- - scrollTop 1000 - -
-
- - - - - - - - - - - - -
-
-
- Notebook List View -
-
-
-
- - Notebook List View - -
-
- - - - - - - - - - - - - - - - -
-
-
- scrollTop 1050 -
-
-
-
- - scrollTop 1050 - -
-
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- Notebook List View -
-
-
-
- - Notebook List View - -
-
- - - - - - - - - - - - - - - - -
-
-
- scrollTop 1050 -
-
-
-
- - scrollTop 1050 - -
-
- - - - - - -
-
-
- Adjust top -
-
-
-
- - Adjust top - -
-
- - - - - - -
-
-
- UpdateScrollTop -
-
-
-
- - UpdateScrollTop - -
-
- - - - - - -
-
-
- Webview top -1000 -
-
-
-
- - Webview top -1000 - -
-
- - - - -
-
-
- Webview top -1000 -
-
-
-
- - Webview top -1000 - -
-
- - - - -
-
-
- Webview top -1000 -
-
-
-
- - Webview top -1000 - -
-
- - - - - - - - - - - - - - -
-
-
- Notebook List View -
-
-
-
- - Notebook List View - -
-
- - - - - - - - - - - - - - - - -
-
-
- scrollTop 1050 -
-
-
-
- - scrollTop 1050 - -
-
- - - - -
-
-
- Webview top -950 -
-
-
-
- - Webview top -950 - -
-
- - - - - - - - - - - - - - -
-
-
- Notebook List View -
-
-
-
- - Notebook List View - -
-
- - - - - - - - - - - - - - - - -
-
-
- scrollTop 1050 -
-
-
-
- - scrollTop 1050 - -
-
- - - - -
-
-
- Webview top -950 -
-
-
-
- - Webview top -950 - -
-
- - - - - - -
-
-
- Adjust top -
-
-
-
- - Adjust top - -
-
- - - - - - - - -
-
-
- 1 -
-
-
-
- - 1 - -
-
- - - - -
-
-
- 2 -
-
-
-
- - 2 - -
-
- - - - -
-
-
- 3 -
-
-
-
- - 3 - -
-
- - - - -
-
-
- 4 -
-
-
-
- - 4 - -
-
- - - - -
-
-
- 4' -
-
-
-
- - 4' - -
-
- - - - -
-
-
- 5 -
-
-
-
- - 5 - -
-
-
- - - - - Viewer does not support full SVG 1.1 - - - -
+
Notebook List View
Notebook List View
Webview top -1000
Webview top -1000
Viewport
Viewport
Notebook List View
Notebook List View
Grow Height by 50
Grow Height by 50
scrollTop 1000
scrollTop 1000
scrollTop 1000
scrollTop 1000
Notebook List View
Notebook List View
scrollTop 1050
scrollTop 1050
Notebook List View
Notebook List View
scrollTop 1050
scrollTop 1050
Adjust top
Adjust top
UpdateScrollTop
UpdateScrollTop
Webview top -1000
Webview top -1000
Webview top -1000
Webview top -1000
Webview top -1000
Webview top -1000
Notebook List View
Notebook List View
scrollTop 1050
scrollTop 1050
Webview top -950
Webview top -950
Notebook List View
Notebook List View
scrollTop 1050
scrollTop 1050
Webview top -950
Webview top -950
Adjust top
Adjust top
1
1
2
2
3
3
4
4
4'
4'
5
5
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/.github/instructions/resources/notebook/hybrid-find.drawio.svg b/.github/instructions/resources/notebook/hybrid-find.drawio.svg index a2419b58fce4e..7165dbff18ee5 100644 --- a/.github/instructions/resources/notebook/hybrid-find.drawio.svg +++ b/.github/instructions/resources/notebook/hybrid-find.drawio.svg @@ -1,327 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- window.find -
-
-
-
- - window.find - -
-
- - - - - - -
-
-
- exec("hiliteColor") -
- findMatchColor -
-
-
-
- - exec("hiliteColor")... - -
-
- - - - -
-
-
- Serialize -
- document.getSelection() -
-
-
-
- - Serialize... - -
-
- - - - - - - - - - -
-
-
- Find in Rendered Outputs (Search in DOM) -
-
-
-
- - Find in Rendered Out... - -
-
- - - - -
-
-
- Find -
-
-
-
- - Find - -
-
- - - - -
-
-
- Find in Text Model -
-
-
-
- - Find in Text Model - -
-
- - - - -
-
-
- Mix matches -
-
-
-
- - Mix matches - -
-
- - - - -
-
-
- End of Doc -
-
-
-
- - End of Doc - -
-
- - - - - - -
-
-
- Webview -
-
-
-
- - Webview - -
-
- - - - - - -
-
-
- Find Next -
-
-
-
- - Find Next - -
-
- - - - - - -
-
-
- Is Model Match -
-
-
-
- - Is Model Match - -
-
- - - - -
-
-
- Reveal Editor -
-
-
-
- - Reveal Editor - -
-
- - - - -
-
-
- Y -
-
-
-
- - Y - -
-
- - - - - - -
-
-
- document create range -
-
-
-
- - document create range - -
-
- - - - - - -
-
-
- "hiliteColor" -
- currentFindMatchColor -
-
-
-
- - "hiliteColor"... - -
-
- - - - - - -
-
-
- Find cell/output container -
-
-
-
- - Find cell/output con... - -
-
-
- - - - - Viewer does not support full SVG 1.1 - - - -
+
window.find
window.find
exec("hiliteColor")
findMatchColor
exec("hiliteColor")...
Serialize
document.getSelection()
Serialize...
Find in Rendered Outputs (Search in DOM)
Find in Rendered Out...
Find
Find
Find in Text Model
Find in Text Model
Mix matches
Mix matches
End of Doc
End of Doc
Webview
Webview
Find Next
Find Next
Is Model Match
Is Model Match
Reveal Editor
Reveal Editor
Y
Y
document create range
document create range
"hiliteColor"
currentFindMatchColor
"hiliteColor"...
Find cell/output container
Find cell/output con...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/.github/instructions/resources/notebook/viewport-rendering.drawio.svg b/.github/instructions/resources/notebook/viewport-rendering.drawio.svg index 6a51b66c723b1..766736931fab9 100644 --- a/.github/instructions/resources/notebook/viewport-rendering.drawio.svg +++ b/.github/instructions/resources/notebook/viewport-rendering.drawio.svg @@ -1,521 +1 @@ - - - - - - - - - - -
-
-
- Render Viewport -
-
-
-
- - Render Viewport - -
-
- - - - -
-
-
- Notebook List View -
-
-
-
- - Notebook List View - -
-
- - - - - - - - - -
-
-
- Render Template -
-
-
-
- - Render Template - -
-
- - - - - - - - - -
-
-
- Render Element -
-
-
-
- - Render Element - -
-
- - - - - -
-
-
- Get Dynamic Height -
-
-
-
- - Get Dynamic Height - -
-
- - - - - - - - - -
-
-
- Create Cell Templates/Parts -
-
-
-
- - Create Cell Templates/Parts - -
-
- - - - -
-
-
- Toolbar -
-
-
-
- - Toolbar - -
-
- - - - -
-
-
- Editor -
-
-
-
- - Editor - -
-
- - - - -
-
-
- Statusbar -
-
-
-
- - Statusbar - -
-
- - - - - - - -
-
-
- Code Cell -
-
-
-
- - Code Cell - -
-
- - - - - -
-
-
- Render Cell Parts -
-
-
-
- - Render Cell Parts - -
-
- - - - - - - - - - - -
-
-
- CellPart read DOM -
-
-
-
- - CellPart read DOM - -
-
- - - - -
-
-
- Update layout info -
-
-
-
- - Update layout info - -
-
- - - - - - - -
-
-
- Toolbar.renderCell -
-
-
-
- - Toolbar.renderCell - -
-
- - - - -
-
-
- Toolbar.renderCell -
-
-
-
- - Toolbar.renderCell - -
-
- - - - -
-
-
- Toolbar.didRenderCell -
-
-
-
- - Toolbar.didRenderCell - -
-
- - - - -
-
-
- Toolbar.renderCell -
-
-
-
- - Toolbar.renderCell - -
-
- - - - -
-
-
- Toolbar.renderCell -
-
-
-
- - Toolbar.renderCell - -
-
- - - - -
-
-
- - Toolbar.prepareLayout - -
-
-
-
- - Toolbar.prepareLay... - -
-
- - - - - - - - - - - -
-
-
- Cell Layout Change -
-
-
-
- - Cell Layout Change - -
-
- - - - - -
-
-
- Cell Part updateInternalLayoutNow -
-
-
-
- - Cell Part updateInternalLayoutNow - -
-
- - - - -
-
-
- Toolbar.renderCell -
-
-
-
- - Toolbar.renderCell - -
-
- - - - -
-
-
- Toolbar.renderCell -
-
-
-
- - Toolbar.renderCell - -
-
- - - - -
-
-
- - Toolbar.updateInternalLayoutNow - -
-
-
-
- - Toolbar.updateInternalLayout... - -
-
- - - - -
-
-
- Next Frame -
-
-
-
- - Next Frame - -
-
- - - - -
-
-
- - DOM Read - -
-
-
-
- - DOM Read - -
-
- - - - -
-
-
- - DOM Write - -
-
-
-
- - DOM Write - -
-
-
- - - - - Viewer does not support full SVG 1.1 - - - -
+
Render Viewport
Render Viewport
Notebook List View
Notebook List View
Render Template
Render Template
Render Element
Render Element
Get Dynamic Height
Get Dynamic Height
Create Cell Templates/Parts
Create Cell Templates/Parts
Toolbar
Toolbar
Editor
Editor
Statusbar
Statusbar
Code Cell
Code Cell
Render Cell Parts
Render Cell Parts
CellPart read DOM
CellPart read DOM
Update layout info
Update layout info
Toolbar.renderCell
Toolbar.renderCell
Toolbar.renderCell
Toolbar.renderCell
Toolbar.didRenderCell
Toolbar.didRenderCell
Toolbar.renderCell
Toolbar.renderCell
Toolbar.renderCell
Toolbar.renderCell
Toolbar.prepareLayout
Toolbar.prepareLay...
Cell Layout Change
Cell Layout Change
Cell Part updateInternalLayoutNow
Cell Part updateInternalLayoutNow
Toolbar.renderCell
Toolbar.renderCell
Toolbar.renderCell
Toolbar.renderCell
Toolbar.updateInternalLayoutNow
Toolbar.updateInternalLayout...
Next Frame
Next Frame
DOM Read
DOM Read
DOM Write
DOM Write
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md index fe37147e5a494..b11f0fd54c440 100644 --- a/.github/instructions/sessions.instructions.md +++ b/.github/instructions/sessions.instructions.md @@ -59,8 +59,8 @@ Always use `Menus.*` from `browser/menus.ts` — never `MenuId.*` from `vs/platf All sessions-specific context keys live in `common/contextkeys.ts`: - `IsNewChatSessionContext` — whether showing the new session view -- `ActiveSessionProviderIdContext` — which provider owns the active session -- `ActiveSessionTypeContext` — session type of the active session +- `SessionProviderIdContext` — which provider owns the session in scope (the active session globally) +- `SessionTypeContext` — session type of the session in scope (the active session globally) - `IsPhoneLayoutContext` — whether in phone layout mode - `ChatBarVisibleContext` / `ChatBarFocusContext` — chat bar state diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index ef8c7a9f50f7c..50486d8ec4be5 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -25,8 +25,9 @@ Policies allow enterprise administrators to lock configuration settings via OS-l |--------|---------------|----------------------| | **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | | **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | -| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM is a **separate** input (`ICopilotManagedSettingsService`) that `AccountPolicyService` selects between in `getPolicyData()` (server wins when present; no merging between layers) | -| **Copilot managed settings (native MDM)** | `CopilotManagedSettingsService` via `@vscode/policy-watcher` | Watches `SOFTWARE\Policies\GitHubCopilot` (Windows) / `com.github.copilot` prefs (macOS); feeds the canonical `managedSettings` bag — see [github-managed-settings.md](./github-managed-settings.md) | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM (`INativeManagedSettingsService`) and a file on disk (`IFileManagedSettingsService`) are **separate** inputs that `AccountPolicyService` merges in `getPolicyData()` via `pickManagedSettings(nativeMdm, server, file)` (per-key precedence native MDM > server > file; a key locked by a higher channel cannot be overwritten, keys it leaves unset fall through to lower channels) | +| **Copilot managed settings (native MDM)** | `NativeManagedSettingsService` via `@vscode/policy-watcher` | Watches `SOFTWARE\Policies\GitHubCopilot` (Windows) / `com.github.copilot` prefs (macOS); feeds the canonical `managedSettings` bag — see [github-managed-settings.md](./github-managed-settings.md) | +| **Copilot managed settings (file)** | `FileManagedSettingsService` | Reads + watches `managed-settings.json` from a well-known per-OS path in the main process, exposed to renderers over IPC; lowest-precedence managed-settings channel — see [github-managed-settings.md](./github-managed-settings.md) | | **Multiplex** | `MultiplexPolicyService` | In the main process, combines multiple OS/file policy readers; in desktop and Agents-window renderers, combines the main-process `PolicyChannelClient` with `AccountPolicyService` | ### Key Files @@ -35,11 +36,12 @@ Policies allow enterprise administrators to lock configuration settings via OS-l |------|---------| | `src/vs/base/common/policy.ts` | `PolicyCategory` enum, `IPolicy` interface, `IPolicyReference`, `ManagedSettingsData`, `IManagedSettingsPolicyDefinitions` | | `src/vs/platform/policy/common/policy.ts` | `IPolicyService`, `AbstractPolicyService`, `PolicyDefinition`, `toSerializablePolicyDefinition` (drops the non-cloneable `value()` for IPC), `getRestrictedPolicyValue` | -| `src/vs/platform/policy/common/copilotManagedSettings.ts` | Managed-settings key constants, `collectManagedSettingsDefinitions`, `projectManagedSettings`, `flattenManagedSettings`, `ICopilotManagedSettingsService` | -| `src/vs/platform/policy/node/copilotManagedSettingsService.ts` | Native MDM watcher (`@vscode/policy-watcher`) for Copilot managed settings | +| `src/vs/platform/policy/common/copilotManagedSettings.ts` | Managed-settings key constants + well-known file paths, `collectManagedSettingsDefinitions`, `projectManagedSettings`, the shared `normalizeManagedSettings` (single normalizer for all channels), `pickManagedSettings` (per-key channel precedence), `INativeManagedSettingsService` / `IFileManagedSettingsService` | +| `src/vs/platform/policy/node/nativeManagedSettingsService.ts` | Native MDM watcher (`@vscode/policy-watcher`) for Copilot managed settings | +| `src/vs/platform/policy/common/fileManagedSettingsService.ts` | File-based channel: reads + watches `managed-settings.json` on a well-known per-OS path, normalizes via `normalizeManagedSettings` | | `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values; parses JSON-string managed settings back to typed values; applies values to `policyReference` settings | | `src/vs/platform/configuration/common/configurationRegistry.ts` | `policy` / `policyReference` registration; `getPolicyReferenceConfigurations()` (name → subordinate settings) | -| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (server over MDM; single authoritative layer) | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (native MDM over server; single authoritative layer) | | `src/vs/workbench/services/accounts/browser/managedSettings.ts` | `adaptManagedSettings` — normalizes the server `managed_settings` response into the canonical bag | | `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | | `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | @@ -326,4 +328,5 @@ Search the codebase for `policy:` to find all the examples of different policy c ## Learnings * Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree. -* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "server-delivered managed settings win over native MDM; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. +* **Regenerate `policyData.jsonc` in a clean environment, or the `PolicyExport` integration test will fail in CI.** `referencedSettings` is only captured for references **loaded at export time**. A plain `npm run export-policy-data` loads your **dev-profile extensions** (e.g. the Copilot extension), which injects `referencedSettings` onto core policies that the test's **fixture-based** export (clean profile, no user extensions) won't produce — so the checked-in file ends up with extra `referencedSettings` and CI fails. This is **not reproducible locally** because the test reuses your default extensions dir. Regenerate the way the test exports: `DISTRO_PRODUCT_JSON= ./scripts/code.sh --export-policy-data="$PWD/build/lib/policies/policyData.jsonc" --user-data-dir="$(mktemp -d)" --extensions-dir="$(mktemp -d)"` (with `VSCODE_SKIP_PRELAUNCH=1`). +* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "native MDM managed settings win over the server-delivered channel; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. diff --git a/.github/skills/add-policy/github-managed-settings.md b/.github/skills/add-policy/github-managed-settings.md index 41f34e50245bc..8f6bb41039761 100644 --- a/.github/skills/add-policy/github-managed-settings.md +++ b/.github/skills/add-policy/github-managed-settings.md @@ -9,7 +9,7 @@ Managed settings layer **on top of** the existing policy framework. They do **no introduce a new `IPolicyService`; they feed `IPolicyData.managedSettings`, which the existing `policy.value(policyData)` callback already consumes via `AccountPolicyService`. -## The big idea: one canonical bag, two delivery channels (in VS Code) +## The big idea: one canonical bag, three delivery channels (in VS Code) Every enterprise-managed Copilot setting resolves through a single normalized bag: @@ -41,26 +41,27 @@ and parsed back into the object-typed setting on read by `PolicyConfiguration`. ### Delivery channels -VS Code implements **two** channels feeding the bag; the external schema additionally -describes a file-based channel that other Copilot clients may implement but that VS Code -does **not** read today (no `managed-settings.json` reader exists in `src/`). +VS Code implements **three** channels feeding the bag, matching the external schema's +described delivery slots (native MDM, server-managed, and file-based). | Channel | Where it's read | Implementation | Lands on | |---------|-----------------|----------------|----------| -| **Native MDM** (Windows registry / macOS plist) | OS managed preferences | `CopilotManagedSettingsService` (`src/vs/platform/policy/node/copilotManagedSettingsService.ts`) via `@vscode/policy-watcher` | `ICopilotManagedSettingsService.managedSettings` | +| **Native MDM** (Windows registry / macOS plist) | OS managed preferences | `NativeManagedSettingsService` (`src/vs/platform/policy/node/nativeManagedSettingsService.ts`) via `@vscode/policy-watcher` | `INativeManagedSettingsService.managedSettings` | | **Server-managed** (`/copilot_internal/managed_settings`) | GitHub endpoint; per the code comment in `managedSettings.ts`, it returns the enterprise's `.github/copilot/settings.json` content | `adaptManagedSettings` (`src/vs/workbench/services/accounts/browser/managedSettings.ts`) → `DefaultAccountService.policyData` | `accountPolicyData.managedSettings` | -| **File-based** (`managed-settings.json`) | external schema only | *not implemented in VS Code* | — | +| **File-based** (`managed-settings.json`) | well-known per-OS disk path (e.g. `/Library/Application Support/GitHubCopilot/` on macOS), read in the main process and exposed to renderer windows over IPC | `FileManagedSettingsService` (`src/vs/platform/policy/common/fileManagedSettingsService.ts`) | `IFileManagedSettingsService.managedSettings` | -Both VS Code channels converge in `AccountPolicyService.getPolicyData()`. +All three VS Code channels converge in `AccountPolicyService.getPolicyData()`. -**Precedence: server-delivered managed settings win over native MDM.** There is a -single authoritative source at any point in time — the two layers are **not** merged. -When the server delivers managed settings, native MDM (`nativeManagedSettings`) is -ignored entirely; native MDM applies only when the server provides no managed settings. -Rationale: the server is harder to bypass than local MDM/file policies, and admins need -one authoritative source to reason about. The winning layer is then projected onto the -declared schema (see below). Client-side merging still happens *within* the winning -layer (e.g. `enabledPlugins`, `extraKnownMarketplaces`). +**Precedence: native MDM managed settings win over the server-delivered channel, which in +turn wins over the file-based channel** (`pickManagedSettings` in `copilotManagedSettings.ts`). +Precedence is resolved **per key**: for each key the highest-precedence channel that supplies +it wins, but a key that the higher channels leave unset is still filled in by a lower channel. +A value an admin locks via native MDM therefore cannot be overwritten by the server or a file, +while keys the higher channels never set remain available to lower ones. Rationale for the +order: the server is harder to bypass than local MDM, and a local file is the most easily +tampered with. The merged bag is then projected onto the declared schema (see below). +Client-side merging still happens *within* a channel's value (e.g. `enabledPlugins`, +`extraKnownMarketplaces`). ## Schema source of truth @@ -196,25 +197,27 @@ delivery slot for the managed value. | Function | Role | |----------|------| -| `flattenManagedSettings(obj)` | Flattens a nested response into dot-path scalars (used by the server adapter). | +| `flattenManagedSettings(obj)` | Flattens a nested response into dot-path scalars (used by `normalizeManagedSettings`). | +| `normalizeManagedSettings(parsed, onWarn?)` | The **single** normalizer shared by every channel: turns a parsed managed-settings object (server response, file on disk, …) into the canonical bag. Scalar leaves flatten; structured keys (declared in the `STRUCTURED_MANAGED_SETTINGS` table) are JSON-encoded under a single key each. The server adapter `adaptManagedSettings` and the file channel both call it. | | `collectManagedSettingsDefinitions(policyDefinitions)` | Aggregates every policy's `managedSettings` into one `key → { type }` map. **Single source of truth** for which keys (and types) are honored; drives both the MDM watcher and the server projection. | | `hasManagedSettingsDefinitions(policyDefinitions)` | Cheap short-circuiting existence check (does *any* policy declare a managed key?) — used to decide whether the native MDM watcher is needed at all, without building the full aggregate. | | `projectManagedSettings(values, definitions, onWarn?)` | Keeps only declared keys whose runtime value **matches the declared type**. Undeclared keys and type mismatches are **dropped (validated, never coerced)**, with an optional warning. | +| `pickManagedSettings(nativeMdm, server, file)` | Merges the channels **per key** by precedence (native MDM → server → file): the highest-precedence channel that sets a key wins, lower channels fill in keys the higher ones leave unset, and every contribution is recorded for provenance. **The extension point when adding a new channel** — extend the `ManagedSettingsChannel` union, the `MANAGED_SETTINGS_CHANNELS` order, and this function together. | | `managedSettingValue(key)` | Builds the standard pass-through `value` callback `policyData => policyData.managedSettings?.[key]`. Use for the common "lock to the managed value, else fall through" case (see [Declaring a managed setting](#declaring-a-managed-setting-on-a-policy)). | -### Server-side adaptation: the structured-key descriptor table +### Normalization: the structured-key descriptor table -`adaptManagedSettings` (`managedSettings.ts`) normalizes the server `managed_settings` -response into the canonical bag. Scalar leaves flatten directly; **structured** (object/array) +`normalizeManagedSettings` (`copilotManagedSettings.ts`) turns a parsed managed-settings +object into the canonical bag, and is shared by every channel — the server adapter +`adaptManagedSettings` (`managedSettings.ts`) is a thin wrapper around it, and the file +channel calls it directly. Scalar leaves flatten directly; **structured** (object/array) keys are JSON-encoded under a single bag key. The per-key knowledge for the structured ones -lives in **one table**, `STRUCTURED_MANAGED_SETTINGS`. Each row declares the canonical bag -`key`, the `responseField` it consumes (a hand-maintained union kept in sync with -`IManagedSettingsResponse`'s named fields — not compiler-enforced, because the response's index -signature widens `keyof` to `string`; the `adaptManagedSettings` tests are the drift backstop), -and an `encode(raw, onWarn?)` that +lives in **one table**, `STRUCTURED_MANAGED_SETTINGS`. Each row declares the `key` (the source +field name read from the input, which is also the canonical bag key the JSON string is stored +under — identical for structured settings by contract) and an `encode(raw, onWarn?)` that normalizes the value before `JSON.stringify`. Adding a structured key is **one descriptor row** -(plus its response-interface field and the policy declaration) — no new bespoke branch in the -adapter. +(plus the server `IManagedSettingsResponse` field and the policy declaration) — no new bespoke branch in the +normalizer. The `adaptManagedSettings` / `normalizeManagedSettings` tests are the drift backstop. Constants (also in `copilotManagedSettings.ts`): @@ -232,33 +235,41 @@ Constants (also in `copilotManagedSettings.ts`): Native MDM is desktop-main only (`src/vs/code/electron-main/main.ts`). The real wiring constructs the platform service first (Windows / macOS only), then registers it — falling -back to `NullCopilotManagedSettingsService` on Linux (abbreviated): +back to `NullNativeManagedSettingsService` on Linux (abbreviated): ```ts -let copilotManagedSettingsService: CopilotManagedSettingsService | undefined; +let nativeManagedSettingsService: NativeManagedSettingsService | undefined; if (isWindows) { - copilotManagedSettingsService = new CopilotManagedSettingsService( + nativeManagedSettingsService = new NativeManagedSettingsService( logService, GITHUB_COPILOT_WIN32_POLICY_NAME, { registryPath: GITHUB_COPILOT_WIN32_REGISTRY_PATH }); } else if (isMacintosh) { - copilotManagedSettingsService = new CopilotManagedSettingsService( + nativeManagedSettingsService = new NativeManagedSettingsService( logService, GITHUB_COPILOT_MACOS_BUNDLE_ID); } -if (copilotManagedSettingsService) { - services.set(ICopilotManagedSettingsService, copilotManagedSettingsService); +if (nativeManagedSettingsService) { + services.set(INativeManagedSettingsService, nativeManagedSettingsService); } else { - services.set(ICopilotManagedSettingsService, new NullCopilotManagedSettingsService()); + services.set(INativeManagedSettingsService, new NullNativeManagedSettingsService()); } ``` -It is exposed to the renderer over IPC via `CopilotManagedSettingsChannel` / -`CopilotManagedSettingsChannelClient` (`copilotManagedSettingsIpc.ts`), registered as -the `copilotManagedSettings` channel in `app.ts`. `AccountPolicyService` subscribes to +It is exposed to the renderer over IPC via `NativeManagedSettingsChannel` / +`NativeManagedSettingsChannelClient` (`nativeManagedSettingsIpc.ts`), registered as +the `nativeManagedSettings` channel in `app.ts`. `AccountPolicyService` subscribes to `onDidChangeManagedSettings` and re-evaluates policy values when managed settings change. The service only watches keys that some policy declares: `updatePolicyDefinitions` calls `collectManagedSettingsDefinitions`, then `@vscode/policy-watcher` watches exactly those dot-paths. No declared keys ⇒ no watcher. +The **file-based** channel is wired the same way (`src/vs/code/electron-main/main.ts`): +`FileManagedSettingsService` reads `managed-settings.json` from the per-OS well-known path +(`MANAGED_SETTINGS_*` constants in `copilotManagedSettings.ts`), falling back to +`NullFileManagedSettingsService` when no path applies. It is exposed to the renderer over IPC +via `FileManagedSettingsChannel` / `FileManagedSettingsChannelClient` +(`fileManagedSettingsIpc.ts`), registered as the `fileManagedSettings` channel in `app.ts`, +and `AccountPolicyService` subscribes to its `onDidChangeManagedSettings` too. + ## Adding a brand-new managed-settings key (checklist) 1. **Pick the canonical dot-path** and add it as a constant in @@ -268,11 +279,12 @@ those dot-paths. No declared keys ⇒ no watcher. and a `value` callback. For a plain pass-through use `value: managedSettingValue(KEY)`; only hand-write the callback when combining with another condition. 3. **If the value is structured** (object/array), declare the managed key as `'string'` and - let `PolicyConfiguration` parse the JSON back on read. Then teach the **server adapter** by - adding one row to `STRUCTURED_MANAGED_SETTINGS` in `managedSettings.ts` (canonical `key`, the - `responseField` it consumes, and an `encode` that normalizes the server shape into what an - admin would author in MDM) plus the matching field on `IManagedSettingsResponse`. That table - is the single place server-side structured-key handling lives. + let `PolicyConfiguration` parse the JSON back on read. Then teach the **shared normalizer** by + adding one row to `STRUCTURED_MANAGED_SETTINGS` in `copilotManagedSettings.ts` (the `key` and an + `encode` that normalizes the raw shape into what an admin would author in MDM) plus the matching + field on the server `IManagedSettingsResponse`. That table + is the single place structured-key handling lives, and it applies to every channel (server, + file-based, …) because they all funnel through `normalizeManagedSettings`. 4. **Keep the schema aligned.** The runtime, the server endpoint, and `managed-settings-schema.json` must agree on the key name and value type. The declaration-driven projection (`projectManagedSettings`) silently drops anything that @@ -282,11 +294,13 @@ those dot-paths. No declared keys ⇒ no watcher. Reference tests: - `src/vs/platform/policy/test/common/copilotManagedSettings.test.ts` -- `src/vs/platform/policy/test/node/copilotManagedSettingsService.test.ts` +- `src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts` - `src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts` - `src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts` (includes an end-to-end equivalence test: a server JSON string and a native MDM JSON string resolve to the **identical** typed object). +- `src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts` + (covers `normalizeManagedSettings` and the file-based channel reader). **Manual/local testing:** use the mock policy server to serve arbitrary `managed_settings` (and entitlement/token) responses and apply them via @@ -342,6 +356,6 @@ Rules & internals: | PR | Change | |----|--------| | [#318623](https://github.com/microsoft/vscode/pull/318623) | Wire `/copilot_internal/managed_settings` into `AccountPolicyService`/`IPolicyData`; add `chat.plugins.enabledPlugins`/`extraMarketplaces`/`strictMarketplaces` settings + `adaptManagedSettings` shape adaptation. No new `IPolicyService`. | -| [#320991](https://github.com/microsoft/vscode/pull/320991) | Add native MDM delivery: `CopilotManagedSettingsService` + `@vscode/policy-watcher`; let policies declare `managedSettings` mappings; wire the first V0 key `permissions.disableBypassPermissionsMode` → force `ChatToolsAutoApprove=false`. | +| [#320991](https://github.com/microsoft/vscode/pull/320991) | Add native MDM delivery: `NativeManagedSettingsService` + `@vscode/policy-watcher`; let policies declare `managedSettings` mappings; wire the first V0 key `permissions.disableBypassPermissionsMode` → force `ChatToolsAutoApprove=false`. | | [#321218](https://github.com/microsoft/vscode/pull/321218) | Make `IPolicyData.managedSettings` the **single** channel: server + native MDM project into one canonical bag; structured settings carried as canonical JSON strings; remove the typed `enabledPlugins`/`extraKnownMarketplaces`/`strictKnownMarketplaces` fields. End-to-end equivalence test. | | [#321515](https://github.com/microsoft/vscode/pull/321515) | Add `policyReference` so one policy governs many settings; callback-free serialization; catalog + diagnostics list governed settings. Used to gate Claude (`Claude3PIntegration`) and Codex (`Codex3PIntegration`) across the editor and Agents windows. | diff --git a/.github/skills/azure-pipelines/SKILL.md b/.github/skills/azure-pipelines/SKILL.md index 8903496983c88..2e7b7f15ec7c6 100644 --- a/.github/skills/azure-pipelines/SKILL.md +++ b/.github/skills/azure-pipelines/SKILL.md @@ -119,6 +119,7 @@ node .github/skills/azure-pipelines/azure-pipeline.ts queue --parameter "VSCODE_ | `VSCODE_PUBLISH` | boolean | `true` | `true`, `false` | Publish to builds.code.visualstudio.com | | `VSCODE_RELEASE` | boolean | `false` | `true`, `false` | Trigger release flow if successful | | `VSCODE_STEP_ON_IT` | boolean | `false` | `true`, `false` | Skip tests | +| `VSCODE_USE_LEGACY_OSS_NOTICE` | boolean | `false` | `true`, `false` | Keep the legacy mixin ThirdPartyNotices.txt instead of the Component Governance notice | Example: run a quick CI-oriented validation with minimal publish/release side effects: diff --git a/.github/skills/chat-perf/SKILL.md b/.github/skills/chat-perf/SKILL.md index 7769d511cf41c..41c5e22524a4b 100644 --- a/.github/skills/chat-perf/SKILL.md +++ b/.github/skills/chat-perf/SKILL.md @@ -42,6 +42,8 @@ npm run perf:chat-leak -- --messages 20 --verbose Launches VS Code via Playwright Electron, opens the chat panel, sends a message with a mock LLM response, and measures timing, layout, and rendering metrics. By default, downloads VS Code 1.115.0 as a baseline, benchmarks it, then benchmarks the local dev build and compares. +> **You don't always need a baseline.** A baseline exists only for *comparison* (regression detection). If you just want the current build's numbers — profiling a single change, capturing traces/heap snapshots, or iterating on a scenario — pass `--no-baseline` to skip downloading and benchmarking the baseline entirely (roughly halves runtime). Baseline comparison is what turns raw measurements into a pass/fail verdict; without it you still get all the metrics, just no verdict. + ### Key flags | Flag | Default | Description | @@ -51,7 +53,7 @@ Launches VS Code via Playwright Electron, opens the chat panel, sends a message | `--build ` / `-b` | local dev | Build to test. Accepts path or version (`1.110.0`, `insiders`, commit hash). | | `--baseline ` | — | Compare against a previously saved baseline JSON file. | | `--baseline-build ` | `1.115.0` | Version or local path to benchmark as baseline. | -| `--no-baseline` | — | Skip baseline comparison entirely. | +| `--no-baseline` | — | Skip the baseline entirely — just measure the test build (no download, no comparison, ~2× faster). Use when you only need raw numbers, not a regression verdict. | | `--save-baseline` | — | Save results as the new baseline (requires `--baseline `). | | `--resume ` | — | Resume a previous run, adding more iterations to increase confidence. | | `--threshold ` | `0.2` | Regression threshold (0.2 = flag if 20% slower). | @@ -60,6 +62,7 @@ Launches VS Code via Playwright Electron, opens the chat panel, sends a message | `--force` | — | Skip build mode mismatch confirmation prompt. | | `--ci` | — | CI mode: write Markdown summary to `ci-summary.md` (implies `--no-cache`, `--heap-snapshots`, `--cleanup-diagnostics`). | | `--heap-snapshots` | — | Take heap snapshots after each run (slow; auto-enabled in `--ci` mode). | +| `--gc-object-stats` | — | **GC deep-dives only.** Enables V8 `gc_stats` tracing (per-type heap object dump on every GC). ⚠️ Corrupts all timing metrics — a major GC landing mid-request adds ~550ms — so never use it for benchmarking. Off by default; prefer heap snapshots for memory analysis. | | `--cleanup-diagnostics` | — | Delete heap snapshots, CPU profiles, and traces to save disk. During runs, only the latest run's files are kept; after comparison, files for non-regressed scenarios are deleted. Auto-enabled in `--ci` mode. | | `--setting ` | — | Set a VS Code setting override for all builds (repeatable). | | `--test-setting ` | — | Set a VS Code setting override for the test build only. | @@ -186,13 +189,13 @@ Run `npm run perf:chat -- --help` to see the full list of registered scenario ID Only these metrics trigger a regression failure (when they exceed the threshold with statistical significance): - `timeToFirstToken`, `timeToComplete` — user-perceived latency +- `layoutDurationMs` — total layout time from the trace (the *real* layout cost) - `forcedReflowCount` — forced synchronous layouts are always bad - `longTaskCount`, `longAnimationFrameCount` — main thread jank These are reported but **informational only** (won't fail CI): -- `layoutCount` — inflated by CSS animations; use `layoutDurationMs` instead -- `layoutDurationMs` — total layout time from trace (more meaningful than count) -- `recalcStyleCount` — inflated by CSS animations (compositor-driven, cheap) +- `layoutCount` — number of layout ops; inflated by CSS animations (compositor-driven, cheap). A build can do *more but cheaper* layouts, so gate on `layoutDurationMs`, not this count. +- `recalcStyleCount` — number of style recalcs; inflated by CSS animations (compositor-driven, cheap) - `timeToRenderComplete` — includes typewriter animation tail - Memory/heap metrics — too noisy for single-request benchmarks @@ -229,6 +232,54 @@ Launches one VS Code session, sends N messages sequentially, forces GC between e - DOM nodes stable after first message — normal (chat list virtualization working) - DOM nodes growing linearly — rendering leak, check disposable cleanup +## CI runs & pinpointing regressions + +The perf + leak checks run in CI via the **`.github/workflows/chat-perf.yml`** workflow (a scheduled daily `workflow_dispatch`, plus manual dispatch). Each run benchmarks the current `main` as the **test** build against a fixed release **baseline** (from `config.jsonc`, e.g. `1.122.0`). Because the baseline is fixed, the **test** median for a metric across successive daily runs traces `main`'s trajectory over time — that's what lets you bisect *when* something regressed or went flaky. + +### Finding and reading historic runs + +```bash +# List recent runs (most recent first) — note the run IDs and dates +gh run list --workflow chat-perf.yml -R microsoft/vscode --limit 30 \ + --json databaseId,status,conclusion,createdAt,headBranch \ + --jq '.[] | [.databaseId, (.conclusion//.status), .createdAt, .headBranch] | @tsv' + +# See a run's per-job results +gh run view -R microsoft/vscode --json jobs \ + --jq '.jobs[] | [.name, (.conclusion//.status)] | @tsv' + +# Trigger a run manually against any ref/version: +gh workflow run chat-perf.yml -R microsoft/vscode --ref main \ + -f test_build= -f baseline_build= +``` + +### Artifacts per run (and retention — this matters for old runs) + +| Artifact | Contents | Retention | +|---|---|---| +| `chat-perf-summary` | Unified `ci-summary.md`: verdicts, per-metric medians ±stddev, **per-run raw tables** | 30 days | +| `perf-results-` | Everything below **plus traces, CPU profiles, heap snapshots** (large) | 30 days | +| `leak-results` | Leak log + `chat-simulation-leak-results.json` | 30 days | +| `perf-summary-` | `results.json` (full per-run metrics incl. `rawRuns`) + `baseline-*.json` | **1 day** | + +```bash +# List a run's artifacts + whether they've expired +gh api repos/microsoft/vscode/actions/runs//artifacts \ + --jq '.artifacts[] | [.name, .expired] | @tsv' + +# Download the human-readable summary (best first stop; survives 30 days) +gh run download -R microsoft/vscode -n chat-perf-summary +``` + +### Pinpointing where a metric regressed / went flaky + +1. **Bisect across dates.** Pull `chat-perf-summary/ci-summary.md` from several runs spanning the window. Compare the **test** median (and ±stddev) for the suspect metric+scenario run-to-run — the day it jumps is when `main` changed. A metric that goes *bimodal* (e.g. a raw-run column showing two clusters like `~250 / ~900`) is the flaky signature; a stable shift is a genuine regression. +2. **Confirm it's real vs. measurement noise.** Check the per-run raw tables (in `ci-summary.md`) — high ±stddev / bimodal values mean the *median* is being pulled around by a few outlier runs, not a uniform slowdown. +3. **Deep-dive the cause.** Download `perf-results-` (has the `trace.json` per run) for a slow run and inspect what dominates the slow window. Trick that found the `gc_stats` artifact: sum main-thread `RunTask` durations between two `code/chat/*` marks (e.g. `willCollectInstructions` → `didCollectInstructions`) — if the window is ~0% busy, it's an async wait or a GC pause, not real work; then look at the largest `X`-phase events in that window (`MajorGC`, `Layout`, etc.). +4. **Reproduce a suspect commit locally** to bisect precisely: `npm run perf:chat -- --build --baseline-build --runs 7` (or two commits directly via `--build --baseline-build `). + +> Tip: `perf-summary-*` (the machine-readable `results.json` with `rawRuns`) is deleted after **1 day**, so for older runs rely on `chat-perf-summary` (raw tables, 30 days) or extract `results.json` from `perf-results-*` (also 30 days). + ## Architecture ``` diff --git a/.github/skills/integrated-browser/SKILL.md b/.github/skills/integrated-browser/SKILL.md new file mode 100644 index 0000000000000..c4ace6680938b --- /dev/null +++ b/.github/skills/integrated-browser/SKILL.md @@ -0,0 +1,87 @@ +--- +name: integrated-browser +description: Use this when working on the VS Code integrated browser ("browserView") to understand its architecture and mental model. Covers the embedded Chromium browser, its editor tab, navigation, overlay/layout, sessions, and agent browser tools under `src/vs/platform/browserView` and `src/vs/workbench/contrib/browserView`. +--- + +# Integrated Browser Architecture + +The integrated browser ("browserView") embeds a **real Chromium browser** in VS Code, backed by an Electron `WebContentsView`. It renders live pages, presents each as an editor tab, and lets agents drive those pages through tools. It powers the in-product browser tab and the agent "browser" tools. It is **not** the old `extensions/simple-browser` (an iframe-in-a-webview), which now delegates to this on desktop. + +It's a heavyweight, security-sensitive, multi-process primitive, and **almost every design decision follows from that.** This file describes the load-bearing ideas that rarely change. It deliberately does **not** enumerate current features/tools/commands/settings — those churn; the live `features/` and `tools/` folders are the source of truth. Build the mental model here, then go read the specific code you're changing. + +## The one idea everything follows from + +**A page is a native `WebContentsView` that only the main process may create, own, and position. Nothing else can touch it directly.** It's owned by the main process and painted by the OS compositor *on top of* the workbench DOM — not inside it. Everything else works around this: + +- The **renderer** (editor UI + agent tools) can't hold the page; it holds a model/proxy and talks to main over IPC. +- **Playwright** is heavy and long-lived, so it runs in the **shared process**, reaching the page over IPC too. +- The page **paints over the DOM**, so the workbench choreographs alignment, z-order, focus, and screenshots by hand. + +## Three processes, and why + +| Process | Location | What lives here | +|---------|----------|-----------------| +| **Main** | `platform/browserView/electron-main` | The `WebContentsView`, sessions, trust, permissions, history, CDP, screenshots — authoritative page state. Only main can create native views. | +| **Shared** | `platform/browserView/node` | Playwright + remote/group automation services. Keeps a heavy dependency out of main (stability) and renderer (lifecycle). | +| **Renderer** | `workbench/contrib/browserView` + `platform/browserView/electron-browser` | Editor pane, UI feature contributions, agent tools, page preload script. Holds only lightweight proxies. | + +**Layer rule:** `platform` must not import `workbench`; the agent host can't import `workbench`; shared types belong in `platform/common`. The renderer reaches main/shared only through `ProxyChannel` IPC, never by importing implementations. Channels are registered in `electron-main/app.ts` and `electron-utility/sharedProcess/sharedProcessMain.ts`. Split: **page ops/state → main; agent automation → shared; CDP plumbing is its own channel both renderer and shared use to reach main.** + +## The renderer holds a mirror, not the truth + +The renderer model is a **read replica** of state owned by the main-process view. Flow is one-directional: + +``` +renderer feature ──command──▶ model ──IPC──▶ main BrowserView ──▶ Chromium + ▲ │ + └──────────── event (state changed) ◀──────────┘ +``` + +To *do* something, call a method (round-trips to main); to *react*, listen to a model event. Never **compute** page state in the renderer — it has no access to the web contents. + +- **New state** (url/title/loading/zoom/…): make it authoritative in the main `BrowserView`, emit a change event, then mirror the field + event on the renderer model and forward it over the channel. +- **New operation** (navigate/reload/focus/…): define it in main, expose a thin proxy method on the renderer model that round-trips the channel. + +Wanting the renderer to "just read" something off the page is the signal you need new mirrored state + an event from main. + +## The native view floats above the DOM (the overlay problem) + +The single most error-prone area — the cause of nearly every "won't move / misaligned / shows through a menu / won't focus" bug. The page is painted by main in **screen coordinates on top of** the renderer, so the workbench fakes a normal DOM element. Treat the page's rectangle, visibility, focus, and imagery as things the workbench **coordinates**, never DOM facts: + +- **Alignment.** The editor renders an empty DOM stub, measures its screen rect, and ships bounds to main. CSS zoom and screen pixels disagree, so bounds are **pixel-snapped**. New layout that moves the page must feed this bounds computation, not just move a DOM box. +- **Z-order.** The native view paints *above* all workbench UI, so menus/popups/hovers/dialogs that should sit over the page would be hidden. The workbench detects overlap and **hides the native view**, swapping in a placeholder. New floating UI over the page must be detectable by that machinery — a high CSS z-index won't do it. +- **Flicker masking.** While hidden/repositioning, a periodic screenshot stands in. Screenshots are also the only legit way page imagery reaches the renderer/agents — nobody reads native pixels. +- **Focus & keyboard.** Focus is bridged explicitly. A **preload script** (injected into every page in an isolated world) decides which keystrokes the page keeps vs forwards to VS Code keybindings. Treat it as a **trust boundary**: assume a hostile page, keep it minimal and side-effect-free. + +## A browser tab is a real editor, extended by contributions + +A page is a normal editor — a read-only, serializable `EditorInput` + `EditorPane` on the standard registry, resolving lazily to a view-model (unloadable without closing the tab). This inherits tabs, splitting, persistence, focus, and keybinding scoping for free. + +The pane is thin. Behavior is added via a **local contribution model (separate from workbench contributions)**: small classes that attach to the editor, get DI, and hook a fixed lifecycle (model attach/detach, layout overrides, resize/visibility, focus, UI insertion). Each gets a lifetime **scoped to the attached model**, so per-page disposables clean up on navigate/close. Even native-view rendering is just one contribution. + +**To add behavior, write a new contribution modeled on a sibling — don't grow the editor or the main view.** Contributions affecting the page rect compose through **prioritized layout overrides** (lower runs first; e.g. emulation sizes the viewport, pixel-snap runs last), so **order matters** — never hard-code pixels. + +## Identity and isolation: sessions, groups, CDP + +Keep these three distinct: + +- **Session = storage identity.** Each Electron session maps 1:1 to a `BrowserSession` (cookies, cache, storage), and its id **doubles as the CDP browser-context id**. Scoped **global**, **per-workspace**, or **ephemeral**; multiple tabs can share one. Security is enforced here: `file://`, certificate trust, and permissions are all gated at the session (e.g. local files need workspace trust). New capabilities that expand a page's reach belong here, not on a feature. +- **Group = automation visibility.** A group assembles a dynamic set of views and exposes them as one logical CDP "browser." Groups *reference* views without owning them; a view can be in several. This is how different clients (chat session, DevTools, an extension) each see only their subset. +- **CDP is proxied, never raw.** A protocol-aware proxy implements browser/target-level domains (discovery, auto-attach, flattened sessions, contexts) and forwards the rest per-target. This lets one logical browser be stitched from views that come and go, and lets Playwright connect without touching Chromium directly. + +Cookies/login/storage → **sessions**. "Which pages can this client see" → **groups**. The protocol itself → **the proxy**. + +## Agents share the user's page — under a privacy gate + +- **Same page, shared cooperatively.** Playwright drives the *same* `WebContentsView` the user sees, via CDP, one connection per chat session. Human and agent actions can collide; conflicts resolve in the user's favor (a human prompt/dialog can interrupt automation). The workbench (not Playwright) owns device emulation, so Playwright's auto-emulation is suppressed except during an agent action. Assume a human may interact with the same page concurrently. +- **Pages are private until shared.** Content isn't visible to agents by default. A page's *sharing state* gates content; a separate **availability gate** (chat enabled, agent mode, settings) decides whether the full tool set is registered — when it isn't, only a reduced "open a URL without content access" capability exists. URLs are screened by the network-filter and masked when blocked. Treat page content as **untrusted model input** (prompt injection). Any new agent surface must honor these gates. (Tool *names* live in `platform` so the agent host, which can't depend on `workbench`, can reference them.) + +## Remote pages + +When a page must load as if from a remote machine (forwarded `localhost` in a remote workspace, container, or Codespace), a **tunnel proxy is applied to the page's session**; credentials come from the extension host, and navigation can defer until the proxy is live. "Open localhost" isn't always local — remote URLs are rewritten to their forwarded form, and the proxy lives on the **session**, not an individual call. + +## Practical guidance + +- **Desktop-only.** Nothing runs in web; add to `electron-*` / `node` and let the `browser/` stubs throw "not available in web". +- **Match existing patterns.** New capability → a new feature contribution and/or tool, modeled on a sibling. Cross-process state → a model method + event from main, not local renderer state. Layout change → a prioritized override, not pixels. +- **Mind the trust boundaries:** the preload (hostile page), the session (storage / permissions / file access), agent gating (sharing + availability + network filter), and chat attachment (prompt injection). diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index e02331b1305c3..57d9aefef5d28 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -34,21 +34,47 @@ Then read the relevant spec for the area you are changing (see table below). If - **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components. - **Timeouts as fixes**: Never use `setTimeout`/`disposableTimeout`/arbitrary delays to fix bugs or implement behaviour. They are race-prone guesses that mask the real ordering/state problem. Drive logic off deterministic signals instead — observables (`autorun`/`derived`), explicit events (`onDidChange*`), lifecycle phases, or awaiting the actual async operation. - **Stashed state read back later (side-channels)**: Never stash a value on a service during one method call and read it back from a separate query later, assuming it is still valid (e.g. a `Set`/flag set in `openSession` and consumed by a `shouldX()` pull-API). This is fragile temporal coupling. Instead, make it reactive state that is set **atomically together with its source of truth** and consumed reactively. Example: per-activation intent like "open in background / preserve focus" is exposed as an `IObservable` set in the **same transaction** as `activeSession` (via a single internal setter so it can never go stale), and read with `.read(reader)` in the consumer's `autorun` — never via a consume-once getter. +- **Provider-owned model/mode selection belongs in the loaded chat model, with draft persistence driven by debounce**: For AHP-backed chats, `setModel` / `setAgent` must push the selection into the loaded `IChatModel.inputModel` (like `_updateChatSessionState`) and let the draft-sync debounce emit `chat/draftChanged`. Do not immediately dispatch a model/agent-only draft from the provider, because it can overwrite unsaved typed text before the debounced full input-state draft is persisted. - **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile. Do **not** bound the upgrade with a timeout or even a lifecycle milestone like `LifecyclePhase.Eventually` — an agent host connects lazily and can surface its session types arbitrarily late, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead. -- **Over-commenting**: Don't write long explanatory comments narrating what the code does or justifying ordinary patterns. Per the coding guidelines, only comment code that needs clarification — reserve comments for genuine workarounds/hacks or non-obvious intent, and keep them to a line or two. +- **Over-commenting**: Don't write long explanatory comments narrating what the code does or justifying ordinary patterns. **Hard rules**: JSDoc = 1–2 short sentences max (never enumerate every branch/feature, restate the signature, or list what the function does NOT do); inline method comments = 1 line max, only for a genuine workaround/non-obvious constraint, never to narrate the next statement. Default to no comment — if code needs a paragraph to explain, rename/extract instead. Before writing any comment longer than one line, delete it or shorten it to one line. - **Inserting/removing DOM on demand for transient UI (e.g. inline rename inputs)**: Don't `insertBefore`/`appendChild`+`remove()` a widget on the *tab/row element itself* when an interaction starts/ends — that churns the parent's child list and depends on event ordering during teardown. Also don't eagerly build a heavy widget (e.g. an `InputBox`) per row "just in case", since most rows never use it. Instead, create a **stable, empty container** alongside the label once, toggle its visibility via a CSS class on the row (e.g. `.editing`), and create the widget **inside that container lazily** only while editing — disposing it and emptying the container (`reset(container)`) when done (`InputBox.dispose()` does not detach its own node). Prefer the shared themed widget (`InputBox` + `defaultInputBoxStyles`) over a hand-rolled ``. - **Collapsing distinct provider identities in pickers**: Do not collapse extension-backed chat session ids (e.g. `copilotcli`) and agent-host ids (e.g. `agent-host-copilotcli`) based only on friendly names or well-known provider enums. They can coexist in the Agents window and route to different infrastructure; keep the exact session type id through selection/delegation and hide ambiguous legacy targets when an agent-host target supersedes them. - **Resolving a session's provider via the create-only tracking map**: On the agent host, resolve the owning provider for any per-session operation (createChat, disposeChat, sendMessage, …) through `AgentService._findProviderForSession`, never the raw `_sessionToProvider` map. That map is populated only by `createSession`, so a **restored** session (alive in the state manager after a host restart but never created in this process) is absent from it — a direct lookup throws `no provider for session` and silently breaks the feature (e.g. Add Chat did nothing for restored sessions while messaging worked, because messaging already used the fallback). `_findProviderForSession` falls back to the session URI's scheme provider, which is what makes restored sessions work. - **Dispatching per-chat side-channel actions (agent/model) to the session URI**: An agent-host session can own multiple peer chats, each with its own backend conversation (`CopilotAgent._chatSessions`). Conversation side-channel actions like `SessionAgentChanged`/`SessionModelChanged` must be dispatched to the per-chat **turn channel** (`_resolveTurnDispatchChannel`, which carries a `chatId` fragment for peer chats), not `session.toString()`. The session URI resolves to the session's *default* chat (`_sessions`), so dispatching there silently applies the change to the wrong conversation and an additional chat never sees the agent/model swap. The host must also forward the `chatChannel` through `agentSideEffects.handleAction` → `changeAgent`/`changeModel`, which apply it to `_chatSessions` when present. The protocol models `summary.agent`/`summary.model` at session level only, so equality guards comparing against session summary are valid for the default chat but must be skipped for peer chats. +- **Do not infer or fall back from a peer chat channel after progress was emitted**: Agent progress signals for chat-scoped actions, especially tool-call readiness and permission requests, must be emitted with the exact `ahp-chat://...` channel that owns the tool. Do not recover by scanning active turns, remapping `ChatToolCallConfirmed`, or using `parseDefaultChatUri(...) ?? sessionUri` in `AgentSideEffects`; malformed/misrouted chat channels should fail loudly so the producer or dispatch path is fixed. `handleToolCallConfirmed` and `_toolCallAgents` must use the chat channel URI containing the tool call; keying by the parent session URI makes confirmations miss the pending SDK request. +- **Do not synthesize default chat URIs in the workbench handler**: `AgentHostSessionHandler` must source the upstream default chat URI from hydrated `SessionState.defaultChat` / `SessionState.chats` and store that mapping in its chat-resource-to-upstream-URI map. Calling `buildDefaultChatUri(session)` in the handler assumes one server URI shape and hides protocol/provider bugs; dispatch turn lifecycle and pending/input actions through the mapped upstream chat URI instead. +- **Model subagents as chats, not sessions**: A subagent spawned from a tool call belongs to the parent session as an additional chat with `origin.kind === "tool"`, hidden from the chat tab strip. Do not call `restoreSession` for subagents; that creates `_sessionStates` without a matching `_chatStates` entry, so later chat actions hit "Action for unknown chat". Add a chat on the parent session and dispatch the subagent turn to that chat URI. +- **Keep case-sensitive ids out of URI authority**: URI authorities are case-insensitive, so do not place tool call ids in the `ahp-chat` authority. Subagent chat URIs use a stable `subagent` authority and put the encoded tool call id in the path; use `buildSubagentChatUri(...)` instead of `buildChatUri(..., \`subagent-${toolCallId}\`)`. - **Selected custom agent must be in the SDK's `customAgents`, not just `pluginDirectories`**: The Copilot SDK validates the session-start `agent:` option (passed to `createSession`/`resumeSession`) against the `customAgents` list **by name only** — it does NOT consult `pluginDirectories`. `copilotSessionLauncher._buildSessionConfig` deliberately omits agents from file-dir plugins from `customAgents` (relying on the SDK's `pluginDirectories` discovery to avoid duplicates), so selecting a plugin/extension-contributed agent (e.g. "Inbox") otherwise fails with `Custom agent '' not found`. The fix (`toSdkSessionCustomAgents`) force-adds the resolved selected agent into `customAgents` while every other file-dir agent still loads via `pluginDirectories`. Note the agent picker offers VS Code chat modes from `IChatModeService`, but only `plugin`/`extension` storage agents are synced to the host (`SYNCABLE_STORAGE_SOURCES`); `user`/`local` agents are never synced, so `_resolveAgentName` returns `undefined` for them and no `agent:` is sent. - **Derive SDK custom-agent names exactly like `parseAgentFile`**: `_resolveAgentName` resolves the selected agent through the plugin parser, which trims the frontmatter `name` (`getStringValue('name')?.trim() || nameFromFile`). When building the SDK `customAgents` list (`toSdkCustomAgents`), derive the name the same way (`?.trim() || agent.name`); reading the raw frontmatter `name` without trimming yields a config name that won't match the trimmed `resolvedAgentName`, so the SDK still rejects the session with `Custom agent '' not found`. - **Peer chats have no server `summary`, so dedup side-channel dispatch against the last value sent for that chat**: equality guards before dispatching `SessionModelChanged`/`SessionAgentChanged` compare against `summary.model`/`summary.agent`, which only exist for the session's default chat. For peer chats, track the last-dispatched model/agent on the `AgentHostChatSession` instance (auto-cleaned on dispose) and diff against that — otherwise every peer-chat turn redundantly re-dispatches (and re-resolves the agent), and an intentional "clear selection" (`undefined`) can't be detected. - **Scrollable transcript surfaces must use workbench scrollbars**: Don't make Agents/voice transcript regions scrollable with native `overflow-y: auto` on the content node. Wrap transcript content in `DomScrollableElement`/list widgets so scrollbars match VS Code theming and remain usable in narrow auxiliary-window layouts. +- **Background-sending a multi-chat composer must reset the composer *before* dispatching the send, not concurrently**: in `NewChatInSessionWidget._send`, creating the replacement untitled chat (`openNewChatInSession({ forceNew: true })` → `provider.createNewChat`) and the fire-and-forget background `sendRequest` both reach into shared chat-session state (`acquireOrLoadSession` / `getOrCreateChatSession`) for chats in the **same group**. Running them concurrently (send first, reset second) raced and left the sent chat stuck spinning with its message never dispatched, plus a second empty "New Chat" tab. Fully `await` the composer reset first, then fire the background send so it runs on its own. +- **Chat tab order is the provider's stable creation order; don't reorder in the renderer**: the agent host delivers `state.chats` in stable creation order (append on add, replace-in-place on update — see `agentHostStateManager`/the session reducer), and a genuinely new chat is appended last. The renderer's rebuild autorun (`chatCompositeBar.ts`) must render that order as-is. Do **not** partition/move in-composer `Untitled` chats to the end: a draft is already last, and reordering by status makes a tab jump when a draft commits out of creation order (e.g. sending the 3rd of three drafts first moved it to the front). A chat's `Untitled` presentation (via `AdditionalChat._isNew`, needed so `sessionView.ts` shows the composer) is independent of tab order and must not drive it. Also note `_restorePeerChats` (`agentService.ts`) must seed restored chats in `getChats()` order, not in `Promise.all` resolution order, or the catalog scrambles on reload. +- **A new chat must report `SessionStatus.Untitled` until its first request is sent, regardless of how the provider creates it**: `sessionView.ts` only shows the new-chat composer (which owns the Alt+Enter background-send handler) when `activeChat.status === Untitled`. The agent host commits a new peer chat *eagerly*, so its host status is `Completed` — surfacing the standard chat widget and breaking background send. Gate the chat's presented status on a provider-side `isNew` flag (`AdditionalChat.markNew`/`markSent`, set in `createNewChat` and cleared in `sendRequest`'s committed-chat branch), not on the host-reported status. +- **Service operations should return a result or throw, not `undefined` for unsupported cases**: capability-gated operations like `forkChatInSession` must throw when the provider/session cannot perform them. Keep fallback decisions in the caller before invoking the service instead of encoding fallback as an `undefined` service result. +- **Drop a fork when its turn point is unknown, don't forward it empty**: in `AgentService.createChat`/`createSession`, if the requested fork `turnId`/`turnIndex` resolves to no source turns, set `fork: undefined` and fall through to a fresh create. Forwarding the fork with an empty turn slice makes the Copilot provider call `sessions.fork` with no `toEventId`, inheriting the entire backend conversation while the new chat UI is seeded with zero turns — an inconsistent hidden-history chat. +- **A responsive-layout autorun must re-baseline (not react) to controller-driven restores, holding the flag across the async reveal**: the desktop [D7] responsive sidebar hides the sessions sidebar when small + editor + aux-bar are all open. Switching sessions restores layout via two async paths — the desktop aux-bar restore (`openView`/`openViewContainer`) **and** the base controller's editor working-set apply (`_applyWorkingSet`, which reveals the editor part *after* an `await` and runs on a `Sequencer` microtask). Both reveal parts in a *later* autorun run, so an inline "same-run session changed" check only absorbs the synchronous transition and the async reveal still auto-hid the sidebar on navigation. Fix: a shared base-controller `_withSessionLayoutRestore(work)` epoch wraps **both** restore paths (the working-set wrap is the critical one for non-modal editors); the D7 autorun re-baselines `_previousSpaceConstrained` while `_isRestoringSessionLayout` is true. Also gate the constrained derivation on `!multipleSessionsVisibleObs` so the feature is disabled with multiple sessions visible. Never use a `setTimeout` to bridge the async reveal — tie the flag to the actual promise. +- **A promise-tied "epoch" helper must decrement synchronously for void/sync work, only deferring for real Promises**: `_withSessionLayoutRestore` increments a depth counter, runs `work()`, and decrements when done. If it *always* schedules the decrement on a microtask (`Promise.resolve(result).finally(...)`) — even when `work()` returns `undefined` (the common no-op restore, e.g. a session with no workspace) — the depth stays elevated for the entire synchronous caller/test body, so `_isRestoringSessionLayout` reads `true` forever and the consumer (D7) silently stops acting. Only defer the decrement when `work()` returns a thenable; for void/sync (or throwing) work, decrement in the `finally`. +- **A quick-chat's workspace-less kind is fixed at adapter construction — every construction path must carry `_meta`**: `AgentHostSessionAdapter` resolves its session-kind (`QuickChatSessionKind` vs `WorkspaceSessionKind`) **once**, from `readSessionWorkspaceless(metadata._meta)` in the constructor; `_computeWorkspace()` and `isQuickChat` delegate to that fixed kind and **cannot be flipped by a later `update`/`setMeta`**. So the `_meta.workspaceless` tag must be present in the metadata passed to **every** adapter-construction path: `_refreshSessions()`/`listSessions` **and** the live `_handleSessionAdded(summary)` notification (carry `summary._meta`). Dropping `_meta` on either locks a committed quick chat into `WorkspaceSessionKind` and leaks its `/.copilot/chats/` scratch dir as a `workspace` (breaking the archive-on-delete fallback, list badges, changes/files). On the host, `CopilotAgent.listSessions()` must re-emit `_meta.workspaceless` from persisted `copilot.workspaceless` metadata (mirroring `getSessionMetadata`) so restored sessions carry the tag once the state-manager live summary is gone. The host still keeps the tag on both the summary `_meta` and `SessionState._meta` (`createSessionState(summary)` copies it) so the channels stay consistent. +- **Don't infer "quick chat" from `workspace === undefined`**: a session's workspace observable is `undefined` for genuine quick chats but also transiently/edge-case for workspace-bound sessions, so keying quick-chat UI (context keys, list grouping) on it is imprecise. Expose the intent explicitly via the optional `ISession.isQuickChat: IObservable` (only quick-chat-capable providers set it; absent ⇒ `false`). The agent-host adapter derives it from `readSessionWorkspaceless(_metaObs)`; non-quick-chat providers omit it. Consume it through `isQuickChatSession(session)` / `session.isQuickChat?.read(reader) ?? false`. +- **Workspace-less is inferred from absent `workingDirectory` — exclude forks from that inference**: in `CopilotAgent.createSession`, `isWorkspaceless` must be `!sessionConfig.fork && !sessionConfig.workingDirectory`. A fork that arrives without an explicit `workingDirectory` should inherit the source session's context, not be tagged `copilot.workspaceless` and dropped into a scratch dir + quick-chat system prompt. +- **On a failed quick-chat create, don't activate an unrelated draft**: `SessionsService.openQuickChat` must, on `createQuickChat` throwing, log and return `undefined` without falling back to `_activate(newSession.get())` — that observable can hold a workspace-bound draft from a different call site, so activating it is surprising. Return the activated `IActiveSession` from `_activate`/`openQuickChat` (setActive is synchronous and yields the wrapper) and have the caller focus that exact value, rather than re-reading `activeSession.get()` afterwards. +- **Hide an aux-bar view CONTAINER via `hideIfEmpty: true` + a view `when`, not a container `when`**: `IViewContainerDescriptor` has **no `when`** property (only `IViewDescriptor` does). To conditionally hide a whole container (its tab/title) — e.g. the Agents-window Changes/Files containers for workspace-less sessions — put the context-key `when` on the inner view(s) and set `hideIfEmpty: true` on the container. Container visibility is `hideIfEmpty && activeViewDescriptors.length === 0` (`viewsService.updateViewContainerEnablementContextKey`), and `activeViewDescriptors` already respects each view's `when`, so the container hides reactively when all its views' `when` go false. +- **Don't gate "is this a quick chat?" routing on `isCreated && isQuickChat`**: a quick-chat **draft** is `Untitled`, so `isCreated` (`= status !== Untitled`, `visibleSessions.ts`) is `false` — yet it is still a quick chat. Gating quick-chat routing on `isCreated && isQuickChat` (e.g. the "New" action) makes a draft fall through to the workspace path (scratch-dir composer, no session-type picker, "No models available"). Route on **`isQuickChat` alone** so drafts and committed quick chats behave identically; use `isCreated` only when you genuinely need to distinguish a committed session from an in-composer draft. +- **Cmd+N in the Agents window is a new-**session** gesture only — don't fold quick-chat/peer-chat creation into it**: session creation (**Cmd+N**, `NewChatInSessionsWindowAction`/`workbench.action.sessions.newChat` → always `openNewSession`), quick-chat creation (Chats-section **"+"**, **Cmd+K Cmd+N**, `NewQuickChatAction` → `openQuickChat`), and peer-chat creation (chat **"+"**, **Cmd+T**, `AddChatToSessionAction`) are three **distinct keybindable actions**. Keep them separate — Cmd+N must not become context-aware/mirror-route to a quick chat based on the active session's kind. +- **The New action must never inherit a quick chat's folder into the workspace composer**: `openNewSessionFromActive` seeds the new-session composer with `activeSession.workspace.get()?.uri`. A quick chat is workspace-less by intent, but if its `workspace` observable is ever non-undefined (e.g. a stale-build/coupling leak where `_kind` resolved to `WorkspaceSessionKind` because `_meta.workspaceless` was dropped, exposing the host's scratch `~/.copilot/chats/` cwd as a workspace), Cmd+N would carry that scratch dir into `createNewSession` → "New session in ``", single/no session type, no picker, "No models available". Gate the folder inheritance on `activeSession.isQuickChat` so a quick chat **always** falls to the clean folder-picker composer regardless of any leaked workspace value. +- **A reused new-session composer must re-seed its workspace draft when it swaps out of quick-chat mode**: the session-type picker hides itself when it has no folder types (`sessionTypePicker` `_folderSessionTypes.length === 0`), which is the case whenever the composer has **no active session** (`refresh(undefined)` clears the types). A *freshly opened* new-session composer avoids this by seeding a workspace draft from the restored folder in its constructor — but the same `NewChatWidget` instance is **reused** across the quick-chat→new-session transition (`sessionView.ts` keeps `kind==='newSession'`), and Cmd+N's `openNewSession` discard branch only `_activate(undefined)`, leaving the reused composer session-less → picker hidden. Fix by re-running the constructor's seed (`_seedWorkspaceDraft()`) from an autorun when `_isQuickChatComposer` flips **true→false with no active session**, so the reused composer matches a fresh one (folder + visible picker). Don't assume the constructor-time restore covers a reused composer. +- **Every untitled-session-title fallback must be quick-chat aware**: an untitled session's title observable is `''`, so a hardcoded `localize(…, "New Session")` fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route **all** such fallbacks through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`, boolean param so each caller controls reader-tracked `.read(reader)` vs `.get()`). There are ≥5 sites — titlebar (`sessionsTitleBarWidget`), session header (×2: title + rename placeholder), list-row hover (`sessionHoverContent`), sessions picker (`sessionsActions`) — keep them on the helper; never hardcode "New Session". (The Cmd+N *action* title stays "New Session" — that action creates a session, unrelated to a session's own title.) ## Capturing Feedback (meta-rule) Whenever the user flags a wrong pattern, rejects an approach, or gives design/rules feedback, **automatically add it** as a concise pitfall/learning to this `Common Pitfalls` section (or the most relevant spec doc) in the same change — without being asked again. Keep each entry 1–3 sentences: the anti-pattern, why it is wrong, and the preferred pattern. +- **Default/main chat title persistence differs per provider — don't unify them**: For the **agent host**, the default (main) chat title is independent of the session title (`AgentHostSessionAdapter._defaultChatTitleOverride`, persisted on the host as `customChatTitle:`); it must be seeded back on restore via `restoreSession`/`_ensureDefaultChat` (mirroring `_restorePeerChats`), or it reverts to the session title after a process restart / idle eviction. For the **local chat sessions provider** (`localChatSessionsProvider`), the primary chat and the session intentionally **share one `_title` observable**, so renaming the first/main chat updates the session title live — this is by design; do not "fix" it to be independent. Only additional (non-primary) local chats have their own title. + +- **`ISession.capabilities` must be observable, not a live plain getter**: capabilities can hydrate/change after a session first surfaces (e.g. an agent host whose root state arrives after the session's first `SessionState`). A plain getter cannot be tracked by the context-key autorun (`setActiveSessionContextKeys` reads it inside an `autorun`), so `supportsMultipleChats`/`sessionSupportsFork` would stay stale, and a multi-chat catalog processed while `supportsMultipleChats` was still `false` would stay collapsed to `[defaultChat]`. Expose `capabilities` as `IObservable` (agent host derives it from `connection.rootState` via `observableFromEvent` + `derivedOpts` with `structuralEquals`; static providers use `constObservable`), have consumers read `.read(reader)`/`.get()`, and re-apply the chat catalog from the last `SessionState` in an `autorun` on capability change. Do **not** fix this by firing `_onDidChangeSessions` — the active-session context autorun tracks the session's own observables, not the provider's session-list event. + ## Validating Changes You **must** run these checks before declaring work complete: diff --git a/.github/workflows/chat-perf.yml b/.github/workflows/chat-perf.yml index 8f67c576e02a1..8713e9a567ec0 100644 --- a/.github/workflows/chat-perf.yml +++ b/.github/workflows/chat-perf.yml @@ -57,13 +57,19 @@ env: SCENARIOS_INPUT: ${{ inputs.scenarios || '' }} TEST_SETTINGS_INPUT: ${{ inputs.test_settings || '' }} BASELINE_SETTINGS_INPUT: ${{ inputs.baseline_settings || '' }} + # Skip binary downloads during `npm ci` (they hang intermittently on hosted + # runners inside the nested postinstall installs). Electron and Playwright are + # fetched explicitly later via `Download Electron` and `Install Playwright + # Chromium`, which ignore these flags. Mirrors pr-node-modules.yml. + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 jobs: # ── Shared setup: build once, cache everything ────────────────────── setup: name: Build & Cache runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 50 outputs: test_is_version: ${{ steps.resolve.outputs.is_version }} test_build_arg: ${{ steps.resolve.outputs.build_arg }} @@ -111,13 +117,33 @@ jobs: libasound2t64 libxshmfence1 libgtk-3-0 - name: Install dependencies - run: npm ci + run: | + set -e + # Retry with a per-attempt timeout to survive transient npm registry + # hangs/failures on hosted runners (mirrors pr-node-modules.yml). + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install build dependencies - run: npm ci working-directory: build + run: | + set -e + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done - name: Transpile source run: npm run transpile-client @@ -210,7 +236,16 @@ jobs: libasound2t64 libxshmfence1 libgtk-3-0 - name: Install dependencies - run: npm ci + run: | + set -e + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -383,7 +418,16 @@ jobs: libasound2t64 libxshmfence1 libgtk-3-0 - name: Install dependencies - run: npm ci + run: | + set -e + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index 971fda8191f1e..281f019ad283d 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -122,6 +122,8 @@ jobs: if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: ./scripts/test.sh --tfs "Unit Tests" + env: + VSCODE_SKIP_PRELAUNCH: '1' - name: 🧪 Run unit tests (node.js) if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} @@ -161,6 +163,8 @@ jobs: if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-integration.sh --tfs "Integration Tests" + env: + VSCODE_SKIP_PRELAUNCH: '1' - name: 🧪 Run integration tests (Browser, Webkit) if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} @@ -171,6 +175,8 @@ jobs: if: ${{ inputs.remote_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-remote-integration.sh + env: + VSCODE_SKIP_PRELAUNCH: '1' - name: Compile smoke tests if: ${{ inputs.smoke_tests }} diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 113d41cdda3bb..65dd148287a68 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -209,6 +209,48 @@ jobs: FONTCONFIG_EOF echo "FONTCONFIG_FILE=/tmp/fonts-minimal.conf" >> "$GITHUB_ENV" + # Root-cause fix for the intermittent Electron startup SIGSEGV on Linux CI. + # Symbolizing the crash dumps shows the fault is a Pango concurrency bug, not + # a VS Code bug: + # + # pango: fc_thread_func -> init_in_thread -> FcInit() (pangofc-fontmap.c) + # fontconfig: FcInit -> FcConfigParseAndLoadFromMemory -> _FcConfigParse + # libexpat: XML_ParseBuffer -> libc (NULL deref, SIGSEGV) + # + # Pango >= 1.52's pango_fc_font_map_init() unconditionally spawns a + # "[pango] fontconfig" thread that runs FcInit(). That races against the + # Electron/Chromium main thread's own fontconfig use during startup and + # corrupts fontconfig's global config while it is being parsed. There is no + # env var to disable the Pango thread, and it is still present in Pango 1.56. + # + # Eliminate the race by initializing fontconfig once, single-threaded, from + # an ELF constructor that runs before main() (and therefore before any thread + # exists). Pango's later threaded FcInit() then finds fontconfig already + # initialized and returns immediately, so the concurrent parse never happens. + - name: Pre-initialize fontconfig to avoid Pango threaded FcInit crash + run: | + set -euo pipefail + cat > "$RUNNER_TEMP/fcpreinit.c" <<'CEOF' + #define _GNU_SOURCE + #include + + __attribute__((constructor)) + static void preinit_fontconfig(void) + { + void *handle = dlopen("libfontconfig.so.1", RTLD_NOW | RTLD_GLOBAL); + if (handle) { + int (*fc_init)(void) = (int (*)(void)) dlsym(handle, "FcInit"); + if (fc_init) { + fc_init(); + } + } + } + CEOF + gcc -shared -fPIC -O2 -o "$RUNNER_TEMP/libfcpreinit.so" "$RUNNER_TEMP/fcpreinit.c" -ldl + echo "Built fontconfig pre-init shim at $RUNNER_TEMP/libfcpreinit.so" + # Preload it for every subsequent step that launches Electron/Chromium. + echo "LD_PRELOAD=$RUNNER_TEMP/libfcpreinit.so${LD_PRELOAD:+:$LD_PRELOAD}" >> "$GITHUB_ENV" + - name: Fontconfig diagnostics and cache reset run: | set -e @@ -244,6 +286,12 @@ jobs: echo "" echo "--- fontconfig version ---" fc-cache --version 2>&1 | head -1 + echo "" + echo "--- fontconfig pre-init shim (LD_PRELOAD) ---" + echo "LD_PRELOAD=${LD_PRELOAD:-}" + if [ -n "${LD_PRELOAD:-}" ]; then + ls -l "${LD_PRELOAD%%:*}" 2>/dev/null || true + fi continue-on-error: true - name: Transpile client and extensions @@ -276,6 +324,7 @@ jobs: run: ./scripts/test.sh --tfs "Unit Tests" env: DISPLAY: ":10" + VSCODE_SKIP_PRELAUNCH: '1' - name: 🧪 Run unit tests (node.js) if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} @@ -317,6 +366,7 @@ jobs: run: ./scripts/test-integration.sh --tfs "Integration Tests" env: DISPLAY: ":10" + VSCODE_SKIP_PRELAUNCH: '1' - name: 🧪 Run integration tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} @@ -329,6 +379,7 @@ jobs: run: ./scripts/test-remote-integration.sh env: DISPLAY: ":10" + VSCODE_SKIP_PRELAUNCH: '1' - name: Compile smoke tests if: ${{ inputs.smoke_tests }} diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index 45681aff7077b..f1524e55fcc7d 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -130,6 +130,8 @@ jobs: timeout-minutes: 15 shell: pwsh run: .\scripts\test.bat --tfs "Unit Tests" + env: + VSCODE_SKIP_PRELAUNCH: '1' - name: 🧪 Run unit tests (node.js) if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} @@ -181,6 +183,8 @@ jobs: timeout-minutes: 20 shell: pwsh run: .\scripts\test-integration.bat --tfs "Integration Tests" + env: + VSCODE_SKIP_PRELAUNCH: '1' - name: 🧪 Run integration tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} @@ -193,6 +197,8 @@ jobs: timeout-minutes: 20 shell: pwsh run: .\scripts\test-remote-integration.bat + env: + VSCODE_SKIP_PRELAUNCH: '1' - name: Diagnostics after integration test runs if: ${{ inputs.unit_and_integration_tests && always() }} diff --git a/.github/workflows/require-commit-trailer.yml b/.github/workflows/require-commit-trailer.yml new file mode 100644 index 0000000000000..5b4ccc8606b39 --- /dev/null +++ b/.github/workflows/require-commit-trailer.yml @@ -0,0 +1,53 @@ +name: Require commit trailer + +on: + pull_request: + branches: + - 'release/msrc/*' + +permissions: {} + +jobs: + check-trailer: + name: Check for trailer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 1 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify PR has a single commit + env: + COMMIT_COUNT: ${{ github.event.pull_request.commits }} + run: | + if [ "$COMMIT_COUNT" != "1" ]; then + echo "::error::This PR has $COMMIT_COUNT commits. release/msrc/* PRs must contain exactly one commit. Please squash your commits." + exit 1 + fi + echo "PR has a single commit." + - name: Verify trailer on HEAD commit + shell: python + run: | + import re + import subprocess + import sys + + sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + trailers = subprocess.check_output( + ['git', 'log', '-1', '--pretty=format:%(trailers:key=Msrc-Case-Id,valueonly=true)', 'HEAD'], + text=True, + ) + values = [line.strip() for line in trailers.splitlines() if line.strip()] + + if not values: + print(f"::error::Commit {sha} is missing the required 'Msrc-Case-Id' trailer.") + print("Add a trailer to the commit message, for example:\n\n Msrc-Case-Id: 12345\n") + sys.exit(1) + + if not all(re.fullmatch(r'\d+', value) for value in values): + print(f"::error::Commit {sha} has an invalid 'Msrc-Case-Id' trailer value. Expected a number.") + print("Use a numeric case id, for example:\n\n Msrc-Case-Id: 12345\n") + sys.exit(1) + + print(f"Commit {sha} has the required 'Msrc-Case-Id' trailer.") diff --git a/.github/workflows/sync-cnb.yml b/.github/workflows/sync-cnb.yml new file mode 100644 index 0000000000000..208441719b6c1 --- /dev/null +++ b/.github/workflows/sync-cnb.yml @@ -0,0 +1,25 @@ +# .github/workflows/sync-cnb.yml + +name: Sync to CNB +on: [push] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Sync to CNB Repository + uses: docker://tencentcom/git-sync + env: + # 注入 Git LFS 配置,消除锁定提示 + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: "lfs.https://cnb.cool/soucod-github/soucod/vscode.git/info/lfs.locksverify" + GIT_CONFIG_VALUE_0: "true" + PLUGIN_TARGET_URL: "https://cnb.cool/soucod-github/soucod/vscode.git" + PLUGIN_AUTH_TYPE: "https" + PLUGIN_USERNAME: "cnb" + PLUGIN_PASSWORD: ${{ secrets.GIT_CNB_TOKEN }} + PLUGIN_FORCE: "true" diff --git a/.ide/Dockerfile b/.ide/Dockerfile new file mode 100644 index 0000000000000..6dae62645f611 --- /dev/null +++ b/.ide/Dockerfile @@ -0,0 +1,61 @@ +# .ide/Dockerfile + +# 可将 node 替换为需要的基础镜像 +FROM docker.cnb.cool/avwq/cnb.cool/cnb-avwq-default-dev-env-centos-stream:stream10 + +# 安装 code-server 和 vscode 常用插件 +RUN curl -fsSL https://code-server.dev/install.sh | sh \ + && code-server --install-extension cnbcool.cnb-welcome \ + && code-server --install-extension redhat.vscode-yaml \ + && code-server --install-extension dbaeumer.vscode-eslint \ + && code-server --install-extension waderyan.gitblame \ + && code-server --install-extension mhutchie.git-graph \ + && code-server --install-extension donjayamanne.githistory \ + && code-server --install-extension tencent-cloud.coding-copilot \ + && echo done + +# 安装 ssh 服务,用于支持 VSCode 等客户端通过 Remote-SSH 访问开发环境(也可按需安装其他软件) +RUN dnf install -y dnf-plugins-core && dnf config-manager --set-enabled crb +RUN dnf update -y && dnf install -y git wget unzip openssh-server epel-release curl vim unzip procps && dnf install -y atop btop htop iftop iotop cockpit +# 业务安装 +RUN dnf install -y nodejs24 npm python && dnf groupinstall "Development Tools" -y && dnf install -y libX11-devel libxkbfile libsecret-devel krb5-devel && dnf install -y fakeroot rpm +RUN dnf install -y libxkbfile-devel libX11-devel pkgconf-pkg-config gcc-c++ make python3 + +alternatives --install /usr/bin/node node /usr/bin/node-24 240 +alternatives --install /usr/bin/npm npm /usr/bin/npm-24 240 + +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone +RUN npm i -g pnpm + +RUN dnf install -y \ + atk \ + gtk3 \ + gtk3-devel \ + libX11 \ + libXScrnSaver \ + libnotify \ + nss \ + alsa-lib \ + cups-libs \ + libXcomposite \ + libXdamage \ + libXext \ + libXfixes \ + libXrandr \ + libgbm \ + libdrm \ + mesa-libgbm + + +# 创建 `/ide_cnb` 目录,用于安装 IDE。系统通过此目录自动识别已安装的 IDE,安装路径必须为 `/ide_cnb`。 +RUN mkdir -p /ide_cnb +# WebStorm +RUN wget https://download.jetbrains.com/webstorm/WebStorm-2026.1.tar.gz \ + && tar -zxvf WebStorm-2026.1.tar.gz -C /ide_cnb \ + && rm WebStorm-2026.1.tar.gz + + + +# 指定字符集支持命令行输入中文(根据需要选择字符集) +ENV LANG C.UTF-8 +ENV LANGUAGE C.UTF-8 diff --git a/.mailmap b/.mailmap index 5bd99619330ac..9c4139cbdd3d2 100644 --- a/.mailmap +++ b/.mailmap @@ -1,3 +1,2 @@ Raymond Zhao Tyler Leonhardt Tyler Leonhardt -João Moreno João Moreno diff --git a/.npmrc b/.npmrc index 8c21e58ef14d5..b6b2ee025a57d 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="42.3.0" -ms_build_id="14159160" +target="42.5.0" +ms_build_id="14525058" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.nvmrc b/.nvmrc index 5bf4400f22922..1dd37d53743d1 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15.0 +24.17.0 diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index 0b26098f8edaf..b2c13bf7d1380 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.126.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" + "value": "$MILESTONE=milestone:\"1.127.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index d7a3f66d69a06..201378fdc8eb7 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.126.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.127.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/.vscode/settings.json b/.vscode/settings.json index c0daea168a8f5..7d69353d3a690 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -157,6 +157,7 @@ ], "githubPullRequests.codingAgent.enabled": true, "githubPullRequests.codingAgent.uiIntegration": true, + "githubPullRequests.enableAttestationCommits": true, // --- Testing & Debugging --- "testing.autoRun.mode": "rerun", "debug.javascript.terminalOptions": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e80188ca5ca23..807b327404732 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,111 @@ { "version": "2.0.0", "tasks": [ + { + "label": "Install & Watch (Client)", + "detail": "Install dependencies, compile and watch VS Code core for fixing a bug or implementing a feature.", + "dependsOn": [ + "Install Dependencies", + "Compile", + "Watch Client Transpile" + ], + "dependsOrder": "sequence", + "inAgents": true, + "problemMatcher": [] + }, + { + "label": "Run VS Code", + "detail": "Run VS Code with a user data directory inside the worktree.", + "type": "shell", + "command": "./scripts/code.sh", + "windows": { + "command": ".\\scripts\\code.bat" + }, + "args": [ + "--user-data-dir=${workspaceFolder}/.profile-oss", + "--extensions-dir=${workspaceFolder}/.profile-oss/extensions" + ], + "inAgents": true, + "problemMatcher": [] + }, + { + "label": "Install & Watch (All)", + "detail": "Install dependencies and watch all of VS Code (core, extensions and copilot).", + "dependsOn": [ + "Install Dependencies", + "Watch" + ], + "dependsOrder": "sequence", + "inAgents": true, + "problemMatcher": [] + }, + { + "label": "Install Dependencies", + "type": "shell", + "command": "npm install", + "windows": { + "command": "cmd /c \"npm install\"" + }, + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": [] + }, + { + "label": "Compile", + "type": "npm", + "script": "compile", + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": "$tsc" + }, + { + "label": "Watch", + "type": "npm", + "script": "watch", + "isBackground": true, + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": [] + }, + { + "label": "Watch Client Transpile", + "type": "npm", + "script": "watch-client-transpile", + "isBackground": true, + "presentation": { + "reveal": "always", + "group": "session", + "close": false + }, + "problemMatcher": { + "owner": "esbuild", + "applyTo": "closedDocuments", + "fileLocation": [ + "relative", + "${workspaceFolder}/src" + ], + "pattern": { + "regexp": "^(.+?):(\\d+):(\\d+): ERROR: (.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + }, + "background": { + "beginsPattern": "Starting transpilation\\.\\.\\.", + "endsPattern": "Finished transpilation with" + } + } + }, { "type": "npm", "script": "watch-client-transpiled", @@ -294,7 +399,6 @@ "Run Dev Agents" ], "dependsOrder": "sequence", - "inAgents": true, "problemMatcher": [] }, { @@ -304,7 +408,6 @@ "Run Dev" ], "dependsOrder": "sequence", - "inAgents": true, "problemMatcher": [] }, { @@ -372,6 +475,35 @@ "reveal": "never" } }, + { + "label": "Run VS Code (Web)", + "detail": "Run the Agents window as a web client.", + "type": "shell", + "command": "./scripts/code-server.sh", + "windows": { + "command": ".\\scripts\\code-server.bat" + }, + "args": [ + "--connection-token", + "dev-token", + "--port", + "8080" + ], + "inAgents": true, + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "" + }, + "background": { + "beginsPattern": ".*node .*", + "endsPattern": "Web UI available at .*" + } + }, + "presentation": { + "reveal": "always" + } + }, { "type": "npm", "script": "eslint", @@ -445,8 +577,7 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "problemMatcher": [], - "inAgents": true + "problemMatcher": [] }, { "type": "npm", @@ -477,8 +608,7 @@ "beginsPattern": "Starting compilation\\.\\.\\.", "endsPattern": "Finished compilation with" } - }, - "inAgents": true + } }, { "label": "Component Explorer Server", @@ -509,10 +639,6 @@ "windows": { "command": "cmd /c \"npm install && npm run watch-transpile\"" }, - "inAgents": true, - "runOptions": { - "runOn": "worktreeCreated" - }, "presentation": { "focus": false, "reveal": "never" diff --git a/build/.cachesalt b/build/.cachesalt index df1d8c82aab64..d382d477ac6c7 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2026-06-19T12:30:00.000Z +2026-06-22T15:10:10.546Z diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index c5f767a870a81..d7d9cfd7b29c6 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -6,26 +6,26 @@ "": { "name": "agent-sdk-claude", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.169" + "@anthropic-ai/claude-agent-sdk": "0.3.198" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.169.tgz", - "integrity": "sha512-pzsd0BBnlfHwAUfKouSNwDLcCxGDq+lbPZTsIdiNSLw+AQw7mjxPxqnFwd4YH7qw+E3XhlotuihCm01XzcybPQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.169", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.169" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -34,9 +34,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.169.tgz", - "integrity": "sha512-5PJU3wqfvJUrgUF3H7iroxAGDyliKmq5q8kK+glERf8YZgmPeDMvQL4mGSB0Vf9RgBWS01dCen93TIsN0O8NPQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -47,9 +47,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.169.tgz", - "integrity": "sha512-1Hzt0QYG7N/ExfPbN/C92YRc3BoDnyQMJpuWLVyI/r0NmKV/se/cZbIygbvQXs2ywoXOB/EyNj/hkVHaC1G22g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -60,9 +60,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.169.tgz", - "integrity": "sha512-J0tyM4t5qxc2/xilsfHqcJv+fyEDhX66Nv+CMXq4mRbquZmMHhbZbh8ryb+BnKYXUeqKX3uoU/J+7K6/fjcY4g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], @@ -76,9 +76,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.169.tgz", - "integrity": "sha512-xkmyNdU6f7HXXzgR6IZym7x41bjL3rjGrXf5PAmx9PjdThy476+TrpO1evKUza1zlVmUj4tj/jYKQsV4ER7QFw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], @@ -92,9 +92,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.169.tgz", - "integrity": "sha512-ktEPBwzXZagt0cntfRLlvNL0sAplt2HOCbR5iBvq2IV5dLvbEOiBjZQArIcSAN7CTQUFqkZs9HzQMdKe5QPwuQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], @@ -108,9 +108,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.169.tgz", - "integrity": "sha512-Me8VW91pCKgkct/y1isjKNVt+ZPMPhHk3C7O078UO1bakHD6kuPiMtugcCsg4jmmmuuywXQIQUESRbcf0GluUg==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.169.tgz", - "integrity": "sha512-HL9ecP82A/H8aC896Q7ETyl+nLFkMzBHfO0yvK7Lhxbi98CEpqXn3BADTAc5pEgzkO+r18+CDmZdgjTFMvGoTw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.169.tgz", - "integrity": "sha512-34pBbiKe7FNsY8LHuuTmKujmYko/cBOrKJpk9H+MOot+dSfB/FmIHq9USL17otNal5Droq9lqpezAWPBPv3lAQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], diff --git a/build/agent-sdk/agents/claude/package.json b/build/agent-sdk/agents/claude/package.json index 6684cedf37720..bcdae1739cfbe 100644 --- a/build/agent-sdk/agents/claude/package.json +++ b/build/agent-sdk/agents/claude/package.json @@ -3,6 +3,6 @@ "private": true, "comment": "Pinned dependency set for the build/agent-sdk claude tarball. The package-lock.json alongside is the source of truth for transitive deps — produce.ts runs `npm ci` against this directory to get byte-deterministic output across pipeline runs.", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.169" + "@anthropic-ai/claude-agent-sdk": "0.3.198" } } diff --git a/build/agent-sdk/agents/codex/package-lock.json b/build/agent-sdk/agents/codex/package-lock.json index 23a40dc300728..f526dd5a5416f 100644 --- a/build/agent-sdk/agents/codex/package-lock.json +++ b/build/agent-sdk/agents/codex/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "agent-sdk-codex", "dependencies": { - "@openai/codex": "0.135.0" + "@openai/codex": "0.142.0" } }, "node_modules/@openai/codex": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0.tgz", - "integrity": "sha512-ID75QEYmAT1WsUQmpxPlNsL5W1a+2eeD7fP6ywdwGseiXUG8D5i16L+dzbr8MT+2oTkaVqzOdvAqVOCeV/H/Bw==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0.tgz", + "integrity": "sha512-c7WftbRyE4zOLJV5p73mcGn4jVK3FmK84Q65hrZrXboZzLPDWRfbFedCbsIjU4PJS/kvgyMPhv7dH4/nhXzUyg==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -21,19 +21,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.135.0-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.135.0-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.135.0-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.135.0-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.135.0-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.135.0-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.142.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.142.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.135.0-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-arm64.tgz", - "integrity": "sha512-wpNzssusKfrldVlq39+HyQh1wCyc9SQNpHdAFGKtPenrgRte4Ct8/oVsDtKWuFZsqLBFwbL4MrzrevnB63+9HA==", + "version": "0.142.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-darwin-arm64.tgz", + "integrity": "sha512-jwbriCRTSNfqoGov5bNnnYAKarnNv4QfGkut8psKAajebL6VPI2ZjCxCJ1OFA5HhUQsN8GQgEjKlj6WhYcMmUA==", "cpu": [ "arm64" ], @@ -48,9 +48,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.135.0-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-x64.tgz", - "integrity": "sha512-ZrjAqce23lbv9KfkYOhElf1lTI+SysXmyGM0FV5u4+PBCKPkkEs4eaS3H8Uig0i4bUSu1QylrOOCskzYhZ6VyQ==", + "version": "0.142.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-darwin-x64.tgz", + "integrity": "sha512-qwdPfW8sOBEfWw1Rt7baveUsOjrudrM7qfKNnJvHPRrwtt4jemTd22VtZ6r02kUQdLwt6Ddvs0Pwzk6QJW6PqA==", "cpu": [ "x64" ], @@ -65,9 +65,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.135.0-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-arm64.tgz", - "integrity": "sha512-dM+cv5ZL+BgIQzEIvMg9AxZ98n5lkKLgtp5zJLXWSrbCllbnUSqxYMUiWI5c1a1uBDUtkbY9fcGKXFLf+d+gyg==", + "version": "0.142.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-linux-arm64.tgz", + "integrity": "sha512-gX9hCK59bE0Itnous1MCrKiXTYuoGVE6oJUE0kyRSzaBD267sh9TNR3DXKbY7TsP7iAs19n8YXDJNdHZJtakSQ==", "cpu": [ "arm64" ], @@ -82,9 +82,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.135.0-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-x64.tgz", - "integrity": "sha512-5EosY67yU28UJSnl/obdN2F1CDaimYbzm9SLR8dwwzkeBBnY6dHgAKJ2GTu9Nc8CmgmtVFBGzgPqehsIcueVvA==", + "version": "0.142.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-linux-x64.tgz", + "integrity": "sha512-nywS+ogFPRhZnQnKL+jzWKzFhmjTQbYPh6n8dtQ/E+sJ3FoZVcb9a6kvHL5yjgtlCiDkP0jPDYY5AK3tDShYyw==", "cpu": [ "x64" ], @@ -99,9 +99,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.135.0-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-arm64.tgz", - "integrity": "sha512-SAeR+CUv7KWwE6eTc2UFaFjo6FpHywYfDFKrK6FqLms1rq1NPju2SoX7rhM6UEew/lUx2mdZv/LDs11s/N/Qgg==", + "version": "0.142.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-win32-arm64.tgz", + "integrity": "sha512-Lz8xEn7uNn0XTi8k0jApKM5P9BD7QvAH3ZbtlGi1zfSOgh1kmgpfXxaTlJF503mozdHaA2r6UA7hfTi+bI0CvQ==", "cpu": [ "arm64" ], @@ -116,9 +116,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.135.0-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-x64.tgz", - "integrity": "sha512-uYwUBMbOfmVlCESJZmZsOG+cYwNFYvkMbQ+FB6C1u9RYz0m3mZeYNN0j+l1hRSyUgPMFJHzNpgNx1Usal5QZFQ==", + "version": "0.142.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-win32-x64.tgz", + "integrity": "sha512-LkARkXh3NM0XA8R8hFAgjx017fadfb9RgmqLzdhO1Qt/bDVSJnmDf4cwJ2h+FWZcm9/rRk3JC/IuqBvwg/TN+Q==", "cpu": [ "x64" ], diff --git a/build/agent-sdk/agents/codex/package.json b/build/agent-sdk/agents/codex/package.json index b7c1b24f3a085..5ae85c37e911b 100644 --- a/build/agent-sdk/agents/codex/package.json +++ b/build/agent-sdk/agents/codex/package.json @@ -3,6 +3,6 @@ "private": true, "comment": "Pinned dependency set for the build/agent-sdk codex tarball. The package-lock.json alongside is the source of truth for transitive deps — produce.ts runs `npm ci` against this directory to get byte-deterministic output across pipeline runs.", "dependencies": { - "@openai/codex": "0.135.0" + "@openai/codex": "0.142.0" } } diff --git a/build/azure-pipelines/alpine/product-build-alpine-cli.yml b/build/azure-pipelines/alpine/product-build-alpine-cli.yml index 4a8bc36e0ecf9..3081390b6a8c2 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-cli.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-cli.yml @@ -34,6 +34,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - script: | @@ -41,7 +48,7 @@ jobs: npm ci workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - task: Npm@1 diff --git a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml index 07f1545ca2483..7b560b328677b 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml @@ -25,9 +25,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -88,11 +88,20 @@ jobs: mkdir -p .build/nodejs-musl NODE_VERSION=$(grep '^target=' remote/.npmrc | cut -d '"' -f 2) BUILD_ID=$(grep '^ms_build_id=' remote/.npmrc | cut -d '"' -f 2) - gh release download "v${NODE_VERSION}-${BUILD_ID}" -R microsoft/vscode-node -p "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" --dir .build/nodejs-musl --clobber + az extension add --name azure-devops --upgrade --only-show-errors + az artifacts universal download \ + --organization "https://dev.azure.com/monacotools" \ + --project "Monaco" \ + --scope project \ + --feed "vscode-node" \ + --name "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar" \ + --version "${NODE_VERSION}-${BUILD_ID}" \ + --path .build/nodejs-musl \ + --only-show-errors tar -xzf ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" -C ".build/nodejs-musl" --strip-components=1 rm ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + AZURE_DEVOPS_EXT_PAT: "$(System.AccessToken)" displayName: Download NodeJS MUSL condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -111,7 +120,7 @@ jobs: npm_config_arch: $(NPM_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:alpine-$(VSCODE_ARCH) VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" VSCODE_NPMRC_PATH: $(NPMRC_PATH) diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index 3359224d3ed8d..8151a3397af5a 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -67,9 +67,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -134,11 +134,20 @@ jobs: mkdir -p .build/nodejs-musl NODE_VERSION=$(grep '^target=' remote/.npmrc | cut -d '"' -f 2) BUILD_ID=$(grep '^ms_build_id=' remote/.npmrc | cut -d '"' -f 2) - gh release download "v${NODE_VERSION}-${BUILD_ID}" -R microsoft/vscode-node -p "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" --dir .build/nodejs-musl --clobber + az extension add --name azure-devops --upgrade --only-show-errors + az artifacts universal download \ + --organization "https://dev.azure.com/monacotools" \ + --project "Monaco" \ + --scope project \ + --feed "vscode-node" \ + --name "node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar" \ + --version "${NODE_VERSION}-${BUILD_ID}" \ + --path .build/nodejs-musl \ + --only-show-errors tar -xzf ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" -C ".build/nodejs-musl" --strip-components=1 rm ".build/nodejs-musl/node-v${NODE_VERSION}-linux-${VSCODE_ARCH}-musl.tar.gz" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + AZURE_DEVOPS_EXT_PAT: "$(System.AccessToken)" displayName: Download NodeJS MUSL condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -157,7 +166,7 @@ jobs: npm_config_arch: $(NPM_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:alpine-$(VSCODE_ARCH) VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" VSCODE_NPMRC_PATH: $(NPMRC_PATH) @@ -194,7 +203,7 @@ jobs: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: npx deemon --attach -- node build/azure-pipelines/common/downloadCopilotVsix.ts @@ -214,7 +223,8 @@ jobs: echo "##vso[task.setvariable variable=SERVER_DIR_PATH]$DIR_PATH" echo "##vso[task.setvariable variable=SERVER_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - script: | @@ -229,5 +239,6 @@ jobs: echo "##vso[task.setvariable variable=WEB_DIR_PATH]$DIR_PATH" echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) diff --git a/build/azure-pipelines/cli/cli-compile.yml b/build/azure-pipelines/cli/cli-compile.yml index c3565a9bf4b23..9b92bfcd08389 100644 --- a/build/azure-pipelines/cli/cli-compile.yml +++ b/build/azure-pipelines/cli/cli-compile.yml @@ -135,7 +135,7 @@ steps: env: CARGO_NET_GIT_FETCH_WITH_CLI: true VSCODE_CLI_COMMIT: $(Build.SourceVersion) - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" RUSTC_WRAPPER: sccache SCCACHE_DIR: $(Pipeline.Workspace)/sccache ${{ each pair in parameters.VSCODE_CLI_ENV }}: diff --git a/build/azure-pipelines/common/checkDistroCommit.ts b/build/azure-pipelines/common/checkDistroCommit.ts index 4003a10df317c..f6bf4657b1472 100644 --- a/build/azure-pipelines/common/checkDistroCommit.ts +++ b/build/azure-pipelines/common/checkDistroCommit.ts @@ -5,10 +5,16 @@ import path from 'path'; import fs from 'fs'; -import { retry } from './retry.ts'; +import { execSync } from 'child_process'; const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); +// The microsoft/vscode-distro repository is checked out locally by +// download-distro.yml (into .build/distro) using the agent's GitHub App +// (Monaco) credentials, so we can resolve branch heads without a token that +// has private repository access. +const distroPath = path.join(root, '.build', 'distro'); + function getEnv(name: string): string { const result = process.env[name]; @@ -19,30 +25,14 @@ function getEnv(name: string): string { return result; } -interface GitHubBranchResponse { - commit: { - sha: string; - }; -} - -async function getDistroBranchHead(branch: string, token: string): Promise { - const url = `https://api.github.com/repos/microsoft/vscode-distro/branches/${encodeURIComponent(branch)}`; - - const response = await fetch(url, { - headers: { - 'Accept': 'application/vnd.github+json', - 'Authorization': `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'VSCode Build' - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch branch ${branch} from vscode-distro: ${response.status} ${response.statusText}`); +function assertDistroCheckout(): void { + if (!fs.existsSync(path.join(distroPath, '.git'))) { + throw new Error(`Expected a vscode-distro checkout at ${distroPath} but found none. Ensure download-distro.yml ran before this check.`); } +} - const data = await response.json() as GitHubBranchResponse; - return data.commit.sha; +function getDistroBranchHead(branch: string): string { + return execSync(`git -C "${distroPath}" rev-parse "refs/remotes/origin/${branch}"`, { encoding: 'utf8' }).trim(); } async function checkDistroCommit(): Promise { @@ -71,16 +61,18 @@ async function checkDistroCommit(): Promise { const branch = branchMatch[1]; console.log(`Current branch: ${branch}`); - // Get the GitHub token - const token = getEnv('GITHUB_TOKEN'); + // Make sure the distro repository is actually checked out before we try to + // resolve a branch head from it; otherwise a missing checkout would be + // indistinguishable from a branch that simply doesn't exist in distro. + assertDistroCheckout(); - // Fetch the HEAD of the matching branch in vscode-distro + // Resolve the HEAD of the matching branch from the local distro checkout let distroBranchHead: string; try { - distroBranchHead = await retry(() => getDistroBranchHead(branch, token)); + distroBranchHead = getDistroBranchHead(branch); } catch (error) { // If the branch doesn't exist in distro, that's expected for feature branches - console.log(`Could not fetch branch '${branch}' from vscode-distro: ${error}`); + console.log(`Could not resolve branch '${branch}' from local vscode-distro checkout: ${error}`); console.log('This is expected for feature branches that have not been merged to distro'); return; } diff --git a/build/azure-pipelines/common/checkout.yml b/build/azure-pipelines/common/checkout.yml index 427f190480a53..d1313b5e2ed6d 100644 --- a/build/azure-pipelines/common/checkout.yml +++ b/build/azure-pipelines/common/checkout.yml @@ -1,5 +1,10 @@ steps: + # Pin self to the default sources directory (`s`) so that adding a second + # checkout (e.g. the distro repository in download-distro.yml) does not relocate + # self into a repo-named subfolder, which would break every script that assumes + # self lives at $(Build.SourcesDirectory). - checkout: self + path: s fetchDepth: 1 fetchTags: false retryCountOnTaskFailure: 3 diff --git a/build/azure-pipelines/common/downloadNotice.ts b/build/azure-pipelines/common/downloadNotice.ts new file mode 100644 index 0000000000000..8c6aa297f2262 --- /dev/null +++ b/build/azure-pipelines/common/downloadNotice.ts @@ -0,0 +1,392 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Component Governance (CG) NOTICE cutover. +// +// During each platform compile stage this script pulls the CG-generated NOTICE +// (produced by the parallel Quality stage as the `notice_output` artifact) and +// overwrites the repo-root ThirdPartyNotices.txt BEFORE gulp packages it +// (build/gulpfile.vscode.ts reads 'ThirdPartyNotices.txt' at package time). +// +// It clones the proven copilot VSIX background-download harness: a detached +// `deemon` poller starts at compile start (maximum overlap) and the package +// step attaches/blocks on it right before packaging. If the artifact is ready +// by then the added wall-clock is near-zero; otherwise we block for at most a +// short budget and then DEGRADE to the legacy mixin notice. +// +// Design notes: +// * copy-then-overwrite: mixin-quality.ts is left untouched (shared chokepoint +// used by 8+ pipelines). The legacy ThirdPartyNotices.txt it lays down is +// our guaranteed fallback, so we never ship with NO notice. +// * NON-FATAL: this script always exits 0. A notice problem must never break +// packaging during the cutover. Steady-state can flip to fatal later. +// * SHORT poll budget (not the copilot 30min): a missing artifact must degrade +// to fallback fast, otherwise the non-fatal design is defeated by a 30min hang. +// * Three greppable markers so a build log answers "fresh vs stale vs off": +// [notice-cutover] RESULT=fresh overwrote from notice_output (logs producer) +// [notice-cutover] RESULT=fallback artifact missing -> kept legacy notice +// [notice-cutover] RESULT=disabled feature flag off -> kept legacy notice + +import fs from 'fs'; +import path from 'path'; +import { Readable } from 'stream'; +import type { ReadableStream } from 'stream/web'; +import { pipeline } from 'node:stream/promises'; +import yauzl from 'yauzl'; +import { e, requestAZDOAPI } from './publish.ts'; +import { retry } from './retry.ts'; + +const ARTIFACT_NAME = 'notice_output'; +const QUALITY_JOB_NAMES = ['Quality Checks', 'Quality']; +// The merged, shipping NOTICE (CG + scanned extension licenses + cglicenses overrides). +const SHIPPING_NOTICE_NAME = 'ThirdPartyNotices.new.txt'; +const TARGET_NOTICE = path.resolve('ThirdPartyNotices.txt'); + +// Poll budget: 30 attempts x 30s = 15 minutes. Deliberately far below the +// copilot 30min so a never-produced artifact degrades to fallback quickly, but +// with generous margin over the parallel Quality stage (CG + scan + merge). +// The gate accepts only once ThirdPartyNotices.new.txt has been downloaded, +// extracted, and validated non-empty (>1KB) -- merely seeing it in the +// container listing is NOT sufficient, because the listing entry can appear +// before the file's content has finished committing (see waitForNotice). On +// such a mid-upload miss we keep polling rather than falling back, so a +// fast-compiling platform can never ship the legacy notice while its peers +// ship the CG notice. The Quality-completion backstop ends the wait early only +// when .new.txt was never produced at all. +// NB: this is the OUTER artifact-availability poll. It is unrelated to the +// inner retry() wrapper in retry.ts, which independently retries each AZDO API +// call up to 10 times for transient network errors and emits no attempt log. +const POLL_ATTEMPTS = 30; +const POLL_INTERVAL_MS = 30_000; + +function log(message: string): void { + console.log(`[notice-cutover] [${new Date().toISOString()}] ${message}`); +} + +interface TimelineRecord { + readonly name: string; + readonly type: string; + readonly state: string; + readonly result: string; +} + +interface Timeline { + readonly records: TimelineRecord[]; +} + +// We need the container `data` field (which the publish.ts Artifact type omits) +// to list the FILES inside the notice_output container artifact. For a container +// artifact, resource.data looks like "#//". +interface NoticeArtifact { + readonly name: string; + readonly resource: { + readonly downloadUrl: string; + readonly data?: string; + readonly properties: { + readonly artifactsize: number; + }; + }; +} + +function installDiagnostics(): void { + process.on('uncaughtException', err => { + console.error('[notice-cutover] Uncaught exception:', err); + // Non-fatal: never break packaging because of a notice problem. + process.exit(0); + }); + process.on('unhandledRejection', reason => { + console.error('[notice-cutover] Unhandled rejection:', reason); + process.exit(0); + }); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) { + process.on(signal, () => { + console.error(`[notice-cutover] Received ${signal}, exiting.`); + process.exit(0); + }); + } +} + +function getAzdoFetchOptions() { + return { + headers: { + 'Accept': 'application/json;api-version=5.0-preview.1', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': 'https://dev.azure.com', + Authorization: `Bearer ${e('SYSTEM_ACCESSTOKEN')}` + } + }; +} + +async function getPipelineArtifacts(): Promise { + const result = await requestAZDOAPI<{ readonly value: NoticeArtifact[] }>('artifacts'); + return result.value; +} + +// Lists the FILES inside a container artifact via the AZDO container-items API. +// NOTE: a file path can appear in this listing slightly BEFORE its content has +// finished committing to the container (observed in build 450517: the listing +// showed ThirdPartyNotices.new.txt one poll before the artifact download +// actually yielded it). So this listing is a NECESSARY but not SUFFICIENT +// readiness signal -- the caller must still download+extract+validate the file +// before accepting it. Returns the file paths, or undefined if the listing +// could not be retrieved (transient error) so the caller can distinguish "file +// genuinely absent" from "could not check". +async function listArtifactFiles(artifact: NoticeArtifact): Promise { + // resource.data for a container artifact looks like: #// + const match = /^#\/(\d+)\/(.+)$/.exec(artifact.resource.data ?? ''); + if (!match) { + return undefined; + } + + const [, containerId, itemPath] = match; + const collectionUri = e('SYSTEM_COLLECTIONURI').replace(/\/$/, ''); + const url = `${collectionUri}/_apis/resources/Containers/${containerId}?itemPath=${encodeURIComponent(itemPath)}&isShallow=false&api-version=4.1-preview.4`; + + const res = await retry(() => fetch(url, getAzdoFetchOptions())); + if (!res.ok) { + throw new Error(`Container items request failed: ${res.status}`); + } + + const body = await res.json() as { readonly value: { readonly path: string; readonly itemType: string }[] }; + return body.value.filter(item => item.itemType === 'file').map(item => item.path); +} + +async function getQualityJob(): Promise { + const timeline = await retry(() => requestAZDOAPI('timeline')); + return timeline.records.find(r => r.type === 'Job' && QUALITY_JOB_NAMES.includes(r.name)); +} + +async function downloadArtifact(artifact: NoticeArtifact, downloadPath: string): Promise { + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), 4 * 60 * 1000); + + try { + const res = await fetch(artifact.resource.downloadUrl, { ...getAzdoFetchOptions(), signal: abortController.signal }); + + if (!res.ok) { + throw new Error(`Unexpected status code: ${res.status}`); + } + + await pipeline(Readable.fromWeb(res.body as ReadableStream), fs.createWriteStream(downloadPath)); + } finally { + clearTimeout(timeout); + } +} + +async function unzip(zipPath: string, outputPath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true, autoClose: true }, (err, zipfile) => { + if (err) { + return reject(err); + } + + const result: string[] = []; + zipfile!.on('entry', entry => { + if (/\/$/.test(entry.fileName)) { + zipfile!.readEntry(); + } else { + zipfile!.openReadStream(entry, (err, istream) => { + if (err) { + return reject(err); + } + + const filePath = path.join(outputPath, entry.fileName); + + // Zip-slip guard: ensure the resolved entry path stays within + // outputPath. A crafted zip could use ../ segments to escape and + // overwrite repo files used later in the build. + const resolvedOutput = path.resolve(outputPath); + const resolvedTarget = path.resolve(filePath); + if (resolvedTarget !== resolvedOutput && !resolvedTarget.startsWith(resolvedOutput + path.sep)) { + return reject(new Error(`Zip entry escapes output directory (zip-slip): ${entry.fileName}`)); + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + + const ostream = fs.createWriteStream(filePath); + ostream.on('finish', () => { + result.push(filePath); + zipfile!.readEntry(); + }); + ostream.on('error', err => reject(err)); + istream?.on('error', err => reject(err)); + istream!.pipe(ostream); + }); + } + }); + + zipfile!.on('close', () => resolve(result)); + zipfile!.readEntry(); + }); + }); +} + +interface ExtractedNotice { + readonly shippingPath: string; + readonly metaFile: string | undefined; + readonly size: number; +} + +// Downloads + extracts the notice_output artifact into tmpDir and validates that +// ThirdPartyNotices.new.txt is actually present AND non-empty (>1KB). Returns the +// extracted notice on success, or undefined if the file is missing/too small in +// the EXTRACTED output -- which, when the container listing claimed it was there, +// means the content is still committing (mid-upload). The caller treats undefined +// as "not ready yet" and re-polls; it must NEVER be treated as terminal fallback, +// because a fast platform that hits this window would otherwise ship the legacy +// notice while its peers ship the CG notice (the cross-arch mismatch bug). +async function tryExtractNotice(artifact: NoticeArtifact, tmpDir: string): Promise { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + const artifactZipPath = path.join(tmpDir, 'notice_output.zip'); + + log(' * downloading notice_output artifact to verify content...'); + await retry(() => downloadArtifact(artifact, artifactZipPath)); + + log(' * extracting notice_output artifact...'); + const files = await unzip(artifactZipPath, tmpDir); + const shipping = files.find(f => path.basename(f) === SHIPPING_NOTICE_NAME); + + if (!shipping) { + log(` * ${SHIPPING_NOTICE_NAME} listed but not yet in extracted output (mid-upload); will recheck next poll.`); + return undefined; + } + + const size = fs.statSync(shipping).size; + // Guard: a tiny file means the merge produced nothing usable OR the upload is + // still in flight. Either way, do not accept it -- re-poll. + if (size <= 1024) { + log(` * ${SHIPPING_NOTICE_NAME} extracted but too small (${size} bytes; mid-upload or empty merge); will recheck next poll.`); + return undefined; + } + + const metaFile = files.find(f => path.basename(f) === 'notice-meta.txt'); + return { shippingPath: shipping, metaFile, size }; +} + +// Returns the validated, extracted CG NOTICE if it becomes available within the +// short budget, otherwise undefined (caller keeps the legacy notice). +async function waitForNotice(tmpDir: string): Promise { + const startTime = Date.now(); + + for (let index = 0; index < POLL_ATTEMPTS; index++) { + const elapsed = Math.round((Date.now() - startTime) / 1000); + + try { + log(`Waiting for ${ARTIFACT_NAME} artifact (attempt ${index + 1}/${POLL_ATTEMPTS}, ${elapsed}s elapsed)...`); + + // The Quality job state is only a BACKSTOP (see below): if it has + // completed and .new.txt never even appeared in the listing, the file + // is never coming and we give up early instead of burning the budget. + const qualityJob = await getQualityJob().catch(() => undefined); + if (qualityJob) { + log(` * Quality job: state=${qualityJob.state}, result=${qualityJob.result ?? 'n/a'}`); + } + + const allArtifacts = await retry(() => getPipelineArtifacts()); + log(` * Found ${allArtifacts.length} artifact(s): ${allArtifacts.map(a => a.name).join(', ') || '(none)'}`); + + const artifact = allArtifacts.find(a => a.name === ARTIFACT_NAME); + if (artifact) { + // The notice_output container is populated in TWO phases by the + // Quality stage (first generated.txt + meta, then seconds-to-minutes + // later the shipping .new.txt). We accept ONLY once .new.txt has been + // downloaded, extracted, and validated non-empty -- the listing alone + // races against content-commit (build 450517 armhf), so on a listing + // hit we attempt a real download+extract and re-poll if it isn't ready. + const files = await listArtifactFiles(artifact).catch(() => undefined); + if (files === undefined) { + // Could not list (transient error or unexpected data shape). Do not + // give up; just retry next poll. The budget still bounds the wait. + log(' * could not list notice_output files yet; will recheck next poll'); + } else { + log(` * notice_output files: ${files.map(f => path.basename(f)).join(', ') || '(empty)'}`); + if (files.some(f => path.basename(f) === SHIPPING_NOTICE_NAME)) { + log(` * ${SHIPPING_NOTICE_NAME} listed; verifying extracted content...`); + const extracted = await tryExtractNotice(artifact, tmpDir).catch(err => { + // A download/extract failure here (e.g. partial zip mid-upload) + // is NOT terminal: keep polling within budget. + log(` * download/extract attempt failed (${err}); will recheck next poll.`); + return undefined; + }); + if (extracted) { + log(` * ${SHIPPING_NOTICE_NAME} extracted and validated (${extracted.size} bytes); accepting.`); + return extracted; + } + // Listed but not yet downloadable/valid: fall through to wait + re-poll. + // Deliberately NO early give-up here even if the Quality job reports + // "completed" -- the artifact upload is async (continueOnError) and may + // still be finalizing, so we let the budget bound the wait. + } else if (qualityJob && qualityJob.state === 'completed') { + // BACKSTOP: .new.txt was never even listed AND Quality finished + // => it is never coming. Fall back instead of polling the full budget. + log(` * Quality job completed but ${SHIPPING_NOTICE_NAME} never appeared in artifact; giving up.`); + return undefined; + } else { + log(` * ${SHIPPING_NOTICE_NAME} not in artifact yet; waiting...`); + } + } + } else if (qualityJob && qualityJob.state === 'completed') { + log(' * Quality job completed but no notice_output artifact; giving up.'); + return undefined; + } + + log(` * Not ready yet, waiting ${POLL_INTERVAL_MS / 1000}s...`); + } catch (err) { + console.error(`[notice-cutover] WARNING: poll attempt failed: ${err}`); + } + + await new Promise(c => setTimeout(c, POLL_INTERVAL_MS)); + } + + log(`Poll budget exhausted (${POLL_ATTEMPTS} attempts) without a valid ${SHIPPING_NOTICE_NAME}.`); + return undefined; +} + +async function main(): Promise { + installDiagnostics(); + + // Feature flag = instant rollback. Default ON during the cutover validation; + // set VSCODE_OVERWRITE_TPN=false to force the legacy mixin notice. Check is + // case-insensitive on purpose so YAML casing (true/false vs True/False) can't + // silently break the rollback lever. + if ((process.env['VSCODE_OVERWRITE_TPN'] ?? '').trim().toLowerCase() === 'false') { + log('RESULT=disabled feature flag off (VSCODE_OVERWRITE_TPN=false); keeping legacy notice.'); + return; + } + + const tmpDir = path.resolve('.build/tmp-notice'); + const notice = await waitForNotice(tmpDir); + if (!notice) { + // Only reached on GENUINE exhaustion or a confirmed never-produced artifact + // -- never on a transient mid-upload miss (those re-poll inside waitForNotice). + log(`RESULT=fallback ${SHIPPING_NOTICE_NAME} unavailable within budget; keeping legacy notice.`); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return; + } + + // Log provenance for legal traceability: which build/commit produced this NOTICE. + if (notice.metaFile) { + log('NOTICE provenance:'); + for (const line of fs.readFileSync(notice.metaFile, 'utf8').split(/\r?\n/)) { + if (line.trim()) { + log(` ${line}`); + } + } + } + + const legacySize = fs.existsSync(TARGET_NOTICE) ? fs.statSync(TARGET_NOTICE).size : 0; + fs.copyFileSync(notice.shippingPath, TARGET_NOTICE); + log(`RESULT=fresh overwrote ${TARGET_NOTICE} with CG NOTICE (${notice.size} bytes; legacy was ${legacySize} bytes).`); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +} + +main().catch(err => { + // Non-fatal: log and exit 0 so packaging proceeds with the legacy notice. + console.error('[notice-cutover] Non-fatal error; keeping legacy notice:', err); + process.exitCode = 0; +}); diff --git a/build/azure-pipelines/common/install-builtin-extensions.yml b/build/azure-pipelines/common/install-builtin-extensions.yml index f9cbfd4b0854a..d431bff7fc10e 100644 --- a/build/azure-pipelines/common/install-builtin-extensions.yml +++ b/build/azure-pipelines/common/install-builtin-extensions.yml @@ -19,6 +19,6 @@ steps: - script: node build/lib/builtInExtensions.ts env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" condition: and(succeeded(), ne(variables.BUILTIN_EXTENSIONS_RESTORED, 'true')) displayName: Download built-in extensions diff --git a/build/azure-pipelines/common/mixin-vscode-capi.yml b/build/azure-pipelines/common/mixin-vscode-capi.yml index 6acbd604ddd3a..4d2a42f619dc6 100644 --- a/build/azure-pipelines/common/mixin-vscode-capi.yml +++ b/build/azure-pipelines/common/mixin-vscode-capi.yml @@ -1,4 +1,13 @@ steps: + # Check out the private microsoft/vscode-capi repository using the agent's + # GitHub App (Monaco) credentials instead of cloning it with a PAT. The + # repository resource is declared in the top-level pipeline (resources.repositories). + - checkout: capi + path: s/.build/vscode-capi + fetchDepth: 1 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-capi + - pwsh: | $ErrorActionPreference = 'Stop' @@ -10,25 +19,14 @@ steps: } } - $CapiPath = Join-Path '$(Agent.BuildDirectory)' 'vscode-capi' - if (Test-Path $CapiPath) { - Remove-Item -Recurse -Force $CapiPath - } + $CapiPath = Join-Path '$(Build.SourcesDirectory)' '.build/vscode-capi' + Push-Location $CapiPath try { - Invoke-CheckedCommand { git clone https://github.com/microsoft/vscode-capi.git --depth 1 $CapiPath } - Push-Location $CapiPath - - try { - Invoke-CheckedCommand { npm ci } - $env:BUILD_SOURCESDIRECTORY = '$(Build.SourcesDirectory)' - Invoke-CheckedCommand { npm run mixin_vscode } - } finally { - Pop-Location - } + Invoke-CheckedCommand { npm ci } + $env:BUILD_SOURCESDIRECTORY = '$(Build.SourcesDirectory)' + Invoke-CheckedCommand { npm run mixin_vscode } } finally { - if (Test-Path $CapiPath) { - Remove-Item -Recurse -Force $CapiPath - } + Pop-Location } displayName: Mixin vscode-capi diff --git a/build/azure-pipelines/copilot/build-steps.yml b/build/azure-pipelines/copilot/build-steps.yml index 6a0ae05d86d11..4e4d8afd87ded 100644 --- a/build/azure-pipelines/copilot/build-steps.yml +++ b/build/azure-pipelines/copilot/build-steps.yml @@ -1,10 +1,18 @@ steps: + # Check out the private microsoft/vscode-capi repository using the agent's + # GitHub App (Monaco) credentials instead of cloning it with a PAT. The + # repository resource is declared in the top-level pipeline (resources.repositories). + - checkout: capi + path: s/.build/vscode-capi + fetchDepth: 1 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-capi + - script: | set -e - git clone https://github.com/microsoft/vscode-capi.git --depth 1 $(Agent.BuildDirectory)/vscode-capi - cd $(Agent.BuildDirectory)/vscode-capi - npm ci && BUILD_SOURCESDIRECTORY=$(Build.SourcesDirectory)/extensions/copilot npm run mixin - rm -rf $(Agent.BuildDirectory)/vscode-capi + cd $(Build.SourcesDirectory)/.build/vscode-capi + npm ci + BUILD_SOURCESDIRECTORY=$(Build.SourcesDirectory)/extensions/copilot npm run mixin displayName: Mixin vscode-capi - script: npm run build diff --git a/build/azure-pipelines/copilot/l10n-steps.yml b/build/azure-pipelines/copilot/l10n-steps.yml index 99d5b249d6fc8..a7857a69c8afe 100644 --- a/build/azure-pipelines/copilot/l10n-steps.yml +++ b/build/azure-pipelines/copilot/l10n-steps.yml @@ -1,21 +1,23 @@ steps: + # Check out the private microsoft/vscode-extensions-loc repository using the + # agent's GitHub App (Monaco) credentials instead of cloning it with a PAT. + # The repository resource is declared in the top-level pipeline (resources.repositories). + - checkout: vscode_loc + path: s/.build/vscode-extensions-loc + fetchDepth: 1 + sparseCheckoutDirectories: out/GitHub.copilot-chat + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-extensions-loc + - script: | set -e EXTENSION_ID="GitHub.copilot-chat" - L10N_REPO="https://github.com/microsoft/vscode-extensions-loc.git" - L10N_DIR="$(Agent.TempDirectory)/vscode-extensions-loc" - - echo "Cloning vscode-extensions-loc (sparse checkout)..." - git clone --depth 1 --filter=blob:none --sparse "$L10N_REPO" "$L10N_DIR" - cd "$L10N_DIR" - git sparse-checkout set "out/$EXTENSION_ID" - + L10N_DIR="$(Build.SourcesDirectory)/.build/vscode-extensions-loc" TRANSLATED_DIR="$L10N_DIR/out/$EXTENSION_ID" if [ ! -d "$TRANSLATED_DIR" ] || [ -z "$(ls -A "$TRANSLATED_DIR" 2>/dev/null)" ]; then echo "No translated strings found for $EXTENSION_ID, skipping l10n import." - rm -rf "$L10N_DIR" exit 0 fi @@ -36,7 +38,4 @@ steps: echo "Localized files:" ls -la package.nls.*.json 2>/dev/null || echo " (no package.nls.*.json)" ls -la "$L10N_ROOT"/bundle.l10n.*.json 2>/dev/null || echo " (no bundle.l10n.*.json)" - - # Cleanup - rm -rf "$L10N_DIR" displayName: Import localized strings diff --git a/build/azure-pipelines/copilot/setup-steps.yml b/build/azure-pipelines/copilot/setup-steps.yml index 83699f8152d63..e550d6ac69605 100644 --- a/build/azure-pipelines/copilot/setup-steps.yml +++ b/build/azure-pipelines/copilot/setup-steps.yml @@ -3,28 +3,6 @@ steps: inputs: versionSpec: "22.21.x" - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get Secrets" - inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" - - - pwsh: | - "machine github.com`nlogin vscode`npassword $(github-distro-mixin-password)" | Out-File "$Home/_netrc" -Encoding ASCII - condition: and(succeeded(), contains(variables['Agent.OS'], 'windows')) - displayName: Setup distro auth (Windows) - - - script: | - mkdir -p .build - cat << EOF | tee ~/.netrc .build/.netrc > /dev/null - machine github.com - login vscode - password $(github-distro-mixin-password) - EOF - condition: and(succeeded(), not(contains(variables['Agent.OS'], 'windows'))) - displayName: Setup distro auth (non-Windows) - - pwsh: node build/setup-npm-registry.ts $env:NPM_REGISTRY workingDirectory: $(Build.SourcesDirectory) condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none'), contains(variables['Agent.OS'], 'windows')) diff --git a/build/azure-pipelines/darwin/product-build-darwin-cli.yml b/build/azure-pipelines/darwin/product-build-darwin-cli.yml index 5a705b43b8ef2..1d68fe9161348 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-cli.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-cli.yml @@ -38,6 +38,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 diff --git a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml index d70adbee0af01..e811c7397a72c 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml @@ -28,7 +28,14 @@ jobs: inputs: azureSubscription: vscode KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password,macos-developer-certificate,macos-developer-certificate-key" + SecretsFilter: "macos-developer-certificate,macos-developer-certificate-key" + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -78,7 +85,7 @@ jobs: npm_config_arch: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" # Avoid using dlopen to load Kerberos on macOS which can cause missing libraries # https://github.com/mongodb-js/kerberos/commit/04044d2814ad1d01e77f1ce87f26b03d86692cf2 # flipped the default to support legacy linux distros which shouldn't happen diff --git a/build/azure-pipelines/darwin/product-build-darwin-universal.yml b/build/azure-pipelines/darwin/product-build-darwin-universal.yml index c5e4dfb588a84..455adf5e2cf58 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-universal.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-universal.yml @@ -36,7 +36,14 @@ jobs: inputs: azureSubscription: vscode KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password,macos-developer-certificate,macos-developer-certificate-key" + SecretsFilter: "macos-developer-certificate,macos-developer-certificate-key" + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY build condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -71,7 +78,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - pwsh: node -- build/azure-pipelines/common/waitForArtifacts.ts unsigned_vscode_client_darwin_x64_archive unsigned_vscode_client_darwin_arm64_archive diff --git a/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml b/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml new file mode 100644 index 0000000000000..9450451b0129a --- /dev/null +++ b/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml @@ -0,0 +1,67 @@ +parameters: + - name: iterations + type: object + +jobs: + - job: macOSSmokeFlaky + displayName: macOS (Electron) + timeoutInMinutes: 300 + variables: + VSCODE_ARCH: arm64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/crashes + artifactName: crash-dump-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Crash Reports + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/logs + artifactName: logs-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Log Files + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + steps: + # Build VS Code from source. No VSCODE_RUN_*_TESTS flags are passed, so the + # compile template builds the client/server but skips compiling the test + # suites and skips the unit/integration/smoke test template entirely — we + # run only the smoke tests below, nothing else. + - template: ./steps/product-build-darwin-compile.yml@self + parameters: + VSCODE_ARCH: arm64 + VSCODE_CIBUILD: true + + - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" + env: + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) + displayName: Download Electron and Playwright + retryCountOnTaskFailure: 3 + + # The compile template only compiles the smoke suite when a test flag is + # set; since we pass none, compile it explicitly here. + - script: npm run compile --prefix test/smoke + displayName: Compile smoke tests + + # Guarantee the artifact directories exist so the 1ES output publishing + # never fails on a missing path. The smoke runner writes logs under + # .build/logs and crash dumps under .build/crashes (only on a crash). + - script: mkdir -p .build/crashes .build/logs + displayName: Ensure artifact directories exist + + # Run the Electron smoke test once per iteration. continueOnError lets every + # iteration run even if some fail, and gives each run its own timeline record + # so the SmokeFlaky function can tally passes vs. failures. + - ${{ each i in parameters.iterations }}: + - script: | + set -e + APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) + APP_NAME="`ls $APP_ROOT | head -n 1`" + npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME" + displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" + continueOnError: true + timeoutInMinutes: 20 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 b7315e170ee62..20aa65bd40733 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -28,7 +28,14 @@ steps: inputs: azureSubscription: vscode KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password,macos-developer-certificate,macos-developer-certificate-key" + SecretsFilter: "macos-developer-certificate,macos-developer-certificate-key" + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -82,7 +89,7 @@ steps: npm_config_arch: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" # Avoid using dlopen to load Kerberos on macOS which can cause missing libraries # https://github.com/mongodb-js/kerberos/commit/04044d2814ad1d01e77f1ce87f26b03d86692cf2 # flipped the default to support legacy linux distros which shouldn't happen @@ -112,6 +119,16 @@ steps: - script: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality + # Start pulling the CG-generated NOTICE from the parallel Quality stage + # in the background (overwrites repo-root ThirdPartyNotices.txt before packaging). + # Publish builds only (CIBUILD=false) — CI test jobs never ship a notice. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Pull CG NOTICE (background) + - template: ../../common/install-builtin-extensions.yml@self - template: ../../common/agent-sdk-produce.yml@self @@ -127,7 +144,7 @@ steps: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: node build/azure-pipelines/common/extract-telemetry.ts @@ -151,12 +168,25 @@ steps: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Download Copilot VSIX + # Block on the CG NOTICE download and overwrite ThirdPartyNotices.txt + # before gulp packages it (and before any later signing/notarization). + # Non-fatal: falls back to legacy on any problem. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Apply CG NOTICE + # deemon --attach can exit non-zero if the background daemon died; never fail for that. + continueOnError: true + - script: | set -e npm run gulp vscode-darwin-$(VSCODE_ARCH)-min-ci echo "##vso[task.setvariable variable=BUILT_CLIENT]true" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build client - script: | @@ -164,7 +194,8 @@ steps: npm run gulp vscode-reh-darwin-$(VSCODE_ARCH)-min-ci mv ../vscode-reh-darwin-$(VSCODE_ARCH) ../vscode-server-darwin-$(VSCODE_ARCH) # TODO@joaomoreno env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - script: | @@ -172,7 +203,8 @@ steps: npm run gulp vscode-reh-web-darwin-$(VSCODE_ARCH)-min-ci mv ../vscode-reh-web-darwin-$(VSCODE_ARCH) ../vscode-server-darwin-$(VSCODE_ARCH)-web # TODO@joaomoreno env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml index 2cb75de8dddd6..e790f126a7729 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml @@ -9,7 +9,8 @@ parameters: steps: - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 diff --git a/build/azure-pipelines/dependencies-check.yml b/build/azure-pipelines/dependencies-check.yml index d7d0d9f12b6dd..78b74bc342523 100644 --- a/build/azure-pipelines/dependencies-check.yml +++ b/build/azure-pipelines/dependencies-check.yml @@ -27,6 +27,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - script: | set -e echo "Checking if package.json or package-lock.json files were modified..." @@ -102,7 +109,7 @@ jobs: npm_command: 'install --ignore-scripts' ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies with retries timeoutInMinutes: 1300 condition: and(succeeded(), eq(variables['SHOULD_VALIDATE'], 'true')) diff --git a/build/azure-pipelines/distro-build.yml b/build/azure-pipelines/distro-build.yml index 1d0a50b129778..05ac00cec7db8 100644 --- a/build/azure-pipelines/distro-build.yml +++ b/build/azure-pipelines/distro-build.yml @@ -7,7 +7,35 @@ trigger: include: ["main", "release/*"] pr: none +resources: + repositories: + - repository: distro + type: github + name: microsoft/vscode-distro + ref: main + endpoint: Monaco + - repository: encrypt + type: github + name: microsoft/vscode-encrypt + ref: main + endpoint: Monaco + - repository: vsda + type: github + name: microsoft/vsda + ref: main + endpoint: Monaco + - repository: regexp + type: github + name: microsoft/vscode-regexp-languagedetection + ref: main + endpoint: Monaco + steps: + - checkout: self + path: s + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode + - task: NodeTool@0 inputs: versionSource: fromFile diff --git a/build/azure-pipelines/distro/download-distro.yml b/build/azure-pipelines/distro/download-distro.yml index b7c52dd36b41c..7d6319ee0be73 100644 --- a/build/azure-pipelines/distro/download-distro.yml +++ b/build/azure-pipelines/distro/download-distro.yml @@ -1,61 +1,90 @@ steps: - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get Secrets" - inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" - - # TODO@joaomoreno: Keep pwsh once we move out of running entire jobs in containers - - pwsh: | - "machine github.com`nlogin vscode`npassword $(github-distro-mixin-password)" | Out-File "$Home/_netrc" -Encoding ASCII - condition: and(succeeded(), contains(variables['Agent.OS'], 'windows')) - displayName: Setup distro auth (Windows) + # Check out the private microsoft/vscode-distro repository using the agent's + # GitHub App (Monaco) credentials. This avoids the need for a long-lived PAT. + # The repository resource is declared in the top-level pipeline (resources.repositories) + # and is checked out into .build/distro so that mixin-npm.ts / mixin-quality.ts can + # consume it from there (`.build/distro/npm`, `.build/distro/mixin/`). + - checkout: distro + path: s/.build/distro + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-distro - pwsh: | $ErrorActionPreference = "Stop" - $ArchivePath = "$(Agent.TempDirectory)/distro.zip" - $PackageJson = Get-Content -Path package.json -Raw | ConvertFrom-Json - $DistroVersion = $PackageJson.distro - - Invoke-WebRequest -Uri "https://api.github.com/repos/microsoft/vscode-distro/zipball/$DistroVersion" ` - -OutFile $ArchivePath ` - -Headers @{ "Accept" = "application/vnd.github+json"; "Authorization" = "Bearer $(github-distro-mixin-password)"; "X-GitHub-Api-Version" = "2022-11-28" } + $DistroVersion = (Get-Content -Path package.json -Raw | ConvertFrom-Json).distro + Write-Host "Checking out distro commit $DistroVersion" + git -C .build/distro checkout $DistroVersion + displayName: Checkout distro commit - New-Item -ItemType Directory -Path .build -Force - Expand-Archive -Path $ArchivePath -DestinationPath .build - Rename-Item -Path ".build/microsoft-vscode-distro-$DistroVersion" -NewName distro - condition: and(succeeded(), contains(variables['Agent.OS'], 'windows')) - displayName: Download distro (Windows) + - script: git lfs install --local + displayName: Initialize Git LFS - - script: | - mkdir -p .build - cat << EOF | tee ~/.netrc .build/.netrc > /dev/null - machine github.com - login vscode - password $(github-distro-mixin-password) - EOF - condition: and(succeeded(), not(contains(variables['Agent.OS'], 'windows'))) - displayName: Setup distro auth (non-Windows) + - script: git lfs pull + displayName: Pull Git LFS objects - - script: | - set -e - ArchivePath="$(Agent.TempDirectory)/distro.zip" - DistroVersion=$(node -p "require('./package.json').distro") + # Check out the private microsoft/vscode-encrypt, microsoft/vsda and + # microsoft/vscode-regexp-languagedetection repositories using the agent's GitHub + # App (Monaco) credentials. The distro's npm dependencies reference vsda and + # vscode-regexp-languagedetection as git dependencies, and the CLI cargo patches + # reference vsda and vscode-encrypt; npm resolves these over ssh while cargo + # resolves them over https. We redirect both public GitHub URLs to these local + # checkouts via git's insteadOf so no PAT is required to resolve them. + - checkout: encrypt + path: s/.build/vscode-encrypt + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-encrypt - curl -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $(github-distro-mixin-password)" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - -o $ArchivePath \ - -L "https://api.github.com/repos/microsoft/vscode-distro/zipball/$DistroVersion" + - checkout: vsda + path: s/.build/vsda + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vsda - unzip $ArchivePath -d .build - mv .build/microsoft-vscode-distro-$DistroVersion .build/distro - condition: and(succeeded(), not(contains(variables['Agent.OS'], 'windows'))) - displayName: Download distro (non-Windows) + - checkout: regexp + path: s/.build/vscode-regexp-languagedetection + fetchDepth: 0 + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode-regexp-languagedetection - - script: git lfs install --local - displayName: Initialize Git LFS + # Redirect the private git dependencies to the local checkouts above, so no PAT + # is required to resolve them. npm resolves these over ssh while cargo resolves + # them over https, with and without a trailing `.git`, so we register every + # form. This step runs on the agent directly and must work in both bash and + # cmd.exe (Windows), so it uses plain `git config` invocations. + - script: | + git config --global url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "https://github.com/microsoft/vscode-encrypt" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "https://github.com/microsoft/vscode-encrypt.git" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "ssh://git@github.com/microsoft/vscode-encrypt" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-encrypt".insteadOf "ssh://git@github.com/microsoft/vscode-encrypt.git" + git config --global url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "https://github.com/microsoft/vsda" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "https://github.com/microsoft/vsda.git" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "ssh://git@github.com/microsoft/vsda" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vsda".insteadOf "ssh://git@github.com/microsoft/vsda.git" + git config --global url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "https://github.com/microsoft/vscode-regexp-languagedetection" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "https://github.com/microsoft/vscode-regexp-languagedetection.git" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "ssh://git@github.com/microsoft/vscode-regexp-languagedetection" + git config --global --add url."file://$(Build.SourcesDirectory)/.build/vscode-regexp-languagedetection".insteadOf "ssh://git@github.com/microsoft/vscode-regexp-languagedetection.git" + displayName: Redirect private git dependencies to local checkouts - - script: git lfs pull - displayName: Pull Git LFS objects + # The Alpine server build installs the distro's npm dependencies inside a docker + # container that mounts the sources at /root/vscode, so the host redirects above + # (which use agent paths) do not apply there. Emit a container-pathed gitconfig + # that postinstall.ts bind-mounts as /root/.gitconfig. Only needed (and only + # bash-compatible) on Linux agents. + - script: | + set -e + CONTAINER_GITCONFIG="$(Build.SourcesDirectory)/.build/.gitconfig-distro" + rm -f "$CONTAINER_GITCONFIG" + for repo in vscode-encrypt vsda vscode-regexp-languagedetection; do + for url in \ + "https://github.com/microsoft/$repo" \ + "https://github.com/microsoft/$repo.git" \ + "ssh://git@github.com/microsoft/$repo" \ + "ssh://git@github.com/microsoft/$repo.git"; do + git config --file "$CONTAINER_GITCONFIG" --add url."file:///root/vscode/.build/$repo".insteadOf "$url" + done + done + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + displayName: Write container git redirects for Alpine diff --git a/build/azure-pipelines/linux/product-build-linux-cli.yml b/build/azure-pipelines/linux/product-build-linux-cli.yml index a9107129b73b5..c84a1fd08e273 100644 --- a/build/azure-pipelines/linux/product-build-linux-cli.yml +++ b/build/azure-pipelines/linux/product-build-linux-cli.yml @@ -34,6 +34,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 @@ -84,7 +91,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - script: | diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml index ad0e149816014..9be0c3f1daf40 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -27,9 +27,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: | set -e @@ -93,7 +93,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -109,7 +109,7 @@ jobs: SYSROOT_ARCH="$SYSROOT_ARCH" VSCODE_SYSROOT_PREFIX="-glibc-2.28-gcc-8.5.0" node -e 'import { getVSCodeSysroot } from "./build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' env: VSCODE_ARCH: $(VSCODE_ARCH) - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Download vscode sysroots condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -135,7 +135,7 @@ jobs: VSCODE_ARCH: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) diff --git a/build/azure-pipelines/linux/product-smoke-flaky-linux.yml b/build/azure-pipelines/linux/product-smoke-flaky-linux.yml new file mode 100644 index 0000000000000..b3a7e11ded72a --- /dev/null +++ b/build/azure-pipelines/linux/product-smoke-flaky-linux.yml @@ -0,0 +1,82 @@ +parameters: + - name: VSCODE_QUALITY + type: string + - name: iterations + type: object + +jobs: + - job: LinuxSmokeFlaky + displayName: Linux (Electron) + timeoutInMinutes: 300 + variables: + DISPLAY: ":10" + NPM_ARCH: x64 + VSCODE_ARCH: x64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/crashes + artifactName: crash-dump-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Crash Reports + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/logs + artifactName: logs-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Log Files + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + steps: + # Build VS Code from source. No VSCODE_RUN_*_TESTS flags are passed, so the + # compile template builds the client/server but skips compiling the test + # suites and skips the unit/integration/smoke test template entirely — we + # run only the smoke tests below, nothing else. + - template: ./steps/product-build-linux-compile.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_CIBUILD: true + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + + - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" + env: + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) + displayName: Download Electron and Playwright + retryCountOnTaskFailure: 3 + + - script: | + set -e + APP_ROOT=$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH) + ELECTRON_ROOT=.build/electron + sudo chown root $APP_ROOT/chrome-sandbox + sudo chown root $ELECTRON_ROOT/chrome-sandbox + sudo chmod 4755 $APP_ROOT/chrome-sandbox + sudo chmod 4755 $ELECTRON_ROOT/chrome-sandbox + displayName: Change setuid helper binary permission + + # The compile template only compiles the smoke suite when a test flag is + # set; since we pass none, compile it explicitly here. + - script: npm run compile --prefix test/smoke + displayName: Compile smoke tests + + # Guarantee the artifact directories exist so the 1ES output publishing + # never fails on a missing path. The smoke runner writes logs under + # .build/logs and crash dumps under .build/crashes (only on a crash). + - script: mkdir -p .build/crashes .build/logs + displayName: Ensure artifact directories exist + + # Run the Electron smoke test once per iteration. continueOnError lets every + # iteration run even if some fail, and gives each run its own timeline record + # so the SmokeFlaky function can tally passes vs. failures. + - ${{ each i in parameters.iterations }}: + - script: | + set -e + npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" + env: + TMPDIR: $(Agent.TempDirectory) + displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" + continueOnError: true + timeoutInMinutes: 20 diff --git a/build/azure-pipelines/linux/setup-env.sh b/build/azure-pipelines/linux/setup-env.sh index 2f275d1597581..b9dc1d80b995e 100755 --- a/build/azure-pipelines/linux/setup-env.sh +++ b/build/azure-pipelines/linux/setup-env.sh @@ -39,7 +39,7 @@ EOF if [ "$npm_config_arch" == "x64" ]; then # Download clang based on chromium revision used by vscode - curl -s https://raw.githubusercontent.com/chromium/chromium/148.0.7778.97/tools/clang/scripts/update.py | python - --output-dir=$PWD/.build/CR_Clang --host-os=linux + curl -s https://raw.githubusercontent.com/chromium/chromium/148.0.7778.271/tools/clang/scripts/update.py | python - --output-dir=$PWD/.build/CR_Clang --host-os=linux # Download libcxx headers and objects from upstream electron releases DEBUG=libcxx-fetcher \ @@ -51,9 +51,9 @@ if [ "$npm_config_arch" == "x64" ]; then # Set compiler toolchain # Flags for the client build are based on - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:build/config/arm.gni - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:build/config/compiler/BUILD.gn - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:build/config/c++/BUILD.gn + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:build/config/arm.gni + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:build/config/compiler/BUILD.gn + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:build/config/c++/BUILD.gn export CC="$PWD/.build/CR_Clang/bin/clang --gcc-toolchain=$VSCODE_CLIENT_SYSROOT_DIR/x86_64-linux-gnu" export CXX="$PWD/.build/CR_Clang/bin/clang++ --gcc-toolchain=$VSCODE_CLIENT_SYSROOT_DIR/x86_64-linux-gnu" export CXXFLAGS="-nostdinc++ -D__NO_INLINE__ -DSPDLOG_USE_STD_FORMAT -I$PWD/.build/libcxx_headers -isystem$PWD/.build/libcxx_headers/include -isystem$PWD/.build/libcxxabi_headers/include -fPIC -flto=thin -fsplit-lto-unit -D_LIBCPP_ABI_NAMESPACE=Cr -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE --sysroot=$VSCODE_CLIENT_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot" 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 4443e0cd869cc..7c60614f64ce6 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -34,9 +34,9 @@ steps: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: | set -e @@ -104,7 +104,7 @@ steps: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -120,7 +120,7 @@ steps: SYSROOT_ARCH="$SYSROOT_ARCH" VSCODE_SYSROOT_PREFIX="-glibc-2.28-gcc-8.5.0" node -e 'import { getVSCodeSysroot } from "./build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' env: VSCODE_ARCH: $(VSCODE_ARCH) - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Download vscode sysroots - script: | @@ -145,7 +145,7 @@ steps: VSCODE_ARCH: $(VSCODE_ARCH) ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -170,6 +170,16 @@ steps: - script: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality + # Start pulling the CG-generated NOTICE from the parallel Quality stage + # in the background (overwrites repo-root ThirdPartyNotices.txt before packaging). + # Publish builds only (CIBUILD=false) — CI test jobs never ship a notice. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Pull CG NOTICE (background) + - template: ../../common/install-builtin-extensions.yml@self - ${{ if ne(parameters.VSCODE_ARCH, 'armhf') }}: @@ -186,7 +196,7 @@ steps: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: @@ -211,6 +221,17 @@ steps: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Download Copilot VSIX + # Block on the CG NOTICE download and overwrite ThirdPartyNotices.txt + # right before gulp packages it. Non-fatal: falls back to legacy on any problem. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Apply CG NOTICE + # deemon --attach can exit non-zero if the background daemon died; never fail for that. + continueOnError: true + - script: | set -e npm run gulp vscode-linux-$(VSCODE_ARCH)-min-ci @@ -219,7 +240,8 @@ steps: echo "##vso[task.setvariable variable=CLIENT_PATH]$ARCHIVE_PATH" echo "##vso[task.setvariable variable=CLIENT_ARCHIVE_NAME]$(basename $ARCHIVE_PATH)" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build client - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: @@ -247,7 +269,7 @@ steps: set -e tar -czf $CLIENT_PATH -C .. VSCode-linux-$(VSCODE_ARCH) env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Archive client - ${{ if ne(parameters.VSCODE_ARCH, 'armhf') }}: @@ -262,7 +284,8 @@ steps: echo "##vso[task.setvariable variable=SERVER_PATH]$ARCHIVE_PATH" echo "##vso[task.setvariable variable=SERVER_UNARCHIVE_PATH]$UNARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - script: | @@ -274,7 +297,8 @@ steps: tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-server-linux-$(VSCODE_ARCH)-web echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) - ${{ if or(eq(parameters.VSCODE_ARCH, 'x64'), eq(parameters.VSCODE_ARCH, 'arm64')) }}: @@ -296,7 +320,7 @@ steps: set -e npm run gulp "vscode-linux-$(VSCODE_ARCH)-prepare-deb" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Prepare deb package - script: | diff --git a/build/azure-pipelines/linux/steps/product-build-linux-test.yml b/build/azure-pipelines/linux/steps/product-build-linux-test.yml index 5c6223cf371d8..57b712fe1c349 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-test.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-test.yml @@ -9,7 +9,8 @@ parameters: steps: - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 @@ -109,10 +110,23 @@ steps: - script: | set -e + df -h + # Break down what is occupying the small OS root disk (`/`). The `-x` flag + # keeps `du` on the root filesystem so it does not descend into the large + # `/mnt` work volume. This identifies the directory that fills `/` and + # crashes the agent worker with "No space left on device". + echo "--- top consumers on / ---" + du -xhd1 / 2>/dev/null | sort -rh | head -30 || true + echo "--- top consumers under \$HOME ---" + du -xhd1 "$HOME" 2>/dev/null | sort -rh | head -30 || true + echo "--- top consumers under /tmp ---" + du -xhd1 /tmp 2>/dev/null | sort -rh | head -30 || true + echo "--- top consumers under /var ---" + du -xhd1 /var 2>/dev/null | sort -rh | head -30 || true ps -ef cat /proc/sys/fs/inotify/max_user_watches lsof | wc -l - displayName: Diagnostics before smoke test run (processes, max_user_watches, number of opened file handles) + displayName: Diagnostics before smoke test run (disk space, processes, max_user_watches, number of opened file handles) continueOnError: true condition: succeededOrFailed() @@ -120,6 +134,12 @@ steps: - script: | set -e npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" + env: + # Keep the smoke test data dir (user-data, extensions, workspace and the + # Copilot CLI session state under `os.tmpdir()`) on the large work volume + # instead of the small OS root disk (`/tmp`), which has filled up and + # crashed the agent worker with "No space left on device". + TMPDIR: $(Agent.TempDirectory) timeoutInMinutes: 20 displayName: 🧪 Run smoke tests (Electron) @@ -127,6 +147,7 @@ steps: - script: npm run smoketest-no-compile -- --web --tracing --headless env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)-web + TMPDIR: $(Agent.TempDirectory) timeoutInMinutes: 20 displayName: 🧪 Run smoke tests (Browser, Chromium) @@ -136,15 +157,26 @@ steps: APP_PATH=$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH) VSCODE_REMOTE_SERVER_PATH="$(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)" \ npm run smoketest-no-compile -- --tracing --remote --build "$APP_PATH" + env: + TMPDIR: $(Agent.TempDirectory) timeoutInMinutes: 20 displayName: 🧪 Run smoke tests (Remote) - script: | set -e + df -h + # Same root-disk breakdown as the "before" step so the delta attributable to + # the smoke run itself is visible, plus the smoke test data dirs explicitly. + echo "--- top consumers on / ---" + du -xhd1 / 2>/dev/null | sort -rh | head -30 || true + echo "--- top consumers under \$HOME ---" + du -xhd1 "$HOME" 2>/dev/null | sort -rh | head -30 || true + echo "--- smoke test data dirs ---" + du -sh "$(Agent.TempDirectory)"/vscsmoke-* /tmp/vscsmoke-* 2>/dev/null || true ps -ef cat /proc/sys/fs/inotify/max_user_watches lsof | wc -l - displayName: Diagnostics after smoke test run (processes, max_user_watches, number of opened file handles) + displayName: Diagnostics after smoke test run (disk space, processes, max_user_watches, number of opened file handles) continueOnError: true condition: succeededOrFailed() diff --git a/build/azure-pipelines/oss/merge-notices.test.ts b/build/azure-pipelines/oss/merge-notices.test.ts new file mode 100644 index 0000000000000..33687f699242b --- /dev/null +++ b/build/azure-pipelines/oss/merge-notices.test.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/*--------------------------------------------------------------------------------------------- + * Focused unit checks for computeUnaccounted() in merge-notices.ts. + * + * Run: npx tsx merge-notices.test.ts + * + * computeUnaccounted cross-checks the scanner's "unresolved" list against the + * FINAL merged NOTICE to surface packages genuinely missing from it -- either + * (a) the scanner failed to create a row AND nothing rescued it downstream, or + * (b) a row exists but its license body is empty. Covers: + * 1. A genuinely-absent unresolved pkg is included. + * 2. An unresolved pkg that IS present in `merged` (any version) is excluded + * -- the linux-keyutils cglicenses.json-override rescue case. + * 3. An entry present with empty licenseText is included (no-license-text). + * 4. An entry present with real license text is excluded. + * 5. Dedupe when the same pkg surfaces from both sources. + *--------------------------------------------------------------------------------------------*/ + +import { computeUnaccounted } from './merge-notices.js'; + +interface NoticeEntry { + name: string; + version: string; + license: string; + url: string; + licenseText: string; +} + +let passed = 0; +let failed = 0; + +function check(name: string, cond: boolean): void { + if (cond) { + passed++; + console.log(` ok ${name}`); + } else { + failed++; + console.error(` FAIL ${name}`); + } +} + +function entry(name: string, version: string, licenseText: string): NoticeEntry { + return { name, version, license: 'MIT', url: '', licenseText }; +} + +function mapOf(...entries: NoticeEntry[]): Map { + const m = new Map(); + for (const e of entries) { + m.set(`${e.name.toLowerCase()}@${e.version || ''}`, e); + } + return m; +} + +// -- 1. genuinely-absent unresolved pkg is included --------------------------- +console.log('computeUnaccounted -- genuinely absent:'); +{ + const merged = mapOf(entry('present-pkg', '1.0.0', 'Real license text')); + const unresolved = [{ name: 'ghost-pkg', version: '2.0.0', reason: 'cargo-no-license-resolved' }]; + const out = computeUnaccounted(merged, unresolved); + check('absent unresolved pkg is listed', out.some(u => u.name === 'ghost-pkg' && u.reason === 'cargo-no-license-resolved')); + check('present pkg with text is NOT listed', !out.some(u => u.name === 'present-pkg')); + check('exactly one unaccounted', out.length === 1); +} + +// -- 2. unresolved pkg present in merged (any version) is excluded ------------- +console.log('computeUnaccounted -- downstream-rescued (linux-keyutils case):'); +{ + // Scanner failed at 0.2.4, but cglicenses.json injected it at a different + // version. Name-only match must exclude it from the unaccounted list. + const merged = mapOf(entry('linux-keyutils', '0.2.5', 'BSD-3-Clause text')); + const unresolved = [{ name: 'linux-keyutils', version: '0.2.4', reason: 'cargo-no-license-resolved' }]; + const out = computeUnaccounted(merged, unresolved); + check('rescued pkg (version drift) is excluded', !out.some(u => u.name === 'linux-keyutils')); + check('nothing unaccounted', out.length === 0); +} +{ + // Exact-version rescue too. + const merged = mapOf(entry('linux-keyutils', '0.2.4', 'BSD-3-Clause text')); + const unresolved = [{ name: 'linux-keyutils', version: '0.2.4', reason: 'cargo-no-license-resolved' }]; + const out = computeUnaccounted(merged, unresolved); + check('rescued pkg (same version) is excluded', out.length === 0); +} + +// -- 3. present entry with empty license body is included ---------------------- +console.log('computeUnaccounted -- empty license body:'); +{ + const merged = mapOf( + entry('blank-pkg', '1.0.0', ' \n\t '), + entry('good-pkg', '1.0.0', 'Real text'), + ); + const out = computeUnaccounted(merged, []); + check('empty-body entry listed as no-license-text', out.some(u => u.name === 'blank-pkg' && u.reason === 'no-license-text')); + check('real-text entry excluded', !out.some(u => u.name === 'good-pkg')); + check('exactly one unaccounted', out.length === 1); +} + +// -- 4. dedupe between the two sources ---------------------------------------- +console.log('computeUnaccounted -- dedupe across sources:'); +{ + // Same pkg is both in unresolved AND present-with-empty-body. (Edge case, but + // the dedupe must keep a single row keyed by name@version.) + const merged = mapOf(entry('dup-pkg', '3.1.4', '')); + const unresolved = [{ name: 'dup-pkg', version: '3.1.4', reason: 'cargo-api-failed' }]; + const out = computeUnaccounted(merged, unresolved); + // Present in merged, so the unresolved branch (name-match) excludes it; the + // empty-body branch adds it once. Either way: exactly one row, not two. + check('single deduped row for dup-pkg', out.filter(u => u.name === 'dup-pkg').length === 1); +} +{ + // Two distinct unresolved entries for the same name@version dedupe to one. + const merged = new Map(); + const unresolved = [ + { name: 'twice', version: '1.0.0', reason: 'cargo-api-failed' }, + { name: 'Twice', version: '1.0.0', reason: 'cargo-no-repo-url' }, + ]; + const out = computeUnaccounted(merged, unresolved); + check('case-insensitive dedupe to one row', out.filter(u => u.name.toLowerCase() === 'twice').length === 1); +} + +// -- 5. sort order ------------------------------------------------------------ +console.log('computeUnaccounted -- sorted by name then version:'); +{ + const merged = new Map(); + const unresolved = [ + { name: 'zeta', version: '1.0.0', reason: 'r' }, + { name: 'alpha', version: '2.0.0', reason: 'r' }, + { name: 'alpha', version: '1.0.0', reason: 'r' }, + ]; + const out = computeUnaccounted(merged, unresolved); + check('first is alpha@1.0.0', out[0].name === 'alpha' && out[0].version === '1.0.0'); + check('second is alpha@2.0.0', out[1].name === 'alpha' && out[1].version === '2.0.0'); + check('last is zeta', out[2].name === 'zeta'); +} + +// -- summary ------------------------------------------------------------------ +console.log(''); +console.log(`merge-notices computeUnaccounted unit checks: ${passed} passed, ${failed} failed`); +if (failed > 0) { + process.exit(1); +} diff --git a/build/azure-pipelines/oss/merge-notices.ts b/build/azure-pipelines/oss/merge-notices.ts index b4f0d9925d6f2..46c78ffde10ca 100644 --- a/build/azure-pipelines/oss/merge-notices.ts +++ b/build/azure-pipelines/oss/merge-notices.ts @@ -116,6 +116,62 @@ function parseNoticeFile(filePath: string): NoticeEntry[] { return entries; } +/** + * Compute the packages NOT accounted for in the final merged NOTICE. A package + * is "unaccounted" if EITHER: + * (a) the scanner tried to resolve it but created no row (it appears in the + * `unresolved` sibling) AND its name is absent from the final merged map + * (name-only match avoids version-drift false positives, and correctly + * excludes packages rescued downstream via a cglicenses.json override), OR + * (b) it IS a row in the final notice but its license body is empty/whitespace. + * + * Pure and exported for unit testing. Informational only — never affects exit code. + */ +export function computeUnaccounted( + merged: Map, + unresolved: Array<{ name: string; version: string; reason: string }> +): Array<{ name: string; version: string; reason: string }> { + const presentNamesLower = new Set([...merged.values()].map(e => e.name.toLowerCase())); + const out: Array<{ name: string; version: string; reason: string }> = []; + + // (a) Genuinely absent: scanner failed AND the name never landed in the notice. + for (const item of unresolved) { + if (!presentNamesLower.has(item.name.toLowerCase())) { + out.push({ name: item.name, version: item.version, reason: item.reason }); + } + } + + // (b) Present but with an empty license body. + for (const entry of merged.values()) { + if (!entry.licenseText || entry.licenseText.trim() === '') { + out.push({ name: entry.name, version: entry.version, reason: 'no-license-text' }); + } + } + + // Dedupe by `@` (a pkg may appear from both sources). + const seen = new Set(); + const deduped: Array<{ name: string; version: string; reason: string }> = []; + for (const u of out) { + const key = `${u.name.toLowerCase()}@${u.version || ''}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(u); + } + + deduped.sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + if (an !== bn) { + return an.localeCompare(bn); + } + return (a.version || '').localeCompare(b.version || ''); + }); + + return deduped; +} + async function mainAsync(): Promise { const args = parseArgs(process.argv.slice(2)); const cgPath = args['cg'] || ''; @@ -409,19 +465,92 @@ required to debug changes to any libraries licensed under the GNU Lesser General const sizeMB = (output.length / 1024 / 1024).toFixed(2); + // Reconcile the numbers so the summary is self-explaining. Only three inputs + // ADD a row to the final NOTICE: CG packages, brand-new extension-scanner + // packages, and injected overrides. Everything else either lands on a package + // CG already covers (folded -> no new row) or edits an existing row in place + // (count-neutral). "Found" is the gross discovery count across all sources; + // "included" is what actually ships after folding the overlaps. + const extScannerFound = extAdded + extStubOverridden + extSkipped; + const totalFound = cgCount + extScannerFound + overridesInjected; + const totalFolded = extSkipped + extStubOverridden; + const totalIncluded = sorted.length; + + // Load the scanner's unresolved index (packages it tried to resolve but + // produced no row for). Cross-checked against the final merged notice below so + // packages rescued downstream (e.g. a cglicenses.json override) are excluded. + // Never throws — a missing/garbled sibling just yields an empty list. + const unresolvedPath = args['unresolved'] || (extPath ? extPath + '.unresolved.json' : ''); + let unresolvedList: Array<{ name: string; version: string; reason: string }> = []; + if (unresolvedPath && fs.existsSync(unresolvedPath)) { + try { + const raw: unknown = JSON.parse(fs.readFileSync(unresolvedPath, 'utf8')); + if (Array.isArray(raw)) { + unresolvedList = (raw as Array<{ name?: unknown; version?: unknown; reason?: unknown }>) + .filter(item => item && typeof item.name === 'string') + .map(item => ({ + name: item.name as string, + version: typeof item.version === 'string' ? item.version : '', + reason: typeof item.reason === 'string' ? item.reason : 'unresolved', + })); + } + } catch (err) { + console.warn(` WARN: could not read unresolved index ${unresolvedPath}: ${(err as Error).message}`); + } + } + console.log('=== Merge Summary ==='); - console.log(` Total packages: ${sorted.length}`); - console.log(` From CG: ${cgCount}`); - console.log(` From extension scanner: ${extAdded}`); - console.log(` Cargo stub-overrides (beat CG): ${extStubOverridden}`); - console.log(` Extension duplicates skipped: ${extSkipped}`); + console.log(''); + console.log(' Packages found (gross, across all sources):'); + console.log(` Component Governance: ${cgCount}`); + console.log(` Extension scanner: ${extScannerFound} (${extAdded} new, ${extSkipped} already in CG, ${extStubOverridden} stub-overrides)`); if (cglicensesPath) { - console.log(` cglicenses overrides applied: ${overridesApplied} (${overrideEntryCount} merged entries updated)`); - console.log(` cglicenses overrides injected: ${overridesInjected}`); - console.log(` cglicenses overrides stale (skipped, warn-only): ${staleOverrides.length}`); - console.log(` cglicenses overrides unmatched (no usable text): ${unmatchedOverrides.length}`); + console.log(` cglicenses.json injected: ${overridesInjected} (present-but-unlicensed, added by override)`); + } + console.log(` Total packages found: ${totalFound}`); + console.log(''); + console.log(' Folded during merge (scanner hit a package CG already had -- no new row):'); + console.log(` Duplicates skipped (CG text kept): ${extSkipped}`); + console.log(` Stub-overrides (CG text replaced): ${extStubOverridden}`); + console.log(` Total folded: ${totalFolded}`); + console.log(''); + if (cglicensesPath) { + console.log(' Edited in place (count-neutral):'); + console.log(` cglicenses overrides applied: ${overridesApplied} (${overrideEntryCount} entries updated)`); + console.log(` cglicenses overrides stale (skipped): ${staleOverrides.length} (warn-only)`); + console.log(` cglicenses overrides unmatched: ${unmatchedOverrides.length} (no usable text)`); + console.log(''); } + console.log(` Total packages included in shipping ThirdPartyNotices: ${totalIncluded}`); + console.log(` (${totalFound} found - ${totalFolded} folded = ${totalIncluded})`); + + const unaccounted = computeUnaccounted(merged, unresolvedList); + console.log(''); + if (unaccounted.length > 0) { + // Surface as warnings so they show up in the ADO build log's warning count + // and any warning-count tooling. Informational only -- never fails the build. + console.warn(` Packages NOT accounted for in the final NOTICE: ${unaccounted.length}`); + console.warn(' (genuinely absent, or present with an empty license body -- need a cglicenses.json override or upstream fix)'); + for (const u of unaccounted) { + console.warn(` ! ${u.name}@${u.version || '(no version)'} -- ${u.reason}`); + } + } else { + // Zero is good news, not a warning. + console.log(` Packages NOT accounted for in the final NOTICE: ${unaccounted.length}`); + } + + // Defensive: if a future code path mutates the merged map without updating a + // counter, this catches the drift instead of silently shipping a wrong total. + if (totalFound - totalFolded !== totalIncluded) { + console.warn(` WARN: merge accounting mismatch -- found(${totalFound}) - folded(${totalFolded}) = ${totalFound - totalFolded}, but the final NOTICE has ${totalIncluded} packages. A source or override path is uncounted.`); + } + + console.log(''); console.log(` Output: ${outputPath} (${sizeMB} MB)`); } -mainAsync().catch(err => { console.error(err); process.exit(1); }); +// Only run mainAsync() when merge-notices is the entry point -- not when a test +// or another module imports the exported helpers (mirrors scan-licenses.ts). +if (/merge-notices(\.[jt]s)?$/.test(process.argv[1] || '')) { + mainAsync().catch(err => { console.error(err); process.exit(1); }); +} diff --git a/build/azure-pipelines/oss/scan-licenses.ts b/build/azure-pipelines/oss/scan-licenses.ts index 0f8806b59e2b6..9997a8ce31e55 100644 --- a/build/azure-pipelines/oss/scan-licenses.ts +++ b/build/azure-pipelines/oss/scan-licenses.ts @@ -1101,6 +1101,12 @@ async function main(): Promise { // *.stuboverride.json file that merge-notices.ts consumes to let the scanner // entry beat CG on collision (mirrors the presence.json sibling pattern). const stubOverrideKeys = new Set(); + // Unresolved index: packages the scanner TRIED to resolve but for which it + // created NO row at all (fetch failed / no license / API failed). Written as + // a sibling *.unresolved.json that merge-notices.ts cross-checks against the + // final merged NOTICE so packages rescued downstream (e.g. via a + // cglicenses.json override) are excluded from the "not accounted for" list. + const unresolved: Array<{ name: string; version: string; reason: string }> = []; let scanned = 0; let noLicense = 0; @@ -1358,6 +1364,7 @@ async function main(): Promise { continue; } cgManifestFetchFailed++; + unresolved.push({ name, version: reg.version || commitHash.substring(0, 7), reason: 'cgmanifest-fetch-failed' }); console.warn(` FETCH FAILED: ${name} (${repoUrl}@${commitHash.substring(0, 7)}) -- no LICENSE resolved`); continue; } @@ -1484,6 +1491,7 @@ async function main(): Promise { const info = await fetchCratesIoJson(p.name); if (!info) { cargoApiFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-api-failed' }); console.warn(` CRATES.IO FAILED: ${p.name}@${p.version} -- no crate info`); return; } @@ -1495,6 +1503,7 @@ async function main(): Promise { // Spec sec. 6.6: a failed crates.io call must log and continue, never crash. if (!Array.isArray(info.versions) || !info.crate || typeof info.crate.id !== 'string') { cargoApiFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-api-failed' }); console.warn(` CRATES.IO FAILED: ${p.name}@${p.version} -- unexpected response shape (no versions/crate)`); return; } @@ -1506,6 +1515,7 @@ async function main(): Promise { const repoUrl = getCrateRepository(info); if (!repoUrl) { cargoFetchFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-no-repo-url' }); console.warn(` FETCH FAILED: ${p.name}@${p.version} -- no repository URL`); return; } @@ -1518,6 +1528,7 @@ async function main(): Promise { const result = await fetchCargoLicense(repoUrl, p.name, p.version, license); if (!result) { cargoFetchFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-no-license-resolved' }); console.warn(` FETCH FAILED: ${p.name}@${p.version} (${repoUrl}) -- no LICENSE resolved at any ref (needs cglicenses.json override)`); return; } @@ -1553,6 +1564,7 @@ async function main(): Promise { // Defense-in-depth: any unexpected throw in this task must log and // continue, never reject (which would crash the build per sec. 6.6). cargoApiFailed++; + unresolved.push({ name: p.name, version: p.version, reason: 'cargo-api-failed' }); console.warn(` CRATES.IO FAILED: ${p.name}@${p.version} -- unexpected error: ${(err as Error).message}`); } }); @@ -2131,6 +2143,14 @@ async function main(): Promise { const stubOverrideList = [...stubOverrideKeys].sort(); fs.writeFileSync(stubOverridePath, JSON.stringify(stubOverrideList, null, '\t'), 'utf8'); + // Write the unresolved index as a sibling file. These are packages the scanner + // tried to resolve but produced NO row for. merge-notices.ts cross-checks this + // against the final merged NOTICE so packages rescued downstream (e.g. a + // cglicenses.json override) are excluded. Mirrors the presence.json sibling. + const unresolvedPath = args['unresolved'] || (outputPath + '.unresolved.json'); + const unresolvedSorted = unresolved.slice().sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + fs.writeFileSync(unresolvedPath, JSON.stringify(unresolvedSorted, null, '\t'), 'utf8'); + // Summary console.log(''); console.log('=== License Scan Summary ==='); @@ -2188,6 +2208,7 @@ async function main(): Promise { console.log(` Output: ${outputPath}`); console.log(` Presence index (present but unlicensed): ${presence.length}`); console.log(` Presence output: ${presencePath}`); + console.log(` Unresolved (tried, no row created): ${unresolved.length}`); console.log(` Stub-override index: ${stubOverrideList.length}`); console.log(` Stub-override output: ${stubOverridePath}`); diff --git a/build/azure-pipelines/product-build-template.yml b/build/azure-pipelines/product-build-template.yml index 8b66a8f95630c..7ff584ae04135 100644 --- a/build/azure-pipelines/product-build-template.yml +++ b/build/azure-pipelines/product-build-template.yml @@ -82,6 +82,10 @@ parameters: displayName: "Skip tests" type: boolean default: false + - name: VSCODE_USE_LEGACY_OSS_NOTICE + displayName: "Use legacy OSS Notice" + type: boolean + default: false - name: VSCODE_RUN_APISCAN type: boolean default: true diff --git a/build/azure-pipelines/product-build-variables.yml b/build/azure-pipelines/product-build-variables.yml index bcc546b2d1940..d7bef76175b4b 100644 --- a/build/azure-pipelines/product-build-variables.yml +++ b/build/azure-pipelines/product-build-variables.yml @@ -53,6 +53,9 @@ parameters: - name: VSCODE_STEP_ON_IT type: boolean default: false + - name: VSCODE_USE_LEGACY_OSS_NOTICE + type: boolean + default: false variables: - name: VSCODE_PRIVATE_BUILD @@ -85,6 +88,16 @@ variables: value: $[counter(variables['VSCODE_PUBLISH_COUNTER_PREFIX'], 1)] - name: VSCODE_STEP_ON_IT value: ${{ eq(parameters.VSCODE_STEP_ON_IT, true) }} + # Derived as an explicit lowercase string literal so downloadNotice.ts's + # case-sensitive JS check (process.env['VSCODE_OVERWRITE_TPN'] === 'false') + # works. ${{ eq(...) }} would emit 'True'/'False' (capitalized) and silently + # fail that comparison. Checkbox on => keep legacy notice ('false' = no + # overwrite); off => apply CG notice ('true'). + - name: VSCODE_OVERWRITE_TPN + ${{ if eq(parameters.VSCODE_USE_LEGACY_OSS_NOTICE, true) }}: + value: 'false' + ${{ else }}: + value: 'true' - name: VSCODE_BUILD_MACOS_UNIVERSAL value: ${{ and(eq(parameters.VSCODE_BUILD_MACOS, true), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true), eq(parameters.VSCODE_BUILD_MACOS_UNIVERSAL, true)) }} - name: VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index db99e5013997d..7212e74e881b6 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -111,6 +111,10 @@ parameters: displayName: "Skip tests" type: boolean default: false + - name: VSCODE_USE_LEGACY_OSS_NOTICE + displayName: "Use legacy OSS Notice" + type: boolean + default: false variables: - name: VSCODE_PRIVATE_BUILD @@ -143,6 +147,16 @@ variables: value: $[counter(variables['VSCODE_PUBLISH_COUNTER_PREFIX'], 1)] - name: VSCODE_STEP_ON_IT value: ${{ eq(parameters.VSCODE_STEP_ON_IT, true) }} + # Derived as an explicit lowercase string literal so downloadNotice.ts's + # case-sensitive JS check (process.env['VSCODE_OVERWRITE_TPN'] === 'false') + # works. ${{ eq(...) }} would emit 'True'/'False' (capitalized) and silently + # fail that comparison. Checkbox on => keep legacy notice ('false' = no + # overwrite); off => apply CG notice ('true'). + - name: VSCODE_OVERWRITE_TPN + ${{ if eq(parameters.VSCODE_USE_LEGACY_OSS_NOTICE, true) }}: + value: 'false' + ${{ else }}: + value: 'true' - name: VSCODE_BUILD_MACOS_UNIVERSAL value: ${{ and(eq(parameters.VSCODE_BUILD_MACOS, true), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true), eq(parameters.VSCODE_BUILD_MACOS_UNIVERSAL, true)) }} - name: VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME @@ -180,11 +194,49 @@ resources: type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release + - repository: distro + type: github + name: microsoft/vscode-distro + endpoint: Monaco + ref: main + - repository: capi + type: github + name: microsoft/vscode-capi + endpoint: Monaco + ref: main + - repository: encrypt + type: github + name: microsoft/vscode-encrypt + endpoint: Monaco + ref: main + - repository: vsda + type: github + name: microsoft/vsda + endpoint: Monaco + ref: main + - repository: regexp + type: github + name: microsoft/vscode-regexp-languagedetection + endpoint: Monaco + ref: main + - repository: vscode_loc + type: github + name: microsoft/vscode-extensions-loc + endpoint: Monaco + ref: main extends: template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines parameters: sdl: + sourceRepositoriesToScan: + exclude: + - repository: distro + - repository: capi + - repository: encrypt + - repository: vsda + - repository: regexp + - repository: vscode_loc tsa: enabled: true configFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/tsaoptions.json diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index a0c52e6830247..6f7888e934c21 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -10,6 +10,16 @@ resources: name: microsoft/vscode-engineering ref: main endpoint: Monaco + - repository: capi + type: github + name: microsoft/vscode-capi + ref: main + endpoint: Monaco + - repository: vscode_loc + type: github + name: microsoft/vscode-extensions-loc + ref: main + endpoint: Monaco parameters: - name: customNPMRegistry @@ -24,6 +34,12 @@ parameters: variables: - name: VSCODE_QUALITY value: stable + # The 1ES extension template configures the NPM registry itself (via the + # customNPMRegistry / Terrapin parameter below). Set NPM_REGISTRY to 'none' + # so the shared copilot/setup-steps.yml skips its own registry rewrite, which + # would otherwise corrupt package-lock.json resolved URLs when unset. + - name: NPM_REGISTRY + value: none extends: template: azure-pipelines/extension/stable.yml@templates @@ -31,6 +47,24 @@ extends: workingDirectory: ./extensions/copilot l10nSourcePaths: ./extensions/copilot/src nodeVersion: 22.21.x + # The copilot extension is bundled with esbuild and vendors prebuilt native + # modules (e.g. @os-theme, @picovoice/pvrecorder-node) that depend on a + # newer GLIBC than the template's sysroot check allows. The main product + # build (product-copilot.yml) ships these natives without a sysroot or GLIBC + # check, so disable the sysroot here for parity; this also skips the GLIBC/ + # GLIBCXX verification that would otherwise fail on those vendored natives. + buildPlatforms: + - name: Linux + vsceTarget: "" + useSysroot: false + codesignPaths: [] + # Copilot handles localization itself via copilot/l10n-steps.yml (importing + # translated strings from the microsoft/vscode-extensions-loc checkout), + # mirroring product-copilot.yml. Disable the template's own l10n jobs and + # the import-localized-files step: the latter runs before buildSteps and + # expects an implicit self checkout, which is unavailable here because + # build-steps.yml checks out microsoft/vscode-capi. + l10nShouldProcess: false cgIgnoreDirectories: $(Build.SourcesDirectory)/extensions/copilot/script @@ -46,21 +80,17 @@ extends: exit 1 displayName: Validate release branch + - checkout: self + path: s + lfs: true + fetchDepth: 1 + fetchTags: false + retryCountOnTaskFailure: 3 + displayName: Checkout microsoft/vscode + - template: copilot/setup-steps.yml - template: copilot/build-steps.yml - - script: | - set -e - - # The GLIBC check (from the shared extension template) scans every .node - # under $PWD. @github/copilot vendors @picovoice/pvrecorder-node, whose - # linux/x86_64 prebuild depends on GLIBC > 2.28. The native is not - # shipped in the VSIX, so just remove the pvrecorder prebuilds before - # the check runs. - find "$(Build.SourcesDirectory)" \ - -type d -name pvrecorder-node \ - -path "*@picovoice/pvrecorder-node" \ - -print -exec rm -rf {} + - displayName: Remove pvrecorder native binaries before GLIBC check + - template: copilot/l10n-steps.yml uploadSourceMaps: enabled: true @@ -78,6 +108,18 @@ extends: serviceTreeID: '1788a767-5861-45fb-973b-c686b67c5541' enabled: true + # The Copilot build steps check out microsoft/vscode-capi and + # microsoft/vscode-extensions-loc (via the Monaco GitHub App instead of a + # PAT). Every checked-out repository must be declared for the 1ES SDL source + # analysis, so re-declare the template defaults plus capi and vscode_loc here. + sourceRepositoriesToScan: + exclude: + - repository: translations + - repository: vscode-copilot-cache + - repository: templates + - repository: capi + - repository: vscode_loc + ${{ if eq(parameters.customNPMRegistry, false) }}: customNPMRegistry: '' diff --git a/build/azure-pipelines/product-copilot.yml b/build/azure-pipelines/product-copilot.yml index cebe647882fac..d47a23a6d24dc 100644 --- a/build/azure-pipelines/product-copilot.yml +++ b/build/azure-pipelines/product-copilot.yml @@ -5,6 +5,9 @@ parameters: - name: VSCODE_RELEASE type: boolean default: false + - name: VSCODE_UPLOAD_SOURCEMAPS + type: boolean + default: true jobs: - job: Copilot @@ -17,6 +20,7 @@ jobs: value: true steps: - checkout: self + path: s lfs: true fetchDepth: 1 fetchTags: false @@ -93,86 +97,88 @@ jobs: parameters: runIntegrationTests: false - - task: AzureCLI@2 - displayName: Upload source maps to CDN - inputs: - azureSubscription: vscode-cdn - scriptType: bash - addSpnToEnvironment: true - scriptLocation: inlineScript - inlineScript: | - set -e - - WORKING_DIR="$(Build.SourcesDirectory)/extensions/copilot" - SOURCE_MAP_DIR="$WORKING_DIR/dist-sourcemaps" - STORAGE_ACCOUNT="vscodeweb" - - if [ ! -d "$SOURCE_MAP_DIR" ]; then - echo "Source maps directory not found: $SOURCE_MAP_DIR" - echo "Skipping upload." - exit 0 - fi - - PUBLISHER=$(node -p "require('$WORKING_DIR/package.json').publisher") - NAME=$(node -p "require('$WORKING_DIR/package.json').name") - VERSION=$(node -p "require('$WORKING_DIR/package.json').version") - EXTENSION_ID=$(echo "${PUBLISHER}.${NAME}" | tr '[:upper:]' '[:lower:]') - - echo "Extension: $EXTENSION_ID" - echo "Version: $VERSION" - - MAP_FILES=$(find "$SOURCE_MAP_DIR" -name "*.map" -type f) - FILE_COUNT=$(echo "$MAP_FILES" | grep -c . || true) - - if [ "$FILE_COUNT" -eq 0 ]; then - echo "No source map files found in $SOURCE_MAP_DIR" - exit 0 - fi - - echo "Found $FILE_COUNT source map files" - - BLOB_URL="https://${STORAGE_ACCOUNT}.blob.core.windows.net" - PREFIX="sourcemaps/${EXTENSION_ID}/${VERSION}" - - echo "Uploading to: $BLOB_URL/\$web/$PREFIX/" - - UPLOADED=0 - for FILE in $MAP_FILES; do - FILENAME=$(basename "$FILE") - BLOB_NAME="$PREFIX/$FILENAME" - + # Source map upload is only needed for builds that ship the extension. + - ${{ if eq(parameters.VSCODE_UPLOAD_SOURCEMAPS, true) }}: + - task: AzureCLI@2 + displayName: Upload source maps to CDN + inputs: + azureSubscription: vscode-cdn + scriptType: bash + addSpnToEnvironment: true + scriptLocation: inlineScript + inlineScript: | + set -e + + WORKING_DIR="$(Build.SourcesDirectory)/extensions/copilot" + SOURCE_MAP_DIR="$WORKING_DIR/dist-sourcemaps" + STORAGE_ACCOUNT="vscodeweb" + + if [ ! -d "$SOURCE_MAP_DIR" ]; then + echo "Source maps directory not found: $SOURCE_MAP_DIR" + echo "Skipping upload." + exit 0 + fi + + PUBLISHER=$(node -p "require('$WORKING_DIR/package.json').publisher") + NAME=$(node -p "require('$WORKING_DIR/package.json').name") + VERSION=$(node -p "require('$WORKING_DIR/package.json').version") + EXTENSION_ID=$(echo "${PUBLISHER}.${NAME}" | tr '[:upper:]' '[:lower:]') + + echo "Extension: $EXTENSION_ID" + echo "Version: $VERSION" + + MAP_FILES=$(find "$SOURCE_MAP_DIR" -name "*.map" -type f) + FILE_COUNT=$(echo "$MAP_FILES" | grep -c . || true) + + if [ "$FILE_COUNT" -eq 0 ]; then + echo "No source map files found in $SOURCE_MAP_DIR" + exit 0 + fi + + echo "Found $FILE_COUNT source map files" + + BLOB_URL="https://${STORAGE_ACCOUNT}.blob.core.windows.net" + PREFIX="sourcemaps/${EXTENSION_ID}/${VERSION}" + + echo "Uploading to: $BLOB_URL/\$web/$PREFIX/" + + UPLOADED=0 + for FILE in $MAP_FILES; do + FILENAME=$(basename "$FILE") + BLOB_NAME="$PREFIX/$FILENAME" + + az storage blob upload \ + --account-name "$STORAGE_ACCOUNT" \ + --container-name '$web' \ + --name "$BLOB_NAME" \ + --file "$FILE" \ + --content-type "application/json" \ + --content-cache-control "max-age=31536000, public" \ + --auth-mode login \ + --overwrite \ + --only-show-errors + + UPLOADED=$((UPLOADED + 1)) + done + + echo "Successfully uploaded $UPLOADED source maps" + + # Upload commit-to-version mapping so the deminify service + # can resolve the patched extension version from a VS Code commit hash. + COMMIT_HASH="$(Build.SourceVersion)" + MAPPING_BLOB="sourcemaps/${EXTENSION_ID}/commits/${COMMIT_HASH}.json" + echo "{\"version\":\"${VERSION}\",\"extensionId\":\"${EXTENSION_ID}\"}" > /tmp/commit-version.json + + echo "Uploading commit mapping: $COMMIT_HASH -> $VERSION" az storage blob upload \ --account-name "$STORAGE_ACCOUNT" \ --container-name '$web' \ - --name "$BLOB_NAME" \ - --file "$FILE" \ + --name "$MAPPING_BLOB" \ + --file "/tmp/commit-version.json" \ --content-type "application/json" \ - --content-cache-control "max-age=31536000, public" \ + --content-cache-control "no-cache, no-store, must-revalidate" \ --auth-mode login \ --overwrite \ --only-show-errors - UPLOADED=$((UPLOADED + 1)) - done - - echo "Successfully uploaded $UPLOADED source maps" - - # Upload commit-to-version mapping so the deminify service - # can resolve the patched extension version from a VS Code commit hash. - COMMIT_HASH="$(Build.SourceVersion)" - MAPPING_BLOB="sourcemaps/${EXTENSION_ID}/commits/${COMMIT_HASH}.json" - echo "{\"version\":\"${VERSION}\",\"extensionId\":\"${EXTENSION_ID}\"}" > /tmp/commit-version.json - - echo "Uploading commit mapping: $COMMIT_HASH -> $VERSION" - az storage blob upload \ - --account-name "$STORAGE_ACCOUNT" \ - --container-name '$web' \ - --name "$MAPPING_BLOB" \ - --file "/tmp/commit-version.json" \ - --content-type "application/json" \ - --content-cache-control "no-cache, no-store, must-revalidate" \ - --auth-mode login \ - --overwrite \ - --only-show-errors - - echo "Commit mapping uploaded: $MAPPING_BLOB" + echo "Commit mapping uploaded: $MAPPING_BLOB" diff --git a/build/azure-pipelines/product-publish.yml b/build/azure-pipelines/product-publish.yml index 0a0ada4ec3d41..6403a6303a159 100644 --- a/build/azure-pipelines/product-publish.yml +++ b/build/azure-pipelines/product-publish.yml @@ -39,9 +39,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get ESRP Secrets" @@ -58,7 +58,7 @@ jobs: npm ci workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies - download: current diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml index 792c70ebb3890..6ff1658deafae 100644 --- a/build/azure-pipelines/product-quality-checks.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -20,9 +20,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -68,7 +68,7 @@ jobs: done workingDirectory: build env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install build dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -78,7 +78,7 @@ jobs: SYSROOT_ARCH="amd64" VSCODE_SYSROOT_PREFIX="-glibc-2.28-gcc-8.5.0" node -e 'import { getVSCodeSysroot } from "./build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' env: VSCODE_ARCH: x64 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Download vscode sysroots - script: | @@ -100,7 +100,7 @@ jobs: VSCODE_ARCH: x64 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -122,7 +122,6 @@ jobs: - script: node build/azure-pipelines/common/checkDistroCommit.ts displayName: Check distro commit env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" BUILD_SOURCEBRANCH: "$(Build.SourceBranch)" continueOnError: true condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) @@ -131,7 +130,7 @@ jobs: - script: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile & Hygiene - script: npm run download-builtin-extensions-cg @@ -149,6 +148,7 @@ jobs: displayName: "Generate ThirdPartyNotices (CG)" inputs: outputfile: $(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt + retryCountOnTaskFailure: 3 continueOnError: true # ========================================================================= @@ -160,8 +160,13 @@ jobs: # from a prior build. # # Fallback order: - # 1. Most recent successful run on the CURRENT branch - # 2. Most recent successful run on `main` + # 1. Most recent run on the CURRENT branch + # 2. Most recent run on `main` + # + # Carry-forward: the cache download reads only the SINGLE latest build, so a + # sustained CG outage (>1 build) breaks the chain unless every build keeps a + # copy. The "Upload CG NOTICE artifact" step below runs on the fallback path + # too, handing the last-good NOTICE forward. Ref: builds 450982->451029. # # TODO: consider enforcing a max-age check (~30 days) on cached NOTICEs. # Today the only signal is the warning emitted below. @@ -171,7 +176,27 @@ jobs: # run fresh against current HEAD, so extension licenses and # cglicenses.json overrides are always current-PR-accurate. # ========================================================================= - - task: DownloadPipelineArtifact@2 + + # Detect up front whether CG produced a usable NOTICE. The cache-download + # tasks below run ONLY when it did not -- otherwise they execute on the + # happy path and, when no prior cache artifact exists (e.g. the first build + # on a branch), fail-with-continueOnError and mark the whole build + # partiallySucceeded for no real reason. Uses the same >1KB heuristic as + # the "apply NOTICE fallback" step below so the two always agree. + - script: | + set -u + GEN="$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" + if [ -f "$GEN" ] && [ "$(wc -c < "$GEN")" -gt 1024 ]; then + echo "##[section]CG NOTICE generation succeeded; skipping cache download." + echo "##vso[task.setvariable variable=CG_NOTICE_OK]true" + else + echo "##[warning]CG NOTICE generation failed or produced empty output; will attempt cache fallback." + echo "##vso[task.setvariable variable=CG_NOTICE_OK]false" + fi + displayName: "Cache: detect whether CG NOTICE needs fallback" + continueOnError: true + + - task: DownloadBuildArtifacts@1 displayName: "Cache: download last good NOTICE (same branch)" inputs: buildType: specific @@ -181,11 +206,20 @@ jobs: branchName: $(Build.SourceBranch) allowPartiallySucceededBuilds: true artifactName: notice_output - itemPattern: "**/{ThirdPartyNotices.generated.txt,notice-meta.txt}" - targetPath: $(Pipeline.Workspace)/cached-notice-branch + # NB: one glob per line. DownloadBuildArtifacts@1 runs minimatch with + # brace expansion disabled, so a "**/{a,b}" pattern matches NOTHING and + # silently downloads 0 files (observed in build 450982, the first real + # CG outage post-cutover: the artifact downloaded but every file was + # excluded, so the apply-fallback step saw no cache). Multi-line plain + # globs are matched reliably. + itemPattern: | + **/ThirdPartyNotices.generated.txt + **/notice-meta.txt + downloadPath: $(Pipeline.Workspace)/cached-notice-branch continueOnError: true + condition: and(succeeded(), ne(variables.CG_NOTICE_OK, 'true')) - - task: DownloadPipelineArtifact@2 + - task: DownloadBuildArtifacts@1 displayName: "Cache: download last good NOTICE (main fallback)" inputs: buildType: specific @@ -195,18 +229,22 @@ jobs: branchName: refs/heads/main allowPartiallySucceededBuilds: true artifactName: notice_output - itemPattern: "**/{ThirdPartyNotices.generated.txt,notice-meta.txt}" - targetPath: $(Pipeline.Workspace)/cached-notice-main + # See note on the same-branch download above: brace patterns match 0 + # files in DownloadBuildArtifacts@1. Keep one plain glob per line. + itemPattern: | + **/ThirdPartyNotices.generated.txt + **/notice-meta.txt + downloadPath: $(Pipeline.Workspace)/cached-notice-main continueOnError: true - condition: ne(variables['Build.SourceBranch'], 'refs/heads/main') + condition: and(succeeded(), ne(variables.CG_NOTICE_OK, 'true'), ne(variables['Build.SourceBranch'], 'refs/heads/main')) - script: | set -u GEN="$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" - BRANCH_CACHE="$(Pipeline.Workspace)/cached-notice-branch/ThirdPartyNotices.generated.txt" - BRANCH_META="$(Pipeline.Workspace)/cached-notice-branch/notice-meta.txt" - MAIN_CACHE="$(Pipeline.Workspace)/cached-notice-main/ThirdPartyNotices.generated.txt" - MAIN_META="$(Pipeline.Workspace)/cached-notice-main/notice-meta.txt" + BRANCH_CACHE="$(Pipeline.Workspace)/cached-notice-branch/notice_output/ThirdPartyNotices.generated.txt" + BRANCH_META="$(Pipeline.Workspace)/cached-notice-branch/notice_output/notice-meta.txt" + MAIN_CACHE="$(Pipeline.Workspace)/cached-notice-main/notice_output/ThirdPartyNotices.generated.txt" + MAIN_META="$(Pipeline.Workspace)/cached-notice-main/notice_output/notice-meta.txt" # Emit what a cached NOTICE was built from, if we have the sidecar. # The sidecar is written by the upload step on every successful run. @@ -221,7 +259,8 @@ jobs: fi } - # `notice@0` succeeded if the file exists and is non-trivial (>1KB). + # Safety net: never overwrite a valid NOTICE. If CG produced a good + # file, skip the fallback even if we were reached by mistake. if [ -f "$GEN" ] && [ "$(wc -c < "$GEN")" -gt 1024 ]; then echo "##[section]CG NOTICE generation succeeded; no fallback needed." echo "##vso[task.setvariable variable=NOTICE_FROM_CACHE]false" @@ -251,38 +290,58 @@ jobs: echo "##vso[task.setvariable variable=NOTICE_FROM_CACHE]none" displayName: "Cache: apply NOTICE fallback if CG failed" continueOnError: true + condition: and(succeeded(), ne(variables.CG_NOTICE_OK, 'true')) - script: | + set -u + GEN="$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" echo "=== CG NOTICE artifact ===" - if [ -f "$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" ]; then - SIZE=$(wc -c < "$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt") - echo "Generated file size: $SIZE bytes (NOTICE_FROM_CACHE=$NOTICE_FROM_CACHE)" - cp "$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" "$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.generated.txt" - echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.generated.txt" - - # Write a sidecar so future runs (and humans) know what this NOTICE - # was built from. Consumed by the "Cache: apply NOTICE fallback" step - # of subsequent builds when they fall back to this artifact. - # Only meaningful when this run generated the NOTICE fresh; if we - # fell back to a cache, skip writing the sidecar so we don't - # mis-attribute someone else's NOTICE to this commit. - if [ "${NOTICE_FROM_CACHE:-false}" = "false" ]; then - { - echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "source_branch: $(Build.SourceBranch)" - echo "source_commit: $(Build.SourceVersion)" - echo "build_id: $(Build.BuildId)" - echo "build_number: $(Build.BuildNumber)" - echo "size_bytes: $SIZE" - } > "$(Build.ArtifactStagingDirectory)/notice-meta.txt" - echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$(Build.ArtifactStagingDirectory)/notice-meta.txt" + + # Publish on both paths: fresh CG success AND carry-forward (re-publishing + # a cached NOTICE so the chain survives a multi-build outage; see above). + if [ ! -f "$GEN" ] || [ "$(wc -c < "$GEN")" -le 1024 ]; then + echo "No usable ThirdPartyNotices.generated.txt to publish; skipping (no CG output and no cached fallback)." + exit 0 + fi + + SIZE=$(wc -c < "$GEN") + echo "Generated file size: $SIZE bytes (source: CG_NOTICE_OK=${CG_NOTICE_OK:-unset}, NOTICE_FROM_CACHE=${NOTICE_FROM_CACHE:-unset})" + cp "$GEN" "$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.generated.txt" + echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.generated.txt" + + # Sidecar for the next build's fallback. Fresh run records this build's + # provenance; carry-forward preserves the original + appends a marker. + META_OUT="$(Build.ArtifactStagingDirectory)/notice-meta.txt" + if [ "${CG_NOTICE_OK:-}" = "true" ]; then + { + echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "source_branch: $(Build.SourceBranch)" + echo "source_commit: $(Build.SourceVersion)" + echo "build_id: $(Build.BuildId)" + echo "build_number: $(Build.BuildNumber)" + echo "size_bytes: $SIZE" + } > "$META_OUT" + else + case "${NOTICE_FROM_CACHE:-}" in + branch) SRC_META="$(Pipeline.Workspace)/cached-notice-branch/notice_output/notice-meta.txt" ;; + main) SRC_META="$(Pipeline.Workspace)/cached-notice-main/notice_output/notice-meta.txt" ;; + *) SRC_META="" ;; + esac + if [ -n "$SRC_META" ] && [ -f "$SRC_META" ]; then + cp "$SRC_META" "$META_OUT" else - echo "Skipping notice-meta.txt sidecar: NOTICE_FROM_CACHE=$NOTICE_FROM_CACHE (don't re-publish someone else's metadata as our own)" + echo "original_provenance: unavailable (cached NOTICE predates the notice-meta.txt sidecar)" > "$META_OUT" fi - else - echo "ERROR: ThirdPartyNotices.generated.txt not found (CG failed AND no cache available)" + { + echo "carried_forward_by_build: $(Build.BuildId)" + echo "carried_forward_by_number: $(Build.BuildNumber)" + echo "carried_forward_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "carried_forward_reason: CG NOTICE generation failed; re-published last-good NOTICE to keep the cache chain alive." + } >> "$META_OUT" fi - displayName: "Upload CG NOTICE artifact" + echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$META_OUT" + displayName: "Upload CG NOTICE artifact (fresh or carried-forward)" + condition: and(succeeded(), or(eq(variables.CG_NOTICE_OK, 'true'), in(variables.NOTICE_FROM_CACHE, 'branch', 'main'))) continueOnError: true - script: | @@ -292,10 +351,6 @@ jobs: --repo "$(Build.SourcesDirectory)" \ --cg "$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" \ --output "$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.extensions.txt" - displayName: "Scan extension-bundled licenses" - continueOnError: true - - - script: | node .oss-build-out/merge-notices.js \ --cg "$(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt" \ --extensions "$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.extensions.txt" \ @@ -306,7 +361,7 @@ jobs: # Upload all artifacts echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.extensions.txt" echo "##vso[artifact.upload containerfolder=notice_output;artifactname=notice_output]$(Build.ArtifactStagingDirectory)/ThirdPartyNotices.new.txt" - displayName: "Merge CG + extension notices into .new.txt" + displayName: "Build merged NOTICE (scan extensions + merge into .new.txt)" continueOnError: true - task: AzureCLI@2 diff --git a/build/azure-pipelines/product-smoke-flaky.yml b/build/azure-pipelines/product-smoke-flaky.yml new file mode 100644 index 0000000000000..de729e6d13944 --- /dev/null +++ b/build/azure-pipelines/product-smoke-flaky.yml @@ -0,0 +1,209 @@ +# Daily smoke-test flaky-detection pipeline. +# +# Builds VS Code from source on Linux, Windows and macOS and runs ONLY the +# Electron smoke tests, repeated 20 times per platform. Each repetition is its +# own step (continueOnError) so a single flaky failure does not abort the run and +# every iteration produces its own timeline record. The pass/fail tally is read +# back from the build timeline by the vscode-probot `SmokeFlaky` Azure Function +# (wired up via an ADO service hook on build completion), which posts a summary +# to the Slack #build channel. +# +# We build from source rather than testing a published build on purpose: product +# builds are not always released, so depending on a published artifact would make +# us miss the daily schedule. Building here keeps the run self-contained. + +pr: none +trigger: none + +schedules: + # Once per day at 04:00 UTC (= 5am CET / 6am CEST) — a quiet window for the ADO + # org. `always: true` forces the run even when there are no new commits, since + # flaky behavior is environmental and worth sampling every day regardless of + # source changes. + - cron: "0 4 * * *" + displayName: Daily at 04:00 UTC / 5am CET (smoke flaky detection) + branches: + include: + - main + always: true + +parameters: + - name: VSCODE_QUALITY + displayName: Quality + type: string + default: insider + values: + - exploration + - insider + - stable + - name: NPM_REGISTRY + displayName: "Custom NPM Registry" + type: string + default: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/" + # The smoke test is run once per entry in this list. 20 entries => 20 runs per + # platform (the flaky-detection sample size). Exposed as an object so it can be + # shortened for manual debugging runs from the ADO UI. + - name: iterations + displayName: "Smoke test iterations (one run per entry)" + type: object + default: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] + - name: VSCODE_BUILD_MACOS + displayName: "🎯 macOS arm64" + type: boolean + default: true + - name: VSCODE_BUILD_LINUX + displayName: "🎯 Linux x64" + type: boolean + default: true + - name: VSCODE_BUILD_WIN32 + displayName: "🎯 Windows x64" + type: boolean + default: true + +variables: + - name: VSCODE_QUALITY + value: ${{ parameters.VSCODE_QUALITY }} + - name: NPM_REGISTRY + value: ${{ parameters.NPM_REGISTRY }} + # Compile templates skip publish/codesign/artifact-staging when VSCODE_CIBUILD + # is true; it's passed directly as a job parameter. VSCODE_STEP_ON_IT must be + # 'false' to keep the build steps enabled. + - name: VSCODE_STEP_ON_IT + value: false + - name: VSCODE_MIXIN_REPO + value: microsoft/vscode-distro + - name: VSCODE_OVERWRITE_TPN + value: "true" + # The Copilot extension build patches its package.json version for pre-release + # (non-stable) quality and requires a per-build counter (0-99, reset daily) to + # keep versions unique. Mirrors product-build.yml. The counter resets each day + # per quality+branch, so this daily pipeline stays at a low value. + - name: VSCODE_PUBLISH_COUNTER_PREFIX + value: $[format('{0:yyyyMMdd}-{1}-{2}', pipeline.startTime, variables['VSCODE_QUALITY'], variables['Build.SourceBranch'])] + - name: VSCODE_PUBLISH_COUNTER + value: $[counter(variables['VSCODE_PUBLISH_COUNTER_PREFIX'], 1)] + - name: skipComponentGovernanceDetection + value: true + - name: Codeql.SkipTaskAutoInjection + value: true + +name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }} smoke flaky)" + +resources: + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + - repository: distro + type: github + name: microsoft/vscode-distro + endpoint: Monaco + ref: main + - repository: capi + type: github + name: microsoft/vscode-capi + endpoint: Monaco + ref: main + - repository: encrypt + type: github + name: microsoft/vscode-encrypt + endpoint: Monaco + ref: main + - repository: vsda + type: github + name: microsoft/vsda + endpoint: Monaco + ref: main + - repository: regexp + type: github + name: microsoft/vscode-regexp-languagedetection + endpoint: Monaco + ref: main + - repository: vscode_loc + type: github + name: microsoft/vscode-extensions-loc + endpoint: Monaco + ref: main + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + sdl: + sourceRepositoriesToScan: + exclude: + - repository: distro + - repository: capi + - repository: encrypt + - repository: vsda + - repository: regexp + - repository: vscode_loc + tsa: + enabled: false + codeql: + compiled: + enabled: false + justificationForDisabling: Smoke test repetition only, not a shipping build + credscan: + suppressionsFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/CredScanSuppressions.json + eslint: + enabled: false + binskim: + enabled: false + sourceAnalysisPool: 1es-windows-2022-x64 + createAdoIssuesForJustificationsForDisablement: false + stages: + # The per-OS compile template unconditionally waits (up to 30 min) for a + # `Copilot` job to publish the `copilot_vsix` artifact, which is mixed into + # the build. This stage produces it — mirroring product-build.yml. It runs + # in parallel with the build stages (dependsOn: []); the download step polls + # the whole-run timeline for the artifact. Without it every build stage + # fails at "Download Copilot VSIX". + - stage: Copilot + dependsOn: [] + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + jobs: + - template: build/azure-pipelines/product-copilot.yml@self + parameters: + VSCODE_PUBLISH: false + VSCODE_RELEASE: false + # This is a test-only run; don't push the Copilot extension's source + # maps to the CDN. + VSCODE_UPLOAD_SOURCEMAPS: false + + - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: + - stage: Linux + dependsOn: [] + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + jobs: + - template: build/azure-pipelines/linux/product-smoke-flaky-linux.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + iterations: ${{ parameters.iterations }} + + - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: + - stage: Windows + dependsOn: [] + pool: + name: 1es-windows-2022-x64 + os: windows + jobs: + - template: build/azure-pipelines/win32/product-smoke-flaky-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + iterations: ${{ parameters.iterations }} + + - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: + - stage: macOS + dependsOn: [] + pool: + name: AcesShared + os: macOS + jobs: + - template: build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml@self + parameters: + iterations: ${{ parameters.iterations }} diff --git a/build/azure-pipelines/web/product-build-web-node-modules.yml b/build/azure-pipelines/web/product-build-web-node-modules.yml index 75a0cc6cd6e75..fdd15d54f1a10 100644 --- a/build/azure-pipelines/web/product-build-web-node-modules.yml +++ b/build/azure-pipelines/web/product-build-web-node-modules.yml @@ -18,9 +18,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -73,7 +73,7 @@ jobs: env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index 86d6215f31cf0..7666105b4d147 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -30,9 +30,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -89,7 +89,7 @@ jobs: env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -119,7 +119,7 @@ jobs: - script: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: npx deemon --attach -- node build/azure-pipelines/common/downloadCopilotVsix.ts @@ -135,7 +135,7 @@ jobs: tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-web echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Build - task: AzureCLI@2 diff --git a/build/azure-pipelines/win32/product-build-win32-cli.yml b/build/azure-pipelines/win32/product-build-win32-cli.yml index c01e421e6d443..fd56ba730a085 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli.yml @@ -35,6 +35,13 @@ jobs: versionSource: fromFile versionFilePath: .nvmrc + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get GitHub token" + inputs: + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 diff --git a/build/azure-pipelines/win32/product-build-win32-node-modules.yml b/build/azure-pipelines/win32/product-build-win32-node-modules.yml index 6780073f57af7..9167e5b6a004b 100644 --- a/build/azure-pipelines/win32/product-build-win32-node-modules.yml +++ b/build/azure-pipelines/win32/product-build-win32-node-modules.yml @@ -29,9 +29,9 @@ jobs: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -76,7 +76,7 @@ jobs: npm_config_foreground_scripts: "true" ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" retryCountOnTaskFailure: 5 displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) diff --git a/build/azure-pipelines/win32/product-smoke-flaky-win32.yml b/build/azure-pipelines/win32/product-smoke-flaky-win32.yml new file mode 100644 index 0000000000000..e1241ae3c70b3 --- /dev/null +++ b/build/azure-pipelines/win32/product-smoke-flaky-win32.yml @@ -0,0 +1,66 @@ +parameters: + - name: VSCODE_QUALITY + type: string + - name: iterations + type: object + +jobs: + - job: WindowsSmokeFlaky + displayName: Windows (Electron) + timeoutInMinutes: 300 + variables: + VSCODE_ARCH: x64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/crashes + artifactName: crash-dump-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Crash Reports + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/logs + artifactName: logs-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Log Files + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + steps: + # Build VS Code from source. No VSCODE_RUN_*_TESTS flags are passed, so the + # compile template builds the client/server but skips compiling the test + # suites and skips the unit/integration/smoke test template entirely — we + # run only the smoke tests below, nothing else. + - template: ./steps/product-build-win32-compile.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_CIBUILD: true + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + + - powershell: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" + env: + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) + displayName: Download Electron and Playwright + retryCountOnTaskFailure: 3 + + # The compile template only compiles the smoke suite when a test flag is + # set; since we pass none, compile it explicitly here. + - powershell: npm run compile --prefix test/smoke + displayName: Compile smoke tests + + # Guarantee the artifact directories exist so the 1ES output publishing + # never fails on a missing path. The smoke runner writes logs under + # .build/logs and crash dumps under .build/crashes (only on a crash). + - powershell: New-Item -ItemType Directory -Force -Path .build\crashes, .build\logs | Out-Null + displayName: Ensure artifact directories exist + + # Run the Electron smoke test once per iteration. continueOnError lets every + # iteration run even if some fail, and gives each run its own timeline record + # so the SmokeFlaky function can tally passes vs. failures. + - ${{ each i in parameters.iterations }}: + - powershell: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)" + displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" + continueOnError: true + timeoutInMinutes: 20 diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index 1d41892bf8d31..65c3423d64800 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -22,9 +22,9 @@ steps: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -88,7 +88,7 @@ steps: npm_config_foreground_scripts: "true" ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" retryCountOnTaskFailure: 5 displayName: Install dependencies @@ -106,12 +106,13 @@ steps: - powershell: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - powershell: npm run gulp "vscode-symbols-win32-${{ parameters.VSCODE_ARCH }}" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Symbols - powershell: | 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 2539ad4f9180c..416c522d7d195 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -33,9 +33,9 @@ steps: - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" + azureSubscription: vscode-oss-build-secrets + KeyVaultName: vscode-oss-build-secrets + SecretsFilter: "github-token-code-oss" - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) @@ -84,7 +84,7 @@ steps: npm_config_foreground_scripts: "true" ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" retryCountOnTaskFailure: 5 displayName: Install dependencies condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -111,6 +111,16 @@ steps: - powershell: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality + # Start pulling the CG-generated NOTICE from the parallel Quality stage + # in the background (overwrites repo-root ThirdPartyNotices.txt before packaging). + # Publish builds only (CIBUILD=false) — CI test jobs never ship a notice. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - pwsh: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Pull CG NOTICE (background) + - template: ../../common/install-builtin-extensions.yml@self - template: ../../common/agent-sdk-produce.yml@self @@ -126,7 +136,7 @@ steps: - powershell: npm run gulp core-ci env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" displayName: Compile - script: node build/azure-pipelines/common/extract-telemetry.ts @@ -157,6 +167,17 @@ steps: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Download Copilot VSIX + # Block on the CG NOTICE download and overwrite ThirdPartyNotices.txt + # right before gulp packages it. Non-fatal: falls back to legacy on any problem. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - pwsh: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Apply CG NOTICE + # deemon --attach can exit non-zero if the background daemon died; never fail for that. + continueOnError: true + - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" @@ -168,7 +189,8 @@ steps: echo "##vso[task.setvariable variable=BUILT_CLIENT]true" echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH)" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build client # Note: the appx prepare step has to follow Build client step since build step replaces the template @@ -199,7 +221,8 @@ steps: echo "##vso[task.setvariable variable=BUILT_SERVER]true" echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(CodeSigningFolderPath),$(Agent.BuildDirectory)/vscode-server-win32-$(VSCODE_ARCH)" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server - powershell: | @@ -213,7 +236,8 @@ steps: echo "##vso[task.setvariable variable=BUILT_WEB]true" echo "##vso[task.setvariable variable=CodeSigningFolderPath]$(CodeSigningFolderPath),$(Agent.BuildDirectory)/vscode-server-win32-$(VSCODE_ARCH)-web" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Build server (web) - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: diff --git a/build/azure-pipelines/win32/steps/product-build-win32-test.yml b/build/azure-pipelines/win32/steps/product-build-win32-test.yml index 187856b227229..128ff569ca03e 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-test.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-test.yml @@ -11,7 +11,8 @@ parameters: steps: - powershell: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) displayName: Download Electron and Playwright retryCountOnTaskFailure: 3 diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 7db96c8acfc3b..37aeada5f98f6 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -6c25b7496a0f3f4e325965ac3d934f9460e6b39fc2aa05b2aefecb1e212e7535 *chromedriver-v42.2.0-darwin-arm64.zip -7ea0a5378a615b816c6652c1e8f62b25fee751f34c669eb3eff122dcde98dffc *chromedriver-v42.2.0-darwin-x64.zip -8f6c2462e05491ab7a6ea6492fe7f62c8de1b01900978dd3dcbc878fde6f5e3e *chromedriver-v42.2.0-linux-arm64.zip -72cdb458b48306ec4939021198696926651a6a6dd47b8acf4486d77689ffe88f *chromedriver-v42.2.0-linux-armv7l.zip -ebe0fb1e5eb8a83a20612d660191d8292826d46d93701fe6390a9a5ab69aede0 *chromedriver-v42.2.0-linux-x64.zip -f19d5491a6c5d2970b05a42e1e2a5bcab75206423cea9dfe8ef5c2b4a0ceb38b *chromedriver-v42.2.0-mas-arm64.zip -30c88509d9640b4bf0066da7b6df7c4bb61d894df20941daaeeead598da40c28 *chromedriver-v42.2.0-mas-x64.zip -a6a21d19b84938d32ea75ad19013370c8937536c049d4d5c44175fbcf3703c5b *chromedriver-v42.2.0-win32-arm64.zip -da076702e72317843754e72cc1895c4d223e423f177061342ae5aec78a7281a9 *chromedriver-v42.2.0-win32-ia32.zip -97074022e6b5048b0bd9932c011e8377148c04b0e5455ea2e24bfc048d888297 *chromedriver-v42.2.0-win32-x64.zip -4012c6a738d83799544cabd04e41c8eba2467e4de573664326eaea174f2f8b47 *electron-api.json -bffe18be718c641a0fdfa8bf2ab82dbce462b8809be97898a9c4dfd136be071d *electron-v42.2.0-darwin-arm64-dsym-snapshot.zip -5a341581905b84ae62b4f7664c0fd950ec78fef172042dd8b55dc2a8d9aea66a *electron-v42.2.0-darwin-arm64-dsym.tar.xz -110b25eadfd680ad44ba5b43bf59dcf31297976ee128735e3ef9bbc2a35758cf *electron-v42.2.0-darwin-arm64-symbols.zip -f45f80da0a2d005530b70f6f6b00756dbf875947a21e533041a05b3c4d629f79 *electron-v42.2.0-darwin-arm64.zip -aefc2008c08cd3795cf997a5c5d88772497f460c5cc6f5008b01c412d7534417 *electron-v42.2.0-darwin-x64-dsym-snapshot.zip -a6b70b51de1f05541e42138d02768ef72940c125379b0d7a929fc3e5f2e1eb75 *electron-v42.2.0-darwin-x64-dsym.tar.xz -2d64a5bf8af6b9dccecde9ac09f20cbae36a76be537c4b3e5c3a238add5a4a01 *electron-v42.2.0-darwin-x64-symbols.zip -cd6a2d4feca84b7e7b2c4a5a13a443fc3c1173e77b05f798deaab2f0f41002a1 *electron-v42.2.0-darwin-x64.zip -2c0e81bc0e82c9e1966d4042b565dbf4924bd6365c08e6cc715001b3842ff9cd *electron-v42.2.0-linux-arm64-debug.zip -30983431149fc5a813c2145843744292e2553577f19d75aa09cbf8f7100d9319 *electron-v42.2.0-linux-arm64-symbols.zip -1f2037dbdcb8b1327b855ec15fbe3fb8a7f27786b331d17866e88377a0606ad8 *electron-v42.2.0-linux-arm64.zip -948553edfe3fa5272327d867b68aceb6e272cdeb68df4a40a6ffa045976531d2 *electron-v42.2.0-linux-armv7l-debug.zip -fed24d4f0c4fa40c3dfe38fdea423d6f36dc09630a614fb9060373fe30f310c4 *electron-v42.2.0-linux-armv7l-symbols.zip -00de1cc51859a4e064a2178cb29c899feadfabf24d926d8f684c30e6ab9b72a7 *electron-v42.2.0-linux-armv7l.zip -e0a0c2f97e97321ca194bf2ed7275ad6b31c0c0f4f7e4989838c4fd74728f306 *electron-v42.2.0-linux-x64-debug.zip -cf65567a4c5ef25c24cbdc8a6059a419773756f79313a1e682272c42f5716f2a *electron-v42.2.0-linux-x64-symbols.zip -9caeeb15dada37cb3a2d80bf0f5899d175db026a4def11560890bd2f19684909 *electron-v42.2.0-linux-x64.zip -69ad3951065eb33bb6533f872267828e4ea728ca7d539c649cebc966c0ea1bab *electron-v42.2.0-mas-arm64-dsym-snapshot.zip -38c4beeb71171ab97f55e9a213aa44a45e20e62cc73207532f29ff70a2698539 *electron-v42.2.0-mas-arm64-dsym.tar.xz -359be04c4da1b1979f0e1275b3abd5bd2cbab43c2290d71bfdeb174c1f42beba *electron-v42.2.0-mas-arm64-symbols.zip -535aca88dbe2977d22dc63e47887d09c7e41e8ccf4c0f23d67b7ce593e2341dd *electron-v42.2.0-mas-arm64.zip -e621151d901eeab917e225c97e102fa33ba5225c5cd5b8c152948d8e4a60d104 *electron-v42.2.0-mas-x64-dsym-snapshot.zip -0d33ddacaa69b923fe0117f6ed7d92c8918de6e98daa5f0e9462acf1ea758c5e *electron-v42.2.0-mas-x64-dsym.tar.xz -1527430d26a58505c0e3e52f27afc5aa5feffa5dd17d83d1b89ebcb16dfb4c0c *electron-v42.2.0-mas-x64-symbols.zip -576abe887a65587910ffc481fcaac39bb42e2791c54ae3a2c000431a44299cd2 *electron-v42.2.0-mas-x64.zip -d4e06302625806acf6a39b15a22835de6d4c38ce0cedfe1716d4c0df8416fc2b *electron-v42.2.0-win32-arm64-pdb.zip -09792a89e357258afae9d3abe51f42592062bafb1b98e80d77daa1cc554e26f2 *electron-v42.2.0-win32-arm64-symbols.zip -615b0145f304e0eea0ffd20c0fcf0e8e7e5255e1c3f3ae0577725a16ebb91994 *electron-v42.2.0-win32-arm64-toolchain-profile.zip -1e6b5639cbbb0134f41e5cb62a283ebc34bc580a8fb21349d711f275d60f9705 *electron-v42.2.0-win32-arm64.zip -5b5803e32a2ddfb51bbf4a0097a6fb48f964b2844150cec9c6fb48d3eef16865 *electron-v42.2.0-win32-ia32-pdb.zip -218ce2ebacc35de9bc9ac83a1b65551fd53c9622b71516a484bcfed0aec2c4a6 *electron-v42.2.0-win32-ia32-symbols.zip -615b0145f304e0eea0ffd20c0fcf0e8e7e5255e1c3f3ae0577725a16ebb91994 *electron-v42.2.0-win32-ia32-toolchain-profile.zip -1a866e0634ff95f83a043c7c6738e148f38362ed0afa60aa5eb8dcab16def7b5 *electron-v42.2.0-win32-ia32.zip -82329a1c558392b1d2a80cfc24d1463cf88fd0bb7b542eee24305ae099935bf6 *electron-v42.2.0-win32-x64-pdb.zip -00b8c0437bb75b0db1dfa2bf23e6a6d2436c0665e2a627bd2732ccab0fd1e648 *electron-v42.2.0-win32-x64-symbols.zip -615b0145f304e0eea0ffd20c0fcf0e8e7e5255e1c3f3ae0577725a16ebb91994 *electron-v42.2.0-win32-x64-toolchain-profile.zip -6e034b748ad5ed9445bd3da4b7d0792ed49556774a541217b507c156b00dd69a *electron-v42.2.0-win32-x64.zip -acb2fdd6e99a2056ff88fe26d94b141922e30c5a6a2b11868aa5e708ba30266d *electron.d.ts -3099226c4eb0c13134bbdd970c0fac8a372547b401c54552a89fa1689470dbd7 *ffmpeg-v42.2.0-darwin-arm64.zip -1f4fac2e8f4f8b136bc29ce6be50598f217300e10c1a203b6b893f623ba516ba *ffmpeg-v42.2.0-darwin-x64.zip -1fd892c2195d89a7d4f926167c122c34ab7ce15a61b75c8e5ba0cbfc47ebdb4f *ffmpeg-v42.2.0-linux-arm64.zip -6b6a200705ded7211b02d7ee56396d99c2003aa6b0b83bdd8c89f95702ea459d *ffmpeg-v42.2.0-linux-armv7l.zip -ffb78ccf0b2cbf1a1c0da2a69d8021337ae5352642a2d84adae951f95c771696 *ffmpeg-v42.2.0-linux-x64.zip -3099226c4eb0c13134bbdd970c0fac8a372547b401c54552a89fa1689470dbd7 *ffmpeg-v42.2.0-mas-arm64.zip -1f4fac2e8f4f8b136bc29ce6be50598f217300e10c1a203b6b893f623ba516ba *ffmpeg-v42.2.0-mas-x64.zip -87ad1b3868751b2af34cfebd62f9874ced837fc4389adad3e7659a437afcffb1 *ffmpeg-v42.2.0-win32-arm64.zip -57af529a0cc217bb5f20c67554eb526926b853a1f1594a06e8423662cead9c09 *ffmpeg-v42.2.0-win32-ia32.zip -62dc51e9290b444c6640048a715d8d7b60fe3e546a98f0faff945e4a522250f8 *ffmpeg-v42.2.0-win32-x64.zip -72ea4fdeabf3a49dd86bdeb136c916f7b54952b439edd527321963a5dbb0460d *hunspell_dictionaries.zip -98e8f208b8fd697e2a035d03e83de5731ba92b23f13f826abc60c0b7eab0d952 *libcxx-objects-v42.2.0-linux-arm64.zip -7187c3f0406a04b8e19af6ca374e6d7ca7833e47ea2382f7fba1ed7061f7d8be *libcxx-objects-v42.2.0-linux-armv7l.zip -7254b02f7220aa47210c382f45668016710d07cd7c7c61da5c4c556cc2334446 *libcxx-objects-v42.2.0-linux-x64.zip -37ade3096c7362b9df8945311f55abb8d039f93aabacec086e449cee4a31dcab *libcxx_headers.zip -c623bfe37d755eb9020fc080d4ef0df799acf3439b7b588f9a62d9b6e3908b75 *libcxxabi_headers.zip -44b16dd0e2bade1253e6f36235a43f005ed6472c1017195ee1ea1e629b8ab504 *mksnapshot-v42.2.0-darwin-arm64.zip -9d59364581e6001d655cd6c659e76ab80799d794e22b0c990b181268faa0492c *mksnapshot-v42.2.0-darwin-x64.zip -6035f7f148470352c04f0591d360685724a7784780f6d74c4fdaa99c71b8be6f *mksnapshot-v42.2.0-linux-arm64-x64.zip -27d4c39086c376744bd606f55c7b6c1420d0267f94cd8974031ce4b1f88320e6 *mksnapshot-v42.2.0-linux-armv7l-x64.zip -ca549469050d6a360e6b95ea202cb61ea704db51f7d67aa67c457e5dfa98b459 *mksnapshot-v42.2.0-linux-x64.zip -8d2271bd6cb9a16d34e02dfc92efb1fd1a2908038165efc9aa161cf5e62dc7ae *mksnapshot-v42.2.0-mas-arm64.zip -ae8453efb615242dd27de97cf1f9c6b8f4c6ad9cfab5fc476671d8751c5ac319 *mksnapshot-v42.2.0-mas-x64.zip -f21cdde8044078ce14acd0c65af1e7cf999d78e6d40d467cc73f854c9e9ef481 *mksnapshot-v42.2.0-win32-arm64-x64.zip -4b5b7d68067f55a0fc4dd4c295ca115070f288684e7942e0e80badc2b64754a6 *mksnapshot-v42.2.0-win32-ia32.zip -25d850c5674e0a821bb5897197b6e38c0d55a5cf0505d4d79a39b608b54c4b5c *mksnapshot-v42.2.0-win32-x64.zip +f46a52e829e4e8d294887346e2cd65a43cda3cf0c4988f9e8c40bff083b21ca0 *chromedriver-v42.5.0-darwin-arm64.zip +95ea8ac2949531f92a69e935345fb5f2c49dabb4d03244e90431b54e4177d66c *chromedriver-v42.5.0-darwin-x64.zip +217eff942b1fb63767a2fccdf309b8af67bc85dcbe5b6a626159706f8b0f6549 *chromedriver-v42.5.0-linux-arm64.zip +af76c74bebd454a43e6ed463eba64b2d635661203c31bca8efbe829169f1df2a *chromedriver-v42.5.0-linux-armv7l.zip +ada1322977b76a42c97baa12a9388fb3d33e6043a9d4380ded3ccd003c9f3481 *chromedriver-v42.5.0-linux-x64.zip +9a1fc333f04178abc4985596ff5e6843e857d63bc05be8ae9b8d39adc184890c *chromedriver-v42.5.0-mas-arm64.zip +7c1357049a4f4d87e86f80f002b948dbd77f2987002c05d09f67495ae38d6235 *chromedriver-v42.5.0-mas-x64.zip +619bb88746f6708d9f291847ec8f9a360b9dd799cdda96c2125e6e64f6ba5dc6 *chromedriver-v42.5.0-win32-arm64.zip +d3cf1b135f8fd9c27d5736f64b4afd1044b851ba3122c7a0985188aba94f2df0 *chromedriver-v42.5.0-win32-ia32.zip +a22ffa5f552adf98609ce15511133f8d3d2a0dee3f7be21cbf252bac98364ef6 *chromedriver-v42.5.0-win32-x64.zip +9600be1460e73162fa0bc04b0d27f6af9f87af9c9e8c1979feeb81777a984fce *electron-api.json +d32ee862313327486bfe33c2d91d404d4ee20146773c8a5f87bd418f8a8e9a91 *electron-v42.5.0-darwin-arm64-dsym-snapshot.zip +606a47375b0e48343d3a96d3bada5add9e26768036edecbdb6e6f1aea754e55e *electron-v42.5.0-darwin-arm64-dsym.tar.xz +155e135264dfe1b4109140acc23e321e00495516089937ee0909ff3e70a9447f *electron-v42.5.0-darwin-arm64-symbols.zip +5e392f66b3f6d13caa6d50bbe10fce3d848ca16c5680529357aa26c5da58a1e6 *electron-v42.5.0-darwin-arm64.zip +7a4691f6648a10dfb3315a45e1ce4c297a606d1c475b65fbb5c9d80688cbb0bb *electron-v42.5.0-darwin-x64-dsym-snapshot.zip +fc620d7787958dbe74dc9acaf42fe35fcdbba7f0c727b376e979b1b86e0d99c9 *electron-v42.5.0-darwin-x64-dsym.tar.xz +9fca997d9f6187b398730b69819e3643dc182b869e1ad0b80a7b5e1cc55a9ad1 *electron-v42.5.0-darwin-x64-symbols.zip +ca4792d47e74a6afce1d39a019f201d1a47fa81fcf4e8bcaac6fe5756f172940 *electron-v42.5.0-darwin-x64.zip +f144b1025a7b8c5bf34cfbc6f17f47e594db186de4b5647286d40618c8efd5c4 *electron-v42.5.0-linux-arm64-debug.zip +54878afd06169b12e1e2908319883d2e3ecd4a3fc39187152a4110f13a20db46 *electron-v42.5.0-linux-arm64-symbols.zip +0188f1d86efe785e5721cfaa1ae51d6ef68260705c30037d0d271aaaf78af315 *electron-v42.5.0-linux-arm64.zip +d58cf9b92c9e74885d6fd0ea0740602fa368d87967ed35b8286aaf4170e053e1 *electron-v42.5.0-linux-armv7l-debug.zip +45cfe194be6c3af8c8f4f04e83d26cea2f0d01d822d6d0d39cf24ef11298b6f3 *electron-v42.5.0-linux-armv7l-symbols.zip +360ff6d128be77be83fb3eaf458380fdca226be06f247551049fe3b6ef8ac382 *electron-v42.5.0-linux-armv7l.zip +a0fad46643b7bf6bfce7b2d0a3bd26166079e81e0581d9f3420484984e135c4b *electron-v42.5.0-linux-x64-debug.zip +3c1a4dc6c79bad3d9bc7d3af17b22bc3c97543c27a49b18d852b9eeb3ced2c0a *electron-v42.5.0-linux-x64-symbols.zip +6705a9d0cc5c8f225d705d6e1c2607b2b5be8667d2befb18cdafe8b7b29b8008 *electron-v42.5.0-linux-x64.zip +11b3c08d04f8e89983bff6ee1fc872f8d94f0a92c07311c81da6ac493cbedcc9 *electron-v42.5.0-mas-arm64-dsym-snapshot.zip +1ed2d9702069c699e143366d86e24398349072209459d23883c8bda74f62a161 *electron-v42.5.0-mas-arm64-dsym.tar.xz +7a474a3498973aeb9344c193a383e3590fde4ce083b0f0d51a0eced4f6d31ac6 *electron-v42.5.0-mas-arm64-symbols.zip +33166a8b868cd6624cb0d70847b42067219a2f532050d1cdd128d8d7eb9f2b74 *electron-v42.5.0-mas-arm64.zip +b6eaf6f40e20f196974f675c936a2d25c97a3f660faecba8978fd59b838a7b40 *electron-v42.5.0-mas-x64-dsym-snapshot.zip +30ca0601ba38e804152d16805eb01b05a60452359ce3d6b9cebb1e21d024f4dc *electron-v42.5.0-mas-x64-dsym.tar.xz +5da55d91f5b4159f4bc6fa4831b995b621dcab6ef60d20d64aaf074dcba3943c *electron-v42.5.0-mas-x64-symbols.zip +b2c23925f3d6561dadc8f563576b7a222ea20606a8002986e5937bd066710870 *electron-v42.5.0-mas-x64.zip +d7cea2c82487a4af809c181c99cec529d0a00c86fcfc70684d9a2c5c0d295212 *electron-v42.5.0-win32-arm64-pdb.zip +2799cffe27425316d27336fc03d8c77581aa8e65e27964104da68d7780b1a040 *electron-v42.5.0-win32-arm64-symbols.zip +8e245a0179165d88b82f382dda3f68d0851a2f4b21b14b833b9b7c4374d7da3a *electron-v42.5.0-win32-arm64-toolchain-profile.zip +765370134fdd2dd851bb019d7b7d9379ed7087f288eb8bee4043b42cfd9c33d8 *electron-v42.5.0-win32-arm64.zip +d4b34eb9e4e1ac77f11a0be51d39e94548362d1df63f6b7675016e6cb7b9b904 *electron-v42.5.0-win32-ia32-pdb.zip +eaa1fa1bc6bb14f8877e8ec930b90d9cdbc9dc41765cf530f2c5e6aca1da1fe8 *electron-v42.5.0-win32-ia32-symbols.zip +8e245a0179165d88b82f382dda3f68d0851a2f4b21b14b833b9b7c4374d7da3a *electron-v42.5.0-win32-ia32-toolchain-profile.zip +aa040c36074046a723fca9173aac1c8bf70c19d8a156215e8dbeb17ea37f588b *electron-v42.5.0-win32-ia32.zip +615726602fd5d697c12fc9b66de28343919631b24ab1179d83da46508e8a87d4 *electron-v42.5.0-win32-x64-pdb.zip +a913f632a979b7b7fb964b4d43ad45976638a5673cec1b6a69fd6572f57adc0d *electron-v42.5.0-win32-x64-symbols.zip +8e245a0179165d88b82f382dda3f68d0851a2f4b21b14b833b9b7c4374d7da3a *electron-v42.5.0-win32-x64-toolchain-profile.zip +127bbf7a755b438612c076b22baee258a87cd3d07168cc82ea46ffc015936114 *electron-v42.5.0-win32-x64.zip +21ead4c9c384849887ca8cac0a2a7a683d0cf90a3b62a2a2ba8519224e16e249 *electron.d.ts +45d8022f3bd67995d90242c5176c832fdf3abbdaac575b2469ca0537d0eb58db *ffmpeg-v42.5.0-darwin-arm64.zip +3a5e8f19fd4aa38b3d31eeb7da40f0434e87703050912fc0ca9dbca2de06f0e2 *ffmpeg-v42.5.0-darwin-x64.zip +9efdc919ffd357f00f080a8b1e1baeea19dcfc3164ea02d5cd12b93f85e67b73 *ffmpeg-v42.5.0-linux-arm64.zip +2e24581796500f7d6ed0c16076d74b7fe0b7cf625844623535077df03886bc26 *ffmpeg-v42.5.0-linux-armv7l.zip +78e8d6fd7432e6cbf4ef024728b46aa0b7fa292a598fbbadcc42474f87380d42 *ffmpeg-v42.5.0-linux-x64.zip +28e44748d48b6168aefde8601225c5f3dfb9404d616de2e6035c634354e0a6ac *ffmpeg-v42.5.0-mas-arm64.zip +0bdb9e4050c4159935e8640d0facd6e868583d921386e9c2a5bf3c550c5baf3c *ffmpeg-v42.5.0-mas-x64.zip +81a1c0273c172f140fbf261c194d7ae7369a1e7abe27cae413c8590e2bca4cad *ffmpeg-v42.5.0-win32-arm64.zip +87cffd235b85029c21a1e962132b608209e9929d2a22611985fda317cfe7e95f *ffmpeg-v42.5.0-win32-ia32.zip +497790a714fffc090d217b66332dfe6896609aa24293dc3fb4effce57fe29c82 *ffmpeg-v42.5.0-win32-x64.zip +6db69261ec30c95e30cc3bcd526e8fcec59cd1ad785a7985e520f77bc974a03c *hunspell_dictionaries.zip +441e5c5d7ac9407438d1f2a9974bbc0ef1c3a28e9408051f577e790cd6c6d171 *libcxx-objects-v42.5.0-linux-arm64.zip +99a3a1f895debddf023f01631c2c2b81911992bbbc514718d2ad24599040e4da *libcxx-objects-v42.5.0-linux-armv7l.zip +c7e4c5c5ac5505f97a555cc21bd09014a2e740e5d25e999f2bdc7542a86256e0 *libcxx-objects-v42.5.0-linux-x64.zip +92cdadcc04957db6e7b65421790f77f56bc0c8e6dc1407ad1adbfbdf83dbbc06 *libcxx_headers.zip +a169c9558951055efc054d31c3b4c8a04aa588ef1533b4456b6b27950d121994 *libcxxabi_headers.zip +9611bdee31d4464a8b06e9d44e99adf290a898969a502cfc9ac5616ae1026a0c *mksnapshot-v42.5.0-darwin-arm64.zip +89f7e234802958438a115c96b2f6207153f776a13b07247947fd2e5ca9b1ce3d *mksnapshot-v42.5.0-darwin-x64.zip +2a6d92eb9eb89419dc3b2ab1287329acc16955a62833d32bda2bd90ded45a5b0 *mksnapshot-v42.5.0-linux-arm64-x64.zip +391263da6782b2536067113ce7dab8ef19d9b3d59a74430fef59a4d8a7364502 *mksnapshot-v42.5.0-linux-armv7l-x64.zip +f7752a92ad45041e0c00a74bdc10a0d13574849ad944ba2b3424dc255460465f *mksnapshot-v42.5.0-linux-x64.zip +b9425403b15e273be565d7a937c7d28fc09f421df9fa4d376d6932bfecc91ff5 *mksnapshot-v42.5.0-mas-arm64.zip +a7b7230be427b849f31467298c2c4a934c7273e731ea17513c5790c6d66c8205 *mksnapshot-v42.5.0-mas-x64.zip +4a32b6558fb9aaae703cdc2e6a87ce05e95184e6d0e49e350a956c0908724ef7 *mksnapshot-v42.5.0-win32-arm64-x64.zip +ca73fb8ef0d6e4cf92f569a0dee9770f13166e67be68b12688a7ecaa630a2163 *mksnapshot-v42.5.0-win32-ia32.zip +d8a484f4f3347d7fd73bc4af59ce743e90e14fe7743a1be5ea09452e9c3bbd5b *mksnapshot-v42.5.0-win32-x64.zip diff --git a/build/checksums/nodejs.txt b/build/checksums/nodejs.txt index 16e83546f49a1..a787f4a19a9f9 100644 --- a/build/checksums/nodejs.txt +++ b/build/checksums/nodejs.txt @@ -1,6 +1,6 @@ -372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4 node-v24.15.0-darwin-arm64.tar.gz -ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b node-v24.15.0-darwin-x64.tar.gz -73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed node-v24.15.0-linux-arm64.tar.gz -44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89 node-v24.15.0-linux-x64.tar.gz -49a54c103f4919ce64199a043ef5cd309507de491d718085edee089cd8e87543 win-arm64/node.exe -3331e1ffe19874215472217c5e94f5a0c6d8e18c4ac7111d3937aa0ad5e9b4a5 win-x64/node.exe +4fc3266a3702eebc39cc37661cf4eeceeade307e242ab64e4d7ce7949197e11f node-v24.17.0-darwin-arm64.tar.gz +80da552fe037290cb130e9dea590f5eeeb7aa450636f0c89ab41415511c1ec27 node-v24.17.0-darwin-x64.tar.gz +faa0d59ba7fe7045c950ed09b190578fb8eee73e4358686d38fcc99ca58c1480 node-v24.17.0-linux-arm64.tar.gz +e0472427aa791ad80bdc426ff7cc73cdd28ed0f616d1ff9689a23a7f47f1265f node-v24.17.0-linux-x64.tar.gz +44999f9ec6486d01202d8961f343eac8c9f2847b234a8637c3fd0f1e2bb3288a win-arm64/node.exe +c6335d08331c23d68b9f2b18adb102002d76ef150b47248e954c507e0d033664 win-x64/node.exe diff --git a/build/darwin/create-universal-app.ts b/build/darwin/create-universal-app.ts index 459bd55b3500a..7b6910a05e0e2 100644 --- a/build/darwin/create-universal-app.ts +++ b/build/darwin/create-universal-app.ts @@ -121,7 +121,7 @@ async function main(buildDir?: string) { outAppPath, force: true, mergeASARs: true, - x64ArchFiles: '{*/kerberos.node,**/extensions/microsoft-authentication/dist/libmsalruntime.dylib,**/extensions/microsoft-authentication/dist/msal-node-runtime.node,**/node_modules/@github/copilot-darwin-*/copilot,**/node_modules/@github/copilot/prebuilds/darwin-*/*,**/node_modules/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot-darwin-*/copilot,**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/prebuilds/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/ripgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules/@vscode/ripgrep-universal/bin/darwin-*/*,**/node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/darwin-*/*,**/node_modules/@microsoft/mxc-sdk/bin/**,**/node_modules.asar.unpacked/@microsoft/mxc-sdk/bin/**}', + x64ArchFiles: '{*/kerberos.node,**/extensions/microsoft-authentication/dist/libmsalruntime.dylib,**/extensions/microsoft-authentication/dist/msal-node-runtime.node,**/node_modules/@github/copilot-darwin-*/**,**/node_modules/@github/copilot/prebuilds/darwin-*/*,**/node_modules/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot-darwin-*/**,**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/prebuilds/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/ripgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules/@vscode/ripgrep-universal/bin/darwin-*/*,**/node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/darwin-*/*,**/node_modules/@microsoft/mxc-sdk/bin/**,**/node_modules.asar.unpacked/@microsoft/mxc-sdk/bin/**}', filesToSkipComparison: (file: string) => { for (const expected of filesToSkip) { if (minimatch(file, expected)) { diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index 62c30da5216af..caf85b4d72c3b 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -26,10 +26,12 @@ import { compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; import { vscodeWebResourceIncludes, createVSCodeWebFileContentMapper } from './gulpfile.vscode.web.ts'; import * as cp from 'child_process'; +import crypto from 'crypto'; import log from 'fancy-log'; import buildfile from './buildfile.ts'; -import { fetchUrls, fetchGithub } from './lib/fetch.ts'; -import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +import { fetchUrls } from './lib/fetch.ts'; +import { downloadFeedPackage } from './lib/azureFeed.ts'; +import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; @@ -208,6 +210,44 @@ function patchElfLoadAlign(): NodeJS.ReadWriteStream { const { nodeVersion, internalNodeVersion } = getNodeVersion(); +// In product builds, the server (reh) Node.js binaries are fetched on demand +// from our Azure Artifacts feed named by `product.nodejsArtifactFeed` using the +// `az` CLI, instead of from nodejs.org (which is used by OSS builds when no feed +// is configured). Each universal package contains exactly one file, named after +// the asset minus its last extension, lowercased and sanitized (e.g. +// `node-v24.15.0-linux-x64.tar`, `win-x64-node`). +const nodejsArtifactFeed = product.nodejsArtifactFeed; + +function internalNodeFeedPackageName(assetName: string): string { + return assetName + .replace(/\.[^.]+$/, '') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^[._-]+/, '') + .replace(/[._-]+$/, ''); +} + +function fetchNodejsFromInternalFeed(feed: string, assetName: string, version: string, checksumSha256: string | undefined): NodeJS.ReadWriteStream { + const result = es.through(); + (async () => { + try { + const filePath = await downloadFeedPackage(REPO_ROOT, 'nodejs-feed', { feed, name: internalNodeFeedPackageName(assetName), version }); + const contents = await fs.promises.readFile(filePath); + if (checksumSha256) { + const actual = crypto.createHash('sha256').update(contents).digest('hex'); + if (actual !== checksumSha256) { + throw new Error(`Checksum mismatch for ${assetName} (expected ${checksumSha256}, actual ${actual})`); + } + } + result.emit('data', new File({ path: path.basename(filePath), contents })); + result.emit('end'); + } catch (err) { + result.emit('error', err); + } + })(); + return result; +} + BUILD_TARGETS.forEach(({ platform, arch }) => { task.task(task.define(`node-${platform}-${arch}`, () => { const nodePath = path.join('.build', 'node', `v${nodeVersion}`, `${platform}-${arch}`); @@ -237,13 +277,13 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi arch = 'x64'; } - log(`Downloading node.js ${nodeVersion} ${platform} ${arch} from ${product.nodejsRepository}...`); + log(`Downloading node.js ${nodeVersion} ${platform} ${arch} from ${nodejsArtifactFeed || 'https://nodejs.org'}...`); const glibcPrefix = process.env['VSCODE_NODE_GLIBC'] ?? ''; let expectedName: string | undefined; switch (platform) { case 'win32': - expectedName = product.nodejsRepository !== 'https://nodejs.org' ? + expectedName = nodejsArtifactFeed ? `win-${arch}-node.exe` : `win-${arch}/node.exe`; break; @@ -267,14 +307,14 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi switch (platform) { case 'win32': - return (product.nodejsRepository !== 'https://nodejs.org' ? - fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) : + return (nodejsArtifactFeed ? + fetchNodejs(expectedName!, checksumSha256) : fetchUrls(`/dist/v${nodeVersion}/win-${arch}/node.exe`, { base: 'https://nodejs.org', checksumSha256 })) .pipe(rename('node.exe')); case 'darwin': case 'linux': { - const downloaded = (product.nodejsRepository !== 'https://nodejs.org' ? - fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) : + const downloaded = (nodejsArtifactFeed ? + fetchNodejs(expectedName!, checksumSha256) : fetchUrls(`/dist/v${nodeVersion}/node-v${nodeVersion}-${platform}-${arch}.tar.gz`, { base: 'https://nodejs.org', checksumSha256 }) ).pipe(flatmap(stream => stream.pipe(gunzip()).pipe(untar()))) .pipe(filter('**/node')) @@ -283,8 +323,8 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi return platform === 'linux' && arch === 'x64' ? downloaded.pipe(patchElfLoadAlign()) : downloaded; } case 'alpine': - return product.nodejsRepository !== 'https://nodejs.org' ? - fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) + return nodejsArtifactFeed ? + fetchNodejs(expectedName!, checksumSha256) .pipe(flatmap(stream => stream.pipe(gunzip()).pipe(untar()))) .pipe(filter('**/node')) .pipe(util.setExecutableBit('**')) @@ -293,6 +333,13 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi } } +// Fetches a server (reh) Node.js asset from the Azure Artifacts feed named by +// `product.nodejsArtifactFeed`. Only called when that feed is configured. +function fetchNodejs(assetName: string, checksumSha256: string | undefined): NodeJS.ReadWriteStream { + const version = `${nodeVersion}-${internalNodeVersion}`; + return fetchNodejsFromInternalFeed(nodejsArtifactFeed, assetName, version, checksumSha256); +} + function packageTask(type: string, platform: string, arch: string, sourceFolderName: string, destinationFolderName: string) { const destination = path.join(BUILD_ROOT, destinationFolderName); @@ -395,6 +442,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN .pipe(filter(['**', '!**/package-lock.json', '!**/*.{js,css}.map'])) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); + ensureCopilotPlatformPackage(platform, arch, 'remote/node_modules'); const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch, 'remote/node_modules'), { base: 'remote', dot: true, allowEmpty: true }); const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) .pipe(filter(getCopilotExcludeFilter(platform, arch))) diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 53987f88edb1a..02750e9a85610 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -28,7 +28,7 @@ import minimist from 'minimist'; import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; -import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; @@ -340,6 +340,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); + ensureCopilotPlatformPackage(platform, arch); const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch), { base: '.', dot: true, allowEmpty: true }); const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) .pipe(filter(getCopilotExcludeFilter(platform, arch))) diff --git a/build/lib/azureFeed.ts b/build/lib/azureFeed.ts new file mode 100644 index 0000000000000..f32fb21885daa --- /dev/null +++ b/build/lib/azureFeed.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import cp from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +function getEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function azExecFile(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = cp.spawn('az', args, { stdio: 'inherit', shell: process.platform === 'win32' }); + child.on('error', reject); + child.on('close', code => code === 0 ? resolve() : reject(new Error(`az ${args[0]} ${args[1] ?? ''} exited with code ${code}`))); + }); +} + +let azureDevOpsExtension: Promise | undefined; +function ensureAzureDevOpsExtension(): Promise { + if (!azureDevOpsExtension) { + azureDevOpsExtension = (async () => { + const result = cp.spawnSync('az', ['extension', 'show', '--name', 'azure-devops'], { stdio: 'ignore', shell: process.platform === 'win32' }); + if (result.status !== 0) { + await azExecFile(['extension', 'add', '--name', 'azure-devops', '--only-show-errors']); + } + })(); + } + return azureDevOpsExtension; +} + +export interface IFeedPackage { + readonly feed: string; + readonly name: string; + readonly version: string; +} + +/** + * Downloads a universal package from an Azure Artifacts feed into a cache + * directory under `/.build/` and returns the absolute path to + * the single file it contains. Subsequent requests for the same package are + * served from the cache. + * @param root the repository root + * @param cacheDir the `.build` sub directory used to cache downloaded packages + * @param pkg the feed, package name and version to download + */ +export async function downloadFeedPackage(root: string, cacheDir: string, pkg: IFeedPackage): Promise { + const dir = path.join(root, '.build', cacheDir, `${pkg.name}-${pkg.version}`); + if (!fs.existsSync(dir)) { + await ensureAzureDevOpsExtension(); + await azExecFile([ + 'artifacts', 'universal', 'download', + '--organization', getEnv('SYSTEM_COLLECTIONURI').replace(/\/+$/, ''), + '--project', getEnv('SYSTEM_TEAMPROJECT'), + '--scope', 'project', + '--feed', pkg.feed, + '--name', pkg.name, + '--version', pkg.version, + '--path', dir, + ]); + } + const [only] = await fs.promises.readdir(dir); + return path.join(dir, only); +} diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts index 74babc798f4e4..37faf2b066631 100644 --- a/build/lib/copilot.ts +++ b/build/lib/copilot.ts @@ -4,7 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; +import { createHash } from 'crypto'; +import { execFileSync } from 'child_process'; +import * as os from 'os'; import * as path from 'path'; +import { extract } from 'tar'; /** * The platforms that @github/copilot ships platform-specific packages for. @@ -13,6 +17,7 @@ import * as path from 'path'; export const copilotPlatforms = [ 'darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', + 'linuxmusl-arm64', 'linuxmusl-x64', 'win32-arm64', 'win32-x64', ]; @@ -73,6 +78,42 @@ function toCopilotTgrepPlatformArch(platform: string, arch: string): string { return `${nodePlatform}-${nodeArch}`; } +function toCopilotPackagePlatformArch(platform: string, arch: string): string { + if (platform === 'alpine') { + return `linuxmusl-${arch}`; + } + if (arch === 'alpine') { + return 'linuxmusl-x64'; + } + + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + return `${nodePlatform}-${nodeArch}`; +} + +const copilotOptionalNativePayloadDirs = [ + 'clipboard', + 'foundry-local-sdk', + 'mxc-bin', + 'pvrecorder', +]; + +function getCopilotOptionalNativePayloadFiles(platform: string): string[] { + const files = [ + 'prebuilds/*/computer.node', + 'prebuilds/*/computer-use-mcp', + 'prebuilds/*/computer-use-mcp.exe', + 'prebuilds/*/Copilot Computer Use.app/**', + 'prebuilds/*/CopilotComputerUse.exe', + 'prebuilds/*/keytar.node', + ]; + + if (platform !== 'win32') { + files.push('prebuilds/*/cli-native.node'); + } + + return files; +} + /** * Returns a glob filter that strips @vscode/ripgrep-universal bin directories * for architectures other than the build target. @@ -102,72 +143,142 @@ export function getCopilotTgrepExcludeFilter(platform: string, arch: string): st * Returns a glob filter that strips @github/copilot platform packages * for architectures other than the build target. * - * For platforms the copilot SDK doesn't natively support (e.g. alpine, armhf), - * ALL platform packages are stripped - that's fine because the copilot CLI SDK - * resolves `node-pty` from the embedder (VS Code) first via `hostRequire`, - * falling back to its bundled copy only if the embedder can't provide it. + * Alpine uses the linuxmusl-* packages. Other platform package names follow + * Node's `${process.platform}-${process.arch}` naming. If Copilot does not + * ship the computed platform package (for example linux-arm for armhf builds), + * this strips every known @github/copilot-* platform package. */ export function getCopilotExcludeFilter(platform: string, arch: string): string[] { - const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); - const targetPlatformArch = `${nodePlatform}-${nodeArch}`; + const targetPlatformArch = toCopilotPackagePlatformArch(platform, arch); const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); // Strip wrong-architecture @github/copilot-{platform} packages. const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); - return ['**', ...excludes]; + return [ + '**', + ...excludes, + '!**/node_modules/@github/copilot-*/copilot', + '!**/node_modules/@github/copilot-*/copilot.exe', + ]; } /** - * Returns the public @github/copilot-sdk runtime native addon files that must - * survive app/remote packaging for the target platform. + * Returns the public @github/copilot package files that must survive + * app/remote packaging for the target platform. * - * .moduleignore strips @github/copilot/prebuilds/** globally because the - * internal extension SDK uses a copied sdk/prebuilds layout. Agent Host uses - * the public SDK, whose runtime addon loader expects runtime.node in the root - * prebuilds layout. The SDK's built-in shell tool additionally spawns commands - * through node-pty, whose native binaries live in the same root prebuilds - * layout, so those must be preserved too (otherwise the sandboxed shell fails - * with `Cannot find module './prebuilds//pty.node'` — or conpty.node - * on Windows). + * .moduleignore strips all @github/copilot-* platform packages globally. + * Re-add the selected runtime package so Agent Host can launch its index.js + * entrypoint and load runtime prebuilds. Keep the standalone SEA executable + * and optional native payload trees out of the product build. */ export function getCopilotRuntimePrebuildFiles(platform: string, arch: string, nodeModulesRoot = 'node_modules'): string[] { - const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); - const targetPlatformArch = `${nodePlatform}-${nodeArch}`; - const prebuildDir = path.posix.join(nodeModulesRoot, '@github', 'copilot', 'prebuilds', targetPlatformArch); + const copilotPackagePlatformArch = toCopilotPackagePlatformArch(platform, arch); + const copilotPlatformPackageDir = path.posix.join(nodeModulesRoot, '@github', `copilot-${copilotPackagePlatformArch}`); - const files = [ - path.posix.join(prebuildDir, 'runtime.node'), + return [ + path.posix.join(copilotPlatformPackageDir, '**'), + `!${path.posix.join(copilotPlatformPackageDir, 'copilot')}`, + `!${path.posix.join(copilotPlatformPackageDir, 'copilot.exe')}`, + ...copilotOptionalNativePayloadDirs.map(dir => `!${path.posix.join(copilotPlatformPackageDir, dir, '**')}`), + ...getCopilotOptionalNativePayloadFiles(platform).map(file => `!${path.posix.join(copilotPlatformPackageDir, file)}`), ]; +} - // node-pty native binaries for the SDK's built-in shell tool. Windows uses - // ConPTY (conpty.node plus the conpty/ helpers); darwin/linux use pty.node, - // and darwin additionally ships the spawn-helper executable. - if (nodePlatform === 'win32') { - files.push( - path.posix.join(prebuildDir, 'conpty.node'), - path.posix.join(prebuildDir, 'conpty_console_list.node'), - path.posix.join(prebuildDir, 'conpty', 'OpenConsole.exe'), - path.posix.join(prebuildDir, 'conpty', 'conpty.dll'), - ); - } else { - files.push(path.posix.join(prebuildDir, 'pty.node')); - if (nodePlatform === 'darwin') { - files.push(path.posix.join(prebuildDir, 'spawn-helper')); - } +interface NpmPackageLock { + packages?: Record; +} + +interface EnsureCopilotPlatformPackageOptions { + packPackage?: (packageName: string, version: string, tempDir: string) => string; +} + +/** + * Ensures the selected @github/copilot-{platform} package is present before + * packaging. npm only installs the host-compatible optional dependency, but + * VS Code packaging can cross-build targets such as darwin-x64 on arm64 hosts. + */ +export function ensureCopilotPlatformPackage(platform: string, arch: string, nodeModulesRoot = 'node_modules', options: EnsureCopilotPlatformPackageOptions = {}): void { + const copilotPackagePlatformArch = toCopilotPackagePlatformArch(platform, arch); + if (!copilotPlatforms.includes(copilotPackagePlatformArch)) { + return; } - return files; + const packageName = `@github/copilot-${copilotPackagePlatformArch}`; + const packageDir = path.join(nodeModulesRoot, '@github', `copilot-${copilotPackagePlatformArch}`); + if (fs.existsSync(packageDir)) { + return; + } + + const lockFilePath = path.join(path.dirname(nodeModulesRoot), 'package-lock.json'); + const lockPackageKey = path.posix.join('node_modules', '@github', `copilot-${copilotPackagePlatformArch}`); + const lockPackage = readNpmPackageLock(lockFilePath).packages?.[lockPackageKey]; + if (!lockPackage?.version) { + throw new Error(`[ensureCopilotPlatformPackage] Missing ${lockPackageKey} in ${lockFilePath}. Run npm install to refresh the lockfile.`); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-platform-')); + try { + const tarballPath = (options.packPackage ?? packCopilotPlatformPackage)(packageName, lockPackage.version, tempDir); + verifyNpmIntegrity(tarballPath, lockPackage.integrity); + + fs.mkdirSync(packageDir, { recursive: true }); + extract({ file: tarballPath, cwd: packageDir, strip: 1, sync: true }); + console.log(`[ensureCopilotPlatformPackage] Materialized ${packageName}@${lockPackage.version} in ${packageDir}`); + } catch (err) { + fs.rmSync(packageDir, { recursive: true, force: true }); + throw new Error(`[ensureCopilotPlatformPackage] Failed to materialize ${packageName}@${lockPackage.version}: ${err instanceof Error ? err.message : String(err)}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function packCopilotPlatformPackage(packageName: string, version: string, tempDir: string): string { + execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['pack', `${packageName}@${version}`, '--pack-destination', tempDir, '--silent'], { stdio: 'pipe', shell: process.platform === 'win32' }); + + const tarball = fs.readdirSync(tempDir).find(name => name.endsWith('.tgz')); + if (!tarball) { + throw new Error(`npm pack did not produce a tarball in ${tempDir}`); + } + + return path.join(tempDir, tarball); +} + +function readNpmPackageLock(lockFilePath: string): NpmPackageLock { + try { + return JSON.parse(fs.readFileSync(lockFilePath, 'utf8')); + } catch (err) { + throw new Error(`[ensureCopilotPlatformPackage] Failed to read ${lockFilePath}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function verifyNpmIntegrity(tarballPath: string, integrity: string | undefined): void { + if (!integrity) { + return; + } + + const sha512Integrity = integrity.split(/\s+/).find(entry => entry.startsWith('sha512-')); + if (!sha512Integrity) { + return; + } + + const expected = sha512Integrity.slice('sha512-'.length); + const actual = createHash('sha512').update(fs.readFileSync(tarballPath)).digest('base64'); + if (actual !== expected) { + throw new Error(`integrity mismatch for ${tarballPath}`); + } } /** - * Materializes the copilot CLI ripgrep shim directly inside the built-in copilot extension. + * Materializes target-platform Copilot CLI SDK files directly inside the built-in copilot extension. * * This is used when copilot is shipped as a built-in extension so startup does - * not need to create the shim at runtime. The destination layout matches the - * runtime shim logic in the copilot extension: - * - ripgrep: node_modules/@github/copilot/sdk/ripgrep/bin/{platform-arch} - * - marker: node_modules/@github/copilot/shims.txt + * not need to create the shim at runtime. The Copilot VSIX is built once on the + * Linux x64 host, so product packaging also restores target-platform SDK + * natives from the selected @github/copilot-{platform} package. * * Note: `node-pty` is no longer shimmed. The copilot CLI SDK resolves * `node-pty` from the embedder (VS Code) via `hostRequire` and falls back to @@ -179,6 +290,7 @@ export function getCopilotRuntimePrebuildFiles(platform: string, arch: string, n export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, builtInCopilotExtensionDir: string, appNodeModulesDir: string): void { const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); const platformArch = `${nodePlatform}-${nodeArch}`; + const copilotPackagePlatformArch = toCopilotPackagePlatformArch(platform, arch); const tgrepPlatformArch = toCopilotTgrepPlatformArch(platform, arch); const extensionNodeModules = path.join(builtInCopilotExtensionDir, 'node_modules'); @@ -187,7 +299,8 @@ export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, if (!fs.existsSync(copilotSdkBase)) { throw new Error(`[prepareBuiltInCopilotRipgrepShim] Copilot SDK directory not found at ${copilotSdkBase}`); } - pruneNonTargetCopilotSdkPrebuilds(platformArch, path.join(copilotSdkBase, 'prebuilds'), copilotPlatforms); + materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch, tgrepPlatformArch, copilotBase, appNodeModulesDir); + pruneNonTargetCopilotSdkPrebuilds(copilotPackagePlatformArch, path.join(copilotSdkBase, 'prebuilds'), copilotPlatforms); pruneNonTargetCopilotSdkPrebuilds(tgrepPlatformArch, path.join(copilotSdkBase, path.join('tgrep', 'bin')), copilotTgrepPlatforms); pruneNonTargetCopilotSdkPrebuilds(tgrepPlatformArch, path.join(copilotBase, path.join('tgrep', 'bin')), copilotTgrepPlatforms); @@ -219,6 +332,49 @@ export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, } } +function materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch: string, tgrepPlatformArch: string, copilotBase: string, appNodeModulesDir: string): void { + if (!copilotPlatforms.includes(copilotPackagePlatformArch)) { + return; + } + + const platformPackageDir = path.join(appNodeModulesDir, '@github', `copilot-${copilotPackagePlatformArch}`); + if (!fs.existsSync(platformPackageDir)) { + throw new Error(`[prepareBuiltInCopilotRipgrepShim] Copilot platform package not found at ${platformPackageDir}`); + } + + copyRequiredDirectory( + path.join(platformPackageDir, 'prebuilds', copilotPackagePlatformArch), + path.join(copilotBase, 'sdk', 'prebuilds', copilotPackagePlatformArch), + `Copilot SDK native prebuilds for ${copilotPackagePlatformArch}` + ); + + if (!copilotTgrepPlatforms.includes(tgrepPlatformArch)) { + return; + } + + const tgrepSource = path.join(platformPackageDir, 'tgrep', 'bin', tgrepPlatformArch); + copyRequiredDirectory( + tgrepSource, + path.join(copilotBase, 'tgrep', 'bin', tgrepPlatformArch), + `Copilot tgrep for ${tgrepPlatformArch}` + ); + copyRequiredDirectory( + tgrepSource, + path.join(copilotBase, 'sdk', 'tgrep', 'bin', tgrepPlatformArch), + `Copilot SDK tgrep for ${tgrepPlatformArch}` + ); +} + +function copyRequiredDirectory(source: string, target: string, description: string): void { + if (!fs.existsSync(source)) { + throw new Error(`[prepareBuiltInCopilotRipgrepShim] ${description} not found at ${source}`); + } + + fs.rmSync(target, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.cpSync(source, target, { recursive: true }); +} + function pruneNonTargetCopilotSdkPrebuilds(targetPlatformArch: string, prebuildsDir: string, platformArchs: string[]): void { if (!fs.existsSync(prebuildsDir)) { return; diff --git a/build/lib/electron.ts b/build/lib/electron.ts index 016c25f7553db..f9236eaf47235 100644 --- a/build/lib/electron.ts +++ b/build/lib/electron.ts @@ -5,10 +5,12 @@ import fs from 'fs'; import path from 'path'; +import { Readable } from 'stream'; import vfs from 'vinyl-fs'; import { filter, jsonEditor } from './gulp/facade.ts'; import * as util from './util.ts'; import { getVersion } from './getVersion.ts'; +import { downloadFeedPackage } from './azureFeed.ts'; import electron from '@vscode/gulp-electron'; type DarwinDocumentSuffix = 'document' | 'script' | 'file' | 'source code'; @@ -100,12 +102,45 @@ function darwinBundleDocumentTypes(types: { [name: string]: string | string[] }, }); } -const { msBuildId } = util.getElectronVersion(); -const electronVersion = '42.2.0'; +const { electronVersion, msBuildId } = util.getElectronVersion(); + +// In product builds, `@vscode/gulp-electron` is given an asset resolver (via the +// `repo` option) that fetches the prebuilt Electron archives on demand from the +// Azure Artifacts feed named by `product.electronArtifactFeed` using the `az` +// CLI, instead of downloading them from electron's official GitHub releases +// (which OSS builds use when no feed is configured). Each universal package +// contains exactly one file, which is streamed back as a `Response` and +// validated against the feed's `SHASUMS256.txt`. +const electronFeed: string | undefined = product.electronArtifactFeed; + +// Maps the artifact file name `@vscode/gulp-electron` requests to the matching +// universal package name in the feed, or `undefined` when it is not mirrored. +function feedPackageName(fileName: string): string | undefined { + if (fileName === 'SHASUMS256.txt') { + return 'shasums256'; + } + if (fileName.endsWith('-symbols.zip')) { + return undefined; + } + return fileName.replace(/\.zip$/, ''); +} + +const electronAssetResolver = electronFeed + ? async ({ fileName }: { url: string; fileName: string }): Promise => { + const name = feedPackageName(fileName); + if (!name) { + return new Response(null, { status: 404 }); + } + const version = `${electronVersion}-${msBuildId}`; + const filePath = await downloadFeedPackage(root, 'electron-feed', { feed: electronFeed, name, version }); + const size = (await fs.promises.stat(filePath)).size; + const body = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream; + return new Response(body, { status: 200, headers: { 'Content-Length': String(size) } }); + } + : undefined; export const config = { version: electronVersion, - tag: product.electronRepository ? `v${electronVersion}-${msBuildId}` : undefined, productAppName: product.nameLong, companyName: 'Microsoft Corporation', copyright: 'Copyright (C) 2026 Microsoft. All rights reserved', @@ -203,7 +238,7 @@ export const config = { linuxExecutableName: product.applicationName, winIcon: 'resources/win32/code.ico', token: process.env['GITHUB_TOKEN'], - repo: product.electronRepository || undefined, + repo: electronAssetResolver, validateChecksum: true, checksumFile: path.join(root, 'build', 'checksums', 'electron.txt'), createVersionedResources: useVersionedUpdate, diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index e98614e34386f..05de99a03a982 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -306,6 +306,10 @@ "name": "vs/workbench/contrib/welcomeOnboarding", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/onboarding", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/welcomePage", "project": "vscode-workbench" @@ -640,6 +644,10 @@ "name": "vs/sessions", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/automations", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/accountMenu", "project": "vscode-sessions" @@ -704,6 +712,14 @@ "name": "vs/sessions/contrib/git", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/github", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/layout", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/logs", "project": "vscode-sessions" @@ -716,6 +732,10 @@ "name": "vs/sessions/contrib/sessions", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/sessionInputBanners", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/terminal", "project": "vscode-sessions" @@ -739,6 +759,10 @@ { "name": "vs/sessions/contrib/editor", "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/onboardingTours", + "project": "vscode-sessions" } ] } diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 02f95ca785c66..f82aff27a42d7 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -38,6 +38,66 @@ } ], "policies": [ + { + "key": "chat.agentHost.otel.otlpProtocol", + "name": "CopilotOtelOtlpProtocol", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.otlpProtocol.policy", + "value": "Controls the enterprise-managed OTLP wire protocol (protobuf vs JSON) for Copilot OpenTelemetry export." + } + }, + "type": "string", + "default": "", + "included": false + }, + { + "key": "chat.agentHost.otel.serviceName", + "name": "CopilotOtelServiceName", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.serviceName.policy", + "value": "Controls the enterprise-managed OTel `service.name` resource attribute for Copilot OpenTelemetry export." + } + }, + "type": "string", + "default": "", + "included": false + }, + { + "key": "chat.agentHost.otel.resourceAttributes", + "name": "CopilotOtelResourceAttributes", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.resourceAttributes.policy", + "value": "Controls the enterprise-managed OTel resource attributes for Copilot OpenTelemetry export." + } + }, + "type": "object", + "default": {}, + "included": false + }, + { + "key": "chat.agentHost.otel.headers", + "name": "CopilotOtelHeaders", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.headers.policy", + "value": "Controls the enterprise-managed OTLP exporter headers for Copilot OpenTelemetry export." + } + }, + "type": "object", + "default": {}, + "included": false + }, { "key": "mcp.enterpriseManagedAuth.idp", "name": "McpEnterpriseManagedAuthIdp", @@ -46,7 +106,7 @@ "localization": { "description": { "key": "mcp.enterpriseManagedAuth.idp.policy", - "value": "The OAuth/OIDC IdP configuration used for enterprise-managed Model Context Protocol (MCP) server authentication. Delivered through enterprise policy (Windows Group Policy, macOS managed preferences, Linux `/etc/vscode/policy.json`)." + "value": "The OAuth/OIDC IdP configuration used for enterprise-managed Model Context Protocol (MCP) server authentication." } }, "type": "object", @@ -143,6 +203,24 @@ "default": true, "included": true }, + { + "key": "chat.agentHost.claudeAgent.enabled", + "name": "Claude3PIntegration", + "category": "InteractiveSession", + "minimumVersion": "1.113", + "localization": { + "description": { + "key": "chat.agentHost.claudeAgent.enabled.policy", + "value": "Enable Claude Agent sessions in VS Code. Start and resume agentic coding sessions powered by Anthropic Claude Agent SDK directly in the editor. Uses your existing Copilot subscription." + } + }, + "type": "boolean", + "default": true, + "included": true, + "referencedSettings": [ + "github.copilot.chat.claudeAgent.enabled" + ] + }, { "key": "chat.agentHost.codexAgent.enabled", "name": "Codex3PIntegration", @@ -158,6 +236,120 @@ "default": false, "included": true }, + { + "key": "chat.agentHost.otel.enabled", + "name": "CopilotOtelEnabled", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.enabled.policy", + "value": "Controls whether Copilot OpenTelemetry export is enabled. When managed, users cannot override the enterprise value." + } + }, + "type": "boolean", + "default": false, + "included": true + }, + { + "key": "chat.agentHost.otel.exporterType", + "name": "CopilotOtelProtocol", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.protocol.policy", + "value": "Controls the enterprise-managed OTLP protocol for Copilot OpenTelemetry export." + }, + "enumDescriptions": [ + { + "key": "chat.agentHost.otel.protocol.policy.otlpHttp", + "value": "Use OTLP over HTTP." + }, + { + "key": "chat.agentHost.otel.protocol.policy.otlpGrpc", + "value": "Use OTLP over gRPC." + }, + { + "key": "chat.agentHost.otel.protocol.policy.console", + "value": "Console exporter is not selected by enterprise managed settings." + }, + { + "key": "chat.agentHost.otel.protocol.policy.file", + "value": "File exporter is not selected by enterprise managed settings." + } + ] + }, + "type": "string", + "default": "otlp-http", + "enum": [ + "otlp-http", + "otlp-grpc", + "console", + "file" + ], + "included": true + }, + { + "key": "chat.agentHost.otel.otlpEndpoint", + "name": "CopilotOtelEndpoint", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.otlpEndpoint.policy", + "value": "Controls the enterprise-managed OTLP collector endpoint for Copilot OpenTelemetry export." + } + }, + "type": "string", + "default": "", + "included": true + }, + { + "key": "chat.agentHost.otel.captureContent", + "name": "CopilotOtelCaptureContent", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.captureContent.policy", + "value": "Controls whether Copilot OpenTelemetry export captures prompt, response, and tool content." + } + }, + "type": "boolean", + "default": false, + "included": true + }, + { + "key": "chat.agentHost.otel.outfile", + "name": "CopilotOtelOutfile", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.outfile.policy", + "value": "Prevents local file export when enterprise-managed Copilot OpenTelemetry export is configured." + } + }, + "type": "string", + "default": "", + "included": true + }, + { + "key": "chat.defaultModel", + "name": "ChatDefaultModel", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.defaultModel.policy", + "value": "Sets the default chat model for new conversations. Accepts \"auto\", a model family name (such as \"opus\" or \"gemini\"), or a full model id. Users can still switch the model within a conversation." + } + }, + "type": "string", + "default": "", + "included": true + }, { "key": "chat.tools.global.autoApprove", "name": "ChatToolsAutoApprove", @@ -338,7 +530,7 @@ "localization": { "description": { "key": "chat.agent.allowedNetworkDomains", - "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.enabled#` is set to `allowNetwork`, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence." + "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence." } }, "type": "array", @@ -353,7 +545,7 @@ "localization": { "description": { "key": "chat.agent.deniedNetworkDomains", - "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.enabled#` is set to `allowNetwork`. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`." + "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`." } }, "type": "array", @@ -442,7 +634,7 @@ "localization": { "description": { "key": "agentSandbox.enabledSetting", - "value": "Controls whether agent mode uses sandboxing to restrict what tools can do. When enabled, tools like the terminal are run in a sandboxed environment to limit access to the system." + "value": "Controls whether agent mode uses sandboxing to restrict what tools can do. When enabled, tools like the terminal are run in a sandboxed environment to limit access to the system. Use `#chat.agent.sandbox.allowNetwork#` to allow all network domains." }, "enumDescriptions": [ { @@ -452,10 +644,6 @@ { "key": "agentSandbox.enabledSetting.onDescription", "value": "Enable sandboxing for agent mode tools." - }, - { - "key": "agentSandbox.enabledSetting.allowNetworkDescription", - "value": "Enable sandboxing for agent mode tools and allow all network domains." } ] }, @@ -463,11 +651,25 @@ "default": "off", "enum": [ "off", - "on", - "allowNetwork" + "on" ], "included": true }, + { + "key": "chat.agent.sandbox.allowNetwork", + "name": "ChatAgentSandboxAllowNetwork", + "category": "IntegratedTerminal", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "agentSandbox.allowNetwork", + "value": "When `#chat.agent.sandbox.enabled#` is enabled, controls whether to allow all network domains in the sandbox. When enabled, the sandbox preserves file system restrictions while relaxing all network restrictions." + } + }, + "type": "boolean", + "default": false, + "included": true + }, { "key": "chat.agent.sandbox.allowUnsandboxedCommands", "name": "ChatAgentSandboxAllowUnsandboxedCommands", @@ -506,7 +708,7 @@ "localization": { "description": { "key": "updateMode", - "value": "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service." + "value": "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service." }, "enumDescriptions": [ { @@ -650,24 +852,6 @@ "type": "boolean", "default": true, "included": true - }, - { - "key": "github.copilot.chat.claudeAgent.enabled", - "name": "Claude3PIntegration", - "category": "InteractiveSession", - "minimumVersion": "1.113", - "localization": { - "description": { - "key": "github.copilot.chat.claudeAgent.enabled", - "value": "Enable Claude Agent sessions in VS Code. Start and resume agentic coding sessions powered by Anthropic Claude Agent SDK directly in the editor. Uses your existing Copilot subscription." - } - }, - "type": "boolean", - "default": true, - "included": true, - "referencedSettings": [ - "chat.agentHost.claudeAgent.enabled" - ] } ] } diff --git a/build/lib/preLaunch.ts b/build/lib/preLaunch.ts index 5e175afde2884..0aa037e696906 100644 --- a/build/lib/preLaunch.ts +++ b/build/lib/preLaunch.ts @@ -33,9 +33,30 @@ async function ensureNodeModules() { } async function getElectron() { + // `npm run electron` deletes and re-downloads `.build/electron` on every + // invocation. When preLaunch runs repeatedly (e.g. once per integration test + // section) this is both wasteful and a source of flaky failures on Windows, + // where the just-exited Electron process can still hold file locks while the + // directory is being removed and re-extracted. Skip the refresh when the + // already-present Electron matches the expected version; any detection + // failure falls back to a (re)download to preserve the previous behavior. + if (await isExpectedElectronInstalled()) { + return; + } await runProcess(npm, ['run', 'electron']); } +async function isExpectedElectronInstalled(): Promise { + try { + const { getElectronVersion } = await import('./util.ts'); + const { electronVersion } = getElectronVersion(); + const installedVersion = (await fs.readFile(path.join(rootDir, '.build', 'electron', 'version'), 'utf8')).trim().replace(/^v/, ''); + return installedVersion === electronVersion; + } catch { + return false; + } +} + async function ensureCompiled() { if (!(await exists('out'))) { await runProcess(npm, ['run', 'compile']); diff --git a/build/lib/standalone.ts b/build/lib/standalone.ts index 967ff0108bf19..a51fee788a426 100644 --- a/build/lib/standalone.ts +++ b/build/lib/standalone.ts @@ -139,9 +139,9 @@ function transportCSS(module: string, enqueue: (module: string) => void, write: function _rewriteOrInlineUrls(contents: string, forceBase64: boolean): string { return _replaceURL(contents, (url) => { - const fontMatch = url.match(/^(.*).ttf\?(.*)$/); + const fontMatch = url.match(/^(.*)\.ttf(\?.*)?$/); if (fontMatch) { - const relativeFontPath = `${fontMatch[1]}.ttf`; // trim the query parameter + const relativeFontPath = `${fontMatch[1]}.ttf`; // trim the optional query parameter const fontPath = path.join(path.dirname(module), relativeFontPath); enqueue(fontPath); return relativeFontPath; diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a443f51c0a136..930248b9da5e2 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -21,6 +21,8 @@ "--vscode-activityErrorBadge-foreground", "--vscode-activityWarningBadge-background", "--vscode-activityWarningBadge-foreground", + "--vscode-agentFeedbackEditorWidget-background", + "--vscode-agentFeedbackEditorWidget-border", "--vscode-agentFeedbackInputWidget-border", "--vscode-agentSessionReadIndicator-foreground", "--vscode-agentSessionSelectedBadge-border", @@ -1065,6 +1067,9 @@ "--chat-input-anim-angle", "--chat-input-anim-duration", "--chat-input-working-border-color1", + "--session-input-banner-anim-angle", + "--session-input-banner-anim-duration", + "--session-input-banner-working-border-color", "--chat-send-button-anim-angle", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", diff --git a/build/lib/test/copilot.test.ts b/build/lib/test/copilot.test.ts new file mode 100644 index 0000000000000..b0f6b4f18a46a --- /dev/null +++ b/build/lib/test/copilot.test.ts @@ -0,0 +1,285 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { suite, test } from 'node:test'; +import { create } from 'tar'; +import { copilotPlatforms, ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, prepareBuiltInCopilotRipgrepShim } from '../copilot.ts'; + +suite('copilot', () => { + test('keeps the public copilot platform package include list scoped to the selected package', () => { + const files = getCopilotRuntimePrebuildFiles('linux', 'x64'); + + assert.deepStrictEqual(files, [ + 'node_modules/@github/copilot-linux-x64/**', + '!node_modules/@github/copilot-linux-x64/copilot', + '!node_modules/@github/copilot-linux-x64/copilot.exe', + '!node_modules/@github/copilot-linux-x64/clipboard/**', + '!node_modules/@github/copilot-linux-x64/foundry-local-sdk/**', + '!node_modules/@github/copilot-linux-x64/mxc-bin/**', + '!node_modules/@github/copilot-linux-x64/pvrecorder/**', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/computer.node', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/computer-use-mcp', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/computer-use-mcp.exe', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/Copilot Computer Use.app/**', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/CopilotComputerUse.exe', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/keytar.node', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/cli-native.node', + ]); + assertCopilotPlatformPackageIncludes(files, 'node_modules/@github/copilot-linux-x64', [ + 'index.js', + 'app.js', + 'prebuilds/linux-x64/runtime.node', + 'prebuilds/linux-x64/pty.node', + ]); + assertCopilotStandaloneExecutableExcluded(files, 'node_modules/@github/copilot-linux-x64'); + assertOptionalCopilotNativeDependenciesExcluded(files, 'node_modules/@github/copilot-linux-x64'); + }); + + test('uses the linuxmusl package runtime for alpine builds', () => { + const files = getCopilotRuntimePrebuildFiles('alpine', 'x64'); + + assert.deepStrictEqual(files, [ + 'node_modules/@github/copilot-linuxmusl-x64/**', + '!node_modules/@github/copilot-linuxmusl-x64/copilot', + '!node_modules/@github/copilot-linuxmusl-x64/copilot.exe', + '!node_modules/@github/copilot-linuxmusl-x64/clipboard/**', + '!node_modules/@github/copilot-linuxmusl-x64/foundry-local-sdk/**', + '!node_modules/@github/copilot-linuxmusl-x64/mxc-bin/**', + '!node_modules/@github/copilot-linuxmusl-x64/pvrecorder/**', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/computer.node', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/computer-use-mcp', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/computer-use-mcp.exe', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/Copilot Computer Use.app/**', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/CopilotComputerUse.exe', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/keytar.node', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/cli-native.node', + ]); + assertCopilotPlatformPackageIncludes(files, 'node_modules/@github/copilot-linuxmusl-x64', [ + 'index.js', + 'app.js', + 'prebuilds/linuxmusl-x64/runtime.node', + ]); + assertCopilotStandaloneExecutableExcluded(files, 'node_modules/@github/copilot-linuxmusl-x64'); + assertOptionalCopilotNativeDependenciesExcluded(files, 'node_modules/@github/copilot-linuxmusl-x64'); + }); + + test('uses the .exe package runtime for windows builds', () => { + assert.deepStrictEqual(getCopilotRuntimePrebuildFiles('win32', 'x64'), [ + 'node_modules/@github/copilot-win32-x64/**', + '!node_modules/@github/copilot-win32-x64/copilot', + '!node_modules/@github/copilot-win32-x64/copilot.exe', + '!node_modules/@github/copilot-win32-x64/clipboard/**', + '!node_modules/@github/copilot-win32-x64/foundry-local-sdk/**', + '!node_modules/@github/copilot-win32-x64/mxc-bin/**', + '!node_modules/@github/copilot-win32-x64/pvrecorder/**', + '!node_modules/@github/copilot-win32-x64/prebuilds/*/computer.node', + '!node_modules/@github/copilot-win32-x64/prebuilds/*/computer-use-mcp', + '!node_modules/@github/copilot-win32-x64/prebuilds/*/computer-use-mcp.exe', + '!node_modules/@github/copilot-win32-x64/prebuilds/*/Copilot Computer Use.app/**', + '!node_modules/@github/copilot-win32-x64/prebuilds/*/CopilotComputerUse.exe', + '!node_modules/@github/copilot-win32-x64/prebuilds/*/keytar.node', + ]); + assertCopilotPlatformPackageIncludes(getCopilotRuntimePrebuildFiles('win32', 'x64'), 'node_modules/@github/copilot-win32-x64', [ + 'index.js', + 'app.js', + 'prebuilds/win32-x64/cli-native.node', + 'prebuilds/win32-x64/runtime.node', + 'prebuilds/win32-x64/conpty.node', + 'prebuilds/win32-x64/conpty_console_list.node', + 'prebuilds/win32-x64/conpty/OpenConsole.exe', + 'prebuilds/win32-x64/conpty/conpty.dll', + ]); + assertCopilotStandaloneExecutableExcluded(getCopilotRuntimePrebuildFiles('win32', 'x64'), 'node_modules/@github/copilot-win32-x64'); + + assert.deepStrictEqual(getCopilotRuntimePrebuildFiles('win32', 'arm64'), [ + 'node_modules/@github/copilot-win32-arm64/**', + '!node_modules/@github/copilot-win32-arm64/copilot', + '!node_modules/@github/copilot-win32-arm64/copilot.exe', + '!node_modules/@github/copilot-win32-arm64/clipboard/**', + '!node_modules/@github/copilot-win32-arm64/foundry-local-sdk/**', + '!node_modules/@github/copilot-win32-arm64/mxc-bin/**', + '!node_modules/@github/copilot-win32-arm64/pvrecorder/**', + '!node_modules/@github/copilot-win32-arm64/prebuilds/*/computer.node', + '!node_modules/@github/copilot-win32-arm64/prebuilds/*/computer-use-mcp', + '!node_modules/@github/copilot-win32-arm64/prebuilds/*/computer-use-mcp.exe', + '!node_modules/@github/copilot-win32-arm64/prebuilds/*/Copilot Computer Use.app/**', + '!node_modules/@github/copilot-win32-arm64/prebuilds/*/CopilotComputerUse.exe', + '!node_modules/@github/copilot-win32-arm64/prebuilds/*/keytar.node', + ]); + assertOptionalCopilotNativeDependenciesExcluded(getCopilotRuntimePrebuildFiles('win32', 'x64'), 'node_modules/@github/copilot-win32-x64'); + assertCopilotStandaloneExecutableExcluded(getCopilotRuntimePrebuildFiles('win32', 'arm64'), 'node_modules/@github/copilot-win32-arm64'); + }); + + test('keeps macOS runtime prebuilds in the selected platform package', () => { + const files = getCopilotRuntimePrebuildFiles('darwin', 'arm64'); + + assertCopilotPlatformPackageIncludes(files, 'node_modules/@github/copilot-darwin-arm64', [ + 'index.js', + 'app.js', + 'sea-loader.js', + 'prebuilds/darwin-arm64/runtime.node', + 'prebuilds/darwin-arm64/pty.node', + 'prebuilds/darwin-arm64/spawn-helper', + ]); + assertCopilotStandaloneExecutableExcluded(files, 'node_modules/@github/copilot-darwin-arm64'); + assertOptionalCopilotNativeDependenciesExcluded(files, 'node_modules/@github/copilot-darwin-arm64'); + }); + + test('materializes missing target platform packages from the lockfile', () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-platform-test-')); + const nodeModulesRoot = path.join(repoRoot, 'node_modules'); + try { + fs.mkdirSync(nodeModulesRoot, { recursive: true }); + fs.writeFileSync(path.join(repoRoot, 'package-lock.json'), JSON.stringify({ + packages: { + 'node_modules/@github/copilot-darwin-x64': { + version: '1.0.64-1', + } + } + })); + + ensureCopilotPlatformPackage('darwin', 'x64', nodeModulesRoot, { + packPackage: (_packageName, _version, tempDir) => { + const packageRoot = path.join(tempDir, 'package'); + fs.mkdirSync(path.join(packageRoot, 'prebuilds', 'darwin-x64'), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, 'index.js'), ''); + fs.writeFileSync(path.join(packageRoot, 'prebuilds', 'darwin-x64', 'runtime.node'), ''); + const tarball = path.join(tempDir, 'copilot-darwin-x64.tgz'); + create({ file: tarball, cwd: tempDir, gzip: true, sync: true }, ['package']); + return tarball; + } + }); + + assert(fs.existsSync(path.join(nodeModulesRoot, '@github', 'copilot-darwin-x64', 'index.js'))); + assert(fs.existsSync(path.join(nodeModulesRoot, '@github', 'copilot-darwin-x64', 'prebuilds', 'darwin-x64', 'runtime.node'))); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + test('excludes standalone copilot executables from the platform package dependency stream', () => { + const files = getCopilotExcludeFilter('linux', 'x64'); + + assert(files.includes('**')); + assert(files.includes('!**/node_modules/@github/copilot-*/copilot')); + assert(files.includes('!**/node_modules/@github/copilot-*/copilot.exe')); + }); + + test('materializes target Copilot SDK prebuilds and tgrep for the built-in extension', () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-sdk-prebuild-test-')); + try { + const builtInCopilotExtensionDir = path.join(repoRoot, 'extensions', 'copilot'); + const extensionCopilotDir = path.join(builtInCopilotExtensionDir, 'node_modules', '@github', 'copilot'); + const appNodeModulesDir = path.join(repoRoot, 'node_modules'); + const platformPackageDir = path.join(appNodeModulesDir, '@github', 'copilot-win32-x64'); + + fs.mkdirSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64'), { recursive: true }); + fs.writeFileSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64', 'runtime.node'), ''); + fs.mkdirSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty'), { recursive: true }); + fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'runtime.node'), ''); + fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty.node'), ''); + fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'), ''); + fs.mkdirSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64'), { recursive: true }); + fs.writeFileSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), ''); + fs.mkdirSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64'), { recursive: true }); + fs.writeFileSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64', 'rg.exe'), ''); + + prepareBuiltInCopilotRipgrepShim('win32', 'x64', builtInCopilotExtensionDir, appNodeModulesDir); + + assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'runtime.node'))); + assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'conpty.node'))); + assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'))); + assert(!fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64'))); + assert(fs.existsSync(path.join(extensionCopilotDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'))); + assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'))); + assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'ripgrep', 'bin', 'win32-x64', 'rg.exe'))); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + test('strips all copilot platform packages for unsupported armhf builds', () => { + assert.deepStrictEqual( + getCopilotExcludeFilter('linux', 'armhf'), + [ + '**', + ...copilotPlatforms.map(platform => `!**/node_modules/@github/copilot-${platform}/**`), + '!**/node_modules/@github/copilot-*/copilot', + '!**/node_modules/@github/copilot-*/copilot.exe', + ] + ); + }); +}); + +function assertCopilotPlatformPackageIncludes(patterns: string[], packageDir: string, relativeFiles: string[]): void { + assert(patterns.includes(`${packageDir}/**`)); + for (const relativeFile of relativeFiles) { + assert(matchesGlob(`${packageDir}/${relativeFile}`, patterns), relativeFile); + } +} + +function assertCopilotStandaloneExecutableExcluded(patterns: string[], packageDir: string): void { + for (const executable of ['copilot', 'copilot.exe']) { + assert(patterns.includes(`!${packageDir}/${executable}`), executable); + assert(!matchesGlob(`${packageDir}/${executable}`, patterns), executable); + } +} + +function assertOptionalCopilotNativeDependenciesExcluded(patterns: string[], packageDir: string): void { + for (const dir of ['clipboard', 'foundry-local-sdk', 'mxc-bin', 'pvrecorder']) { + assert(patterns.includes(`!${packageDir}/${dir}/**`), dir); + assert(!matchesGlob(`${packageDir}/${dir}/index.js`, patterns), dir); + } + assert(patterns.includes(`!${packageDir}/prebuilds/*/computer.node`), 'computer.node'); + assert(!matchesGlob(`${packageDir}/prebuilds/linux-x64/computer.node`, patterns), 'computer.node'); + assert(patterns.includes(`!${packageDir}/prebuilds/*/computer-use-mcp`), 'computer-use-mcp'); + assert(!matchesGlob(`${packageDir}/prebuilds/darwin-arm64/computer-use-mcp`, patterns), 'computer-use-mcp'); + assert(patterns.includes(`!${packageDir}/prebuilds/*/computer-use-mcp.exe`), 'computer-use-mcp.exe'); + assert(!matchesGlob(`${packageDir}/prebuilds/win32-x64/computer-use-mcp.exe`, patterns), 'computer-use-mcp.exe'); + assert(patterns.includes(`!${packageDir}/prebuilds/*/Copilot Computer Use.app/**`), 'Copilot Computer Use.app'); + assert(!matchesGlob(`${packageDir}/prebuilds/darwin-arm64/Copilot Computer Use.app/Contents/MacOS/Copilot Computer Use`, patterns), 'Copilot Computer Use.app'); + assert(patterns.includes(`!${packageDir}/prebuilds/*/CopilotComputerUse.exe`), 'CopilotComputerUse.exe'); + assert(!matchesGlob(`${packageDir}/prebuilds/win32-x64/CopilotComputerUse.exe`, patterns), 'CopilotComputerUse.exe'); + assert(patterns.includes(`!${packageDir}/prebuilds/*/keytar.node`), 'keytar.node'); + assert(!matchesGlob(`${packageDir}/prebuilds/linux-x64/keytar.node`, patterns), 'keytar.node'); + + if (!packageDir.includes('win32')) { + assert(patterns.includes(`!${packageDir}/prebuilds/*/cli-native.node`), 'cli-native.node'); + assert(!matchesGlob(`${packageDir}/prebuilds/linux-x64/cli-native.node`, patterns), 'cli-native.node'); + } +} + +function matchesGlob(file: string, patterns: string[]): boolean { + let included = false; + for (const pattern of patterns) { + const isExclude = pattern.startsWith('!'); + const glob = isExclude ? pattern.slice(1) : pattern; + if (matchesPattern(file, glob)) { + included = !isExclude; + } + } + return included; +} + +function matchesPattern(file: string, pattern: string): boolean { + if (pattern.endsWith('/**') && !pattern.slice(0, -3).includes('*')) { + return file.startsWith(pattern.slice(0, -2)); + } + + if (pattern.includes('*')) { + const regex = new RegExp(`^${pattern.split('**').map(part => part.split('*').map(escapeRegExp).join('[^/]+')).join('.*')}$`); + return regex.test(file); + } + + return file === pattern; +} + +function escapeRegExp(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); +} diff --git a/build/lib/util.ts b/build/lib/util.ts index d463aec5e9999..760768dd07fc5 100644 --- a/build/lib/util.ts +++ b/build/lib/util.ts @@ -302,7 +302,7 @@ export function rimraf(dir: string): () => Promise { return c(); } - if (err.code === 'ENOTEMPTY' && ++retries < 5) { + if ((err.code === 'ENOTEMPTY' || err.code === 'EBUSY' || err.code === 'EPERM') && ++retries < 5) { return setTimeout(() => retry(), 10); } diff --git a/build/linux/debian/dep-lists.ts b/build/linux/debian/dep-lists.ts index 7690e25d5fd31..d7bd81ccd104d 100644 --- a/build/linux/debian/dep-lists.ts +++ b/build/linux/debian/dep-lists.ts @@ -40,6 +40,7 @@ export const referenceGeneratedDepsByArch = { 'libdbus-1-3 (>= 1.9.14)', 'libexpat1 (>= 2.1~beta3)', 'libgbm1 (>= 17.1.0~rc2)', + 'libglib2.0-0 (>= 2.12.0)', 'libglib2.0-0 (>= 2.39.4)', 'libgtk-3-0 (>= 3.9.10)', 'libgtk-3-0 (>= 3.9.10) | libgtk-4-1', @@ -47,7 +48,6 @@ export const referenceGeneratedDepsByArch = { 'libnss3 (>= 2:3.30)', 'libnss3 (>= 3.26)', 'libpango-1.0-0 (>= 1.14.0)', - 'libstdc++6 (>= 6)', 'libudev1 (>= 183)', 'libx11-6', 'libx11-6 (>= 2:1.4.99.1)', diff --git a/build/linux/dependencies-generator.ts b/build/linux/dependencies-generator.ts index eb1d73d011ac9..2fcb15e027192 100644 --- a/build/linux/dependencies-generator.ts +++ b/build/linux/dependencies-generator.ts @@ -22,7 +22,7 @@ import product from '../../product.json' with { type: 'json' }; // are valid, are in dep-lists.ts const FAIL_BUILD_FOR_NEW_DEPENDENCIES: boolean = true; -// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:chrome/installer/linux/BUILD.gn;l=64-80 +// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:chrome/installer/linux/BUILD.gn;l=64-80 // and the Linux Archive build // Shared library dependencies that we already bundle. const bundledDeps = [ diff --git a/build/linux/rpm/dep-lists.ts b/build/linux/rpm/dep-lists.ts index e9f414e4c7ef9..9c5241350ca59 100644 --- a/build/linux/rpm/dep-lists.ts +++ b/build/linux/rpm/dep-lists.ts @@ -109,15 +109,6 @@ export const referenceGeneratedDepsByArch = { 'libsmime3.so(NSS_3.10)(64bit)', 'libsmime3.so(NSS_3.2)(64bit)', 'libssl3.so(NSS_3.28)(64bit)', - 'libstdc++.so.6()(64bit)', - 'libstdc++.so.6(CXXABI_1.3)(64bit)', - 'libstdc++.so.6(CXXABI_1.3.8)(64bit)', - 'libstdc++.so.6(CXXABI_1.3.9)(64bit)', - 'libstdc++.so.6(GLIBCXX_3.4)(64bit)', - 'libstdc++.so.6(GLIBCXX_3.4.11)(64bit)', - 'libstdc++.so.6(GLIBCXX_3.4.14)(64bit)', - 'libstdc++.so.6(GLIBCXX_3.4.21)(64bit)', - 'libstdc++.so.6(GLIBCXX_3.4.22)(64bit)', 'libudev.so.1()(64bit)', 'libudev.so.1(LIBUDEV_183)(64bit)', 'libutil.so.1()(64bit)', diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index 0d00ac3926141..5df04a796373f 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -79,7 +79,7 @@ async function npmInstallAsync(dir: string, opts?: child_process.SpawnOptions): 'docker', 'run', '-e', 'GITHUB_TOKEN', '-v', `${process.env['VSCODE_HOST_MOUNT']}:/root/vscode`, - '-v', `${process.env['VSCODE_HOST_MOUNT']}/.build/.netrc:/root/.netrc`, + '-v', `${process.env['VSCODE_HOST_MOUNT']}/.build/.gitconfig-distro:/root/.gitconfig`, '-v', `${process.env['VSCODE_NPMRC_PATH']}:/root/.npmrc`, '-w', path.resolve('/root/vscode', dir), process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME'], diff --git a/build/package-lock.json b/build/package-lock.json index 12162d6054f94..4271a5f06a58b 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -1320,28 +1320,28 @@ } }, "node_modules/@textlint/ast-node-types": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz", - "integrity": "sha512-9ByYNzWV8tpz6BFaRzeRzIov8dkbSZu9q7IWqEIfmRuLWb2qbI/5gTvKcoWT1HYs4XM7IZ8TKSXcuPvMb6eorA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.2.2.tgz", - "integrity": "sha512-oMVaMJ3exFvXhCj3AqmCbLaeYrTNLqaJnLJMIlmnRM3/kZdxvku4OYdaDzgtlI194cVxamOY5AbHBBVnY79kEg==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.2.2", - "@textlint/resolver": "15.2.2", - "@textlint/types": "15.2.2", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", "chalk": "^4.1.2", - "debug": "^4.4.1", - "js-yaml": "^3.14.1", - "lodash": "^4.17.21", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", @@ -1456,27 +1456,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.2.2.tgz", - "integrity": "sha512-2rmNcWrcqhuR84Iio1WRzlc4tEoOMHd6T7urjtKNNefpTt1owrTJ9WuOe60yD3FrTW0J/R0ux5wxUbP/eaeFOA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.2.2.tgz", - "integrity": "sha512-4hGWjmHt0y+5NAkoYZ8FvEkj8Mez9TqfbTm3BPjoV32cIfEixl2poTOgapn1rfm73905GSO3P1jiWjmgvii13Q==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.2.2.tgz", - "integrity": "sha512-X2BHGAR3yXJsCAjwYEDBIk9qUDWcH4pW61ISfmtejau+tVqKtnbbvEZnMTb6mWgKU1BvTmftd5DmB1XVDUtY3g==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.2.2" + "@textlint/ast-node-types": "15.7.1" } }, "node_modules/@types/ansi-colors": { @@ -2408,21 +2408,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/argparse/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "BSD-3-Clause" + "license": "Python-2.0" }, "node_modules/arr-diff": { "version": "4.0.0", @@ -3403,20 +3393,6 @@ "node": ">=0.8.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/events": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", @@ -3642,17 +3618,17 @@ "dev": true }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4004,9 +3980,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4358,14 +4334,23 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -4509,10 +4494,20 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -4609,15 +4604,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -4626,13 +4631,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/markdown-it/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5488,26 +5486,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/rc-config-loader/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/rc-config-loader/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index c125386c34cf6..29d6a8e71997c 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -794,9 +794,9 @@ ] }, "node_modules/@rspack/cli": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-1.7.11.tgz", - "integrity": "sha512-vUnflkq4F654wTEpCd+L4RYVbet8L2lNqLMmAGIZvoZddlXm4Duvg+eqcFE9iF8plAjFflRcU7DhB7WZa76pwg==", + "version": "1.7.12", + "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-1.7.12.tgz", + "integrity": "sha512-aiVj5hm12/rhkH/KTvs9BugpRuLEutIw5o3KmQDvSMd6EqEIASuCCUI3/97uaNP58hqZSl7mAotsLcwn/OCbJQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2625,9 +2625,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2849,14 +2849,14 @@ "license": "MIT" }, "node_modules/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/loader-runner": { @@ -4298,9 +4298,9 @@ } }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "dev": true, "license": "MIT", "engines": { @@ -4461,9 +4461,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { diff --git a/build/setup-npm-registry.ts b/build/setup-npm-registry.ts index 670c3e339db0f..4560f2c000728 100644 --- a/build/setup-npm-registry.ts +++ b/build/setup-npm-registry.ts @@ -37,6 +37,10 @@ async function setup(url: string, file: string): Promise { * Main function to set up custom NPM registry */ async function main(url: string, dir?: string): Promise { + if (!url) { + throw new Error('Usage: node setup-npm-registry.ts [dir]. A registry URL is required.'); + } + const root = dir ?? process.cwd(); for await (const file of getPackageLockFiles(root)) { @@ -45,4 +49,7 @@ async function main(url: string, dir?: string): Promise { } } -main(process.argv[2], process.argv[3]); +main(process.argv[2], process.argv[3]).catch(err => { + console.error(err.message); + process.exit(1); +}); diff --git a/cgmanifest.json b/cgmanifest.json index b90a69f0349a5..afc549e769e0b 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "chromium", "repositoryUrl": "https://chromium.googlesource.com/chromium/src", - "commitHash": "6b3fa66a923a9442c8ab0bc71b4b41ff24528d3b" + "commitHash": "21c21077f4937668031f80089a90618b7e1fe779" } }, "licenseDetail": [ @@ -40,7 +40,7 @@ "SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." ], "isOnlyProductionDependency": true, - "version": "148.0.7778.97" + "version": "148.0.7778.271" }, { "component": { @@ -516,12 +516,12 @@ "git": { "name": "nodejs", "repositoryUrl": "https://github.com/nodejs/node", - "commitHash": "848430679556aed0bd073f2bc263331ad84fa119", - "tag": "24.15.0" + "commitHash": "413e874773eef1e27a8397ddc949dbbe19cadb31", + "tag": "24.17.0" } }, "isOnlyProductionDependency": true, - "version": "24.15.0" + "version": "24.17.0" }, { "component": { @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "87740a867bddf434afec16e1f8b4f02235d3e7f7", - "tag": "42.2.0" + "commitHash": "d7bccf5b2f27969b4e7f34cf877f55cf0813be7a", + "tag": "42.5.0" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "42.2.0" + "version": "42.5.0" }, { "component": { diff --git a/eslint.config.js b/eslint.config.js index 51b085eebfe15..b9ba2cead680a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1577,6 +1577,7 @@ export default defineConfig( 'undici', 'undici-types', 'url', + 'module', 'util', 'vscode-regexpp', 'vscode-textmate', diff --git a/extensions/copilot/.esbuild.mts b/extensions/copilot/.esbuild.mts index 2377e0cc6e58b..e7654ab6879b6 100644 --- a/extensions/copilot/.esbuild.mts +++ b/extensions/copilot/.esbuild.mts @@ -246,6 +246,7 @@ const nodeSimulationWorkbenchUIBuildOptions = { // @ulugbekna: libs provided by node that need to be specified manually because of 'platform' is set to 'browser' 'fs', + 'os', 'path', 'readline', 'child_process', diff --git a/extensions/copilot/.github/CODEOWNERS b/extensions/copilot/.github/CODEOWNERS index 0e2c2176d7ee9..4bb631691afea 100644 --- a/extensions/copilot/.github/CODEOWNERS +++ b/extensions/copilot/.github/CODEOWNERS @@ -1,5 +1,5 @@ # Ensure Lad and Joao review cache relevant code -.github/workflows/pr-check-cache.yml @lszomoru @alexdima @joaomoreno -build/pr-check-cache-files.ts @lszomoru @alexdima @joaomoreno -test/base/cache-cli.ts @lszomoru @alexdima @joaomoreno -test/base/cache.ts @lszomoru @alexdima @joaomoreno +.github/workflows/pr-check-cache.yml @lszomoru @alexdima +build/pr-check-cache-files.ts @lszomoru @alexdima +test/base/cache-cli.ts @lszomoru @alexdima +test/base/cache.ts @lszomoru @alexdima diff --git a/extensions/copilot/.nvmrc b/extensions/copilot/.nvmrc index 5bf4400f22922..1dd37d53743d1 100644 --- a/extensions/copilot/.nvmrc +++ b/extensions/copilot/.nvmrc @@ -1 +1 @@ -24.15.0 +24.17.0 diff --git a/extensions/copilot/.vscodeignore b/extensions/copilot/.vscodeignore index 6ba2b99990919..e1fe34c33bcb7 100644 --- a/extensions/copilot/.vscodeignore +++ b/extensions/copilot/.vscodeignore @@ -22,7 +22,10 @@ assets/walkthroughs/** !node_modules/@vscode/copilot-typescript-server-plugin/dist/*.js node_modules/@github/copilot/index.js node_modules/@github/copilot/clipboard/** +node_modules/@github/copilot/foundry-local-sdk/** +node_modules/@github/copilot/mxc-bin/** node_modules/@github/copilot/prebuilds/** +node_modules/@github/copilot/pvrecorder/** node_modules/@github/copilot/sharp/** !node_modules/@github/copilot/package.json !node_modules/@github/copilot/sdk/**/package.json diff --git a/extensions/copilot/assets/debug-icon.svg b/extensions/copilot/assets/debug-icon.svg index 48ac425514ae8..fc3676b38e1a5 100644 --- a/extensions/copilot/assets/debug-icon.svg +++ b/extensions/copilot/assets/debug-icon.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/extensions/copilot/chat-lib/package-lock.json b/extensions/copilot/chat-lib/package-lock.json index 7480a9c237fcf..36c24bfb5a07d 100644 --- a/extensions/copilot/chat-lib/package-lock.json +++ b/extensions/copilot/chat-lib/package-lock.json @@ -147,25 +147,27 @@ } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk": { - "version": "1.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0-beta.5.tgz", - "integrity": "sha512-fsUarKQDvjhmBO4nIfaZkfNSApm1hZBzcvpNbSrXdcUBxu7lRvKsV5DnwszX7cnhLyVOW9yl1uigtRQ1yDANjA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0.tgz", + "integrity": "sha512-Y8rZOIMXQY/GwNRL+uLVuwIn9aEa/KnnggyYUmFxC1MigmRJCNH5NxMmxKSpddXF9SW6Z1ijRd6Pptd2A5OhGw==", + "license": "MIT", "dependencies": { - "@azure/core-tracing": "^1.0.0", + "@azure/core-tracing": "^1.2.0", "@azure/logger": "^1.0.0", - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/core": "^1.15.2", - "@opentelemetry/instrumentation": "^0.41.2", - "tslib": "^2.2.0" + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "tslib": "^2.7.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -776,6 +778,18 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -801,18 +815,17 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.41.2.tgz", - "integrity": "sha512-rxU72E0pKNH6ae2w5+xgVYZLzc5mlxAbGzF4shxMVK8YC2QQsfN38B2GPbj0jvrKWWNUElfclQ+YTykkNg/grw==", + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", "dependencies": { - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.4.2", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.1", - "shimmer": "^1.2.1" + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" @@ -869,6 +882,70 @@ "node": ">=14" } }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.8.0.tgz", + "integrity": "sha512-P3ZM8BGJ5mwjtyfAxRyxsCyWHvaj+xahdhLoS3YiPsEyTHcWTVzx2691C8SrGkpvro3tNFCsWuNNrvM+spKODg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.30.0.tgz", @@ -1229,11 +1306,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/shimmer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.3.tgz", - "integrity": "sha512-F/IjUGnV6pIN7R4ZV4npHJVoNtaLZWvb+2/9gctxjb99wkpI7Ozg8VPogwDiTRyjLwZXAYxjvdg1KS8LTHKdDA==" - }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1372,9 +1444,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1383,11 +1455,10 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "deprecated": "package has been renamed to acorn-import-attributes", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", "peerDependencies": { "acorn": "^8" @@ -1427,9 +1498,9 @@ } }, "node_modules/applicationinsights": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.7.tgz", - "integrity": "sha512-dxIVB2AAEMec3FDiYThgEbc9R4u1TatrzL+kFgOf+ABaEgHm8+i8ngVLHfKObjHvy2HHPf810OLWTrqyeWT/oA==", + "version": "2.9.8", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.8.tgz", + "integrity": "sha512-eB/EtAXJ6mDLLvHrtZj/7h31qUfnC2Npr2pHGqds5+1OP7BFLsn5us+HCkwTj7Q+1sHXujLphE5Cyvq5grtV6g==", "license": "MIT", "dependencies": { "@azure/core-auth": "1.7.2", @@ -1656,9 +1727,10 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" }, "node_modules/cls-hooked": { "version": "4.2.2", @@ -2381,6 +2453,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2627,6 +2700,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2661,14 +2735,15 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", - "integrity": "sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", "dependencies": { - "acorn": "^8.8.2", - "acorn-import-assertions": "^1.9.0", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" } }, "node_modules/inflight": { @@ -2796,6 +2871,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -3511,9 +3587,10 @@ } }, "node_modules/module-details-from-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", - "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" }, "node_modules/monaco-editor": { "version": "0.44.0", @@ -3946,7 +4023,8 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-scurry": { "version": "2.0.0", @@ -4161,22 +4239,23 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz", - "integrity": "sha512-3TLx5TGyAY6AOqLBoXmHkNql0HIf2RGbuMgCDT2WO/uGVAPJs6h7Kl+bN6TIZGd9bWhWPwnDnTHGtW8Iu77sdw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.1" + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">=8.6.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/resolve": { "version": "1.22.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", + "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -4318,18 +4397,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4784,6 +4851,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -4990,9 +5058,9 @@ } }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 60e0422bbb3ab..76de7393f8aa4 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -36,7 +36,7 @@ Open **Settings** (`Ctrl+,`) and add: } ``` -> **Note:** You can also use environment variables instead of VS Code settings (see [Configuration](#configuration)). Environment variables always take precedence. +> **Note:** You can also use environment variables instead of VS Code settings (see [Configuration](#configuration)). Precedence is **enterprise policy > environment variables > settings**. ### 3. Generate Telemetry @@ -70,13 +70,17 @@ Open **Settings** (`Ctrl+,`) and search for `copilot otel`: | `github.copilot.chat.otel.exporterType` | string | `"otlp-http"` | `otlp-http`, `otlp-grpc`, `console`, or `file` | | `github.copilot.chat.otel.otlpEndpoint` | string | `"http://localhost:4318"` | OTLP collector endpoint | | `github.copilot.chat.otel.captureContent` | boolean | `false` | Capture full prompt/response content | +| `github.copilot.chat.otel.protocol` | string | `""` | OTLP wire protocol: `http/json` (default), `http/protobuf`, or `grpc` | +| `github.copilot.chat.otel.serviceName` | string | `""` | Override the `service.name` resource attribute | +| `github.copilot.chat.otel.resourceAttributes` | object | `{}` | Extra resource attributes (`{ "key": "value" }`) | +| `github.copilot.chat.otel.headers` | object | `{}` | Extra OTLP exporter headers, applied directly to the exporter (`{ "key": "value" }`) | | `github.copilot.chat.otel.maxAttributeSizeChars` | integer | `0` | Max characters per OTel content attribute (prompts, tool args/results, hook input/output). `0` (the default) disables truncation so backends with no per-attribute limit get full payloads. Set to a positive value to match your backend's per-attribute size limit — consult your backend's documentation. The value counts JavaScript string characters (UTF-16 code units); for non-ASCII content one character can be multiple UTF-8 bytes on the wire. | | `github.copilot.chat.otel.outfile` | string | `""` | File path for JSON-lines output | | `github.copilot.chat.otel.dbSpanExporter.enabled` | boolean | `false` | Persist OTel spans to a local SQLite database for the **Chat: Export Agent Traces DB** command. Implicitly enables OTel. | ### Environment Variables -Environment variables **always take precedence** over VS Code settings. +Environment variables take precedence over VS Code settings, and **enterprise managed settings (policy) take precedence over both** — admins can centrally mandate any `github.copilot.chat.otel.*` value. | Variable | Default | Description | |---|---|---| @@ -98,6 +102,7 @@ Environment variables **always take precedence** over VS Code settings. OTel is **off by default** with zero overhead. It activates when: +- enterprise policy enables it (managed `telemetry.enabled` or a managed endpoint), or - `COPILOT_OTEL_ENABLED=true`, or - `OTEL_EXPORTER_OTLP_ENDPOINT` is set, or - `github.copilot.chat.otel.enabled` is `true`, or @@ -471,7 +476,7 @@ All signals carry: | Attribute | Value | |---|---| -| `service.name` | `copilot-chat` (configurable via `OTEL_SERVICE_NAME`) | +| `service.name` | `copilot-chat` (override via the `github.copilot.chat.otel.serviceName` setting, `OTEL_SERVICE_NAME`, or enterprise policy) | | `service.version` | Extension version | | `session.id` | Unique per VS Code window | @@ -541,7 +546,7 @@ Content is captured in full with no truncation. } ``` -> **Note:** Authentication headers are only configurable via the `OTEL_EXPORTER_OTLP_HEADERS` environment variable (e.g., `Authorization=Bearer your-token`). See [Environment Variables](#environment-variables). +> **Note:** Authentication headers can be set via the `github.copilot.chat.otel.headers` setting (a `{ "key": "value" }` map applied directly to the exporter) or the `OTEL_EXPORTER_OTLP_HEADERS` environment variable, and can be mandated by enterprise policy. See [Environment Variables](#environment-variables). **File-based output (offline / CI):** diff --git a/extensions/copilot/docs/monitoring/agent_monitoring_arch.md b/extensions/copilot/docs/monitoring/agent_monitoring_arch.md index 09c99b26de8ed..2eb3bc29fbac8 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring_arch.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring_arch.md @@ -209,10 +209,13 @@ Both export to the same OTLP endpoint. Bridge processor sits on Provider B, forw `resolveOTelConfig()` implements layered precedence: -1. `COPILOT_OTEL_*` env vars (highest) -2. `OTEL_EXPORTER_OTLP_*` standard env vars -3. VS Code settings (`github.copilot.chat.otel.*`) -4. Defaults (lowest) +1. Enterprise managed settings (policy) — highest; overrides env vars +2. `COPILOT_OTEL_*` env vars +3. `OTEL_EXPORTER_OTLP_*` standard env vars +4. VS Code settings (`github.copilot.chat.otel.*`) +5. Defaults (lowest) + +The OTLP wire protocol distinguishes `http/json` (default) from `http/protobuf`; `grpc` is selected by the gRPC exporter type. Kill switch: `telemetry.telemetryLevel === 'off'` → all OTel disabled. @@ -222,6 +225,7 @@ The resolved config records *how* OTel was enabled in `OTelConfig.enabledVia` (u | `enabledVia` | Trigger | |---|---| +| `policy` | Enterprise managed settings enable OTel (`telemetry.enabled` or a managed endpoint) | | `envVar` | `COPILOT_OTEL_ENABLED=true` | | `setting` | `github.copilot.chat.otel.enabled` is `true` | | `otlpEndpointEnvVar` | `OTEL_EXPORTER_OTLP_ENDPOINT` is set without an explicit enable | diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 765ab4aee4ee7..34fd4e9898566 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -1,31 +1,34 @@ { "name": "copilot-chat", - "version": "0.55.0", + "version": "0.56.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-chat", - "version": "0.55.0", + "version": "0.56.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.64-0", + "@github/copilot": "^1.0.67", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", @@ -48,7 +51,7 @@ "isbinaryfile": "^5.0.4", "jsonc-parser": "^3.3.1", "lru-cache": "^11.1.0", - "markdown-it": "^14.1.1", + "markdown-it": "^14.2.0", "minimatch": "^10.2.1", "undici": "^7.24.1", "vscode-tas-client": "^0.1.84", @@ -94,16 +97,16 @@ "@vscode/lsif-language-service": "^0.1.0-pre.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", - "@vscode/test-web": "^0.0.80", + "@vscode/test-web": "^0.0.81", "@vscode/vsce": "3.6.0", "copyfiles": "^2.4.1", "csv-parse": "^6.0.0", "dotenv": "^17.2.0", - "electron": "^39.8.5", + "electron": "^42.5.0", "esbuild": "0.28.1", "fastq": "^1.19.1", "glob": "^11.1.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -116,7 +119,7 @@ "openai": "^6.7.0", "outdent": "^0.8.0", "picomatch": "^4.0.4", - "playwright": "^1.58.2", + "playwright": "^1.61.1", "prettier": "^3.6.2", "react": "^17.0.2", "react-dom": "17.0.2", @@ -141,7 +144,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.127.0" + "vscode": "^1.128.0" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { @@ -627,19 +630,36 @@ } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk": { - "version": "1.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0-beta.5.tgz", - "integrity": "sha512-fsUarKQDvjhmBO4nIfaZkfNSApm1hZBzcvpNbSrXdcUBxu7lRvKsV5DnwszX7cnhLyVOW9yl1uigtRQ1yDANjA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0.tgz", + "integrity": "sha512-Y8rZOIMXQY/GwNRL+uLVuwIn9aEa/KnnggyYUmFxC1MigmRJCNH5NxMmxKSpddXF9SW6Z1ijRd6Pptd2A5OhGw==", + "license": "MIT", "dependencies": { - "@azure/core-tracing": "^1.0.0", + "@azure/core-tracing": "^1.2.0", "@azure/logger": "^1.0.0", - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/core": "^1.15.2", - "@opentelemetry/instrumentation": "^0.41.2", - "tslib": "^2.2.0" + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "tslib": "^2.7.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/@azure/opentelemetry-instrumentation-azure-sdk/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "node_modules/@babel/code-frame": { @@ -748,34 +768,35 @@ "node": ">=10" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" + "undici": "^7.24.4" } }, "node_modules/@emnapi/core": { @@ -2948,32 +2969,31 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", - "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.67.tgz", + "integrity": "sha512-5YEY9LNXBT9Q8uShjCdYcornJJJhGtdIzSYla2+pjfXYpHsDVibqYubzYjfgffOUKFChyzOpH7n/868+t56iIg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "detect-libc": "^2.1.2", - "os-theme": "^0.0.8" + "detect-libc": "^2.1.2" }, "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.64-0", - "@github/copilot-darwin-x64": "1.0.64-0", - "@github/copilot-linux-arm64": "1.0.64-0", - "@github/copilot-linux-x64": "1.0.64-0", - "@github/copilot-linuxmusl-arm64": "1.0.64-0", - "@github/copilot-linuxmusl-x64": "1.0.64-0", - "@github/copilot-win32-arm64": "1.0.64-0", - "@github/copilot-win32-x64": "1.0.64-0" + "@github/copilot-darwin-arm64": "1.0.67", + "@github/copilot-darwin-x64": "1.0.67", + "@github/copilot-linux-arm64": "1.0.67", + "@github/copilot-linux-x64": "1.0.67", + "@github/copilot-linuxmusl-arm64": "1.0.67", + "@github/copilot-linuxmusl-x64": "1.0.67", + "@github/copilot-win32-arm64": "1.0.67", + "@github/copilot-win32-x64": "1.0.67" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", - "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.67.tgz", + "integrity": "sha512-CO3mpgFXcN6e7ZsSmjMkt1AKxMfb1+mjdn3yrf2DRnnWIURSK9kGvw+E+E1+YE37D1MBiUn/VOBmhRad5+vl0A==", "cpu": [ "arm64" ], @@ -2987,9 +3007,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", - "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.67.tgz", + "integrity": "sha512-M20Hpn3bOJRkVwAIVRK4ZlX66AqtmGfXZRxZBRFQC045QIwcfmVUP45sTSgXDb4uHWeK0cZgdTdniHwKGtMplw==", "cpu": [ "x64" ], @@ -3003,9 +3023,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", - "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.67.tgz", + "integrity": "sha512-b4ePtFBow+Ior+aVLKA1hHxhR5wF+ql5CD7TSg/NHGYgc1kwD+3a9uKSENy05J5Lit/G/DZ9C6JwowvvdMWSKg==", "cpu": [ "arm64" ], @@ -3022,9 +3042,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", - "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.67.tgz", + "integrity": "sha512-4ynZyfKnWAdvEPAFDDBIz1wpFttcOTJu4Y8Mlz5oXCBA0NM/rwr8K4l7Adp8UzwbfmdrMJ9y+zivqRBMDbPInA==", "cpu": [ "x64" ], @@ -3041,9 +3061,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", - "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.67.tgz", + "integrity": "sha512-IjezxBU8fYUr/b5hEiniXqzwoOrJ4egrQSBbG96M+roLTqd9txP0MgxZtcRtKV7phRIdIGE109wwrn4H6hSqmA==", "cpu": [ "arm64" ], @@ -3060,9 +3080,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", - "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.67.tgz", + "integrity": "sha512-Zy/rbja1lnhzDoNfn051H0EybCseCvjvH7WmbcHCayjXUjzXeKF6OmAt4hvqFZH87ttT3KbKtQ8/6oDUhhM2YQ==", "cpu": [ "x64" ], @@ -3079,9 +3099,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", - "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.67.tgz", + "integrity": "sha512-O3VFRS5v9NXRP8o+N1SvcFbBqECDzZP7XQBeBj2Vcrma80gdJc5GQub/w2mwmr1w5UbwgzJkRasm0Ec/jxbcoA==", "cpu": [ "arm64" ], @@ -3095,9 +3115,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", - "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.67.tgz", + "integrity": "sha512-td5tQ/nve5dB7RPvNglBZwa/6DJqiOBgacXXa1GpYcohqpCzoI8gONNkeaeyr6oF4iu5wXJ9krUNr6QXL4yB5Q==", "cpu": [ "x64" ], @@ -3693,16 +3713,16 @@ } }, "node_modules/@koa/router": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.4.0.tgz", - "integrity": "sha512-vKYlXtoCfcAN8z4dHiveYX55rTYOgHEYJNumK1WM9ZAwaArhreGVkyC1LTMGfUQUJyIO/SbwRFBOHeOCY8/MaQ==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.6.0.tgz", + "integrity": "sha512-iEOXlvGIBqSNkGXrg0XtMARAOm5zA24oedXxiTGEkrD4JgwVjfRDddCQvW1s4WEcwDYvyecRbf8BikXsuEEj8w==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", "http-errors": "^2.0.1", "koa-compose": "^4.1.0", - "path-to-regexp": "^8.3.0" + "path-to-regexp": "^8.4.2" }, "engines": { "node": ">= 20" @@ -4043,9 +4063,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", - "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", + "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4079,17 +4099,17 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-SwmFRwO8mi6nndzbsjPgSFg7qy1WeNHRFD+s6uCsdiUDUt3+yzI2qiHE3/ub2f37+/CbeGcG+Ugc8Gwr6nu2Aw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.219.0.tgz", + "integrity": "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/sdk-logs": "0.214.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/sdk-logs": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4099,9 +4119,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4111,9 +4131,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4126,12 +4146,12 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4142,14 +4162,14 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4160,16 +4180,16 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.214.0.tgz", - "integrity": "sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.219.0.tgz", + "integrity": "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/sdk-logs": "0.214.0" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/sdk-logs": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4179,9 +4199,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4191,9 +4211,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4206,12 +4226,12 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4222,14 +4242,96 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.219.0.tgz", + "integrity": "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/api-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4239,20 +4341,37 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-0NGxWHVYHgbp51SEzmsP+Hdups81eRs229STcSWHo3WO0aqY6RpJ9csxfyEtFgaNrBDv6UfOh0je4ss/ROS6XA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.219.0.tgz", + "integrity": "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4262,9 +4381,9 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4277,12 +4396,12 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4293,16 +4412,16 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.214.0.tgz", - "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz", + "integrity": "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4312,9 +4431,9 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4327,12 +4446,63 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.219.0.tgz", + "integrity": "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4343,18 +4513,18 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-FWRZ7AWoTryYhthralHkfXUuyO3l7cRsnr49WcDio1orl2a7KxT8aDZdwQtV1adzoUvZ9Gfo+IstElghCS4zfw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.219.0.tgz", + "integrity": "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4364,9 +4534,9 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4379,12 +4549,12 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4395,13 +4565,13 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4412,16 +4582,16 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.214.0.tgz", - "integrity": "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz", + "integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4431,9 +4601,9 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4446,12 +4616,12 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4462,13 +4632,80 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz", + "integrity": "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4479,31 +4716,42 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.41.2.tgz", - "integrity": "sha512-rxU72E0pKNH6ae2w5+xgVYZLzc5mlxAbGzF4shxMVK8YC2QQsfN38B2GPbj0jvrKWWNUElfclQ+YTykkNg/grw==", + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", "dependencies": { - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.4.2", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.1", - "shimmer": "^1.2.1" + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", - "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-transformer": "0.214.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4513,9 +4761,9 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4528,15 +4776,15 @@ } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.214.0.tgz", - "integrity": "sha512-IDP6zcyA24RhNZ289MP6eToIZcinlmirHjX8v3zKCQ2ZhPpt5cGwkN91tCth337lqHIgWcTy90uKRiX/SzALDw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.219.0.tgz", + "integrity": "sha512-iIk/s8QQu39zpTrRRmsW/Eg3SE2+Hg8tLWepr2FLRgmwUpNd0IpCTLJEHJ77hpt4hgIS8MAh44UYI4xQPZwWlw==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4546,9 +4794,9 @@ } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4561,18 +4809,17 @@ } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", - "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "protobufjs": "^7.0.0" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4582,9 +4829,9 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4594,9 +4841,9 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4609,12 +4856,12 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4625,14 +4872,14 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4643,13 +4890,13 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4723,13 +4970,13 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", - "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4739,9 +4986,9 @@ } }, "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4754,12 +5001,12 @@ } }, "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4812,14 +5059,14 @@ } }, "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.5.1.tgz", - "integrity": "sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", + "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/context-async-hooks": "2.5.1", - "@opentelemetry/core": "2.5.1", - "@opentelemetry/sdk-trace-base": "2.5.1" + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4829,9 +5076,9 @@ } }, "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", - "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4843,14 +5090,94 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", - "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.8.0.tgz", + "integrity": "sha512-P3ZM8BGJ5mwjtyfAxRyxsCyWHvaj+xahdhLoS3YiPsEyTHcWTVzx2691C8SrGkpvro3tNFCsWuNNrvM+spKODg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.1", - "@opentelemetry/resources": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4869,50 +5196,11 @@ "node": ">=14" } }, - "node_modules/@os-theme/darwin-arm64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz", - "integrity": "sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@os-theme/linux-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz", - "integrity": "sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@os-theme/win32-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz", - "integrity": "sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -4930,14 +5218,14 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.59.1.tgz", - "integrity": "sha512-XDwr0qOrzLXAuBAzg4WO/xctVMb+ldJ54yz9KCpFu8G8MVJzUVFO6BvK1tBtBl4DIoFcoFRKHgUGZT+8wOC8BQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.61.1.tgz", + "integrity": "sha512-t3/zE0i9gik5R/NpRs7G2Xo/6NPeABW6ReplGdtkeWeAkaV764CgFgoKjJo21D2xgjnvDvRYubqBUu4xl0VCqA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.61.1" }, "engines": { "node": ">=18" @@ -4962,19 +5250,18 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -4983,12 +5270,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -5100,9 +5381,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5120,9 +5398,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5140,9 +5415,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5160,9 +5432,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5180,9 +5449,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5200,9 +5466,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5337,28 +5600,70 @@ } }, "node_modules/@secretlint/formatter": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.1.1.tgz", - "integrity": "sha512-Gpd8gTPN121SJ0h/9e6nWlZU7PitfhXUiEzW7Kyswg6kNGs+bSqmgTgWFtbo1VQ4ygJYiveWPNT05RCImBexJw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "^10.1.1", - "@secretlint/types": "^10.1.1", - "@textlint/linter-formatter": "^14.8.4", - "@textlint/module-interop": "^14.8.4", - "@textlint/types": "^14.8.4", - "chalk": "^4.1.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", "debug": "^4.4.1", "pluralize": "^8.0.0", - "strip-ansi": "^6.0.1", + "strip-ansi": "^7.1.0", "table": "^6.9.0", - "terminal-link": "^2.1.1" + "terminal-link": "^4.0.0" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/@secretlint/formatter/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/formatter/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@secretlint/node": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.1.1.tgz", @@ -5387,9 +5692,9 @@ "license": "MIT" }, "node_modules/@secretlint/resolver": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.1.1.tgz", - "integrity": "sha512-GdQzxnBtdBRjBULvZ8ERkaRqDp0njVwXrzBCav1pb0XshVk76C1cjeDqtTqM4RJ1Awo/g5U5MIWYztYv67v5Gg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", "dev": true, "license": "MIT" }, @@ -5441,9 +5746,9 @@ } }, "node_modules/@secretlint/types": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.1.1.tgz", - "integrity": "sha512-/JGAvVkurVHkargk3AC7UxRy+Ymc+52AVBO/fZA5pShuLW2dX4O/rKc4n8cyhQiOb/3ym5ACSlLQuQ8apPfxrQ==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", "dev": true, "license": "MIT", "engines": { @@ -5466,18 +5771,6 @@ "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", @@ -5581,41 +5874,29 @@ "tslib": "^2.4.0" } }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@textlint/ast-node-types": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-14.8.4.tgz", - "integrity": "sha512-+fI7miec/r9VeniFV9ppL4jRCmHNsTxieulTUf/4tvGII3db5hGriKHC4p/diq1SkQ9Sgs7kg6UyydxZtpTz1Q==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-14.8.4.tgz", - "integrity": "sha512-sZ0UfYRDBNHnfMVBqLqqYnqTB7Ec169ljlmo+SEHR1T+dHUPYy1/DZK4p7QREXlBSFL4cnkswETCbc9xRodm4Q==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "14.8.4", - "@textlint/resolver": "14.8.4", - "@textlint/types": "14.8.4", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", "chalk": "^4.1.2", - "debug": "^4.4.1", - "js-yaml": "^3.14.1", - "lodash": "^4.17.21", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", @@ -5623,16 +5904,6 @@ "text-table": "^0.2.0" } }, - "node_modules/@textlint/linter-formatter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5640,20 +5911,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@textlint/linter-formatter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@textlint/linter-formatter/node_modules/pluralize": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", @@ -5661,13 +5918,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@textlint/linter-formatter/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@textlint/linter-formatter/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5684,27 +5934,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-14.8.4.tgz", - "integrity": "sha512-1LdPYLAVpa27NOt6EqvuFO99s4XLB0c19Hw9xKSG6xQ1K82nUEyuWhzTQKb3KJ5Qx7qj14JlXZLfnEuL6A16Bw==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-14.8.4.tgz", - "integrity": "sha512-nMDOgDAVwNU9ommh+Db0U+MCMNDPbQ/1HBNjbnHwxZkCpcT6hsAJwBe38CW/DtWVUv8yeR4R40IYNPT84srNwA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-14.8.4.tgz", - "integrity": "sha512-9nyY8vVXlr8hHKxa6+37omJhXWCwovMQcgMteuldYd4dOxGm14AK2nXdkgtKEUQnzLGaXy46xwLCfhQy7V7/YA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "14.8.4" + "@textlint/ast-node-types": "15.7.1" } }, "node_modules/@tybys/wasm-util": { @@ -5729,18 +5979,6 @@ "@types/node": "*" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -5835,12 +6073,6 @@ "@types/unist": "*" } }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz", - "integrity": "sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw==", - "dev": true - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -5868,15 +6100,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -5978,15 +6201,6 @@ "@types/react": "*" } }, - "node_modules/@types/responselike": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.1.tgz", - "integrity": "sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/sarif": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", @@ -6021,11 +6235,6 @@ "@types/node": "*" } }, - "node_modules/@types/shimmer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.3.tgz", - "integrity": "sha512-F/IjUGnV6pIN7R4ZV4npHJVoNtaLZWvb+2/9gctxjb99wkpI7Ozg8VPogwDiTRyjLwZXAYxjvdg1KS8LTHKdDA==" - }, "node_modules/@types/sinon": { "version": "17.0.4", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", @@ -6120,16 +6329,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz", - "integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==", - "dev": true, - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.36.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", @@ -6950,26 +7149,26 @@ } }, "node_modules/@vscode/test-web": { - "version": "0.0.80", - "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.80.tgz", - "integrity": "sha512-QgsRPp8IuPcdbUdVtqQkFN/sGd+6cr6g0ENEVFOHwJbQqZtJSiZD5w0e6tgmoeq9nBjNqZCq87OO1GkwFHkPyw==", + "version": "0.0.81", + "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.81.tgz", + "integrity": "sha512-qAYNX1mf4hE0L3T/186J8AH+Z7Inm81OHMACkkyKE2J6HJZlXou0OgABkSvd8gt0BiPjI+V+xkduAaQ8Kcjexg==", "dev": true, "license": "MIT", "dependencies": { "@koa/cors": "^5.0.0", - "@koa/router": "^15.3.0", - "@playwright/browser-chromium": "^1.58.2", + "@koa/router": "^15.6.0", + "@playwright/browser-chromium": "^1.61.0", "gunzip-maybe": "^1.4.2", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "koa": "^3.1.2", + "http-proxy-agent": "^9.1.0", + "https-proxy-agent": "^9.1.0", + "koa": "^3.2.1", "koa-morgan": "^1.0.1", "koa-mount": "^4.2.0", "koa-static": "^5.0.0", "minimist": "^1.2.8", - "playwright": "^1.58.2", - "tar-fs": "^3.1.1", - "tinyglobby": "^0.2.15", + "playwright": "^1.61.0", + "tar-fs": "^3.1.2", + "tinyglobby": "^0.2.17", "vscode-uri": "^3.1.0" }, "bin": { @@ -6979,10 +7178,50 @@ "node": ">=20" } }, + "node_modules/@vscode/test-web/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@vscode/test-web/node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6995,13 +7234,14 @@ } }, "node_modules/@vscode/test-web/node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } @@ -7265,10 +7505,11 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", "peerDependencies": { "acorn": "^8" } @@ -7324,6 +7565,22 @@ } } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -7374,9 +7631,9 @@ } }, "node_modules/applicationinsights": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.7.tgz", - "integrity": "sha512-dxIVB2AAEMec3FDiYThgEbc9R4u1TatrzL+kFgOf+ABaEgHm8+i8ngVLHfKObjHvy2HHPf810OLWTrqyeWT/oA==", + "version": "2.9.8", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.8.tgz", + "integrity": "sha512-eB/EtAXJ6mDLLvHrtZj/7h31qUfnC2Npr2pHGqds5+1OP7BFLsn5us+HCkwTj7Q+1sHXujLphE5Cyvq5grtV6g==", "license": "MIT", "dependencies": { "@azure/core-auth": "1.7.2", @@ -7577,11 +7834,19 @@ } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/balanced-match": { "version": "1.0.2", @@ -7590,20 +7855,26 @@ "dev": true }, "node_modules/bare-events": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz", - "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", - "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -7624,42 +7895,45 @@ } }, "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.2.tgz", + "integrity": "sha512-h530JsrkYi8518ZfR57GHaLoI5YzXkGGEV0Y+mf4KYPBn4OnNajiznwkDq7FgE+Vnmyss9Utnzi44y7sowiAXA==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } }, "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -7669,12 +7943,11 @@ } }, "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -7828,13 +8101,6 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "dev": true, - "optional": true - }, "node_modules/boundary": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", @@ -8030,43 +8296,6 @@ "node": ">=8" } }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-request/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -8228,9 +8457,10 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" }, "node_modules/cli-cursor": { "version": "5.0.0", @@ -8308,18 +8538,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cls-hooked": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", @@ -8777,6 +8995,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, + "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -8792,6 +9011,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, + "optional": true, "engines": { "node": ">=10" }, @@ -8844,15 +9064,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -8946,13 +9157,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "optional": true - }, "node_modules/diagnostic-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-1.1.1.tgz", @@ -9123,24 +9327,41 @@ "license": "MIT" }, "node_modules/electron": { - "version": "39.8.5", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz", - "integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==", + "version": "42.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.0.tgz", + "integrity": "sha512-cYEKS9XFz+c9fAB4jI0x49yz1FFzB55r3q96wu9YkwwJMv7t9202IE/ltlgy6yitl/J4M7C8JQcmUqdzDvPl/w==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" } }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", @@ -9226,12 +9447,29 @@ } }, "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/error-ex": { @@ -9383,13 +9621,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "optional": true - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -9501,20 +9732,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -9544,6 +9761,16 @@ "node": ">= 0.6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -9714,26 +9941,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9801,15 +10008,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -9920,17 +10118,17 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -9962,20 +10160,6 @@ "dev": true, "optional": true }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -10136,21 +10320,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -10210,24 +10379,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -10304,31 +10455,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -10452,9 +10578,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10582,12 +10708,6 @@ "node": ">= 0.6" } }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -10620,19 +10740,6 @@ "node": ">= 14" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -10683,14 +10790,15 @@ "dev": true }, "node_modules/import-in-the-middle": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", - "integrity": "sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", "dependencies": { - "acorn": "^8.8.2", - "acorn-import-assertions": "^1.9.0", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" } }, "node_modules/inflight": { @@ -10862,6 +10970,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -11409,10 +11518,20 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -11430,13 +11549,6 @@ "bignumber.js": "^9.0.0" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -11478,28 +11590,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "optional": true - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -11622,9 +11718,9 @@ } }, "node_modules/koa": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-3.1.2.tgz", - "integrity": "sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", "dev": true, "license": "MIT", "dependencies": { @@ -11769,16 +11865,20 @@ } }, "node_modules/koa/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/leven": { @@ -11942,9 +12042,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11966,9 +12063,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11990,9 +12084,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12014,9 +12105,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12083,9 +12171,20 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } @@ -12236,15 +12335,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/lru-cache": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", @@ -12293,14 +12383,24 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -12309,19 +12409,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -12461,15 +12548,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -12820,9 +12898,10 @@ } }, "node_modules/module-details-from-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", - "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" }, "node_modules/monaco-editor": { "version": "0.44.0", @@ -13098,18 +13177,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -13595,20 +13662,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/os-theme": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/os-theme/-/os-theme-0.0.8.tgz", - "integrity": "sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==", - "license": "MIT", - "optionalDependencies": { - "@os-theme/darwin-arm64": "0.0.8", - "@os-theme/linux-x64": "0.0.8", - "@os-theme/win32-x64": "0.0.8" - }, - "peerDependencies": { - "typescript": "^5" - } - }, "node_modules/outdent": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", @@ -13633,15 +13686,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13812,7 +13856,8 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-scurry": { "version": "2.0.0", @@ -13887,7 +13932,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -13928,13 +13974,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -13947,9 +13993,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -13974,19 +14020,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/playwright/node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -14118,6 +14151,7 @@ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -14156,24 +14190,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", - "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -14192,6 +14225,24 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -14268,18 +14319,6 @@ } ] }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -14574,22 +14613,23 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz", - "integrity": "sha512-3TLx5TGyAY6AOqLBoXmHkNql0HIf2RGbuMgCDT2WO/uGVAPJs6h7Kl+bN6TIZGd9bWhWPwnDnTHGtW8Iu77sdw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.1" + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">=8.6.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/resolve": { "version": "1.22.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", + "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -14602,12 +14642,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true - }, "node_modules/resolve-path": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", @@ -14672,18 +14706,6 @@ "node": ">= 0.6" } }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -14731,24 +14753,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -15051,13 +15055,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "optional": true - }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -15118,35 +15115,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -15461,6 +15429,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -15522,13 +15508,6 @@ "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", "dev": true }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "optional": true - }, "node_modules/stack-chain": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", @@ -15592,17 +15571,15 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", "dev": true, "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -15829,6 +15806,7 @@ "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" }, @@ -15849,9 +15827,9 @@ } }, "node_modules/supports-hyperlinks": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", "dependencies": { @@ -15859,13 +15837,17 @@ "supports-color": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -15903,24 +15885,6 @@ "dev": true, "license": "MIT" }, - "node_modules/table/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, "node_modules/table/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -16091,56 +16055,37 @@ "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.2.33.tgz", "integrity": "sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg==" }, - "node_modules/terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "streamx": "^2.12.5" } }, - "node_modules/terminal-link/node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terminal-link/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -16151,7 +16096,8 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/textextensions": { "version": "6.11.0", @@ -16529,9 +16475,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -16556,15 +16502,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -17183,9 +17120,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17344,13 +17281,16 @@ } }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, + "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/yazl": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 0e917130e913e..3eae6bd612003 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1,8 +1,8 @@ { "name": "copilot-chat", - "displayName": "GitHub Copilot Chat", + "displayName": "GitHub Copilot", "description": "AI chat features powered by Copilot", - "version": "0.55.0", + "version": "0.56.0", "build": "1", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", @@ -22,7 +22,7 @@ "icon": "assets/copilot.png", "pricing": "Trial", "engines": { - "vscode": "^1.127.0", + "vscode": "^1.128.0", "npm": ">=9.0.0", "node": ">=22.14.0" }, @@ -1826,6 +1826,11 @@ "secret": true, "description": "API key for OpenAI", "title": "API Key" + }, + "zeroDataRetentionEnabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Whether Zero Data Retention (ZDR) is enabled for this provider group. When `true`, OpenAI Responses requests from this group do not send `previous_response_id`." } }, "required": [ @@ -1835,7 +1840,10 @@ }, { "vendor": "ollama", - "displayName": "Ollama", + "displayName": "Ollama (Deprecated)", + "deprecation": { + "link": "vscode:extension/Ollama.ollama" + }, "configuration": { "type": "object", "properties": { @@ -2136,6 +2144,30 @@ "additionalProperties": { "type": "string" } + }, + "modelOptions": { + "type": "object", + "markdownDescription": "Sampling parameters to send with requests to this model. These override Copilot's defaults but are overridden by explicit per-request values. Set a property to `null` to omit it and use the model server's default.", + "properties": { + "temperature": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "markdownDescription": "Sampling temperature. Set to `null` to omit the parameter." + }, + "top_p": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 1, + "markdownDescription": "Nucleus sampling probability. Set to `null` to omit the parameter." + } + }, + "additionalProperties": false } }, "required": [ @@ -3447,6 +3479,11 @@ "tags": [ "onExp" ] + }, + "github.copilot.chat.imageUpload.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%github.copilot.config.imageUpload.enabled%" } } }, @@ -3592,15 +3629,6 @@ "onExp" ] }, - "github.copilot.chat.imageUpload.enabled": { - "type": "boolean", - "default": true, - "tags": [ - "experimental", - "onExp" - ], - "markdownDescription": "%github.copilot.config.imageUpload.enabled%" - }, "github.copilot.chat.codeGeneration.instructions": { "markdownDeprecationMessage": "%github.copilot.config.codeGeneration.instructions.deprecated%", "type": "array", @@ -4013,7 +4041,7 @@ }, "github.copilot.chat.gemini35FlashReducedToolUsePrompt.enabled": { "type": "boolean", - "default": false, + "default": true, "tags": [ "experimental", "onExp" @@ -4035,19 +4063,6 @@ "clear-both" ] }, - "github.copilot.chat.responsesApiReasoningSummary": { - "type": "string", - "default": "detailed", - "markdownDescription": "%github.copilot.config.responsesApiReasoningSummary%", - "tags": [ - "experimental", - "onExp" - ], - "enum": [ - "off", - "detailed" - ] - }, "github.copilot.chat.responsesApiContextManagement.enabled": { "type": "boolean", "default": false, @@ -4066,10 +4081,10 @@ "onExp" ] }, - "github.copilot.chat.responsesApi.persistentCoT.enabled": { + "github.copilot.chat.responsesApi.promptCacheBreakpoint.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%github.copilot.config.responsesApi.persistentCoT.enabled%", + "markdownDescription": "%github.copilot.config.responsesApi.promptCacheBreakpoint.enabled%", "tags": [ "experimental", "onExp" @@ -4104,7 +4119,7 @@ }, "github.copilot.chat.gemini3GetChangedFilesTool.enabled": { "type": "boolean", - "default": true, + "default": false, "markdownDescription": "%github.copilot.config.gemini3GetChangedFilesTool.enabled%", "tags": [ "experimental", @@ -4327,13 +4342,34 @@ }, "github.copilot.chat.tools.grepSearch.outputFormat": { "type": "string", - "default": "tag", - "enum": ["grep", "tag"], + "default": "grep", + "enum": [ + "grep", + "tag" + ], "markdownDescription": "%github.copilot.chat.tools.grepSearch.outputFormat%", "tags": [ "experimental", "onExp" ] + }, + "github.copilot.chat.tools.grepSearch.defaultMaxResults": { + "type": "number", + "default": 20, + "markdownDescription": "%github.copilot.chat.tools.grepSearch.defaultMaxResults%", + "tags": [ + "experimental", + "onExp" + ] + }, + "github.copilot.chat.tools.grepSearch.maxResultsCap": { + "type": "number", + "default": 200, + "markdownDescription": "%github.copilot.chat.tools.grepSearch.maxResultsCap%", + "tags": [ + "experimental", + "onExp" + ] } } }, @@ -5273,7 +5309,10 @@ "type": "boolean", "default": false, "scope": "application", - "markdownDescription": "Enable OpenTelemetry trace/metric/log emission for Copilot Chat operations. Configurable in user settings only. Env var `COPILOT_OTEL_ENABLED` takes precedence. Requires window reload.", + "policyReference": { + "name": "CopilotOtelEnabled" + }, + "markdownDescription": "Enable OpenTelemetry trace/metric/log emission for Copilot Chat operations. Precedence: enterprise policy > env var `COPILOT_OTEL_ENABLED` > user setting. Requires window reload.", "tags": [ "advanced" ] @@ -5288,7 +5327,28 @@ ], "default": "otlp-http", "scope": "application", - "markdownDescription": "OTel exporter type for Copilot Chat telemetry. Configurable in user settings only. Requires window reload.", + "policyReference": { + "name": "CopilotOtelProtocol" + }, + "markdownDescription": "OTel exporter type for Copilot Chat telemetry. Configurable in user settings or managed by enterprise policy (policy takes precedence). Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.protocol": { + "type": "string", + "enum": [ + "", + "http/json", + "http/protobuf", + "grpc" + ], + "default": "", + "scope": "application", + "policyReference": { + "name": "CopilotOtelOtlpProtocol" + }, + "markdownDescription": "OTLP wire protocol for Copilot Chat OTel data, mirroring `OTEL_EXPORTER_OTLP_PROTOCOL`. `http/protobuf` selects the protobuf-over-HTTP exporter; the default (empty) uses `http/json`. Precedence: enterprise policy > env var > user setting. Requires window reload.", "tags": [ "advanced" ] @@ -5297,7 +5357,10 @@ "type": "string", "default": "http://localhost:4318", "scope": "application", - "markdownDescription": "OTLP collector endpoint URL for Copilot Chat OTel data. Configurable in user settings only. Env var `OTEL_EXPORTER_OTLP_ENDPOINT` takes precedence. Requires window reload.", + "policyReference": { + "name": "CopilotOtelEndpoint" + }, + "markdownDescription": "OTLP collector endpoint URL for Copilot Chat OTel data. Precedence: enterprise policy > env var `OTEL_EXPORTER_OTLP_ENDPOINT` > user setting. Requires window reload.", "tags": [ "advanced" ] @@ -5306,7 +5369,52 @@ "type": "boolean", "default": false, "scope": "application", - "markdownDescription": "Capture input/output messages, system instructions, and tool definitions in OTel telemetry. **Contains potentially sensitive data.** Configurable in user settings only. Env var `COPILOT_OTEL_CAPTURE_CONTENT` takes precedence. Requires window reload.", + "policyReference": { + "name": "CopilotOtelCaptureContent" + }, + "markdownDescription": "Capture input/output messages, system instructions, and tool definitions in OTel telemetry. **Contains potentially sensitive data.** Precedence: enterprise policy > env var `COPILOT_OTEL_CAPTURE_CONTENT` > user setting. Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.serviceName": { + "type": "string", + "default": "", + "scope": "application", + "policyReference": { + "name": "CopilotOtelServiceName" + }, + "markdownDescription": "OTel `service.name` resource attribute for Copilot Chat OTel data. Configurable in user settings only. Env var `OTEL_SERVICE_NAME` takes precedence over the setting; enterprise policy takes precedence over both. Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.resourceAttributes": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {}, + "scope": "application", + "policyReference": { + "name": "CopilotOtelResourceAttributes" + }, + "markdownDescription": "Additional OTel resource attributes for Copilot Chat OTel data, as a `{ \"key\": \"value\" }` map. Configurable in user settings only. Merged per-key with `OTEL_RESOURCE_ATTRIBUTES` env (env wins over the setting); enterprise policy wins over both. Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {}, + "scope": "application", + "policyReference": { + "name": "CopilotOtelHeaders" + }, + "markdownDescription": "Extra OTLP exporter headers (e.g. auth tokens) for Copilot Chat OTel data, as a `{ \"key\": \"value\" }` map. Applied directly to the OTLP exporter, not via environment variables. Configurable in user settings only. Merged per-key with `OTEL_EXPORTER_OTLP_HEADERS` env (env wins over the setting); enterprise policy wins over both. **Contains potentially sensitive credentials.** Requires window reload.", "tags": [ "advanced" ] @@ -5325,7 +5433,10 @@ "type": "string", "default": "", "scope": "application", - "markdownDescription": "File path for file-based OTel exporter output (JSON-lines). When set, overrides exporter type to `file`. Configurable in user settings only. Requires window reload.", + "policyReference": { + "name": "CopilotOtelOutfile" + }, + "markdownDescription": "File path for file-based OTel exporter output (JSON-lines). When set, overrides exporter type to `file`. Configurable in user settings or managed by enterprise policy (policy takes precedence). Requires window reload.", "tags": [ "advanced" ] @@ -5480,69 +5591,69 @@ "agents/changes/actions/primary": [ { "command": "github.copilot.sessions.initializeRepository", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == workspace && !sessions.hasGitRepository && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.isolationMode == workspace && !sessions.hasGitRepository && !sessions.isAgentHostSession", "group": "0_init@1" }, { "command": "github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && !sessions.isMergeBaseBranchProtected && !sessions.hasPullRequest && (sessions.hasUncommittedChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && !sessions.isMergeBaseBranchProtected && !sessions.hasPullRequest && (sessions.hasUncommittedChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", "group": "1_merge@1" }, { "command": "github.copilot.chat.mergeCopilotCLIAgentSessionChanges.mergeAndSync", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && !sessions.isMergeBaseBranchProtected && !sessions.hasPullRequest && (sessions.hasUncommittedChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && !sessions.isMergeBaseBranchProtected && !sessions.hasPullRequest && (sessions.hasUncommittedChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", "group": "1_merge@2" }, { "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && sessions.hasBranchChanges && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && sessions.hasBranchChanges && !sessions.isAgentHostSession", "group": "2_pull_request@1" }, { "command": "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && sessions.hasBranchChanges && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && sessions.hasBranchChanges && !sessions.isAgentHostSession", "group": "2_pull_request@2" }, { "command": "github.copilot.sessions.commit", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && !sessions.isAgentHostSession", "group": "3_commit@1" }, { "command": "github.copilot.sessions.commitAndSync", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && !sessions.isAgentHostSession", "group": "3_commit@2" }, { "command": "github.copilot.sessions.sync", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUpstream && !sessions.hasUncommittedChanges && (sessions.hasIncomingChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUpstream && !sessions.hasUncommittedChanges && (sessions.hasIncomingChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", "group": "4_sync@1" }, { "command": "github.copilot.claude.sessions.initializeRepository", - "when": "chatSessionType == claude-code && isSessionsWindow && !sessions.hasGitRepository", + "when": "sessionType == claude-code && isSessionsWindow && !sessions.hasGitRepository", "group": "init@1" }, { "command": "github.copilot.claude.sessions.commit", - "when": "chatSessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges", + "when": "sessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges", "group": "commit@1" }, { "command": "github.copilot.claude.sessions.commitAndSync", - "when": "chatSessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && sessions.hasUpstream", + "when": "sessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && sessions.hasUpstream", "group": "commit@2" }, { "command": "github.copilot.claude.sessions.sync", - "when": "chatSessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && !sessions.hasUncommittedChanges && sessions.hasUpstream", + "when": "sessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && !sessions.hasUncommittedChanges && sessions.hasUpstream", "group": "sync@1" } ], "agents/change/inline": [ { "command": "github.copilot.sessions.discardChanges", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && !isActiveSessionArchived && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && !sessionIsArchived && !sessions.isAgentHostSession", "group": "navigation@2" } ], @@ -6292,12 +6403,12 @@ "chatSessions/item/context": [ { "command": "github.copilot.claude.sessions.rename", - "when": "chatSessionType == claude-code && chatSessionProviderId == default-copilot", + "when": "sessionType == claude-code && sessionProviderId == default-copilot", "group": "1_edit@4" }, { "command": "github.copilot.cli.sessions.rename", - "when": "chatSessionType == copilotcli && chatSessionProviderId == default-copilot", + "when": "sessionType == copilotcli && sessionProviderId == default-copilot", "group": "1_edit@4" } ], @@ -6311,7 +6422,7 @@ "chat/input/editing/sessionTitleToolbar": [ { "command": "github.copilot.sessions.refreshChanges", - "when": "chatSessionType == copilotcli && isSessionsWindow && !sessions.isAgentHostSession", + "when": "sessionType == copilotcli && isSessionsWindow && !sessions.isAgentHostSession", "group": "9_refresh@1" } ], @@ -6681,6 +6792,7 @@ "canDelegate": true, "requiresCustomModels": true, "supportsAutoModel": false, + "requiresCopilotSignIn": true, "capabilities": { "supportsFileAttachments": true, "supportsImageAttachments": true @@ -6741,6 +6853,7 @@ "description": "%github.copilot.session.providerDescription.background%", "when": "config.github.copilot.chat.backgroundAgent.enabled", "supportsAutoModel": true, + "requiresCopilotSignIn": true, "capabilities": { "supportsFileAttachments": true, "supportsProblemAttachments": true, @@ -6799,6 +6912,7 @@ "description": "%github.copilot.session.providerDescription.cloud%", "when": "config.github.copilot.chat.cloudAgent.enabled", "supportsAutoModel": false, + "requiresCopilotSignIn": true, "capabilities": { "supportsFileAttachments": true }, @@ -7038,16 +7152,16 @@ "@vscode/lsif-language-service": "^0.1.0-pre.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", - "@vscode/test-web": "^0.0.80", + "@vscode/test-web": "^0.0.81", "@vscode/vsce": "3.6.0", "copyfiles": "^2.4.1", "csv-parse": "^6.0.0", "dotenv": "^17.2.0", - "electron": "^39.8.5", + "electron": "^42.5.0", "esbuild": "0.28.1", "fastq": "^1.19.1", "glob": "^11.1.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -7060,7 +7174,7 @@ "openai": "^6.7.0", "outdent": "^0.8.0", "picomatch": "^4.0.4", - "playwright": "^1.58.2", + "playwright": "^1.61.1", "prettier": "^3.6.2", "react": "^17.0.2", "react-dom": "17.0.2", @@ -7086,19 +7200,22 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.64-0", + "@github/copilot": "^1.0.67", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", @@ -7121,7 +7238,7 @@ "isbinaryfile": "^5.0.4", "jsonc-parser": "^3.3.1", "lru-cache": "^11.1.0", - "markdown-it": "^14.1.1", + "markdown-it": "^14.2.0", "minimatch": "^10.2.1", "undici": "^7.24.1", "vscode-tas-client": "^0.1.84", @@ -7129,6 +7246,7 @@ }, "overrides": { "string_decoder": "npm:string_decoder@1.2.0", + "yauzl": "^3.3.1", "zod": "3.25.76" }, "vscodeCommit": "94c8e2adc50e26ef70af85a0de3a9efed757acaa", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 61c63fcca21b9..aecb9d3a3396f 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -278,7 +278,9 @@ "github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.", "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", - "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'tag'.", + "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'grep'.", + "github.copilot.chat.tools.grepSearch.defaultMaxResults": "The default maximum number of results to return from the grep search tool. The default is 20.", + "github.copilot.chat.tools.grepSearch.maxResultsCap": "The maximum number of results that can be returned from the grep search tool. The default is 200.", "copilot.tools.errors.description": "Check errors for a particular file", "copilot.tools.applyPatch.description": "Edit text files in the workspace", "copilot.tools.findTestFiles.description": "For a source code file, find the file that contains the tests. For a test file, find the file that contains the code under test", @@ -344,10 +346,9 @@ "github.copilot.config.anthropic.promptCaching.extendedTtlMessages": "Also extend the 1 hour prompt cache TTL to message-level breakpoints. Requires `chat.anthropic.promptCaching.extendedTtl` to be enabled; has no effect on its own.\n\n**Note**: This is an experimental feature.", "github.copilot.config.modelCapabilityOverrides": "Per-model capability overrides keyed by model id, intended for evaluating preview and tenanted models against an existing model's capability profile. For each model id, declare an aliased `family`. Setting `family` to a known production family (e.g. `\"claude-opus-4.7\"`) makes the model receive that family's full capability profile — Anthropic family detection, latest Opus prompt, multi-replace tools, tool search, context editing, extended cache TTL — without a code change.\n\n**Note**: This is an advanced setting for evaluation use; it is not intended for regular end-user configuration.", "github.copilot.config.useResponsesApi": "Use the Responses API instead of the Chat Completions API when supported. Enables reasoning and reasoning summaries.\n\n**Note**: This is an experimental feature that is not yet activated for all users.\n\n**Important**: For Custom Endpoint models, the API type is independent of this setting and is determined per-model via the `apiType` property, or inferred from the `url` path when omitted.", - "github.copilot.config.responsesApiReasoningSummary": "Sets the reasoning summary style used for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApiContextManagement.enabled": "Enables context management for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApi.promptCacheKey.enabled": "Enables prompt cache key being set for the Responses API.", - "github.copilot.config.responsesApi.persistentCoT.enabled": "Enables persistent chain of thought for supported Responses API models.", + "github.copilot.config.responsesApi.promptCacheBreakpoint.enabled": "Enables explicit prompt cache breakpoint markers for the Responses API.", "github.copilot.config.updated53CodexPrompt.enabled": "Enables the updated prompt for gpt-5.3-codex model.", "github.copilot.config.claude47OpusPrompt.enabled": "Enables the updated system prompt tuned for the Claude Opus 4.7 model.", "github.copilot.config.gpt55GetChangedFilesTool.enabled": "Enables the Get Changed Files tool for gpt-5.5 models.", @@ -416,7 +417,7 @@ "github.copilot.config.claudeAgent.useSdkExtension": "Load the Claude Agent SDK from the `ms-vscode.vscode-claude-sdk` extension (installed on demand) instead of the version bundled with Copilot Chat.", "github.copilot.config.claudeAgent.sdkExtensionInstallTimeout": "Maximum time in milliseconds to wait for the `ms-vscode.vscode-claude-sdk` extension to be installed and detected when loading the Claude Agent SDK from the marketplace.", "github.copilot.config.cli.mcp.enabled": "Enable Model Context Protocol (MCP) server for Copilot CLI.", - "github.copilot.config.cli.sandbox.enabled": "Run Copilot CLI tools (such as the terminal) inside a sandbox to limit what they can access on your system.", + "github.copilot.config.cli.sandbox.enabled": "Run Copilot CLI tools (such as the terminal) inside a sandbox to limit what they can access on your system. The sandbox only applies to requests that run with default approvals — it is not used when bypassing approvals — and is not supported on Windows yet.", "github.copilot.config.cli.sandbox.enabled.offDescription": "Disable sandboxing for Copilot CLI tools.", "github.copilot.config.cli.sandbox.enabled.onDescription": "Enable sandboxing for Copilot CLI tools.", "github.copilot.config.cli.sandbox.enabled.allowNetworkDescription": "Enable sandboxing for Copilot CLI tools and allow all network domains.", diff --git a/extensions/copilot/script/alternativeAction/index.ts b/extensions/copilot/script/alternativeAction/index.ts index 8f90cd8fa273d..93bc883633bba 100644 --- a/extensions/copilot/script/alternativeAction/index.ts +++ b/extensions/copilot/script/alternativeAction/index.ts @@ -46,7 +46,7 @@ async function extractFromCsv(csvContents: string): Promise<(Scoring.t | undefin if (!altAction || !altAction.recording) { return undefined; } - return Processor.createScoringForAlternativeAction(altAction, coalesce([parseSuggestedEdit(obj.postProcessingOutcome.suggestedEdit)]), false); + return Processor.createScoring(altAction.recording.entries ?? [], altAction.recording.requestTime, coalesce([parseSuggestedEdit(obj.postProcessingOutcome.suggestedEdit)]), false); }); return scoredEdits; @@ -113,7 +113,12 @@ async function handleAlternativeActionJson(inputFilePath: string) { } isAccepted = data.suggestionStatus === 'accepted'; } - const scoring = Processor.createScoringForAlternativeAction(altAction, edits, isAccepted); + const altActionRecording = altAction.recording; + if (!altActionRecording) { + console.error('Alternative action has no recording'); + return; + } + const scoring = Processor.createScoring(altActionRecording.entries ?? [], altActionRecording.requestTime, edits, isAccepted); if (!scoring) { console.error('Failed to create scoring from alternative action'); return; diff --git a/extensions/copilot/script/electron/simulationWorkbench.css b/extensions/copilot/script/electron/simulationWorkbench.css index 5fc0b94d0c03f..7cab1b6f52978 100644 --- a/extensions/copilot/script/electron/simulationWorkbench.css +++ b/extensions/copilot/script/electron/simulationWorkbench.css @@ -193,6 +193,11 @@ box-shadow: 0 0 0 2pt #0000004b; } +/* Only the focused adhoc editor should show a text caret. */ +.adhoc-request-editor .monaco-editor:not(.focused) .cursors-layer .cursor { + display: none !important; +} + .file-editor-draggable-border { height: 5px; width: 95%; diff --git a/extensions/copilot/script/electron/simulationWorkbenchMain.js b/extensions/copilot/script/electron/simulationWorkbenchMain.js index f462ee04aa8c1..87f6a97a59a62 100644 --- a/extensions/copilot/script/electron/simulationWorkbenchMain.js +++ b/extensions/copilot/script/electron/simulationWorkbenchMain.js @@ -33,6 +33,27 @@ function createWindow() { }); } +/** + * Installs a standard application menu. This is required for the clipboard + * keyboard shortcuts (Cmd/Ctrl+C/V/X) to be delivered to the web contents - + * without an Edit menu containing the paste role, pasting into editors (e.g. + * the Monaco editors in the "Adhoc request sender" mode) does not work, + * especially on macOS. + */ +function setupApplicationMenu() { + /** @type {Electron.MenuItemConstructorOptions[]} */ + const template = []; + if (process.platform === 'darwin') { + template.push({ role: 'appMenu' }); + } + template.push({ role: 'fileMenu' }); + template.push({ role: 'editMenu' }); + template.push({ role: 'viewMenu' }); + template.push({ role: 'windowMenu' }); + + electron.Menu.setApplicationMenu(electron.Menu.buildFromTemplate(template)); +} + app.on('ready', () => { if (process.argv.includes('--help')) { console.log(`Options: @@ -40,6 +61,7 @@ app.on('ready', () => { --grep=STRING Pre-populates simulation workbench 'grep' input box.`); app.quit(); } + setupApplicationMenu(); registerListeners(); createWindow(); }); diff --git a/extensions/copilot/script/postinstall.ts b/extensions/copilot/script/postinstall.ts index a3a322b2c5932..46a3533bb18c5 100644 --- a/extensions/copilot/script/postinstall.ts +++ b/extensions/copilot/script/postinstall.ts @@ -60,54 +60,144 @@ const treeSitterGrammars: ITreeSitterGrammar[] = [ ]; const REPO_ROOT = path.join(__dirname, '..'); +const COPILOT_PACKAGE_DIR = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot'); +const COPILOT_CLI_TOP_LEVEL_DIRS = [ + 'worker', + 'definitions', + 'builtin-skills', + 'builtin', + 'tgrep', + 'queries', + 'prebuilds', + 'ripgrep', + 'foundry-local-sdk', + 'pvrecorder', + 'mxc-bin', + 'clipboard', + 'copilot-sdk', + 'schemas', + 'preloads', +]; + +interface ICopilotPackageJson { + exports?: Record; +} + +function isLinuxMuslRuntime(): boolean { + if (process.platform !== 'linux') { + return false; + } + + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined; + return !report?.header?.glibcVersionRuntime; +} + +function getCopilotPlatformPackageCandidates(): string[] { + const arch = process.arch; + + if (process.platform === 'linux') { + const linuxCandidates = [`linux-${arch}`, `linuxmusl-${arch}`]; + return isLinuxMuslRuntime() ? linuxCandidates.reverse() : linuxCandidates; + } + + return [`${process.platform}-${arch}`]; +} + +async function resolveCopilotCliSourceDir(): Promise { + const tried: string[] = []; + for (const platformPackage of getCopilotPlatformPackageCandidates()) { + const sourceDir = path.join(REPO_ROOT, 'node_modules', '@github', `copilot-${platformPackage}`); + tried.push(sourceDir); + if (fs.existsSync(path.join(sourceDir, 'sdk', 'index.js'))) { + return sourceDir; + } + } + + if (fs.existsSync(path.join(COPILOT_PACKAGE_DIR, 'sdk', 'index.js'))) { + return COPILOT_PACKAGE_DIR; + } + + throw new Error(`Could not find @github/copilot SDK files. Tried: ${[COPILOT_PACKAGE_DIR, ...tried].join(', ')}`); +} + +async function ensureCopilotSdkExport() { + const packageJsonPath = path.join(COPILOT_PACKAGE_DIR, 'package.json'); + const packageJson = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8')) as ICopilotPackageJson; + packageJson.exports = { + ...(packageJson.exports ?? {}), + './sdk': { + types: './sdk/index.d.ts', + import: './sdk/index.js' + } + }; + + await fs.promises.writeFile(packageJsonPath, `${JSON.stringify(packageJson, undefined, 2)}\n`); +} + +async function materializeCopilotCliSdkLayout(): Promise { + const sourceDir = await resolveCopilotCliSourceDir(); + + if (sourceDir !== COPILOT_PACKAGE_DIR) { + await copyCopilotCLIFolders(path.join(sourceDir, 'sdk'), path.join(COPILOT_PACKAGE_DIR, 'sdk')); + for (const dir of COPILOT_CLI_TOP_LEVEL_DIRS) { + const sourcePath = path.join(sourceDir, dir); + if (fs.existsSync(sourcePath)) { + await copyCopilotCLIFolders(sourcePath, path.join(COPILOT_PACKAGE_DIR, dir)); + } + } + + for (const entry of await fs.promises.readdir(sourceDir)) { + if (entry.startsWith('tree-sitter') && entry.endsWith('.wasm')) { + await fs.promises.copyFile(path.join(sourceDir, entry), path.join(COPILOT_PACKAGE_DIR, entry)); + } + } + } + + await ensureCopilotSdkExport(); + return sourceDir; +} async function removeCopilotCLIShim() { - const shimsPath = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'shims.txt'); + const shimsPath = path.join(COPILOT_PACKAGE_DIR, 'shims.txt'); await fs.promises.rm(shimsPath, { force: true }).catch(() => { /* ignore */ }); } -/** - * @github/copilot/sdk/index.js depends on @github/copilot/worker/*.js files. - * We need to copy these files into the sdk directory to ensure they are available at runtime. - */ -async function copyCopilotCliWorkerFiles() { - const sourceDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'worker'); - const targetDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'sdk', 'worker'); - - await copyCopilotCLIFolders(sourceDir, targetDir); +async function removeCopilotCliWorkerFiles() { + const targetDir = path.join(COPILOT_PACKAGE_DIR, 'sdk', 'worker'); + await fs.promises.rm(targetDir, { recursive: true, force: true }); } -async function copyCopilotCliTGrepFiles() { - const sourceDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'tgrep'); - const targetDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'sdk', 'tgrep'); +async function copyCopilotCliTGrepFiles(copilotCliSourceDir: string) { + const sourceDir = path.join(copilotCliSourceDir, 'tgrep'); + const targetDir = path.join(COPILOT_PACKAGE_DIR, 'sdk', 'tgrep'); await copyCopilotCLIFolders(sourceDir, targetDir); } -async function copyCopilotCliDefinitionFiles() { - const sourceDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'definitions'); - const targetDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'sdk', 'definitions'); +async function copyCopilotCliDefinitionFiles(copilotCliSourceDir: string) { + const sourceDir = path.join(copilotCliSourceDir, 'definitions'); + const targetDir = path.join(COPILOT_PACKAGE_DIR, 'sdk', 'definitions'); await copyCopilotCLIFolders(sourceDir, targetDir); } -async function copyCopilotCliSkillsFiles() { - const sourceDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'builtin-skills'); - const targetDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'sdk', 'builtin-skills'); +async function copyCopilotCliSkillsFiles(copilotCliSourceDir: string) { + const sourceDir = path.join(copilotCliSourceDir, 'builtin-skills'); + const targetDir = path.join(COPILOT_PACKAGE_DIR, 'sdk', 'builtin-skills'); await copyCopilotCLIFolders(sourceDir, targetDir); } -async function copyCopilotCliQueryFiles() { - const sourceDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'queries'); - const targetDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'sdk', 'queries'); +async function copyCopilotCliQueryFiles(copilotCliSourceDir: string) { + const sourceDir = path.join(copilotCliSourceDir, 'queries'); + const targetDir = path.join(COPILOT_PACKAGE_DIR, 'sdk', 'queries'); await copyCopilotCLIFolders(sourceDir, targetDir); } -async function copyCopilotCliPrebuildFiles() { - const sourceDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'prebuilds'); - const targetDir = path.join(REPO_ROOT, 'node_modules', '@github', 'copilot', 'sdk', 'prebuilds'); +async function copyCopilotCliPrebuildFiles(copilotCliSourceDir: string) { + const sourceDir = path.join(copilotCliSourceDir, 'prebuilds'); + const targetDir = path.join(COPILOT_PACKAGE_DIR, 'sdk', 'prebuilds'); await fs.promises.rm(targetDir, { recursive: true, force: true }); await fs.promises.mkdir(targetDir, { recursive: true }); await fs.promises.cp(sourceDir, targetDir, { @@ -115,9 +205,6 @@ async function copyCopilotCliPrebuildFiles() { try { if (fs.statSync(src).isFile()) { const normalizedSrc = src.split(path.sep).join(path.posix.sep); - if (normalizedSrc.includes('/prebuilds/linuxmusl-')) { - return false; - } return src.endsWith('computer.node') || src.endsWith('runtime.node') || src.endsWith('cli-native.node') @@ -191,13 +278,14 @@ async function main() { 'node_modules/@github/blackbird-external-ingest-utils/pkg/nodejs/external_ingest_utils_bg.wasm', ], 'dist'); + const copilotCliSourceDir = await materializeCopilotCliSdkLayout(); await removeCopilotCLIShim(); - await copyCopilotCliWorkerFiles(); - await copyCopilotCliDefinitionFiles(); - await copyCopilotCliSkillsFiles(); - await copyCopilotCliTGrepFiles(); - await copyCopilotCliQueryFiles(); - await copyCopilotCliPrebuildFiles(); + await removeCopilotCliWorkerFiles(); + await copyCopilotCliDefinitionFiles(copilotCliSourceDir); + await copyCopilotCliSkillsFiles(copilotCliSourceDir); + await copyCopilotCliTGrepFiles(copilotCliSourceDir); + await copyCopilotCliQueryFiles(copilotCliSourceDir); + await copyCopilotCliPrebuildFiles(copilotCliSourceDir); // Check if the base cache file exists (dev-only sanity check, non-fatal in CI) const baseCachePath = path.join('test', 'simulation', 'cache', 'base.sqlite'); diff --git a/extensions/copilot/script/setup/getEnv.mts b/extensions/copilot/script/setup/getEnv.mts index 0cd82c9a7371b..dd04e6e7d2c3f 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -57,8 +57,7 @@ async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { secrets['HMAC_SECRET'] = await fetchSecret(keyVaultClient, 'hmac-secret'); if (!process.stdin.isTTY) { // only in automation - secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth'); - secrets['VSCODE_COPILOT_CHAT_TOKEN'] = await fetchSecret(keyVaultClient, 'copilot-token'); + secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth-pipeline-token'); secrets['BLACKBIRD_EMBEDDINGS_KEY'] = await fetchSecret(keyVaultClient, 'vsc-aoai-key'); secrets['BLACKBIRD_REDIS_CACHE_KEY'] = await fetchSecret(keyVaultClient, 'blackbird-redis-cache-key'); diff --git a/extensions/copilot/src/extension/byok/common/byokProvider.ts b/extensions/copilot/src/extension/byok/common/byokProvider.ts index a966f2e63c18d..5427c6904d764 100644 --- a/extensions/copilot/src/extension/byok/common/byokProvider.ts +++ b/extensions/copilot/src/extension/byok/common/byokProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { Disposable, LanguageModelChatInformation, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart } from 'vscode'; import { CopilotToken } from '../../../platform/authentication/common/copilotToken'; -import { EndpointEditToolName, IChatModelInformation, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; +import { EndpointEditToolName, IChatModelInformation, IChatModelRequestOptions, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; import { TokenizerType } from '../../../util/common/tokenizer'; export const enum BYOKAuthType { @@ -56,6 +56,7 @@ export interface BYOKModelCapabilities { streaming?: boolean; editTools?: EndpointEditToolName[]; requestHeaders?: Record; + modelOptions?: IChatModelRequestOptions; supportedEndpoints?: ModelSupportedEndpoint[]; zeroDataRetentionEnabled?: boolean; supportsReasoningEffort?: string[]; @@ -128,6 +129,7 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod model_picker_enabled: true, supported_endpoints: knownModelInfo?.supportedEndpoints, zeroDataRetentionEnabled: knownModelInfo?.zeroDataRetentionEnabled, + modelOptions: knownModelInfo?.modelOptions, reasoningEffortFormat: knownModelInfo?.reasoningEffortFormat }; if (knownModelInfo?.requestHeaders && Object.keys(knownModelInfo.requestHeaders).length > 0) { diff --git a/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts b/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts index 8a78e8e0e3f7e..caaaf0dbfad25 100644 --- a/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts @@ -71,6 +71,21 @@ describe('resolveModelInfo', () => { expect(info.capabilities.supports.reasoning_effort).toBeUndefined(); expect(info.reasoningEffortFormat).toBeUndefined(); }); + + it('propagates configured model options into chat-endpoint inputs', () => { + const info = resolveModelInfo('m1', 'TestProvider', undefined, { + ...baseCapabilities, + modelOptions: { + temperature: null, + top_p: 0.95, + }, + }); + + expect(info.modelOptions).toEqual({ + temperature: null, + top_p: 0.95, + }); + }); }); describe('isClientBYOKAllowed', () => { diff --git a/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts b/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts index 0cf838e3c235a..aed5d1b64dd52 100644 --- a/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts +++ b/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts @@ -265,10 +265,10 @@ export class OpenAIEndpoint extends ChatEndpoint { body.previous_response_id = undefined; } this._applyReasoningEffort(body, options); - return body; + return this._applyConfiguredModelOptions(body, options); } else if (this.useMessagesApi) { // Delegate to base ChatEndpoint for Messages API dispatch - return super.createRequestBody(options); + return this._applyConfiguredModelOptions(super.createRequestBody(options), options); } else { // Handle Chat Completions: provide callback for thinking data processing const supportsThinking = !!this.modelMetadata.capabilities.supports.thinking; @@ -290,8 +290,32 @@ export class OpenAIEndpoint extends ChatEndpoint { }; const body = createCapiRequestBody(options, this.model, callback); this._applyReasoningEffort(body, options); + return this._applyConfiguredModelOptions(body, options); + } + } + + private _applyConfiguredModelOptions(body: IEndpointBody, options: ICreateEndpointBodyOptions): IEndpointBody { + const modelOptions = this.modelMetadata.modelOptions; + if (!modelOptions) { return body; } + + for (const key of ['temperature', 'top_p'] as const) { + const requestValue = options.requestOptions?.[key]; + if (requestValue !== undefined) { + body[key] = requestValue; + continue; + } + + const configuredValue = modelOptions[key]; + if (configuredValue === null) { + delete body[key]; + } else if (configuredValue !== undefined) { + body[key] = configuredValue; + } + } + + return body; } /** diff --git a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts index b9b9eed5184d4..e8716894eff28 100644 --- a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts +++ b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts @@ -7,8 +7,8 @@ import { Raw } from '@vscode/prompt-tsx'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatFetchResponseType, ChatResponse } from '../../../../platform/chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; -import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../../platform/endpoint/common/endpointProvider'; +import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; import { ChatEndpoint } from '../../../../platform/endpoint/node/chatEndpoint'; import { ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions } from '../../../../platform/networking/common/networking'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; @@ -246,9 +246,19 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { describe('Responses API mode (useResponsesApi = true)', () => { it('should preserve reasoning object when thinking is supported', () => { - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); + const modelWithReasoningEffort = { + ...modelMetadata, + capabilities: { + ...modelMetadata.capabilities, + supports: { + ...modelMetadata.capabilities.supports, + reasoning_effort: ['low', 'medium', 'high'] + } + } + }; + const endpoint = instaService.createInstance(OpenAIEndpoint, - modelMetadata, + modelWithReasoningEffort, 'test-api-key', 'https://api.openai.com/v1/chat/completions'); @@ -275,7 +285,6 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { } }; - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); const endpoint = instaService.createInstance(OpenAIEndpoint, modelWithoutThinking, 'test-api-key', diff --git a/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts index 6e32e34a46050..c160d03e02574 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { CancellationToken, LanguageModelChatMessage, LanguageModelChatMessage2, LanguageModelResponsePart2, Progress, ProvideLanguageModelChatResponseOptions } from 'vscode'; import { AzureAuthMode, ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; -import { isEndpointEditToolName } from '../../../platform/endpoint/common/endpointProvider'; +import { isEndpointEditToolName, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { ILogService } from '../../../platform/log/common/logService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; @@ -45,6 +45,29 @@ export function resolveAzureUrl(modelId: string, url: string): string { } } +/** + * Determines the `supported_endpoints` for a resolved Azure BYOK URL. When the URL targets the + * Responses API, both Chat Completions and Responses are advertised so a Responses-shaped body + * (`input`) is sent; otherwise `undefined` preserves the default Chat Completions behavior. This + * keeps the Entra auth path consistent with the API-key path (issue #318114). + */ +export function azureSupportedEndpointsForUrl(url: string): ModelSupportedEndpoint[] | undefined { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + // Tolerate malformed URLs by preserving the default Chat Completions behavior. + return undefined; + } + // Match the final path segment so a deployment named "responses" (e.g. + // `/openai/deployments/responses/chat/completions`) is not misclassified. Compare + // case-insensitively to tolerate APIM vanity routes that may use different casing. + if (pathname.replace(/\/$/, '').toLowerCase().endsWith('/responses')) { + return [ModelSupportedEndpoint.ChatCompletions, ModelSupportedEndpoint.Responses]; + } + return undefined; +} + export class AzureBYOKModelProvider extends AbstractCustomOAIBYOKModelProvider { public static readonly providerName = 'Azure'; @@ -119,6 +142,13 @@ export class AzureBYOKModelProvider extends AbstractCustomOAIBYOKModelProvider { zeroDataRetentionEnabled: modelConfiguration?.zeroDataRetentionEnabled }; const modelInfo = resolveModelInfo(model.id, this._name, undefined, modelCapabilities); + // Mirror the API-key path (customOAIProvider.createOpenAIEndPoint): when the resolved Azure URL + // targets the Responses API, mark the endpoint accordingly so a Responses-shaped body (`input`) is + // sent instead of a Chat Completions body (`messages`), which Azure Responses rejects (issue #318114). + const supportedEndpoints = azureSupportedEndpointsForUrl(url); + if (supportedEndpoints) { + modelInfo.supported_endpoints = supportedEndpoints; + } const openAIChatEndpoint = this._instantiationService.createInstance( AzureOpenAIEndpoint, diff --git a/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts index db5df5f555f46..ea5896d727974 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts @@ -6,7 +6,7 @@ import { IChatMLFetcher } from '../../../platform/chat/common/chatMLFetcher'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IDomainService } from '../../../platform/endpoint/common/domainService'; -import { EndpointEditToolName, IChatModelInformation, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; +import { EndpointEditToolName, IChatModelInformation, IChatModelRequestOptions, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; import { ILogService } from '../../../platform/log/common/logService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { IChatWebSocketManager } from '../../../platform/networking/node/chatWebSocketManager'; @@ -98,6 +98,7 @@ interface _CustomEndpointModelConfig { streaming?: boolean; editTools?: EndpointEditToolName[]; requestHeaders?: Record; + modelOptions?: IChatModelRequestOptions; zeroDataRetentionEnabled?: boolean; supportsReasoningEffort?: string[]; reasoningEffortFormat?: 'chat-completions' | 'responses'; @@ -159,6 +160,7 @@ export class CustomEndpointBYOKModelProvider extends AbstractOpenAICompatibleLMP thinking: modelConfiguration?.thinking ?? false, streaming: modelConfiguration?.streaming, requestHeaders: modelConfiguration?.requestHeaders, + modelOptions: modelConfiguration?.modelOptions, zeroDataRetentionEnabled: modelConfiguration?.zeroDataRetentionEnabled, supportsReasoningEffort: modelConfiguration?.supportsReasoningEffort, reasoningEffortFormat: modelConfiguration?.reasoningEffortFormat diff --git a/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts index cc10693357616..27df5d409e37f 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts @@ -9,10 +9,22 @@ import { IFetcherService } from '../../../platform/networking/common/fetcherServ import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { BYOKKnownModels } from '../common/byokProvider'; -import { AbstractOpenAICompatibleLMProvider } from './abstractLanguageModelChatProvider'; +import { OpenAIEndpoint } from '../node/openAIEndpoint'; +import { AbstractOpenAICompatibleLMProvider, LanguageModelChatConfiguration, OpenAICompatibleLanguageModelChatInformation } from './abstractLanguageModelChatProvider'; import { IBYOKStorageService } from './byokStorageService'; -export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { +export interface OpenAIProviderConfig extends LanguageModelChatConfiguration { + readonly zeroDataRetentionEnabled?: boolean; +} + +export function applyOpenAIProviderConfig(modelInfo: IChatModelInformation, configuration: OpenAIProviderConfig | undefined): IChatModelInformation { + return { + ...modelInfo, + zeroDataRetentionEnabled: configuration?.zeroDataRetentionEnabled ?? modelInfo.zeroDataRetentionEnabled, + }; +} + +export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { public static readonly providerName = 'OpenAI'; public static readonly providerId = this.providerName.toLowerCase(); @@ -43,6 +55,14 @@ export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { return 'https://api.openai.com/v1'; } + protected override async createOpenAIEndPoint(model: OpenAICompatibleLanguageModelChatInformation): Promise { + const modelInfo = applyOpenAIProviderConfig(this.getModelInfo(model.id, model.url), model.configuration); + const url = modelInfo.supported_endpoints?.includes(ModelSupportedEndpoint.Responses) ? + `${model.url}/responses` : + `${model.url}/chat/completions`; + return this._instantiationService.createInstance(OpenAIEndpoint, modelInfo, model.configuration?.apiKey ?? '', url); + } + protected override getModelInfo(modelId: string, modelUrl: string): IChatModelInformation { const modelInfo = super.getModelInfo(modelId, modelUrl); modelInfo.supported_endpoints = [ diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/azureProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/azureProvider.spec.ts index 36722b25890c7..54527b9c53164 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/test/azureProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/test/azureProvider.spec.ts @@ -5,10 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BlockedExtensionService, IBlockedExtensionService } from '../../../../platform/chat/common/blockedExtensionService'; +import { ModelSupportedEndpoint } from '../../../../platform/endpoint/common/endpointProvider'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; -import { resolveAzureUrl } from '../azureProvider'; +import { azureSupportedEndpointsForUrl, resolveAzureUrl } from '../azureProvider'; describe('AzureBYOKModelProvider', () => { const disposables = new DisposableStore(); @@ -62,10 +63,36 @@ describe('AzureBYOKModelProvider', () => { expect(result).toBe('https://my-endpoint.models.ai.azure.com/v1/chat/completions'); }); + it('should preserve an explicit APIM /responses URL behind a vanity domain', () => { + const url = 'https://my-apim.azure-api.net/openai/responses?api-version=2025-04-01-preview'; + const result = resolveAzureUrl('gpt-4', url); + expect(result).toBe(url); + }); + it('should throw error for unrecognized Azure URL', () => { const url = 'https://unknown.example.com'; expect(() => resolveAzureUrl('gpt-4', url)).toThrow('Unrecognized Azure deployment URL'); }); }); + describe('azureSupportedEndpointsForUrl', () => { + it('marks Responses (and Chat Completions) for /responses URLs and leaves Chat Completions URLs unmarked', () => { + expect({ + responses: azureSupportedEndpointsForUrl('https://my-resource.openai.azure.com/openai/responses?api-version=2025-04-01-preview'), + apimResponses: azureSupportedEndpointsForUrl('https://my-apim.azure-api.net/openai/responses'), + mixedCaseResponses: azureSupportedEndpointsForUrl('https://my-apim.azure-api.net/openai/Responses'), + chatCompletions: azureSupportedEndpointsForUrl('https://my-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2025-01-01-preview'), + deploymentNamedResponses: azureSupportedEndpointsForUrl('https://my-resource.openai.azure.com/openai/deployments/responses/chat/completions?api-version=2025-01-01-preview'), + malformed: azureSupportedEndpointsForUrl('not a url'), + }).toEqual({ + responses: [ModelSupportedEndpoint.ChatCompletions, ModelSupportedEndpoint.Responses], + apimResponses: [ModelSupportedEndpoint.ChatCompletions, ModelSupportedEndpoint.Responses], + mixedCaseResponses: [ModelSupportedEndpoint.ChatCompletions, ModelSupportedEndpoint.Responses], + chatCompletions: undefined, + deploymentNamedResponses: undefined, + malformed: undefined, + }); + }); + }); + }); diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts index 9a3197f855e50..0b62c12267347 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts @@ -6,6 +6,7 @@ import { Raw } from '@vscode/prompt-tsx'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BlockedExtensionService, IBlockedExtensionService } from '../../../../platform/chat/common/blockedExtensionService'; +import { ChatLocation } from '../../../../platform/chat/common/commonTypes'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../../platform/endpoint/common/endpointProvider'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; import { TokenizerType } from '../../../../util/common/tokenizer'; @@ -278,6 +279,180 @@ describe('CustomEndpointBYOKModelProvider', () => { expect(endpoint.ownsAuthorization).toBe(true); }); + it('issue #321514: applies configured model options over default sampling parameters', () => { + const metadata: IChatModelInformation = { + ...makeMetadata(undefined), + modelOptions: { + temperature: 1, + top_p: 0.95, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + 'https://api.example.com/v1/chat/completions'); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: 'test-req-custom-model-options', + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + expect({ + temperature: body.temperature, + topP: body.top_p, + }).toEqual({ + temperature: 1, + topP: 0.95, + }); + }); + + it('omits sampling parameters configured as null', () => { + const metadata: IChatModelInformation = { + ...makeMetadata(undefined), + modelOptions: { + temperature: null, + top_p: null, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + 'https://api.example.com/v1/chat/completions'); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: 'test-req-omitted-model-options', + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + expect({ + temperature: body.temperature, + topP: body.top_p, + }).toEqual({ + temperature: undefined, + topP: undefined, + }); + }); + + it('keeps explicit per-request sampling parameters ahead of configured model options', () => { + const metadata: IChatModelInformation = { + ...makeMetadata(undefined), + modelOptions: { + temperature: 1, + top_p: null, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + 'https://api.example.com/v1/chat/completions'); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: 'test-req-explicit-model-options', + requestOptions: { + temperature: 0.7, + top_p: 0.9, + }, + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + expect({ + temperature: body.temperature, + topP: body.top_p, + }).toEqual({ + temperature: 0.7, + topP: 0.9, + }); + }); + + it('applies configured model options to Responses and Messages API bodies', () => { + const results = [ + { + supportedEndpoints: [ModelSupportedEndpoint.Responses], + url: 'https://api.example.com/v1/responses', + }, + { + supportedEndpoints: [ModelSupportedEndpoint.Messages], + url: 'https://api.example.com/v1/messages', + }, + ].map(({ supportedEndpoints, url }) => { + const metadata: IChatModelInformation = { + ...makeMetadata(supportedEndpoints), + modelOptions: { + temperature: 1, + top_p: 0.95, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + url); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: `test-req-${endpoint.apiType}-model-options`, + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + return { + apiType: endpoint.apiType, + temperature: body.temperature, + topP: body.top_p, + }; + }); + + expect(results).toEqual([ + { + apiType: 'responses', + temperature: 1, + topP: 0.95, + }, + { + apiType: 'messages', + temperature: 1, + topP: 0.95, + }, + ]); + }); + it('replaces default Bearer with user-supplied Authorization header on Chat Completions endpoints', () => { const metadata = makeMetadata(undefined); metadata.requestHeaders = { 'Authorization': 'Bearer user-token' }; diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts new file mode 100644 index 0000000000000..b56047aee9313 --- /dev/null +++ b/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { IChatModelInformation } from '../../../../platform/endpoint/common/endpointProvider'; +import { TokenizerType } from '../../../../util/common/tokenizer'; +import { applyOpenAIProviderConfig } from '../openAIProvider'; + +function createModelInfo(zeroDataRetentionEnabled: boolean | undefined): IChatModelInformation { + return { + id: 'gpt-4.1', + name: 'GPT-4.1', + vendor: 'OpenAI', + version: '1.0.0', + is_chat_default: false, + is_chat_fallback: false, + model_picker_enabled: true, + zeroDataRetentionEnabled, + capabilities: { + type: 'chat', + family: 'gpt-4.1', + supports: { + streaming: true, + tool_calls: true, + vision: false, + thinking: false, + }, + tokenizer: TokenizerType.O200K, + limits: { + max_context_window_tokens: 128000, + max_prompt_tokens: 100000, + max_output_tokens: 8192, + }, + }, + }; +} + +describe('applyOpenAIProviderConfig', () => { + it('uses provider-level zeroDataRetentionEnabled when configured', () => { + const merged = applyOpenAIProviderConfig(createModelInfo(undefined), { + apiKey: 'test-key', + zeroDataRetentionEnabled: true, + }); + + expect(merged.zeroDataRetentionEnabled).toBe(true); + }); + + it('falls back to model metadata zeroDataRetentionEnabled when provider-level value is unset', () => { + const merged = applyOpenAIProviderConfig(createModelInfo(true), { + apiKey: 'test-key', + }); + + expect(merged.zeroDataRetentionEnabled).toBe(true); + }); +}); diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts index 8b282e80b4ee7..eb6d53d172747 100644 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts @@ -16,9 +16,8 @@ const UTILITY_SMALL_MODEL_SETTING = 'chat.utilitySmallModel'; /** * Shows a chat input notification in air-gapped BYOK scenarios (no GitHub * session) when at least one BYOK model is available but the utility model - * settings are still defaults. The default utility models require GitHub - * Copilot access, so without it the utility slots silently fall back and - * degrade the experience until the user points them at a BYOK model. + * settings are still defaults. Some utility flows are unavailable until the + * user points them at a BYOK model. * * The notification hides automatically once the user signs in, BYOK models * disappear, or both utility settings are configured. diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts index 9acd2aa786d68..fe11ad14a684b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts @@ -610,7 +610,7 @@ export function buildChatHistoryFromEvents(sessionId: string, modelId: string | } }); ((event.data.attachments || [])) - .filter(attachment => attachment.type === 'selection' || attachment.type === 'github_reference' || attachment.type === 'blob' || attachment.type === 'extension_context' ? true : !isInstructionAttachmentPath(attachment.path)) + .filter(attachment => attachment.type === 'selection' || attachment.type === 'github_reference' || attachment.type === 'blob' || attachment.type === 'github_actions_job' || attachment.type === 'github_url' || attachment.type === 'github_tree_comparison' || attachment.type === 'github_release' || attachment.type === 'github_repository' || attachment.type === 'github_file_diff' || attachment.type === 'github_commit' || attachment.type === 'github_file' || attachment.type === 'extension_context' ? true : !isInstructionAttachmentPath(attachment.path)) .forEach(attachment => { if (attachment.type === 'github_reference') { return; @@ -643,9 +643,13 @@ export function buildChatHistoryFromEvents(sessionId: string, modelId: string | range }); } else if (attachment.type === 'blob') { + if (typeof attachment.data !== 'string') { + return; + } + const data = attachment.data; const binaryDataSupplier = async () => { try { - return decodeBase64(attachment.data).buffer; + return decodeBase64(data).buffer; } catch (error) { logger.error(error, `Failed to decode blob attachment ${attachment.displayName || ''}`); throw error; @@ -1260,6 +1264,9 @@ function formatSearchToolInvocationCompleted(invocation: ChatToolInvocationPart, } const searchInPath = toolCall.arguments.path ? ` in \`${toolCall.arguments.path}\`` : ''; let files: string[] = []; + // TODO: Revisit this legacy SDK terminal content usage. SDK `terminal` + // content is deprecated for shell command exit metadata; this search + // result parsing should not rely on it long term. if (Array.isArray(result.result?.contents) && result.result.contents.length > 0 && result.result.contents[0].type === 'terminal' && typeof result.result.contents[0].text === 'string') { const matches = result.result.contents[0].text.trim(); const noMatches = matches.length === 0; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 87c261072f795..bce3ca26dd4f9 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -26,7 +26,7 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio import { ensureNodePtyShim } from './nodePtyShim'; import { ensureRipgrepShim } from './ripgrepShim'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; -import { formatTokenCount, getModelCapabilitiesDescription, getReasoningEffortDescription, normalizeTokenPrices } from '../../../conversation/common/languageModelAccess'; +import { formatTokenCount, getAutoModelDescription, getModelCapabilitiesDescription, getReasoningEffortDescription, normalizeTokenPrices } from '../../../conversation/common/languageModelAccess'; export const COPILOT_CLI_REASONING_EFFORT_PROPERTY = 'reasoningEffort'; const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel'; @@ -60,6 +60,7 @@ export interface CopilotCLIModelInfo { readonly supportsReasoningEffort?: boolean; readonly defaultReasoningEffort?: string; readonly supportedReasoningEfforts?: string[]; + readonly warningText?: Record; } export interface ICopilotCLIModels { @@ -252,6 +253,7 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { toolCalling: true }, targetChatSessionType: 'copilotcli', + warningText: model.warningText, isDefault: !isAutoModelEnabled && index === 0 ? true : undefined, }; const tooltip = getModelCapabilitiesDescription(modelInfo) ?? ''; @@ -271,7 +273,7 @@ function buildAutoModel(defaultModel?: CopilotCLIModelInfo): vscode.LanguageMode return { id: 'auto', name: 'Auto', - tooltip: l10n.t('Auto selects the best model based on your request complexity and model performance.'), + tooltip: getAutoModelDescription(), family: defaultModel?.id ?? '', version: '', maxInputTokens: defaultModel?.maxInputTokens ?? defaultModel?.maxContextWindowTokens ?? 0, @@ -315,20 +317,33 @@ function buildConfigurationSchema(modelInfo: CopilotCLIModelInfo, isReasoningEff if (defaultContextMax && defaultContextMax < fullMax) { const hasLongContextSurcharge = modelInfo.longContextInputCost !== undefined || modelInfo.longContextOutputCost !== undefined; - properties[COPILOT_CLI_CONTEXT_SIZE_PROPERTY] = { - type: 'number', - title: l10n.t('Context Size'), - enum: [defaultContextMax, fullMax], - enumItemLabels: [formatTokenCount(defaultContextMax), formatTokenCount(fullMax)], - enumDescriptions: [ - l10n.t('Default'), - hasLongContextSurcharge - ? l10n.t('Longer sessions') - : l10n.t('Longer sessions without compaction'), - ], - default: defaultContextMax, - group: 'tokens', - }; + if (hasLongContextSurcharge) { + properties[COPILOT_CLI_CONTEXT_SIZE_PROPERTY] = { + type: 'number', + title: l10n.t('Context Size'), + enum: [defaultContextMax, fullMax], + enumItemLabels: [formatTokenCount(defaultContextMax), formatTokenCount(fullMax)], + enumDescriptions: [ + l10n.t('Default'), + l10n.t('Longer sessions'), + ], + default: defaultContextMax, + group: 'tokens', + }; + } else { + // No surcharge — show only the long context option as a non-switchable indicator. + properties[COPILOT_CLI_CONTEXT_SIZE_PROPERTY] = { + type: 'number', + title: l10n.t('Context Size'), + enum: [fullMax], + enumItemLabels: [formatTokenCount(fullMax)], + enumDescriptions: [ + l10n.t('Longer sessions'), + ], + default: fullMax, + group: 'tokens', + }; + } } if (Object.keys(properties).length === 0) { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 6e0634f28de55..ca6ed26a50c05 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -911,7 +911,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private readonly _agentName: string | undefined, private readonly _sdkSession: Session, private readonly _additionalWorkspaces: IWorkspaceInfo[], - private readonly _sandboxEnabled: boolean, + private readonly _sandboxConfig: SessionOptions['sandboxConfig'], @ILogService private readonly logService: ILogService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IChatSessionMetadataStore private readonly _chatSessionMetadataStore: IChatSessionMetadataStore, @@ -943,6 +943,33 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._permissionLevel = level; } + /** + * Whether the session was configured with the sandbox enabled. The sandbox + * only actually applies to requests that run with default approvals — see + * {@link _applyEffectiveSandboxConfig}. + */ + private get _sandboxEnabled(): boolean { + return !!this._sandboxConfig?.enabled; + } + + /** + * Apply the sandbox policy for the request that is about to be sent. The + * sandbox enable setting only applies under default approvals; the sandbox + * is explicitly disabled when the request runs with bypass approvals + * (autopilot / autoApprove) or when no sandbox is configured for the + * session. Pushing `{ enabled: false }` (rather than skipping the update) + * ensures the SDK never retains a stale or auto-discovered sandbox. + */ + private _applyEffectiveSandboxConfig(bypassApprovals: boolean): void { + const base = this._sandboxConfig; + const sandboxConfig = (base?.enabled && !bypassApprovals) ? base : { enabled: false }; + try { + this._sdkSession.updateOptions({ sandboxConfig }); + } catch (error) { + this.logService.error(error, '[CopilotCLISession] Failed to update sandbox config for request'); + } + } + // TODO: This should be pre-populated when we restore a session based on its original context. // E.g. if we're resuming a session, and it tries to read a file, we shouldn't prompt for permissions again. /** @@ -1276,14 +1303,16 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const permissionRequest = event.data.permissionRequest; const requestId = event.data.requestId; + const isSandboxBypassShell = permissionRequest.kind === 'shell' && permissionRequest.requestSandboxBypass === true; + // Auto-approve all requests when the permission level allows it. - if (effectivePermissionLevel === 'autoApprove' || effectivePermissionLevel === 'autopilot') { + if (!isSandboxBypassShell && (effectivePermissionLevel === 'autoApprove' || effectivePermissionLevel === 'autopilot')) { this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${effectivePermissionLevel})`); this._sdkSession.respondToPermission(requestId, { kind: 'approve-once' }); return; } - if (permissionRequest.kind === 'shell' && this._sandboxEnabled) { + if (!isSandboxBypassShell && permissionRequest.kind === 'shell' && this._sandboxEnabled) { this.logService.trace(`[CopilotCLISession] Auto Approving shell request (sandbox is enabled)`); this._sdkSession.respondToPermission(requestId, { kind: 'approve-once' }); return; @@ -1328,7 +1357,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes try { let response: PermissionRequestResult; - if (effectivePermissionLevel === 'autoApprove' || effectivePermissionLevel === 'autopilot') { + if (!isSandboxBypassShell && (effectivePermissionLevel === 'autoApprove' || effectivePermissionLevel === 'autopilot')) { this.logService.trace(`[CopilotCLISession] Auto Approving ${permissionRequest.kind} request (permission level: ${effectivePermissionLevel})`); response = { kind: 'approve-once' }; } else if (this._mcState) { @@ -1435,7 +1464,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes reportUsage(event.data.inputTokens, event.data.outputTokens); } // Accumulate per-turn credits from SDK copilotUsage data - const copilotUsage = (event.data as Record).copilotUsage; + const copilotUsage = (event.data as unknown as Record).copilotUsage; let copilotUsageNanoAiu: number | undefined; if (copilotUsage && typeof copilotUsage === 'object') { const { totalNanoAiu } = copilotUsage as { totalNanoAiu?: number }; @@ -1859,6 +1888,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } else { this._sdkSession.currentMode = 'interactive'; } + // The sandbox only applies under default approvals — disable it for + // this request when running in a bypass-approvals mode. + const bypassApprovals = remoteMode + ? remoteMode === 'autopilot' + : this._permissionLevel === 'autopilot' || this._permissionLevel === 'autoApprove'; + this._applyEffectiveSandboxConfig(bypassApprovals); const sendOptions: SendOptions = { prompt: input.prompt ?? '', attachments, agentMode: this._sdkSession.currentMode }; if (steering) { sendOptions.mode = 'immediate'; @@ -1896,6 +1931,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } else { this._sdkSession.currentMode = 'interactive'; } + // The sandbox only applies under default approvals — disable it when + // fleet runs in autopilot (a bypass-approvals mode). + this._applyEffectiveSandboxConfig(this._permissionLevel === 'autopilot'); const result = await this._sdkSession.fleet.start({ prompt }); if (!result.started) { this.logService.info('[CopilotCLISession] Fleet mode not started'); @@ -2741,14 +2779,58 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private _renderAttachments(attachments: Attachment[]): string[] { const lines: string[] = []; for (const attachment of attachments) { - if (attachment.type === 'github_reference') { - lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`); - } else if (attachment.type === 'blob') { - lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`); - } else if (attachment.type === 'extension_context') { - lines.push(`- ${attachment.title ?? 'extension_context'} (${attachment.type}, ${attachment.extensionId})`); - } else { - lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`); + switch (attachment.type) { + case 'github_reference': { + lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`); + break; + } + case 'github_actions_job': { + lines.push(`- ${attachment.jobName}: (${attachment.jobId}, ${attachment.type})`); + break; + } + case 'github_commit': { + lines.push(`- ${attachment.message}: (${attachment.oid}, ${attachment.type})`); + break; + } + case 'github_file': { + lines.push(`- ${attachment.path}: (${attachment.ref}, ${attachment.type})`); + break; + } + case 'github_file_diff': { + lines.push(`- ${attachment.url}: (${attachment.type})`); + break; + } + case 'github_release': { + lines.push(`- ${attachment.name}: (${attachment.tagName}, ${attachment.type})`); + break; + } + case 'github_repository': { + lines.push(`- ${attachment.repo.name}: (${attachment.url}, ${attachment.type})`); + break; + } + case 'github_tree_comparison': { + lines.push(`- ${attachment.head}: (${attachment.base}, ${attachment.type})`); + break; + } + case 'github_url': { + lines.push(`- ${attachment.url}: (${attachment.type})`); + break; + } + case 'github_snippet': { + lines.push(`- ${attachment.path}: (${attachment.type})`); + break; + } + case 'blob': { + lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`); + break; + } + case 'extension_context': { + lines.push(`- ${attachment.title ?? 'extension_context'} (${attachment.type}, ${attachment.extensionId})`); + break; + } + default: { + lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`); + } } } return lines; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index c2e2c427b123d..3c47edec8ce05 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -856,7 +856,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } this.logService.trace(`[CopilotCLISession] Created new CopilotCLI session ${sdkSession.sessionId}.`); - const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager, !!sessionOptions.sandboxConfig?.enabled); + const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager, sessionOptions.sandboxConfig); session.object.add(mcpGateway); // Set origin @@ -1086,7 +1086,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS return undefined; } await sessionManager.loadDeferredRepoHooks(sdkSession.sessionId); - const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager, !!sessionOptions.sandboxConfig?.enabled); + const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager, sessionOptions.sandboxConfig); session.object.add(mcpGateway); return session; } @@ -1366,9 +1366,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS return firstUserMessage; } - private createCopilotSession(sdkSession: Session, workspaceInfo: IWorkspaceInfo, agentName: string | undefined, sessionManager: internal.LocalSessionManager, sandboxEnabled: boolean): RefCountedSession { + private createCopilotSession(sdkSession: Session, workspaceInfo: IWorkspaceInfo, agentName: string | undefined, sessionManager: internal.LocalSessionManager, sandboxConfig: SessionOptions['sandboxConfig']): RefCountedSession { sdkSession.permissions.setRequired({ required: true }); - const session = this.instantiationService.createInstance(CopilotCLISession, workspaceInfo, agentName, sdkSession, [], sandboxEnabled); + const session = this.instantiationService.createInstance(CopilotCLISession, workspaceInfo, agentName, sdkSession, [], sandboxConfig); this._debugFileLogger.startSession(session.sessionId).catch(err => { this.logService.error('[CopilotCLISession] Failed to start debug log session', err); }); @@ -1652,6 +1652,15 @@ export interface IAgentSandboxFileSystemSettings { windows?: IAgentSandboxFileSystemSetting; } +/** + * Whether the CLI sandbox is supported on Windows. Not enabled yet, so + * {@link buildSandboxConfigForCLI} bails out early on `win32`; the Windows + * handling is kept so support can be turned on by flipping this flag once the + * runtime is ready. Typed as `boolean` (not the `false` literal) so the Windows + * branch is not flagged as unreachable by control-flow narrowing. + */ +const WINDOWS_SANDBOX_SUPPORTED: boolean = false; + /** * Maps the workbench `chat.agent.sandbox.*` settings onto the SDK's * {@link SessionOptions.sandboxConfig}. @@ -1660,6 +1669,11 @@ export interface IAgentSandboxFileSystemSettings { * setting wins: `denyRead` > `denyWrite` > `allowWrite` > `allowRead`. Each * path is emitted in exactly one of `deniedPaths` / `readonlyPaths` / * `readwritePaths`, regardless of how many settings reference it. + * + * Windows is not supported yet, so this bails out early and returns `undefined` + * there. The Windows handling below is intentionally kept (and exercised when + * {@link WINDOWS_SANDBOX_SUPPORTED} is flipped) so support can be turned on once + * the runtime is ready. */ export function buildSandboxConfigForCLI( platform: NodeJS.Platform, @@ -1672,6 +1686,12 @@ export function buildSandboxConfigForCLI( return undefined; } + // Typed as `boolean` (not the `false` literal) so the Windows branch below + // is not flagged as unreachable by control-flow narrowing. + if (platform === 'win32' && !WINDOWS_SANDBOX_SUPPORTED) { + return undefined; + } + const fs = (platform === 'win32' ? fileSystemSetting?.windows : platform === 'darwin' @@ -1703,17 +1723,17 @@ export function buildSandboxConfigForCLI( // the host filter is actually enforced (the SDK strips host lists when outbound is off). A // deny-list-only configuration still permits non-denied domains. // - // macOS Seatbelt has no per-host filter — the runtime strips both lists and would silently - // degrade `allowOutbound: true` to "allow all outbound". To avoid surprising the user with - // unrestricted access when they explicitly configured host rules, fail closed on darwin: keep - // outbound off and drop the unenforceable lists. + // Host lists are currently disabled on all platforms: the runtime does not yet enforce them + // reliably everywhere, so we fail closed — keep outbound off and drop the host lists — to avoid + // surprising the user with unrestricted access when they explicitly configured host rules. const allowAllNetwork = sandboxSetting === 'allowNetwork'; - const hostListsEnforceable = platform !== 'darwin'; + const hostListsEnforceable = false; const allowedHosts = !allowAllNetwork && hostListsEnforceable && networkHosts?.allowedHosts?.length ? [...networkHosts.allowedHosts] : undefined; const blockedHosts = !allowAllNetwork && hostListsEnforceable && networkHosts?.blockedHosts?.length ? [...networkHosts.blockedHosts] : undefined; const allowOutbound = allowAllNetwork || !!allowedHosts || !!blockedHosts; return { enabled: true, + allowBypass: true, userPolicy: { filesystem: { ...(readwrite.size ? { readwritePaths: [...readwrite] } : {}), diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts index 20a47e2e12773..73320476b605c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts @@ -28,6 +28,8 @@ type CoreTerminalConfirmationToolParams = { message: string; command: string | undefined; isBackground: boolean; + sandboxBypass?: boolean; + sandboxBypassReason?: string; }; }; @@ -217,12 +219,21 @@ export function buildShellConfirmationParams( const userFriendlyCommand = fullCommandText ? getCdPresentationOverrides(fullCommandText, isPowershell, workingDirectory)?.commandLine : undefined; const command = userFriendlyCommand ?? fullCommandText; + // When the model opted this command out of the sandbox, surface that to the + // user so the confirmation makes the elevation of privilege clear (the + // terminal confirmation tool renders its own title, so the note must be + // passed as a dedicated flag rather than baked into the message). + const sandboxBypass = permissionRequest.requestSandboxBypass === true; + const sandboxBypassReason = sandboxBypass ? permissionRequest.requestSandboxBypassReason : undefined; + return { tool: ToolName.CoreTerminalConfirmationTool, input: { message: permissionRequest.intention || command || codeBlock(permissionRequest), command, - isBackground: false + isBackground: false, + ...(sandboxBypass ? { sandboxBypass: true } : {}), + ...(sandboxBypassReason ? { sandboxBypassReason } : {}), } }; } @@ -424,7 +435,7 @@ export function getFileBeingEdited(permissionRequest: Extract): string { +function codeBlock(obj: unknown): string { return `\n\n\`\`\`\n${JSON.stringify(obj, null, 2)}\n\`\`\``; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/buildSandboxConfigForCLI.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/buildSandboxConfigForCLI.spec.ts index 47bd1da784cbd..5da73bb335b09 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/buildSandboxConfigForCLI.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/buildSandboxConfigForCLI.spec.ts @@ -23,23 +23,31 @@ describe('buildSandboxConfigForCLI', () => { expect(buildSandboxConfigForCLI('win32', 'off', undefined)).toBeUndefined(); }); - it('enables sandbox for `on` on every platform', () => { - for (const platform of ['darwin', 'linux', 'win32'] as const) { + it('enables sandbox for `on` on non-Windows platforms', () => { + for (const platform of ['darwin', 'linux'] as const) { expect(buildSandboxConfigForCLI(platform, 'on', undefined)).toEqual({ enabled: true, + allowBypass: true, userPolicy: { filesystem: {}, network: { allowOutbound: false } }, }); } }); - it('enables sandbox and outbound network for `allowNetwork` on every platform', () => { - for (const platform of ['darwin', 'linux', 'win32'] as const) { + it('enables sandbox and outbound network for `allowNetwork` on non-Windows platforms', () => { + for (const platform of ['darwin', 'linux'] as const) { expect(buildSandboxConfigForCLI(platform, 'allowNetwork', undefined)).toEqual({ enabled: true, + allowBypass: true, userPolicy: { filesystem: {}, network: { allowOutbound: true } }, }); } }); + + it('ignores the enable setting on Windows', () => { + // The sandbox is not supported on Windows, so the enable setting is ignored. + expect(buildSandboxConfigForCLI('win32', 'on', undefined)).toBeUndefined(); + expect(buildSandboxConfigForCLI('win32', 'allowNetwork', undefined)).toBeUndefined(); + }); }); describe('filesystem policy', () => { @@ -51,7 +59,8 @@ describe('buildSandboxConfigForCLI', () => { }; expect(buildSandboxConfigForCLI('linux', 'on', setting)?.userPolicy?.filesystem).toEqual({ readwritePaths: ['/linux'] }); expect(buildSandboxConfigForCLI('darwin', 'on', setting)?.userPolicy?.filesystem).toEqual({ readwritePaths: ['/mac'] }); - expect(buildSandboxConfigForCLI('win32', 'on', setting)?.userPolicy?.filesystem).toEqual({ readwritePaths: ['C:\\win'] }); + // Windows is ignored entirely. + expect(buildSandboxConfigForCLI('win32', 'on', setting)).toBeUndefined(); }); it('maps each setting to the corresponding SDK list', () => { @@ -63,6 +72,7 @@ describe('buildSandboxConfigForCLI', () => { }; expect(buildSandboxConfigForCLI('darwin', 'on', fsFor('darwin', fs))).toEqual({ enabled: true, + allowBypass: true, userPolicy: { filesystem: { readwritePaths: ['/work'], @@ -77,6 +87,7 @@ describe('buildSandboxConfigForCLI', () => { it('omits filesystem lists that are empty', () => { expect(buildSandboxConfigForCLI('darwin', 'on', { mac: {} })).toEqual({ enabled: true, + allowBypass: true, userPolicy: { filesystem: {}, network: { allowOutbound: false } }, }); }); @@ -127,32 +138,20 @@ describe('buildSandboxConfigForCLI', () => { }); describe('network hosts', () => { - it('forwards allowedHosts and opens outbound even when sandbox is `on`', () => { - expect(buildSandboxConfigForCLI('linux', 'on', undefined, { allowedHosts: ['github.com'] })?.userPolicy?.network).toEqual({ - allowOutbound: true, - allowedHosts: ['github.com'], - }); + it('drops host lists and keeps outbound closed when sandbox is `on` (host lists disabled on all platforms)', () => { + for (const platform of ['darwin', 'linux'] as const) { + expect(buildSandboxConfigForCLI(platform, 'on', undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] })?.userPolicy?.network).toEqual({ + allowOutbound: false, + }); + } }); it('ignores host lists when sandbox is `allowNetwork` (allow all)', () => { - expect(buildSandboxConfigForCLI('linux', 'allowNetwork', undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] })?.userPolicy?.network).toEqual({ - allowOutbound: true, - }); - }); - - it('forwards blockedHosts and opens outbound when only blockedHosts is set (deny-list-only allows non-denied domains)', () => { - expect(buildSandboxConfigForCLI('linux', 'on', undefined, { blockedHosts: ['evil.example'] })?.userPolicy?.network).toEqual({ - allowOutbound: true, - blockedHosts: ['evil.example'], - }); - }); - - it('forwards both allowedHosts and blockedHosts together when sandbox is `on`', () => { - expect(buildSandboxConfigForCLI('linux', 'on', undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] })?.userPolicy?.network).toEqual({ - allowOutbound: true, - allowedHosts: ['a.example'], - blockedHosts: ['b.example'], - }); + for (const platform of ['darwin', 'linux'] as const) { + expect(buildSandboxConfigForCLI(platform, 'allowNetwork', undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] })?.userPolicy?.network).toEqual({ + allowOutbound: true, + }); + } }); it('ignores empty host lists', () => { @@ -160,18 +159,6 @@ describe('buildSandboxConfigForCLI', () => { allowOutbound: false, }); }); - - it('drops host lists and keeps outbound closed on darwin (Seatbelt has no per-host filter)', () => { - expect(buildSandboxConfigForCLI('darwin', 'on', undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] })?.userPolicy?.network).toEqual({ - allowOutbound: false, - }); - }); - - it('darwin `allowNetwork` still opens outbound (host lists were already ignored)', () => { - expect(buildSandboxConfigForCLI('darwin', 'allowNetwork', undefined, { allowedHosts: ['github.com'] })?.userPolicy?.network).toEqual({ - allowOutbound: true, - }); - }); }); }); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts index 531be8525c5ea..eabe2168224b4 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts @@ -44,7 +44,7 @@ function buildAutoModel(defaultModel?: CopilotCLIModelInfo): LanguageModelChatIn return { id: 'auto', name: 'Auto', - tooltip: 'Auto selects the best model based on your request complexity and model performance.', + tooltip: 'Auto routes based on your task and real-time system health and model performance. [Learn More](https://docs.github.com/en/copilot/concepts/models/auto-model-selection)', family: defaultModel?.id ?? '', version: '', maxInputTokens: defaultModel?.maxInputTokens ?? defaultModel?.maxContextWindowTokens ?? 0, diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 069c4a7206c32..65d2be693fa38 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -179,7 +179,7 @@ describe('CopilotCLISessionService', () => { deleteSession: vi.fn(async () => { }), }; } - return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], false, logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, new NullTelemetryService())); + return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], undefined, logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, new NullTelemetryService())); } } as unknown as IInstantiationService; const configurationService = accessor.get(IConfigurationService); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 27de7188f4c4c..82ba5f455948c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -152,10 +152,16 @@ class MockSdkSession { set toolMetadata(value: unknown[] | undefined) { this._toolMetadata = value; } setAuthInfo(info: any) { this.authInfo = info; } - updateOptions(options: { authInfo?: unknown }) { + public lastSandboxConfig: unknown; + public sandboxConfigUpdates: unknown[] = []; + updateOptions(options: { authInfo?: unknown; sandboxConfig?: unknown }) { if (options.authInfo !== undefined) { this.authInfo = options.authInfo; } + if (options.sandboxConfig !== undefined) { + this.lastSandboxConfig = options.sandboxConfig; + this.sandboxConfigUpdates.push(options.sandboxConfig); + } } async getSelectedModel() { return this._selectedModel; } async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; } @@ -257,23 +263,26 @@ describe('CopilotCLISession', () => { }); - async function createSession(): Promise { - return createSessionWith(new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' }))); + async function createSession(opts?: { sandboxEnabled?: boolean }): Promise { + return createSessionWith(new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), opts?.sandboxEnabled ?? false); } - async function createSessionWith(otelService: IOTelService): Promise { + async function createSessionWith(otelService: IOTelService, sandboxEnabled = false): Promise { class FakeUserQuestionHandler implements IUserQuestionHandler { _serviceBrand: undefined; async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, toolCallId?: string): Promise { return userQuestionAnswer; } } + const sandboxConfig = sandboxEnabled + ? { enabled: true as const, userPolicy: { filesystem: {}, network: { allowOutbound: false } } } + : undefined; return disposables.add(new CopilotCLISession( sessionWorkspaceInfo, sessionAgentName, sdkSession as unknown as Session, [], - false, + sandboxConfig as any, logger, workspaceService, chatSessionMetadataStore, @@ -852,6 +861,92 @@ describe('CopilotCLISession', () => { await requestPromise; }); + it('auto-approves an ordinary shell command when the sandbox is enabled', async () => { + let result: unknown; + sdkSession.send = async () => { + result = await sdkSession.emitPermissionRequest({ + kind: 'shell', + toolCallId: 'sandboxed-tool', + commands: [{ identifier: 'ls', readOnly: true }], + intention: 'List files', + fullCommandText: 'ls', + possiblePaths: [], + possibleUrls: [], + hasWriteFileRedirection: false, + canOfferSessionApproval: false, + }); + }; + const session = await createSession({ sandboxEnabled: true }); + session.attachStream(new MockChatResponseStream()); + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run bash' }, [], undefined, authInfo, CancellationToken.None); + + // The sandbox contains the command, so it is auto-approved without a prompt. + expect(result).toEqual({ kind: 'approve-once' }); + expect(toolsService.invokeToolCalls.filter(c => c.name === 'vscode_get_terminal_confirmation')).toHaveLength(0); + }); + + it('does not auto-approve a sandbox-bypass shell command and flags it runs outside the sandbox', async () => { + let result: unknown; + sdkSession.send = async () => { + result = await sdkSession.emitPermissionRequest({ + kind: 'shell', + toolCallId: 'bypass-tool', + commands: [{ identifier: 'cat ~/secret', readOnly: true }], + intention: 'Read secret', + fullCommandText: 'cat ~/secret', + possiblePaths: [], + possibleUrls: [], + hasWriteFileRedirection: false, + canOfferSessionApproval: false, + requestSandboxBypass: true, + requestSandboxBypassReason: 'Needs access outside the workspace', + }); + }; + toolsService.setConfirmationResult('yes'); + const session = await createSession({ sandboxEnabled: true }); + session.attachStream(new MockChatResponseStream()); + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run bash' }, [], undefined, authInfo, CancellationToken.None); + + // Opting out of the sandbox is an elevation of privilege, so it must fall + // through to the confirmation prompt rather than being auto-approved, and + // the confirmation must be flagged as running outside the sandbox. + const confirmationCalls = toolsService.invokeToolCalls.filter(c => c.name === 'vscode_get_terminal_confirmation'); + expect(confirmationCalls).toHaveLength(1); + expect(confirmationCalls[0].input as { sandboxBypass?: boolean; sandboxBypassReason?: string }).toMatchObject({ + sandboxBypass: true, + sandboxBypassReason: 'Needs access outside the workspace', + }); + expect(result).toEqual({ kind: 'approve-once' }); + }); + + it('applies the configured sandbox for a request running with default approvals', async () => { + const session = await createSession({ sandboxEnabled: true }); + session.attachStream(new MockChatResponseStream()); + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run' }, [], undefined, authInfo, CancellationToken.None); + + expect(sdkSession.lastSandboxConfig).toEqual({ enabled: true, userPolicy: { filesystem: {}, network: { allowOutbound: false } } }); + }); + + it('disables the sandbox for a request running with bypass approvals', async () => { + for (const level of ['autopilot', 'autoApprove'] as const) { + sdkSession = new MockSdkSession(); + const session = await createSession({ sandboxEnabled: true }); + session.setPermissionLevel(level); + session.attachStream(new MockChatResponseStream()); + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run' }, [], undefined, authInfo, CancellationToken.None); + + expect(sdkSession.lastSandboxConfig, level).toEqual({ enabled: false }); + } + }); + + it('explicitly disables the sandbox when the session has no sandbox configured', async () => { + const session = await createSession({ sandboxEnabled: false }); + session.attachStream(new MockChatResponseStream()); + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run' }, [], undefined, authInfo, CancellationToken.None); + + expect(sdkSession.lastSandboxConfig).toEqual({ enabled: false }); + }); + it('emits languageModelToolInvoked telemetry for each completed tool invocation', async () => { class RecordingTelemetryService extends NullTelemetryService { readonly events: { name: string; properties?: TelemetryEventProperties; measurements?: TelemetryEventMeasurements }[] = []; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts index 7c44c2db384f0..eec97b0af1442 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts @@ -147,6 +147,21 @@ describe('CopilotCLI permissionHelpers', () => { // Bash regex doesn't recognize Set-Location, so full command is kept expect(result.input.command).toBe(fullCommandText); }); + + it('shell: passes sandboxBypass and reason when the command opts out of the sandbox', () => { + const req = { kind: 'shell', intention: 'Read secret', fullCommandText: 'cat ~/secret', requestSandboxBypass: true, requestSandboxBypassReason: 'Needs access outside the workspace' } as any; + const result = buildShellConfirmationParams(req, undefined); + expect(result.input.command).toBe('cat ~/secret'); + expect(result.input.sandboxBypass).toBe(true); + expect(result.input.sandboxBypassReason).toBe('Needs access outside the workspace'); + }); + + it('shell: omits sandboxBypass for ordinary commands', () => { + const req = { kind: 'shell', intention: 'List workspace files', fullCommandText: 'ls -la' } as any; + const result = buildShellConfirmationParams(req, undefined); + expect(result.input.sandboxBypass).toBeUndefined(); + expect(result.input.sandboxBypassReason).toBeUndefined(); + }); }); describe('buildMcpConfirmationParams', () => { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index e9bdf3f1d9c07..f2fcb50255496 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -17,6 +17,7 @@ import { Disposable } from '../../../../util/vs/base/common/lifecycle'; import { basename } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { ICopilotCLIAgents, isEnabledForCopilotCLI } from '../../copilotcli/node/copilotCli'; +import { INativeEnvService } from '../../../../platform/env/common/envService'; export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { @@ -44,6 +45,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod @ILogService private readonly logService: ILogService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IFileSystemService private readonly fileSystemService: IFileSystemService, + @INativeEnvService private readonly envService: INativeEnvService, ) { super(); @@ -82,6 +84,32 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod return items; } + async provideSourceFolders(_sessionResource: vscode.Uri, type: vscode.ChatSessionCustomizationType, _token: vscode.CancellationToken): Promise { + const folders: vscode.ChatSessionCustomizationSourceFolder[] = []; + const roots = getSearchRoots(); + for (const folder of this.workspaceService.getWorkspaceFolders()) { + for (const root of roots.workspace) { + if (root.type === type) { + folders.push({ + uri: URI.joinPath(folder, ...root.path), + label: root.path[0], + source: 'local' + }); + } + } + } + for (const root of roots.user) { + if (root.type === type) { + folders.push({ + uri: URI.joinPath(this.envService.userHome, ...root.path), + label: `~/${root.path[0]}`, + source: 'user' + }); + } + } + return folders; + } + /** * Builds agent items from ICopilotCLIAgents, which already merges SDK * and prompt-file agents with source URIs. @@ -240,3 +268,25 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod })); } } + + +function getSearchRoots() { + return { + workspace: [ + { path: ['.github', 'agents'], type: vscode.ChatSessionCustomizationType.Agent }, + { path: ['.agents', 'agents'], type: vscode.ChatSessionCustomizationType.Agent }, + { path: ['.claude', 'agents'], type: vscode.ChatSessionCustomizationType.Agent }, + { path: ['.github', 'skills'], recursive: true, type: vscode.ChatSessionCustomizationType.Skill }, + { path: ['.agents', 'skills'], recursive: true, type: vscode.ChatSessionCustomizationType.Skill }, + { path: ['.claude', 'skills'], recursive: true, type: vscode.ChatSessionCustomizationType.Skill }, + { path: ['.github', 'instructions'], recursive: true, type: vscode.ChatSessionCustomizationType.Instructions }, + { path: ['.github', 'hooks'], recursive: true, type: vscode.ChatSessionCustomizationType.Hook }, + ], + user: [ + { path: ['.copilot', 'agents'], type: vscode.ChatSessionCustomizationType.Agent }, + { path: ['.agents', 'skills'], recursive: true, type: vscode.ChatSessionCustomizationType.Skill }, + { path: ['.copilot', 'instructions'], recursive: true, type: vscode.ChatSessionCustomizationType.Instructions }, + { path: ['.copilot', 'hooks'], recursive: true, type: vscode.ChatSessionCustomizationType.Hook }, + ], + }; +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts index 26b7f5dfdb074..9d839270ef991 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -126,6 +126,7 @@ describe('CopilotCLICustomizationProvider', () => { new TestLogService(), { getWorkspaceFolders: () => [] } as any, { stat: () => Promise.reject(new Error('not found')) } as any, + { userHome: URI.file('/home/test') } as any, )); }); @@ -347,6 +348,7 @@ describe('CopilotCLICustomizationProvider', () => { ? Promise.resolve({ type: 1, ctime: 0, mtime: 0, size: 0 }) : Promise.reject(new Error('not found')), } as any, + { userHome: URI.file('/home/test') } as any, )); mockPromptsService.setInstructions([]); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts index d3e0c4f38103a..55f5988b18e8d 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts @@ -19,7 +19,7 @@ describe('CopilotCLI SDK Upgrade', function () { it('should be able to load the SDK without errors', async function () { await import('@github/copilot/sdk'); - }); + }, 10000); it('should not contain new native binaries nor removed native binaries', async function () { // This is a very basic check to ensure that when the Copilot CLI SDK is upgraded, @@ -27,35 +27,25 @@ describe('CopilotCLI SDK Upgrade', function () { // Such changes may require us to update our extension packaging or other handling. const existingBinaries = new Set(await findAllBinaries(copilotSDKPath)); const knownBinaries = new Set([ - // node-pty related files (already accounted for in SDK, using VS Code node-pty). - path.join('prebuilds', 'darwin-arm64', 'pty.node'), - path.join('prebuilds', 'darwin-x64', 'pty.node'), - path.join('prebuilds', 'linux-arm64', 'pty.node'), - path.join('prebuilds', 'linux-x64', 'pty.node'), - path.join('prebuilds', 'win32-arm64', 'conpty', 'OpenConsole.exe'), - path.join('prebuilds', 'win32-arm64', 'conpty', 'conpty.dll'), - path.join('prebuilds', 'win32-arm64', 'conpty.node'), - path.join('prebuilds', 'win32-arm64', 'conpty.pdb'), - path.join('prebuilds', 'win32-arm64', 'conpty_console_list.node'), - path.join('prebuilds', 'win32-arm64', 'conpty_console_list.pdb'), - path.join('prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'), - path.join('prebuilds', 'win32-x64', 'conpty', 'conpty.dll'), - path.join('prebuilds', 'win32-x64', 'conpty.node'), - path.join('prebuilds', 'win32-x64', 'conpty.pdb'), - path.join('prebuilds', 'win32-x64', 'conpty_console_list.node'), - path.join('prebuilds', 'win32-x64', 'conpty_console_list.pdb'), // ripgrep path.join('ripgrep', 'bin', 'win32-arm64', 'rg.exe'), path.join('ripgrep', 'bin', 'win32-x64', 'rg.exe'), - path.join('prebuilds', 'darwin-arm64', 'spawn-helper'), - path.join('prebuilds', 'darwin-x64', 'spawn-helper'), - // computer use - path.join('prebuilds', 'darwin-arm64', 'computer.node'), - path.join('prebuilds', 'darwin-x64', 'computer.node'), - path.join('prebuilds', 'linux-arm64', 'computer.node'), - path.join('prebuilds', 'linux-x64', 'computer.node'), - path.join('prebuilds', 'win32-arm64', 'computer.node'), - path.join('prebuilds', 'win32-x64', 'computer.node'), + // Computer-use payloads present in the raw package. Root prebuilds + // are stripped from the shipped extension by .vscodeignore. + path.join('prebuilds', 'darwin-arm64', 'computer-use-mcp'), + path.join('prebuilds', 'darwin-arm64', 'Copilot Computer Use.app', 'Contents', 'CodeResources'), + path.join('prebuilds', 'darwin-arm64', 'Copilot Computer Use.app', 'Contents', 'Resources', 'Assets.car'), + path.join('prebuilds', 'darwin-arm64', 'Copilot Computer Use.app', 'Contents', 'Resources', 'icon.icns'), + path.join('prebuilds', 'darwin-arm64', 'Copilot Computer Use.app', 'Contents', 'MacOS', 'Copilot Computer Use'), + path.join('prebuilds', 'darwin-x64', 'computer-use-mcp'), + path.join('prebuilds', 'darwin-x64', 'Copilot Computer Use.app', 'Contents', 'CodeResources'), + path.join('prebuilds', 'darwin-x64', 'Copilot Computer Use.app', 'Contents', 'Resources', 'Assets.car'), + path.join('prebuilds', 'darwin-x64', 'Copilot Computer Use.app', 'Contents', 'Resources', 'icon.icns'), + path.join('prebuilds', 'darwin-x64', 'Copilot Computer Use.app', 'Contents', 'MacOS', 'Copilot Computer Use'), + path.join('prebuilds', 'win32-arm64', 'CopilotComputerUse.exe'), + path.join('prebuilds', 'win32-arm64', 'computer-use-mcp.exe'), + path.join('prebuilds', 'win32-x64', 'CopilotComputerUse.exe'), + path.join('prebuilds', 'win32-x64', 'computer-use-mcp.exe'), // cli-native to be included path.join('prebuilds', 'darwin-arm64', 'cli-native.node'), path.join('prebuilds', 'darwin-x64', 'cli-native.node'), @@ -77,34 +67,14 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('prebuilds', 'win32-x64', 'runtime.node'), // Second copy of native prebuilds re-shipped by the @github/copilot/sdk subpackage // (previously hidden by a broad sdk/prebuilds/** exclusion that masked the node-pty files we used to shim in at test setup). - path.join('sdk', 'prebuilds', 'darwin-arm64', 'computer.node'), - path.join('sdk', 'prebuilds', 'darwin-x64', 'computer.node'), - path.join('sdk', 'prebuilds', 'linux-arm64', 'computer.node'), - path.join('sdk', 'prebuilds', 'linux-x64', 'computer.node'), - path.join('sdk', 'prebuilds', 'win32-arm64', 'computer.node'), - path.join('sdk', 'prebuilds', 'win32-x64', 'computer.node'), path.join('sdk', 'prebuilds', 'darwin-arm64', 'runtime.node'), path.join('sdk', 'prebuilds', 'darwin-x64', 'runtime.node'), path.join('sdk', 'prebuilds', 'linux-arm64', 'runtime.node'), path.join('sdk', 'prebuilds', 'linux-x64', 'runtime.node'), + path.join('sdk', 'prebuilds', 'linuxmusl-arm64', 'runtime.node'), + path.join('sdk', 'prebuilds', 'linuxmusl-x64', 'runtime.node'), path.join('sdk', 'prebuilds', 'win32-arm64', 'runtime.node'), path.join('sdk', 'prebuilds', 'win32-x64', 'runtime.node'), - // node-pty natives re-shipped into the @github/copilot/sdk subpackage by our - // postinstall so built-in installs can spawn through node-pty (used by mxc). - path.join('sdk', 'prebuilds', 'darwin-arm64', 'pty.node'), - path.join('sdk', 'prebuilds', 'darwin-x64', 'pty.node'), - path.join('sdk', 'prebuilds', 'linux-arm64', 'pty.node'), - path.join('sdk', 'prebuilds', 'linux-x64', 'pty.node'), - path.join('sdk', 'prebuilds', 'darwin-arm64', 'spawn-helper'), - path.join('sdk', 'prebuilds', 'darwin-x64', 'spawn-helper'), - path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty.node'), - path.join('sdk', 'prebuilds', 'win32-x64', 'conpty.node'), - path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty_console_list.node'), - path.join('sdk', 'prebuilds', 'win32-x64', 'conpty_console_list.node'), - path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty', 'OpenConsole.exe'), - path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty', 'conpty.dll'), - path.join('sdk', 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'), - path.join('sdk', 'prebuilds', 'win32-x64', 'conpty', 'conpty.dll'), path.join('ripgrep', 'bin', 'darwin-arm64', 'rg'), path.join('ripgrep', 'bin', 'darwin-x64', 'rg'), path.join('ripgrep', 'bin', 'linux-x64', 'rg'), @@ -134,6 +104,8 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('sdk', 'prebuilds', 'darwin-x64', 'cli-native.node'), path.join('sdk', 'prebuilds', 'linux-arm64', 'cli-native.node'), path.join('sdk', 'prebuilds', 'linux-x64', 'cli-native.node'), + path.join('sdk', 'prebuilds', 'linuxmusl-arm64', 'cli-native.node'), + path.join('sdk', 'prebuilds', 'linuxmusl-x64', 'cli-native.node'), path.join('sdk', 'prebuilds', 'win32-arm64', 'cli-native.node'), path.join('sdk', 'prebuilds', 'win32-x64', 'cli-native.node'), // foundry-local-sdk vendored native bindings. @@ -148,34 +120,6 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('pvrecorder', 'node_modules', '@picovoice', 'pvrecorder-node', 'lib', 'mac', 'x86_64', 'pv_recorder.node'), path.join('pvrecorder', 'node_modules', '@picovoice', 'pvrecorder-node', 'lib', 'windows', 'amd64', 'pv_recorder.node'), path.join('pvrecorder', 'node_modules', '@picovoice', 'pvrecorder-node', 'lib', 'windows', 'arm64', 'pv_recorder.node'), - // mxc-bin (Windows sandbox + WSL helpers used by the SDK's command execution). - path.join('mxc-bin', 'arm64', 'lxc-exec'), - path.join('mxc-bin', 'arm64', 'mxc-exec-mac'), - path.join('mxc-bin', 'arm64', 'winhttp-proxy-shim.exe'), - path.join('mxc-bin', 'arm64', 'wslcsdk.dll'), - path.join('mxc-bin', 'arm64', 'wxc-exec.exe'), - path.join('mxc-bin', 'arm64', 'wxc-test-proxy.exe'), - path.join('mxc-bin', 'arm64', 'wxc-host-prep.exe'), - path.join('mxc-bin', 'arm64', 'wxc-windows-sandbox-daemon.exe'), - path.join('mxc-bin', 'arm64', 'wxc-windows-sandbox-guest.exe'), - path.join('mxc-bin', 'arm64', 'mxc-diagnostic-console.exe'), - path.join('mxc-bin', 'arm64', '_manifest', 'spdx_2.2', 'bsi.cose'), - path.join('mxc-bin', 'arm64', '_manifest', 'spdx_2.2', 'manifest.cat'), - path.join('mxc-bin', 'arm64', '_manifest', 'spdx_2.2', 'manifest.spdx.cose'), - path.join('mxc-bin', 'arm64', 'linux-test-proxy'), - path.join('mxc-bin', 'x64', 'lxc-exec'), - path.join('mxc-bin', 'x64', 'winhttp-proxy-shim.exe'), - path.join('mxc-bin', 'x64', 'wslcsdk.dll'), - path.join('mxc-bin', 'x64', 'wxc-exec.exe'), - path.join('mxc-bin', 'x64', 'wxc-test-proxy.exe'), - path.join('mxc-bin', 'x64', 'wxc-host-prep.exe'), - path.join('mxc-bin', 'x64', 'wxc-windows-sandbox-daemon.exe'), - path.join('mxc-bin', 'x64', 'wxc-windows-sandbox-guest.exe'), - path.join('mxc-bin', 'x64', 'mxc-diagnostic-console.exe'), - path.join('mxc-bin', 'x64', '_manifest', 'spdx_2.2', 'bsi.cose'), - path.join('mxc-bin', 'x64', '_manifest', 'spdx_2.2', 'manifest.cat'), - path.join('mxc-bin', 'x64', '_manifest', 'spdx_2.2', 'manifest.spdx.cose'), - path.join('mxc-bin', 'x64', 'linux-test-proxy'), // parsing commands for shell. 'tree-sitter-bash.wasm', 'tree-sitter.wasm', @@ -197,10 +141,6 @@ describe('CopilotCLI SDK Upgrade', function () { 'tree-sitter-scala.wasm', ].map(p => path.join(copilotSDKPath, p))); - const optionalKnownBinaries = new Set([ - path.join(copilotSDKPath, 'mxc-bin', 'x64', 'mxc-exec-mac'), - ]); - // Exclude ripgrep files that we copy over in src/extension/chatSessions/copilotcli/node/ripgrepShim.ts (until we get better API/solution from SDK) const ripgrepFilesWeCopy = path.join(copilotSDKPath, 'sdk', 'ripgrep', 'bin'); @@ -214,7 +154,7 @@ describe('CopilotCLI SDK Upgrade', function () { if (binaryName.startsWith('keytar') || binaryName.startsWith('clipboard')) { continue; } - if (!knownBinaries.has(binary) && !optionalKnownBinaries.has(binary)) { + if (!knownBinaries.has(binary)) { errors.push(`Unexpected native binary found in Copilot CLI SDK: ${path.relative(copilotSDKPath, binary)}`); } } @@ -224,6 +164,9 @@ describe('CopilotCLI SDK Upgrade', function () { continue; } if (!existingBinaries.has(binary)) { + if (isNonCurrentCopilotPlatformBinary(copilotSDKPath, binary) || isNonCurrentPvRecorderBinary(copilotSDKPath, binary)) { + continue; + } errors.push(`Expected native binary missing from Copilot CLI SDK: ${path.relative(copilotSDKPath, binary)}`); } } @@ -231,13 +174,66 @@ describe('CopilotCLI SDK Upgrade', function () { if (errors.length > 0) { throw new Error(errors.join('\n')); } - }); + }, 30000); it('should be able to load the @github/copilot module without errors', async function () { await import('@github/copilot/sdk'); }); }); +const copilotPlatformArchs = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'linuxmusl-arm64', + 'linuxmusl-x64', + 'win32-arm64', + 'win32-x64', +]); + +function currentCopilotPlatformArch(): string { + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined; + if (process.platform === 'linux' && !report?.header?.glibcVersionRuntime) { + return `linuxmusl-${process.arch}`; + } + + return `${process.platform}-${process.arch}`; +} + +function isNonCurrentCopilotPlatformBinary(copilotSDKPath: string, binary: string): boolean { + const relativeSegments = path.relative(copilotSDKPath, binary).split(path.sep); + const platformArch = relativeSegments.find(segment => copilotPlatformArchs.has(segment)); + return platformArch !== undefined && platformArch !== currentCopilotPlatformArch(); +} + +function isNonCurrentPvRecorderBinary(copilotSDKPath: string, binary: string): boolean { + const relative = path.relative(copilotSDKPath, binary).split(path.sep).join(path.posix.sep); + const pvRecorderPrefix = 'pvrecorder/node_modules/@picovoice/pvrecorder-node/lib/'; + if (!relative.startsWith(pvRecorderPrefix)) { + return false; + } + + const currentPlatformArch = currentCopilotPlatformArch(); + if (relative.startsWith(`${pvRecorderPrefix}mac/arm64/`)) { + return currentPlatformArch !== 'darwin-arm64'; + } + if (relative.startsWith(`${pvRecorderPrefix}mac/x86_64/`)) { + return currentPlatformArch !== 'darwin-x64'; + } + if (relative.startsWith(`${pvRecorderPrefix}linux/x86_64/`)) { + return currentPlatformArch !== 'linux-x64' && currentPlatformArch !== 'linuxmusl-x64'; + } + if (relative.startsWith(`${pvRecorderPrefix}windows/amd64/`)) { + return currentPlatformArch !== 'win32-x64'; + } + if (relative.startsWith(`${pvRecorderPrefix}windows/arm64/`)) { + return currentPlatformArch !== 'win32-arm64'; + } + + return false; +} + async function copyBinaries(extensionPath: string) { const copilotSDKPath = path.join(extensionPath, 'node_modules', '@github', 'copilot'); const vscodeRipgrepPath = path.join(copilotSDKPath, 'ripgrep', 'bin', process.platform + '-' + process.arch); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 1eb0924b21a58..e1f9222abbee4 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -169,6 +169,32 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return items; } + async provideSourceFolders(_sessionResource: vscode.Uri, type: vscode.ChatSessionCustomizationType, _token: vscode.CancellationToken): Promise { + const folders: vscode.ChatSessionCustomizationSourceFolder[] = []; + const searchPaths = getCustomizationSearchPaths(); + for (const folder of this.workspaceService.getWorkspaceFolders()) { + for (const root of searchPaths.workspace) { + if (root.type === type) { + folders.push({ + uri: URI.joinPath(folder, ...root.path), + label: root.path[0], + source: 'local' + }); + } + } + } + for (const root of searchPaths.user) { + if (root.type === type) { + folders.push({ + uri: URI.joinPath(this.envService.userHome, ...root.path), + label: `~/${root.path[0]}`, + source: 'user' + }); + } + } + return folders; + } + private async discoverInstructions(): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; const candidates: { uri: URI; source: vscode.ChatResourceSource }[] = []; @@ -296,3 +322,17 @@ export function isEnabledForClaudeCode(customization: { sessionTypes?: readonly const sessionTypes = customization.sessionTypes; return sessionTypes === undefined || sessionTypes.includes('claude-code') || false; } + +function getCustomizationSearchPaths() { + return { + workspace: [ + { path: ['.claude', 'agents'], type: vscode.ChatSessionCustomizationType.Agent }, + { path: ['.claude', 'skills'], recursive: true, type: vscode.ChatSessionCustomizationType.Skill }, + ], + user: [ + { path: ['.claude', 'agents'], type: vscode.ChatSessionCustomizationType.Agent }, + { path: ['.agents', 'skills'], recursive: true, type: vscode.ChatSessionCustomizationType.Skill }, + { path: ['.copilot', 'instructions'], recursive: true, type: vscode.ChatSessionCustomizationType.Instructions }, + ], + }; +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index a48452fd9fc3d..9735cb2de182a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -5,7 +5,7 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; -import type { AgentTaskSessionEvent } from '@vscode/copilot-api'; +import type { AgentTaskSessionEvent, AgentTaskState } from '@vscode/copilot-api'; import { l10n, Uri } from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; @@ -150,6 +150,36 @@ export function parseSessionLogChunksSafely(rawText: string, logService: ILogSer } } +/** + * Map a Task API lifecycle state (v2) directly to a {@link vscode.ChatSessionStatus}. Unlike v1's + * PR-derived status (which routes through the lossy `SessionInfo['state']`), this keeps the + * non-terminal "agent handed the turn back" states distinct from active work: `idle` (agent + * finished its turn, nothing pending) renders as Completed and `waiting_for_user` (agent paused for + * input) renders as NeedsInput. Only `queued`/`in_progress` are genuinely InProgress — collapsing + * `idle`/`waiting_for_user` into InProgress made settled tasks look like they were still running. + */ +export function taskStateToChatSessionStatus(state: AgentTaskState): vscode.ChatSessionStatus { + switch (state) { + case 'queued': + case 'in_progress': + return vscode.ChatSessionStatus.InProgress; + case 'waiting_for_user': + return vscode.ChatSessionStatus.NeedsInput; + case 'idle': + case 'completed': + return vscode.ChatSessionStatus.Completed; + case 'failed': + case 'timed_out': + case 'cancelled': + return vscode.ChatSessionStatus.Failed; + default: + // Forward-compat fallback: a state outside the known union (e.g. a state added + // server-side after this build) must not yield an invalid `undefined` status. Treat + // unknowns as InProgress — the conservative "agent may still own the turn" assumption. + return vscode.ChatSessionStatus.InProgress; + } +} + const CUSTOM_AGENTS_OPTION_GROUP_ID = 'customAgents'; const MODELS_OPTION_GROUP_ID = 'models'; const PARTNER_AGENTS_OPTION_GROUP_ID = 'partnerAgents'; @@ -1272,6 +1302,13 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C const sessionItem = entry.latestSession; const createdAt = validateISOTimestamp(sessionItem.created_at); + // Task-backed entries (v2) carry their raw lifecycle state; map it directly so the + // agent's post-turn states (`idle`/`waiting_for_user`) don't render as InProgress. + // Jobs API (v1) entries fall back to the PR-derived session state. + const status = entry.taskState !== undefined + ? taskStateToChatSessionStatus(entry.taskState) + : this.getSessionStatusFromSession(sessionItem); + // Task-shaped card path (v2 with no resolvable PR yet, or PR-less by design). if (!pr) { if (!entry.pullArtifact && entry.latestSession.resource_type !== 'task') { @@ -1288,7 +1325,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return { resource: vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + SessionIdForTask.getId(taskId) }), label: entry.latestSession.name || taskId, - status: this.getSessionStatusFromSession(sessionItem), + status, ...(changes?.length ? { changes } : {}), ...(createdAt ? { timing: { @@ -1327,7 +1364,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C const session = { resource, label: pr.title, - status: this.getSessionStatusFromSession(sessionItem), + status, badge: this.getPullRequestBadge(repoIds, pr), tooltip: this.createPullRequestTooltip(pr), ...(createdAt ? { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts index 96a7d58b6baf9..457660e220f3e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts @@ -64,6 +64,36 @@ function mapTaskStateToSessionState(state: AgentTaskState): SessionInfo['state'] } } +/** + * Agent integration slugs that identify the Copilot cloud coding agent. CMC/CAPI returns + * `copilot-developer`; the monolith uses `copilot-swe-agent` for the same agent. Tasks owned by + * any other surface — `copilot-developer-cli` (Copilot CLI), `vscode-chat` (VS Code) or + * `jetbrains-chat` (JetBrains) — are local clients mirrored into Mission Control and must not + * appear in the cloud sessions list. See github-ui `agent-helpers.ts` (`isCopilotCodingAgent`) and + * `agent-profile.ts`. + */ +const CLOUD_CODING_AGENT_SLUGS: ReadonlySet = new Set(['copilot-developer', 'copilot-swe-agent']); + +/** + * The owning agent integration of a task. Mirrors CMC's internal `TaskCollaborator` + * (`agent_collaborators`), which first-party CAPI tokens receive but `@vscode/copilot-api`'s + * `AgentTask` does not yet model. Only `slug` is needed to identify the client surface. + */ +interface TaskAgentCollaborator { + readonly slug?: string; +} + +/** + * Whether a task is owned by the Copilot cloud coding agent rather than a local client surface + * (Copilot CLI / VS Code / JetBrains). The owning surface is identified by the agent integration + * slug on the task's `agent_collaborators`; tasks without a recognized cloud slug are treated as + * non-cloud and excluded from the cloud sessions list. + */ +export function isCloudCodingAgentTask(task: AgentTask): boolean { + const collaborators = (task as AgentTask & { readonly agent_collaborators?: readonly TaskAgentCollaborator[] }).agent_collaborators; + return collaborators?.some(c => typeof c.slug === 'string' && CLOUD_CODING_AGENT_SLUGS.has(c.slug)) ?? false; +} + function findPullArtifact(task: AgentTask): (AgentTaskArtifact & { data: AgentTaskGitHubResourceData }) | undefined { return task.artifacts?.find( (a): a is AgentTaskArtifact & { data: AgentTaskGitHubResourceData } => @@ -300,11 +330,12 @@ export class TaskApiBackend implements TaskCloudAgentBackend { } return tasksWithRepo - .filter(({ task }) => !task.archived_at) + .filter(({ task }) => !task.archived_at && isCloudCodingAgentTask(task)) .map(({ task, repo }): CloudSessionData => ({ latestSession: taskToSessionInfo(task), pullArtifact: taskToPullArtifactRef(task, repo), diffRefs: taskToDiffRefs(task, repo), + taskState: task.state, })); } @@ -475,8 +506,6 @@ export class TaskApiError extends Error { */ export class TaskApiHttpClient implements ITaskApiClient { - private static readonly SIGN_IN_DETAIL = l10n.t('Sign in to GitHub to use the Cloud Agent (Task API).'); - constructor( private readonly _capiClientService: ICAPIClientService, private readonly _authService: IAuthenticationService, @@ -484,8 +513,7 @@ export class TaskApiHttpClient implements ITaskApiClient { ) { } private async _authHeaders(): Promise> { - const session = await this._authService.getGitHubSession('permissive', { silent: true }) - ?? await this._authService.getGitHubSession('permissive', { createIfNone: { detail: TaskApiHttpClient.SIGN_IN_DETAIL } }); + const session = await this._authService.getGitHubSession('permissive', { silent: true }); const token = session?.accessToken; if (!token) { throw new Error(l10n.t('Sign in to GitHub to use the Cloud Agent.')); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 577dbf2a4b5d3..630240433ff1f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -422,7 +422,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { } }(); } - const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], false, logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, new NullTelemetryService()); + const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], undefined, logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, new NullTelemetryService()); cliSessions.push(session); return disposables.add(session); } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts index aa3094718197a..0dcd0ec45c5dd 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; -import type { AgentTask, AgentTaskCreateRequest, AgentTaskGetResponse, AgentTaskListEventsResponse, AgentTaskListResponse, AgentTaskSessionEvent, AgentTaskSteerRequest, AgentTaskCreatePullRequestResponse } from '@vscode/copilot-api'; +import type { AgentTask, AgentTaskCreateRequest, AgentTaskGetResponse, AgentTaskListEventsResponse, AgentTaskListResponse, AgentTaskSessionEvent, AgentTaskState, AgentTaskSteerRequest, AgentTaskCreatePullRequestResponse } from '@vscode/copilot-api'; import { GithubRepoId, IGitService } from '../../../../platform/git/common/gitService'; import { PullRequestSearchItem, SessionInfo } from '../../../../platform/github/common/githubAPI'; import { TestLogService } from '../../../../platform/testing/common/testLogService'; @@ -13,8 +13,8 @@ import { mock } from '../../../../util/common/test/simpleMock'; import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes'; import { ChatSessionContentBuilder } from '../copilotCloudSessionContentBuilder'; -import { normalizeInitialSessionOptions, parseSessionLogChunksSafely } from '../copilotCloudSessionsProvider'; -import { TaskApiBackend, parseRepoFromTaskUrl } from '../taskApiBackend'; +import { normalizeInitialSessionOptions, parseSessionLogChunksSafely, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider'; +import { TaskApiBackend, parseRepoFromTaskUrl, isCloudCodingAgentTask } from '../taskApiBackend'; import { NullCloudBackendInstrumentation } from '../cloudBackendTelemetry'; import { MockOctoKitService } from '../../../agents/vscode-node/test/mockOctoKitService'; @@ -457,6 +457,92 @@ describe('TaskApiBackend', () => { expect(client.listForRepoCalls).toEqual([]); expect(client.listCalls).toEqual([{ options: { per_page: 100 } }]); }); + + it('fetchSessionList carries the raw task lifecycle state so settled tasks are not shown as in_progress', async () => { + // `idle` collapses to `in_progress` in the legacy SessionInfo shape; the raw `taskState` + // must be preserved alongside it so the provider can render it as Completed/NeedsInput. + const client = new FakeTaskApiClient({ repoTasks: [{ id: 't-idle', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-developer' }] } as unknown as AgentTask] }); + const octoKitService = new MockOctoKitService(); + octoKitService.getCurrentAuthedUser = async () => ({ id: 4242, login: 'octocat', name: 'The Octocat', avatar_url: '' }); + const backend = new TaskApiBackend(client, new TestLogService(), octoKitService, NullCloudBackendInstrumentation); + + const result = await backend.fetchSessionList([new GithubRepoId('octocat', 'hello-world')], false, false); + + expect(result.map(r => ({ taskState: r.taskState, sessionState: r.latestSession.state }))).toEqual([ + { taskState: 'idle', sessionState: 'in_progress' }, + ]); + }); + + it('fetchSessionList shows only cloud coding agent tasks and excludes local-client tasks (CLI / VS Code / JetBrains)', async () => { + const repoTasks = [ + { id: 'cloud-dev', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-developer' }] }, + { id: 'cloud-swe', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-swe-agent' }] }, + { id: 'cli', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-developer-cli' }] }, + { id: 'vscode', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'vscode-chat' }] }, + { id: 'jetbrains', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'jetbrains-chat' }] }, + { id: 'no-collaborators', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 } }, + ] as unknown as readonly AgentTask[]; + const client = new FakeTaskApiClient({ repoTasks }); + const octoKitService = new MockOctoKitService(); + octoKitService.getCurrentAuthedUser = async () => ({ id: 4242, login: 'octocat', name: 'The Octocat', avatar_url: '' }); + const backend = new TaskApiBackend(client, new TestLogService(), octoKitService, NullCloudBackendInstrumentation); + + const result = await backend.fetchSessionList([new GithubRepoId('octocat', 'hello-world')], false, false); + + expect(result.map(r => r.latestSession.id)).toEqual(['cloud-dev', 'cloud-swe']); + }); +}); + +describe('isCloudCodingAgentTask', () => { + it('keeps cloud coding agent slugs and rejects local-client / missing / malformed slugs', () => { + const classify = (agent_collaborators?: Array<{ slug?: unknown }>) => + isCloudCodingAgentTask({ id: 't', state: 'idle', created_at: '2026-03-27T00:00:00Z', ...(agent_collaborators && { agent_collaborators }) } as unknown as AgentTask); + + expect({ + 'copilot-developer': classify([{ slug: 'copilot-developer' }]), + 'copilot-swe-agent': classify([{ slug: 'copilot-swe-agent' }]), + 'copilot-developer-cli': classify([{ slug: 'copilot-developer-cli' }]), + 'vscode-chat': classify([{ slug: 'vscode-chat' }]), + 'jetbrains-chat': classify([{ slug: 'jetbrains-chat' }]), + 'missing-slug': classify([{}]), + 'null-slug': classify([{ slug: null }]), + 'no-collaborators': classify(undefined), + 'empty-collaborators': classify([]), + }).toEqual({ + 'copilot-developer': true, + 'copilot-swe-agent': true, + 'copilot-developer-cli': false, + 'vscode-chat': false, + 'jetbrains-chat': false, + 'missing-slug': false, + 'null-slug': false, + 'no-collaborators': false, + 'empty-collaborators': false, + }); + }); +}); + +describe('taskStateToChatSessionStatus', () => { + it('maps each Task API lifecycle state to the right ChatSessionStatus', () => { + const states: readonly AgentTaskState[] = ['queued', 'in_progress', 'idle', 'waiting_for_user', 'completed', 'failed', 'timed_out', 'cancelled']; + const mapped = Object.fromEntries(states.map(state => [state, taskStateToChatSessionStatus(state)])); + + expect(mapped).toEqual({ + queued: vscode.ChatSessionStatus.InProgress, + in_progress: vscode.ChatSessionStatus.InProgress, + // Agent finished its turn / is waiting — must not look like active work. + idle: vscode.ChatSessionStatus.Completed, + waiting_for_user: vscode.ChatSessionStatus.NeedsInput, + completed: vscode.ChatSessionStatus.Completed, + failed: vscode.ChatSessionStatus.Failed, + timed_out: vscode.ChatSessionStatus.Failed, + cancelled: vscode.ChatSessionStatus.Failed, + }); + }); + + it('falls back to InProgress for an unknown/forward-compat state instead of returning undefined', () => { + expect(taskStateToChatSessionStatus('some_new_server_state' as AgentTaskState)).toBe(vscode.ChatSessionStatus.InProgress); + }); }); describe('parseRepoFromTaskUrl', () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts b/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts index d697126dced80..eb26e1d244c41 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { AgentTaskCreatePullRequestResponse, AgentTaskGetResponse, AgentTaskSessionEvent } from '@vscode/copilot-api'; +import { AgentTaskCreatePullRequestResponse, AgentTaskGetResponse, AgentTaskSessionEvent, AgentTaskState } from '@vscode/copilot-api'; import { GithubRepoId } from '../../../platform/git/common/gitService'; import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI'; @@ -90,6 +90,14 @@ export interface CloudSessionData { * (changes come from the PR) and for tasks with no branch. */ readonly diffRefs?: { readonly owner: string; readonly repo: string; readonly baseRef: string; readonly headRef: string }; + + /** + * Raw Task API lifecycle state for Task-backed entries (v2). The provider maps this directly + * to a `ChatSessionStatus` so it can keep the non-terminal "agent handed the turn back" states + * (`idle`, `waiting_for_user`) distinct from active work — they must not render as InProgress. + * Absent for Jobs API (v1) entries, whose status is derived from `latestSession.state`. + */ + readonly taskState?: AgentTaskState; } /** diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index ce5823f104e2a..80f6d98b4954e 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -144,6 +144,64 @@ export function getModelCapabilitiesDescription(endpoint: IChatEndpoint | Langua return undefined; } +/** + * Documentation link surfaced in the Auto model description. + * NOTE: Also defined in src/vs/workbench/contrib/chat/common/languageModels.ts (ILanguageModelChatMetadata.autoModelSelectionDocsUrl) — keep in sync. + */ +const AUTO_MODEL_DOCS_URL = 'https://docs.github.com/en/copilot/concepts/models/auto-model-selection'; + +/** + * Classifies an Auto discount range (given as fractions, e.g. `0.1` for 10%) + * into whole-number percentages. Returns `undefined` when there is no discount + * to show, `{ low }` for a single value, or `{ low, high }` for a range. + */ +function classifyDiscountRange(discountRange?: { low: number; high: number }): { low: number; high?: number } | undefined { + if (!discountRange) { + return undefined; + } + const low = Math.round(discountRange.low * 100); + const high = Math.round(discountRange.high * 100); + if (low === high) { + return low !== 0 ? { low } : undefined; + } + return { low, high }; +} + +/** + * Formats the Auto discount as a short label (e.g. "10% discount" or + * "10% to 20% discount"). Returns `undefined` when there is no discount. + * + * @param discountRange Discount as fractions (e.g. `0.1` for 10%). + */ +export function getAutoModelDiscountLabel(discountRange?: { low: number; high: number }): string | undefined { + const discount = classifyDiscountRange(discountRange); + if (!discount) { + return undefined; + } + return discount.high === undefined + ? l10n.t('{0}% discount', discount.low) + : l10n.t('{0}% to {1}% discount', discount.low, discount.high); +} + +/** + * Builds the shared description shown for the Auto model. The discount sentence + * is only included when a non-zero discount is provided. + * + * @param discountRange Discount as fractions (e.g. `0.1` for 10%). When omitted + * or zero, the discount sentence is left out entirely. + */ +export function getAutoModelDescription(discountRange?: { low: number; high: number }): string { + const base = l10n.t('Auto routes based on your task and real-time system health and model performance.'); + const learnMore = l10n.t('[Learn More]({0})', AUTO_MODEL_DOCS_URL); + const discount = classifyDiscountRange(discountRange); + const discountSentence = !discount + ? undefined + : discount.high === undefined + ? l10n.t('Models routed via auto receive a {0}% discount.', discount.low) + : l10n.t('Models routed via auto receive a {0}% to {1}% discount.', discount.low, discount.high); + return discountSentence ? `${base} ${discountSentence} ${learnMore}` : `${base} ${learnMore}`; +} + function formatAicPrice(price: number): string { if (price === 0) { return '0'; @@ -184,12 +242,19 @@ export function formatPricingLabel(pricing: IChatEndpointTokenPricing): string { } const TOKENS_PER_MILLION = 1_000_000; +const NANO_AIU_DIVISOR = 1_000_000_000; /** - * Raw token prices from the CAPI billing response (API 2026-06-01+, prices in AIUs). + * Raw token prices from the CAPI billing response. Supports both the tiered + * format (API 2026-06-01+, prices in AIUs) and the legacy flat format + * (pre-2026-06-01, prices in nano-AIUs) which is still used by some endpoints + * such as the cloud agents (`/agents/swe/models`) model picker. */ export interface IRawTokenPrices { batch_size?: number; + input_price?: number; + cache_price?: number; + output_price?: number; default?: { input_price?: number; cache_price?: number; cache_write_price?: number; output_price?: number; context_max?: number }; long_context?: { input_price?: number; cache_price?: number; cache_write_price?: number; output_price?: number; context_max?: number }; } @@ -209,6 +274,7 @@ export interface INormalizedTokenPricing { /** * Converts raw billing token prices into normalized AICs (credits) per million tokens. + * Handles both tiered (AIU) and legacy flat (nano-AIU) formats. * * When a `long_context` tier is present but its prices match the `default` tier, * it is omitted from the result. @@ -251,6 +317,16 @@ export function normalizeTokenPrices(tokenPrices: IRawTokenPrices | undefined): return { default: normalized, longContext }; } - // No valid pricing data - return undefined; + // Legacy flat format (pre-2026-06-01): values are in nano-AIUs + if (tokenPrices.input_price === undefined || tokenPrices.output_price === undefined) { + return undefined; + } + return { + default: { + inputPrice: (tokenPrices.input_price / NANO_AIU_DIVISOR) * scale, + outputPrice: (tokenPrices.output_price / NANO_AIU_DIVISOR) * scale, + cachePrice: tokenPrices.cache_price !== undefined ? (tokenPrices.cache_price / NANO_AIU_DIVISOR) * scale : undefined, + cacheWritePrice: undefined, + }, + }; } diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index ea6cf63308622..8d1efcc8df926 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -17,7 +17,7 @@ import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpo import { encodeStatefulMarker } from '../../../platform/endpoint/common/statefulMarkerContainer'; import { AutoChatEndpoint } from '../../../platform/endpoint/node/autoChatEndpoint'; import { IAutomodeService } from '../../../platform/endpoint/node/automodeService'; -import { CopilotChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint'; +import { CopilotChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint'; import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/envService'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IOctoKitService } from '../../../platform/github/common/githubService'; @@ -32,6 +32,7 @@ import { ITelemetryService } from '../../../platform/telemetry/common/telemetry' import { isEncryptedThinkingDelta } from '../../../platform/thinking/common/thinking'; import { BaseTokensPerCompletion } from '../../../platform/tokenizer/node/tokenizer'; import { TelemetryCorrelationId } from '../../../util/common/telemetryCorrelationId'; +import { CancellationTokenSource } from '../../../util/vs/base/common/cancellation'; import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable, MutableDisposable } from '../../../util/vs/base/common/lifecycle'; import { isBoolean, isDefined, isNumber, isString, isStringArray } from '../../../util/vs/base/common/types'; @@ -42,7 +43,7 @@ import { IExtensionContribution } from '../../common/contributions'; import { PromptRenderer } from '../../prompts/node/base/promptRenderer'; import { isImageDataPart } from '../common/languageModelChatMessageHelpers'; import { LanguageModelAccessPrompt } from './languageModelAccessPrompt'; -import { formatPricingLabel, formatTokenCount, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty } from '../common/languageModelAccess'; +import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty } from '../common/languageModelAccess'; /** * Markers in the autoModelHint experiment variable that indicate the auto model @@ -59,11 +60,12 @@ const experimentalAutoModelHintMarkers = ['minimax', 'mp3yn0h7', 'yaqq2gxh']; * model does not support configurable context sizes. * * Driven entirely by CAPI billing metadata: - * - When CAPI returns a `long_context` tier, offers `default.context_max` as - * the default option and `modelMaxPromptTokens` as an opt-in larger option. - * - When the long-context tier has higher prices, the larger option includes a - * cost indicator so the user knows they are opting into higher billing. - * - When there is no `long_context` tier, no selector is shown. + * - When CAPI returns a `long_context` tier with higher prices, offers + * `default.context_max` as the default and `modelMaxPromptTokens` as an + * opt-in larger option so the user knowingly opts into higher billing. + * - When there is no `long_context` tier, or prices match the default tier + * (i.e. `longContext` is absent on the pricing object), no selector is + * shown — the model silently uses the full context window. */ function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; description: string; isDefault: boolean }[] | undefined { const pricing = endpoint.tokenPricing; @@ -84,13 +86,19 @@ function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; descri const hasLongContextSurcharge = !!pricing.longContext; + // When both tiers cost the same, show only the full context window as a + // non-switchable indicator — the user always gets the larger window. + if (!hasLongContextSurcharge) { + return [ + { value: fullMax, description: vscode.l10n.t('Longer sessions'), isDefault: true }, + ]; + } + return [ { value: defaultMax, description: vscode.l10n.t('Default recommended context size'), isDefault: true }, { value: fullMax, - description: hasLongContextSurcharge - ? vscode.l10n.t('Longer sessions') - : vscode.l10n.t('Longer sessions without compaction'), + description: vscode.l10n.t('Longer sessions'), isDefault: false, }, ]; @@ -134,23 +142,6 @@ function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchem const utilityAliasFamilies: readonly ChatEndpointFamily[] = ['copilot-utility-small', 'copilot-utility']; -/** - * Checks whether `endpoint` is the built-in Copilot endpoint for a utility alias. - */ -function isDefaultEndpointForUtilityFamily(family: ChatEndpointFamily, endpoint: IChatEndpoint): boolean { - if (!(endpoint instanceof CopilotChatEndpoint)) { - return false; - } - switch (family) { - case 'copilot-utility-small': - return endpoint.family === CopilotUtilitySmallChatEndpoint.capiFamily; - case 'copilot-utility': - return endpoint.isFallback; - default: - return false; - } -} - /** * Builds the {@link vscode.LanguageModelChatInformation} entry that publishes a * utility-family alias (e.g. `copilot-utility-small`) under the copilot vendor. @@ -222,6 +213,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib private _utilityAliasEndpoints: Map = new Map(); // Overrides resolved outside model-info publication, reused on the next alias publish. private readonly _resolvedUtilityEndpoints = new Map(); + private readonly _utilityOverridesRefresh = this._register(new MutableDisposable()); private _lmWrapper: CopilotLanguageModelWrapper; private _promptBaseCountCache: LanguageModelAccessPromptBaseCountCache; @@ -253,6 +245,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } override dispose(): void { + this._utilityOverridesRefresh.value?.cancel(); super.dispose(); } @@ -276,7 +269,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib this._onDidChange.fire(); })); this._register(this._endpointProvider.onDidModelsRefresh(() => { - // Drop stale overrides; model publication uses defaults until refresh completes. + // Drop stale resolutions; aliases are re-published once the refresh re-resolves them. this._resolvedUtilityEndpoints.clear(); void this._refreshUtilityOverrides(); this._onDidChange.fire(); @@ -324,14 +317,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib if (endpoint.degradationReason) { modelTooltip = endpoint.degradationReason; } else if (endpoint instanceof AutoChatEndpoint) { - const baseAutoTooltip = vscode.l10n.t('Auto selects the best model based on your request complexity and model performance.'); - if (endpoint.discountRange.high === endpoint.discountRange.low && endpoint.discountRange.low !== 0) { - modelTooltip = `${baseAutoTooltip} ${vscode.l10n.t('Model use through Auto is billed at a {0}% discount.', endpoint.discountRange.low * 100)}`; - } else if (endpoint.discountRange.high !== endpoint.discountRange.low) { - modelTooltip = `${baseAutoTooltip} ${vscode.l10n.t('Model use through Auto is billed at a {0}% to {1}% discount.', endpoint.discountRange.low * 100, endpoint.discountRange.high * 100)}`; - } else { - modelTooltip = baseAutoTooltip; - } + modelTooltip = getAutoModelDescription(endpoint.discountRange); const isOrgManaged = !!this._authenticationService.copilotToken?.isManagedPlan; const autoModeHint = this._expService.getTreatmentVariable('copilotchat.autoModelHint'); const showExperimentalHint = !isOrgManaged && !!autoModeHint && experimentalAutoModelHintMarkers.some(marker => autoModeHint.includes(marker)); @@ -349,11 +335,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib let modelDetail: string | undefined; if (endpoint instanceof AutoChatEndpoint) { - if (endpoint.discountRange.high === endpoint.discountRange.low && endpoint.discountRange.low !== 0) { - modelDetail = `${endpoint.discountRange.low * 100}% discount`; - } else if (endpoint.discountRange.high !== endpoint.discountRange.low) { - modelDetail = `${endpoint.discountRange.low * 100}% to ${endpoint.discountRange.high * 100}% discount`; - } + modelDetail = getAutoModelDiscountLabel(endpoint.discountRange); } if (endpoint.customModel) { const customModel = endpoint.customModel; @@ -394,6 +376,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib [ApiChatLocation.Editor]: endpoint instanceof AutoChatEndpoint, // inline chat gets 'Auto' by default }, isUserSelectable: endpoint.showInModelPicker, + warningText: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.warningText, capabilities: { imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision, toolCalling: endpoint.supportsToolCalls, @@ -407,14 +390,18 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib this._currentModels = models; this._chatEndpoints = chatEndpoints; - this._registerUtilityAliasModels(models, allEndpoints); + this._registerUtilityAliasModels(models); return models; } - /** Publishes utility aliases without waiting for override resolution. */ + /** + * Publishes utility aliases from the resolved-endpoint cache. The cache is + * populated asynchronously by {@link _refreshUtilityOverrides} (the single + * gate that decides, per the BYOK/override policy, whether a family resolves + * to a model at all), so a family with no cached endpoint publishes nothing. + */ private _registerUtilityAliasModels( models: vscode.LanguageModelChatInformation[], - allEndpoints: readonly IChatEndpoint[], ): void { this._utilityAliasEndpoints.clear(); const session = this._authenticationService.anyGitHubSession; @@ -422,15 +409,15 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib for (const family of utilityAliasFamilies) { const cached = this._resolvedUtilityEndpoints.get(family); - const endpoint = cached?.endpoint ?? allEndpoints.find(e => isDefaultEndpointForUtilityFamily(family, e)); - if (!endpoint) { + if (!cached) { continue; } + const endpoint = cached.endpoint; this._utilityAliasEndpoints.set(family, endpoint); try { // Copilot defaults clone an existing entry; synthesized override aliases need baseCount. - const aliasInfo = buildUtilityAliasModelInfo(family, endpoint, models, cached?.baseCount ?? 0, requiresAuthorization); + const aliasInfo = buildUtilityAliasModelInfo(family, endpoint, models, cached.baseCount, requiresAuthorization); this._logService.trace(`[LanguageModelAccess] Publishing alias '${family}' -> ${endpoint.model} (${aliasInfo.synthesized ? 'synthesized' : 'cloned'}, ${endpoint instanceof CopilotChatEndpoint ? 'copilot' : 'override'}).`); models.push(aliasInfo.info); } catch (err) { @@ -438,23 +425,44 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } } - // Override resolution may hang, so keep it off the model-info request path. + // Resolution may hang (override lookups, base-count tokenization), so keep it off + // the model-info request path. Newly resolved endpoints are published on the next + // request once `_refreshUtilityOverrides` fires `_onDidChange`. void this._refreshUtilityOverrides().catch(err => { this._logService.warn(`[LanguageModelAccess] Failed to refresh utility overrides: ${err}`); }); } - /** Resolves configured utility model overrides for the next alias publish. */ + /** + * Resolves each utility family through {@link IEndpointProvider.getChatEndpoint}, + * which applies the BYOK/override policy (returning the configured override, the + * built-in Copilot model, or throwing when no model should be used). Successful + * resolutions are cached for {@link _registerUtilityAliasModels} to publish. + */ private async _refreshUtilityOverrides(): Promise { + this._utilityOverridesRefresh.value?.cancel(); + const cancellationSource = new CancellationTokenSource(); + this._utilityOverridesRefresh.value = cancellationSource; + const token = cancellationSource.token; let didChange = false; for (const family of utilityAliasFamilies) { let resolved: IChatEndpoint | undefined; try { resolved = await this._endpointProvider.getChatEndpoint(family); } catch (err) { - this._logService.warn(`[LanguageModelAccess] Failed to resolve utility alias '${family}' in background: ${err}`); + if (token.isCancellationRequested) { + return; + } + // Expected when the policy declines a family (e.g. BYOK main model with no + // configured utility model), so this is not necessarily a failure. The cache + // is cleared on policy changes via `onDidModelsRefresh`, so leaving any prior + // entry intact here only preserves a still-valid alias across transient errors. + this._logService.trace(`[LanguageModelAccess] No utility alias resolved for '${family}' in background: ${err}`); continue; } + if (token.isCancellationRequested) { + return; + } if (!resolved) { continue; } @@ -468,9 +476,15 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib try { baseCount = await this._promptBaseCountCache.getBaseCount(resolved); } catch (err) { + if (token.isCancellationRequested) { + return; + } this._logService.warn(`[LanguageModelAccess] Failed to compute baseCount for utility alias '${family}' -> ${resolved.model}; keeping previously-published alias. Error: ${err}`); continue; } + if (token.isCancellationRequested) { + return; + } this._resolvedUtilityEndpoints.set(family, { endpoint: resolved, baseCount }); didChange = true; } @@ -775,6 +789,15 @@ export class CopilotLanguageModelWrapper extends Disposable { const result = await wrappedRequest(); + if (result.type === ChatFetchResponseType.Length) { + // The model stopped generating because it hit the length/context-window limit + // (finish_reason "length"). The partial text has already been streamed to the + // consumer via the finished callback, so treat this as a successful (truncated) + // response instead of throwing "Response too long." and discarding the output. + this._logService.warn(`[LanguageModelAccess] Response from model '${_endpoint.model}' was truncated because it hit the length limit; returning the partial response.`); + return undefined; + } + if (result.type !== ChatFetchResponseType.Success) { if (result.type === ChatFetchResponseType.ExtensionBlocked) { if (extensionId) { diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index 0beeb61674998..35779997bb377 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -143,6 +143,41 @@ suite('CopilotLanguageModelWrapper', () => { assert.deepStrictEqual(decoded, expectedUsage); }); }); + + suite('length finish reason', () => { + let wrapper: CopilotLanguageModelWrapper; + let endpoint: IChatEndpoint; + let fetcher: MockChatMLFetcher; + setup(async () => { + createAccessor(); + fetcher = accessor.get(IChatMLFetcher) as MockChatMLFetcher; + endpoint = await accessor.get(IEndpointProvider).getChatEndpoint('copilot-utility'); + wrapper = instaService.createInstance(CopilotLanguageModelWrapper); + }); + + test('does not throw when the response is truncated due to length', async () => { + // A model (e.g. a custom/BYOK endpoint) can legitimately stop generating + // because it hit the context window ceiling, returning finish_reason "length" + // together with the partial text it already streamed. This must not surface + // as a hard "Response too long." failure that discards the generated text. + fetcher.setNextResponse({ + type: ChatFetchResponseType.Length, + reason: 'Response too long.', + requestId: 'test-request-id', + serverRequestId: 'test-server-request-id', + truncatedValue: 'partial answer' + }); + + await wrapper.provideLanguageModelResponse( + endpoint, + [vscode.LanguageModelChatMessage.User('hello')], + { requestInitiator: 'unknown', toolMode: vscode.LanguageModelChatToolMode.Auto }, + vscode.extensions.all[0].id, + { report: () => { } }, + CancellationToken.None + ); + }); + }); }); suite('LanguageModelAccess model info', () => { @@ -176,6 +211,7 @@ suite('LanguageModelAccess model info', () => { testingServiceCollection.define(IAutomodeService, { _serviceBrand: undefined, resolveAutoModeEndpoint: async () => endpoint, + consumeLastRoutingDecision: () => undefined, invalidateRouterCache: () => { }, } as unknown as IAutomodeService); testingServiceCollection.define(IEndpointProvider, { @@ -248,6 +284,89 @@ suite('LanguageModelAccess model info', () => { languageModelAccess.dispose(); } }); + + test('does not publish utility aliases when the provider declines to resolve them (BYOK)', async () => { + const testingServiceCollection = createExtensionTestingServices(); + testingServiceCollection.define(IEndpointProvider, { + _serviceBrand: undefined, + onDidModelsRefresh: Event.None, + getAllCompletionModels: async () => [], + getAllChatEndpoints: async () => [], + getChatEndpoint: async () => { throw new Error('No utility model is configured while the selected main model is BYOK.'); }, + getEmbeddingsEndpoint: async () => { throw new Error('Not implemented in test'); }, + } as unknown as IEndpointProvider); + const accessor = testingServiceCollection.createTestingAccessor(); + const languageModelAccess = accessor.get(IInstantiationService).createInstance(LanguageModelAccess); + const internals = languageModelAccess as unknown as { + _resolvedUtilityEndpoints: Map; + _promptBaseCountCache: { getBaseCount(endpoint: IChatEndpoint): Promise }; + _registerUtilityAliasModels(models: vscode.LanguageModelChatInformation[]): void; + _refreshUtilityOverrides(): Promise; + }; + internals._promptBaseCountCache = { getBaseCount: async () => 0 }; + + try { + // The provider gates every utility family, so nothing is resolved or cached. + await internals._refreshUtilityOverrides(); + assert.strictEqual(internals._resolvedUtilityEndpoints.size, 0); + + // With an empty cache, no aliases are published into the model list. + const models: vscode.LanguageModelChatInformation[] = []; + internals._registerUtilityAliasModels(models); + assert.deepStrictEqual(models.map(model => model.id), []); + } finally { + languageModelAccess.dispose(); + } + }); + + test('does not cache a utility endpoint from an obsolete refresh', async () => { + const endpoint = { + model: 'gpt-4o-mini', + modelProvider: 'copilot', + } as IChatEndpoint; + const baseCount = new DeferredPromise(); + const baseCountStarted = new DeferredPromise(); + let endpointRequestCount = 0; + const testingServiceCollection = createExtensionTestingServices(); + testingServiceCollection.define(IEndpointProvider, { + _serviceBrand: undefined, + onDidModelsRefresh: Event.None, + getAllCompletionModels: async () => [], + getAllChatEndpoints: async () => [], + getChatEndpoint: async () => { + if (endpointRequestCount++ === 0) { + return endpoint; + } + throw new Error('No utility model configured'); + }, + getEmbeddingsEndpoint: async () => { throw new Error('Not implemented in test'); }, + } as unknown as IEndpointProvider); + const accessor = testingServiceCollection.createTestingAccessor(); + const languageModelAccess = accessor.get(IInstantiationService).createInstance(LanguageModelAccess); + const internals = languageModelAccess as unknown as { + _resolvedUtilityEndpoints: Map; + _promptBaseCountCache: { getBaseCount(endpoint: IChatEndpoint): Promise }; + _refreshUtilityOverrides(): Promise; + }; + internals._promptBaseCountCache = { + getBaseCount: async () => { + baseCountStarted.complete(); + return baseCount.p; + } + }; + + try { + const obsoleteRefresh = internals._refreshUtilityOverrides(); + await baseCountStarted.p; + const currentRefresh = internals._refreshUtilityOverrides(); + baseCount.complete(0); + await Promise.all([obsoleteRefresh, currentRefresh]); + + assert.strictEqual(internals._resolvedUtilityEndpoints.size, 0); + } finally { + languageModelAccess.dispose(); + } + }); }); suite('buildUtilityAliasModelInfo', () => { @@ -445,6 +564,22 @@ suite('normalizeTokenPrices', () => { assert.ok(result.longContext, 'long-context tier should be included when cache_write_price differs'); assert.strictEqual(result.longContext?.cacheWritePrice, 3); }); + + test('converts legacy flat nano-AIU prices to credits per 1M tokens', () => { + // Shape returned by the cloud agents endpoint (/agents/swe/models) + const result = normalizeTokenPrices({ + batch_size: 1_000_000, + input_price: 500_000_000_000, + output_price: 2_500_000_000_000, + cache_price: 50_000_000_000, + }); + assert.ok(result); + assert.strictEqual(result.default.inputPrice, 500); + assert.strictEqual(result.default.outputPrice, 2500); + assert.strictEqual(result.default.cachePrice, 50); + assert.strictEqual(result.default.cacheWritePrice, undefined); + assert.strictEqual(result.longContext, undefined); + }); }); suite('formatPricingLabel', () => { @@ -464,4 +599,3 @@ suite('formatPricingLabel', () => { assert.strictEqual(formatPricingLabel(tier(3, 15)), 'In: 3 · Out: 15 AICs/1M tokens'); }); }); - diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts index 6d304454c86d9..15ca799eec7c0 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/services.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts @@ -289,6 +289,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio // OTel service — resolve config from env + settings, create appropriate impl const otelSettings = workspace.getConfiguration('github.copilot.chat.otel'); + const policyValue = (key: string): T | undefined => (otelSettings.inspect(key) as { policyValue?: T } | undefined)?.policyValue; const otelConfig = resolveOTelConfig({ env: process.env, settingEnabled: otelSettings.get('enabled'), @@ -298,6 +299,19 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio settingMaxAttributeSizeChars: otelSettings.get('maxAttributeSizeChars'), settingOutfile: otelSettings.get('outfile') || undefined, settingDbSpanExporter: otelSettings.get('dbSpanExporter.enabled'), + settingProtocol: otelSettings.get('protocol') || undefined, + policyEnabled: policyValue('enabled'), + policyExporterType: policyValue<'otlp-grpc' | 'otlp-http' | 'console' | 'file'>('exporterType'), + policyOtlpEndpoint: policyValue('otlpEndpoint'), + policyCaptureContent: policyValue('captureContent'), + policyOutfile: policyValue('outfile'), + policyProtocol: policyValue('protocol'), + settingServiceName: otelSettings.get('serviceName') || undefined, + policyServiceName: policyValue('serviceName'), + settingResourceAttributes: otelSettings.get>('resourceAttributes'), + policyResourceAttributes: policyValue>('resourceAttributes'), + settingHeaders: otelSettings.get>('headers'), + policyHeaders: policyValue>('headers'), extensionVersion: extensionContext.extension.packageJSON.version ?? '0.0.0', sessionId: env.sessionId, }); diff --git a/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts b/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts index 9ee4f792802c3..8bdadf3647536 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts @@ -15,6 +15,48 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { DebugRecorder } from './debugRecorder'; import { NES_GH_TELEMETRY_EVENT_NAME } from './nextEditProviderTelemetry'; +/** + * The continuous-recording payload serialized into the `recording` telemetry + * property by {@link ContinuousEnhancedTelemetrySender._sendNow}. + * + * A continuous slice is a sliding window of recent edit activity; unlike a + * per-request ("alternative action") recording it has **no `requestTime`**. + */ +export interface IContinuousRecording { + /** + * The recorded edit timeline for the window, or `undefined` when the + * serialized entries exceed {@link + * ContinuousEnhancedTelemetrySender.MAX_ENTRIES_CHARS} — only `entriesSize` + * is shipped in that case. + */ + readonly entries: LogEntry[] | undefined; + + /** Size, in UTF-16 code units, of the serialized entries at capture time. */ + readonly entriesSize: number; + + /** + * Window start in epoch milliseconds (recorder clock). Adjacent slices can + * have gaps, so consecutive windows are not necessarily contiguous. + */ + readonly windowStart: number; + + /** Window end in epoch milliseconds (recorder clock). */ + readonly windowEnd: number; + + /** + * Identifies the sender instance. Stable per sender lifetime; a single + * extension session can span several ids (a new sender is created on + * copilot-token change, etc.). + */ + readonly sessionId: string; + + /** + * Slice index, monotonically increasing per `sessionId`. Not necessarily + * contiguous — empty slices are skipped at capture time. + */ + readonly sequenceNumber: number; +} + /** * Periodically sends an enhanced GH telemetry event with a fixed-length slice of recent workspace activity. * @@ -163,7 +205,7 @@ export class ContinuousEnhancedTelemetrySender extends Disposable { const entriesSize = entriesJson.length; const sequenceNumber = this._sequenceNumber++; - const recording = { + const recording: IContinuousRecording = { entries: entriesSize > ContinuousEnhancedTelemetrySender.MAX_ENTRIES_CHARS ? undefined : entries, entriesSize, windowStart, diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts index 3659e0c210f89..bfc774395de64 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts @@ -33,10 +33,51 @@ export interface CachedEditOpts { * the cached entry is not served. */ cursorOffset?: number; + /** + * Zero-based index of the model-emitted patch this edit originated from + * (diff-patch response format). `undefined` for formats without an explicit + * patch structure. @see StreamedEdit.patchIndex + */ + patchIndex?: number; + /** + * Patch indices for the bundled `nextEdits`, aligned by position. Stored on the + * first cached entry so that edits served later via rebase (addressed by + * `rebasedEditIndex` into the bundle) can be attributed to the right model patch. + */ + patchIndices?: readonly (number | undefined)[]; + /** + * The document the cached edit actually applies to, when it differs from the + * document the entry is keyed under (cross-file NES). `undefined` means the edit + * targets the owning/key document (the common same-file case). + * + * Used to cache an `A -> suggestion-in-B` association: the entry is keyed and + * gated against the active document A (content + edit window), but the edit it + * carries lands in document B. + */ + targetDocId?: DocumentId; + /** + * The target document's content at the time the cross-file edit was produced + * (i.e. the content the {@link CachedEdit.edit} offsets index into). Only set + * together with {@link targetDocId}. The read path must serve the edit only when + * the target document's live content still equals this snapshot; otherwise the + * edit's offsets are stale and would resolve against the wrong content. + */ + targetDocumentBeforeEdit?: StringText; } export interface CachedEdit { docId: DocumentId; + /** + * The document the cached edit actually applies to, when it differs from {@link docId} + * (cross-file NES). `undefined` means the edit targets the owning/key document + * {@link docId} (the common same-file case). @see CachedEditOpts.targetDocId + */ + targetDocId?: DocumentId; + /** + * The target document's content at the time the cross-file edit was produced. + * Only set together with {@link targetDocId}. @see CachedEditOpts.targetDocumentBeforeEdit + */ + targetDocumentBeforeEdit?: StringText; documentBeforeEdit: StringText; editWindow?: OffsetRange; /** @@ -56,6 +97,17 @@ export interface CachedEdit { * When caching multiple edits, this is the order in which they were applied. */ subsequentN?: number; + /** + * Zero-based index of the model-emitted patch this edit originated from + * (diff-patch response format). @see StreamedEdit.patchIndex + */ + patchIndex?: number; + /** + * Patch indices for the bundled `edits`, aligned by position. Present on the + * first cached entry (the one carrying the `edits` bundle) so rebased subsequent + * edits, addressed by `rebasedEditIndex`, can be attributed to the right patch. + */ + patchIndices?: readonly (number | undefined)[]; source: NextEditFetchRequest; cacheTime: number; /** @@ -236,7 +288,7 @@ class DocumentEditCache { public setKthNextEdit(documentContents: StringText, editWindow: OffsetRange | undefined, nextEdit: StringReplacement, nextEdits: StringReplacement[] | undefined, userEditSince: StringEdit | undefined, subsequentN: number, source: NextEditFetchRequest, opts: CachedEditOpts): CachedEdit { const key = this._getKey(documentContents.value); - const cachedEdit: CachedEdit = { docId: this.docId, edit: nextEdit, edits: nextEdits, detailedEdits: [], userEditSince, subsequentN, source, documentBeforeEdit: documentContents, editWindow, originalEditWindow: opts.originalEditWindow, cacheTime: Date.now(), isFromCursorJump: opts.isFromCursorJump, cursorOffsetAtCacheTime: opts.cursorOffset }; + const cachedEdit: CachedEdit = { docId: this.docId, targetDocId: opts.targetDocId, targetDocumentBeforeEdit: opts.targetDocumentBeforeEdit, edit: nextEdit, edits: nextEdits, detailedEdits: [], userEditSince, subsequentN, patchIndex: opts.patchIndex, patchIndices: opts.patchIndices, source, documentBeforeEdit: documentContents, editWindow, originalEditWindow: opts.originalEditWindow, cacheTime: Date.now(), isFromCursorJump: opts.isFromCursorJump, cursorOffsetAtCacheTime: opts.cursorOffset }; if (userEditSince) { if (!checkEditConsistency(cachedEdit.documentBeforeEdit.value, userEditSince, this._doc.value.get().value, this._logger.createSubLogger('setKthNextEdit'))) { cachedEdit.userEditSince = undefined; @@ -286,7 +338,10 @@ class DocumentEditCache { // If the cursor moved farther from the edit's start line than it was at cache time, // reject the cached edit so the same suggestion is not shown again. // Only applies to non-rebased, non-subsequent edits. + // Skipped for cross-file entries: their `edit` is in the target document's + // coordinate space, so transforming it against this (active) document is meaningless. if (cacheCursorDistanceCheck + && !cachedEdit.targetDocId && cachedEdit.edit && (cachedEdit.subsequentN === undefined || cachedEdit.subsequentN === 0) && cachedEdit.cursorOffsetAtCacheTime !== undefined diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index e13a6facef618..bdcc6685e8b5f 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -97,6 +97,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt docContents: StringText; editsSoFar: StringEdit; nextEdits: StringReplacement[]; + patchIndices: (number | undefined)[]; docId: DocumentId; }> { const statePerDoc = new CachedFunction((id: DocumentId) => { @@ -111,6 +112,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt docContents: baseDocState, editsSoFar: StringEdit.empty, nextEdits: [] as StringReplacement[], + patchIndices: [] as (number | undefined)[], docId: id, }; } @@ -122,6 +124,7 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt docContents: doc.documentAfterEdits, editsSoFar: StringEdit.empty, nextEdits: [] as StringReplacement[], + patchIndices: [] as (number | undefined)[], docId: id, }; }); @@ -130,6 +133,23 @@ function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xt return statePerDoc; } +/** + * Computes the originating model-patch index for a served edit. In the rebase path + * the served edit is addressed by `rebasedEditIndex` into the entry's bundled + * `patchIndices`; otherwise the entry carries its own `patchIndex`. + * + * Invariant: `rebasedEditIndex` is only ever set for entry 0 (the sole entry given a + * `patchIndices` bundle), so whenever it is defined `patchIndices` is defined too. + * We therefore deliberately do NOT fall back to `patchIndex` in the rebase branch: a + * served bundle slot of `undefined` is a genuine "no originating patch" attribution + * and must not be masked by entry 0's own patch index. + */ +function getSourcePatchIndex(cachedEdit: CachedOrRebasedEdit): number | undefined { + return cachedEdit.rebasedEditIndex !== undefined + ? cachedEdit.patchIndices?.[cachedEdit.rebasedEditIndex] + : cachedEdit.patchIndex; +} + export interface NESInlineCompletionContext extends vscode.InlineCompletionContext { enforceCacheDelay: boolean; changeHint?: NesChangeHint; @@ -321,7 +341,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider suggestion-in-targetDoc` + * association. This lets the cross-file suggestion be re-served from cache while the cursor + * is still in the active document (the regular path only caches it under the target + * document, which is reachable from cache only after the user jumps there). + * + * The entry is stored without `userEditSince`, so it is never tracked/rebased: its edit is + * in the target document's coordinate space and is served by exact content match only. + */ + private _maybeCacheCrossFileEditUnderActiveDoc( + ithEdit: number, + activeDocId: DocumentId, + activeDocContents: StringText, + activeDocEditWindow: OffsetRange | undefined, + targetDocId: DocumentId, + targetDocContents: StringText, + nextEdit: StringReplacement, + streamedEdit: { readonly isFromCursorJump: boolean; readonly originalWindow?: OffsetRange; readonly patchIndex?: number }, + source: NextEditFetchRequest, + ): boolean { + if (ithEdit !== 0 || targetDocId === activeDocId) { + return false; // only the first streamed edit, and only when it targets a different document + } + this._nextEditCache.setKthNextEdit( + activeDocId, + activeDocContents, + activeDocEditWindow, + nextEdit, + 0, + undefined, // no bundled edits: served by exact content match only + undefined, // no userEditSince: never tracked/rebased (edit is in target-doc coords) + source, + { isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, patchIndex: streamedEdit.patchIndex, targetDocId, targetDocumentBeforeEdit: targetDocContents } + ); + return true; + } + private determineNesConfigs(telemetryBuilder: LlmNESTelemetryBuilder, logContext: InlineEditRequestLogContext): INesConfigs { const nesConfigs: INesConfigs = { isAsyncCompletions: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService), @@ -749,7 +838,12 @@ export class NextEditProvider extends Disposable implements INextEditProvider { + // Tracks whether this stream stored a cross-file suggestion under the active document. + // When it did, the active document must NOT be cached as "no edit" at stream end — + // that would clobber the cross-file entry stored under the same key. + let didCacheCrossFileActiveDocEntry = false; + + const processEdit = (streamedEdit: { readonly edit: LineReplacement; readonly isFromCursorJump: boolean; readonly window?: OffsetRange; readonly originalWindow?: OffsetRange; readonly targetDocument?: DocumentId; readonly patchIndex?: number }, telemetry: IStatelessNextEditTelemetry): CachedOrRebasedEdit | undefined => { ++ithEdit; const myLogger = logger.createSubLogger('processEdit'); myLogger.trace(`processing edit #${ithEdit} (starts at 0)`); @@ -784,6 +878,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider { readonly isFromCache: boolean; readonly reusedRequest: ReusedRequestKind | undefined; readonly subsequentEditOrder: number | undefined; + readonly sourcePatchIndex: number | undefined; readonly activeDocumentOriginalLineCount: number | undefined; readonly activeDocumentEditsCount: number | undefined; readonly activeDocumentLanguageId: string | undefined; @@ -244,6 +245,7 @@ export class LlmNESTelemetryBuilder extends Disposable { isFromCache: this._isFromCache, reusedRequest: this._reusedRequest, subsequentEditOrder: this._subsequentEditOrder, + sourcePatchIndex: this._sourcePatchIndex, documentsCount, editsCount, activeDocumentEditsCount, @@ -354,6 +356,12 @@ export class LlmNESTelemetryBuilder extends Disposable { return this; } + private _sourcePatchIndex: number | undefined; + public setSourcePatchIndex(sourcePatchIndex: number | undefined): this { + this._sourcePatchIndex = sourcePatchIndex; + return this; + } + private _request: StatelessNextEditRequest | undefined; public setRequest(request: StatelessNextEditRequest): this { this._request = request; @@ -1006,6 +1014,7 @@ export class TelemetrySender implements IDisposable { isFromCache, reusedRequest, subsequentEditOrder, + sourcePatchIndex, activeDocumentLanguageId, activeDocumentOriginalLineCount, nLinesOfCurrentFileInPrompt, @@ -1107,6 +1116,7 @@ export class TelemetrySender implements IDisposable { "isFromCache": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the edit was provided from cache", "isMeasurement": true }, "reusedRequest": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the result was obtained by joining a pending request ('speculative' or 'async'), undefined for fresh requests and cache hits" }, "subsequentEditOrder": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Order of the subsequent edit", "isMeasurement": true }, + "sourcePatchIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Zero-based index of the model-emitted patch this served edit originated from (diff-patch format). A single model patch can expand into multiple edits that share this index; undefined for formats without explicit patches.", "isMeasurement": true }, "activeDocumentOriginalLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the active document before shortening", "isMeasurement": true }, "activeDocumentNLinesInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the active document included in prompt", "isMeasurement": true }, "wasPreviouslyRejected": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the edit was previously rejected", "isMeasurement": true }, @@ -1206,6 +1216,7 @@ export class TelemetrySender implements IDisposable { nextEditProviderDuration, isFromCache: this._boolToNum(isFromCache), subsequentEditOrder, + sourcePatchIndex, activeDocumentOriginalLineCount, activeDocumentNLinesInPrompt: nLinesOfCurrentFileInPrompt, wasPreviouslyRejected: this._boolToNum(wasPreviouslyRejected), diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts index 5881855dde1ba..9f0a7d82c2d31 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { outdent } from 'outdent'; import { afterAll, assert, beforeAll, describe, expect, it } from 'vitest'; -import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { ConfigKey, ExperimentBasedConfig, ExperimentBasedConfigType, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService'; +import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService'; import { NullGitExtensionService } from '../../../../platform/git/common/nullGitExtensionService'; import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId'; @@ -24,6 +25,7 @@ import { mockNotebookService } from '../../../../platform/test/common/testNotebo import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { Result } from '../../../../util/common/result'; +import { DeferredPromise, timeout } from '../../../../util/vs/base/common/async'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../util/vs/base/common/uri'; @@ -58,7 +60,7 @@ describe('NextEditProvider Caching', () => { afterAll(() => { disposableStore.dispose(); }); - function createStatelessNextEditProvider(): IStatelessNextEditProvider { + function createStatelessNextEditProvider(patchIndices?: readonly (number | undefined)[]): IStatelessNextEditProvider { return { ID: 'TestNextEditProvider', provideNextEdit: async function*(request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) { @@ -83,8 +85,11 @@ describe('NextEditProvider Caching', () => { ) ] ); + let editIndex = 0; for (const edit of lineEdit.replacements) { - yield new WithStatelessProviderTelemetry({ targetDocument: request.getActiveDocument().id, edit, isFromCursorJump: false }, telemetryBuilder.build(Result.ok(undefined))); + const patchIndex = patchIndices ? patchIndices[editIndex] : undefined; + editIndex++; + yield new WithStatelessProviderTelemetry({ targetDocument: request.getActiveDocument().id, edit, isFromCursorJump: false, patchIndex }, telemetryBuilder.build(Result.ok(undefined))); } const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, undefined); return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions))); @@ -331,4 +336,287 @@ describe('NextEditProvider Caching', () => { expect(secondCacheEntry).toBe(firstCacheEntry); expect(secondCacheEntry.wasRenderedAsInlineSuggestion).toBe(true); }); + + it('attributes each served edit to its originating model patch via sourcePatchIndex', async () => { + const obsWorkspace = new MutableObservableWorkspace(); + const obsGit = new ObservableGit(gitExtensionService); + // Edits are served in line order: the z parameter (patch 0), the getDistance + // body (also patch 0, i.e. a split of the same model patch per PR #322438), + // then the variable declaration (patch 1). + const statelessNextEditProvider = createStatelessNextEditProvider([0, 0, 1]); + + const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger); + + const doc = obsWorkspace.addDocument({ + id: DocumentId.create(URI.file('/test/test.ts').toString()), + initialValue: outdent` + class Point { + constructor( + private readonly x: number, + private readonly y: number, + ) { } + getDistance() { + return Math.sqrt(this.x ** 2 + this.y ** 2); + } + } + + const myPoint = new Point(0, 1);`.trimStart() + }); + doc.setSelection([new OffsetRange(1, 1)], undefined); + doc.applyEdit(StringEdit.insert(11, '3D')); + + const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false }; + const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context); + const cancellationToken = CancellationToken.None; + + const servedPatchIndices: (number | undefined)[] = []; + for (let i = 0; i < 3; i++) { + const tb = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + const result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb.nesBuilder); + assert(result.result?.edit, `expected an edit on call ${i + 1}`); + servedPatchIndices.push(tb.nesBuilder.build(false).sourcePatchIndex); + tb.dispose(); + doc.applyEdit(result.result.edit.toEdit()); + } + + // The first (fresh) edit must be attributed too, not just the cached ones; + // the split pair shares patch 0 while the final edit comes from patch 1. + expect(servedPatchIndices).toEqual([0, 0, 1]); + }); + + /** + * Configuration that disables the cross-document cache purge (which otherwise deletes + * cross-file cache entries whenever *any* document is edited, by setting + * `InlineEditsTriggerOnEditorChangeAfterSeconds` to `undefined`). With the purge off, a + * cross-file entry survives an edit to its target document, so the read-path staleness guard + * — rather than the purge — becomes responsible for not serving the now-stale suggestion. + */ + class PurgeDisabledConfigurationService extends InMemoryConfigurationService { + override getExperimentBasedConfig(key: ExperimentBasedConfig, experimentationService: IExperimentationService): T { + if (key === ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds) { + return undefined as T; + } + return super.getExperimentBasedConfig(key, experimentationService); + } + } + + /** + * Stateless provider that yields a single edit targeting a *different* document than the + * active one, and only on its first invocation. Later invocations yield nothing, so any + * subsequently served suggestion can only originate from the cache. + * + * Exposes the number of times it was invoked (to distinguish a cache hit from a fresh + * fetch) and a promise that resolves once the first request's stream has fully ended (so + * tests can re-request only after the no-suggestions stream-end handling has run). + */ + function createCrossFileStatelessProvider(targetDocId: DocumentId, targetEdit: LineReplacement, activeDocWindow?: OffsetRange): { provider: IStatelessNextEditProvider; getCallCount: () => number; whenFirstStreamEnded: Promise } { + let callCount = 0; + const firstStreamEnded = new DeferredPromise(); + const provider: IStatelessNextEditProvider = { + ID: 'TestCrossFileNextEditProvider', + provideNextEdit: async function*(request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) { + const telemetryBuilder = new StatelessNextEditTelemetryBuilder(request.headerRequestId); + callCount++; + const isFirstCall = callCount === 1; + try { + if (isFirstCall) { + yield new WithStatelessProviderTelemetry({ targetDocument: targetDocId, edit: targetEdit, isFromCursorJump: false, window: activeDocWindow, patchIndex: undefined }, telemetryBuilder.build(Result.ok(undefined))); + } + const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, undefined); + return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions))); + } finally { + if (isFirstCall) { + firstStreamEnded.complete(); + } + } + } + }; + return { provider, getCallCount: () => callCount, whenFirstStreamEnded: firstStreamEnded.p }; + } + + async function runCrossFileScenario(options?: { activeDocWindow?: OffsetRange; disposeTargetBeforeSecondRequest?: boolean; mutateTargetBeforeSecondRequest?: boolean; disableEditorChangeTrigger?: boolean }) { + const obsWorkspace = new MutableObservableWorkspace(); + const obsGit = new ObservableGit(gitExtensionService); + + // DocumentId is interned, so we can compute the ids before adding the documents. + const docAId = DocumentId.create(URI.file('/test/a.ts').toString()); + const docBId = DocumentId.create(URI.file('/test/b.ts').toString()); + + // By default the shared (defaults-only) config keeps the cross-document cache purge on. + // Opt into the purge-disabled config to isolate the read-path staleness guard. + const scenarioConfigService = options?.disableEditorChangeTrigger ? new PurgeDisabledConfigurationService(new DefaultsOnlyConfigurationService()) : configService; + + // Suggestion (for the non-active document B) replacing its `return 1;` line. + const targetEdit = new LineReplacement(new LineRange(2, 3), ['\treturn 42;']); + const { provider: statelessNextEditProvider, getCallCount, whenFirstStreamEnded } = createCrossFileStatelessProvider(docBId, targetEdit, options?.activeDocWindow); + + const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, scenarioConfigService, expService), undefined, scenarioConfigService, snippyService, logService, expService, requestLogger); + + const docB = obsWorkspace.addDocument({ id: docBId, initialValue: ['export function helper() {', '\treturn 1;', '}'].join('\n') }); + const docA = obsWorkspace.addDocument({ id: docAId, initialValue: ['class Point {', '\tconstructor(', '\t\tprivate readonly x: number,', '\t) { }', '}'].join('\n') }); + + docB.setSelection([new OffsetRange(0, 0)], undefined); + // The active document's cursor sits at offset 1 — relevant to the edit-window gating tests. + docA.setSelection([new OffsetRange(1, 1)], undefined); + + // Give document B a recent edit so it is part of the active document's history + // context (required for the cross-file edit to be processed against it). Append at + // the end so B's `return 1;` line stays put. + docB.applyEdit(StringEdit.insert(docB.value.get().value.length, '\n// touched')); + // Edit document A so it is the active document and has history ("Point" -> "Point3D"). + docA.applyEdit(StringEdit.insert(11, '3D')); + + const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false }; + const logContext = new InlineEditRequestLogContext(docA.id.toString(), 1, context); + const cancellationToken = CancellationToken.None; + + const tb1 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, docA); + const first = await nextEditProvider.getNextEdit(docA.id, context, logContext, cancellationToken, tb1.nesBuilder); + tb1.dispose(); + + // Wait until the first request's stream has fully ended before re-requesting. The + // cross-file edit is processed synchronously (so `first` already has it), but the + // terminal no-suggestions handling — which clears the pending request and would clobber + // the active-doc cache entry via `setNoNextEdit` if not guarded — runs afterwards in the + // background. Draining it here makes the second request deterministically observe the + // post-stream-end cache state (cache hit vs. fresh fetch), so these tests are not vacuous. + await whenFirstStreamEnded; + await timeout(0); + + // Optionally close the target document B before re-requesting, so the cached cross-file + // entry can no longer be resolved against live content. + if (options?.disposeTargetBeforeSecondRequest) { + docB.dispose(); + } + // Optionally mutate the target document B so its live content no longer matches the + // snapshot the cross-file edit was produced against, making the cached edit stale. + if (options?.mutateTargetBeforeSecondRequest) { + docB.applyEdit(StringEdit.insert(0, '// header\n')); + } + + // Second request with no change to the active document A: the provider is now silent, + // so any served suggestion must come from the cache. + const tb2 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, docA); + const second = await nextEditProvider.getNextEdit(docA.id, context, logContext, cancellationToken, tb2.nesBuilder); + tb2.dispose(); + + // Tear down the provider (which registers autoruns/watchers on `openDocuments` in its + // constructor) and the documents so each scenario run is self-contained and does not + // accumulate observers across the cross-file tests. Dispose the provider first so its + // autoruns no longer react to the documents being removed; document disposal is + // idempotent, so re-disposing `docB` after `disposeTargetBeforeSecondRequest` is safe. + nextEditProvider.dispose(); + docA.dispose(); + docB.dispose(); + + return { first, second, docBId, getCallCount }; + } + + it('re-serves a cross-file suggestion from cache while the cursor stays in the active document (surviving the no-suggestions stream end)', async () => { + const { first, second, docBId, getCallCount } = await runCrossFileScenario(); + + // Fresh: the provider produced a suggestion that targets the other document. + assert(first.result?.edit, 'expected a cross-file edit on the first request'); + expect(first.result.targetDocumentId).toBe(docBId); + + // Cached: re-served from the entry keyed under the active document, even though the + // first request's stream ended with no suggestions *for the active document* (which must + // not clobber the cross-file entry) and the provider no longer yields anything. It is the + // very same suggestion (and target), served from cache without re-invoking the provider. + assert(second.result?.edit, 'expected the cross-file edit to be re-served from cache'); + expect(second.result.targetDocumentId).toBe(docBId); + expect(second.result.edit).toBe(first.result.edit); + expect(getCallCount()).toBe(1); + }); + + it('re-serves a cross-file suggestion from cache when the cursor is within the cached edit window', async () => { + // The active document's cursor is at offset 1, which falls inside [0, 14). + const { first, second, docBId, getCallCount } = await runCrossFileScenario({ activeDocWindow: new OffsetRange(0, 14) }); + + assert(first.result?.edit, 'expected a cross-file edit on the first request'); + expect(first.result.targetDocumentId).toBe(docBId); + + assert(second.result?.edit, 'expected the cross-file edit to be re-served from cache'); + expect(second.result.targetDocumentId).toBe(docBId); + expect(second.result.edit).toBe(first.result.edit); + expect(getCallCount()).toBe(1); + }); + + it('does not re-serve a cross-file suggestion from cache when the cursor is outside the cached edit window', async () => { + // The active document's cursor is at offset 1, which is outside [20, 30). Window gating + // only applies to cache hits, so the fresh suggestion is unaffected. + const { first, second, docBId, getCallCount } = await runCrossFileScenario({ activeDocWindow: new OffsetRange(20, 30) }); + + assert(first.result?.edit, 'expected a cross-file edit on the first request'); + expect(first.result.targetDocumentId).toBe(docBId); + + // The cached entry is gated out by the edit window, so the second request misses the + // cache and falls through to a fresh fetch (a second provider call), which is silent. + expect(second.result?.edit).toBeUndefined(); + expect(getCallCount()).toBe(2); + }); + + it('refetches instead of serving a stale cross-file suggestion when the target document is no longer open', async () => { + // Same setup as the happy path, but the target document B is closed before the second + // request. The only difference from the happy path (which serves from cache with a single + // provider call) is the closed target, so a second provider call proves the closed-target + // validity check turned the cache hit into a miss rather than serving an unplaceable edit. + const { first, second, docBId, getCallCount } = await runCrossFileScenario({ disposeTargetBeforeSecondRequest: true }); + + assert(first.result?.edit, 'expected a cross-file edit on the first request'); + expect(first.result.targetDocumentId).toBe(docBId); + + // The cross-file entry is found but its target document is closed, so it cannot be + // resolved against live content; the read path treats it as a cache miss and refetches + // (a second, silent provider call) instead of getting stuck re-serving the dead entry. + expect(second.result?.edit).toBeUndefined(); + expect(getCallCount()).toBe(2); + }); + + it('re-serves a cross-file suggestion (with the purge disabled) while the target document is unchanged', async () => { + // Control for the staleness test below: with the cross-document purge disabled, the + // cross-file entry survives into the second request, and while the target document is + // unchanged it is served straight from cache (a single provider call). This isolates the + // staleness guard — any refetch in the sibling test must come from the content change, + // not from the entry being absent. + const { first, second, docBId, getCallCount } = await runCrossFileScenario({ disableEditorChangeTrigger: true }); + + assert(first.result?.edit, 'expected a cross-file edit on the first request'); + expect(first.result.targetDocumentId).toBe(docBId); + + assert(second.result?.edit, 'expected the cross-file edit to be re-served from cache'); + expect(second.result.targetDocumentId).toBe(docBId); + expect(second.result.edit).toBe(first.result.edit); + expect(getCallCount()).toBe(1); + }); + + it('refetches instead of serving a stale cross-file suggestion when the target document changed since it was produced', async () => { + // Disable the cross-document purge so the cross-file entry survives the edit to B; the + // read-path staleness guard is then the only thing standing between a changed target and a + // misplaced suggestion. Paired with the unchanged-target control above, the extra provider + // call here is attributable to the content change rather than to a missing entry. + const { first, second, docBId, getCallCount } = await runCrossFileScenario({ mutateTargetBeforeSecondRequest: true, disableEditorChangeTrigger: true }); + + assert(first.result?.edit, 'expected a cross-file edit on the first request'); + expect(first.result.targetDocumentId).toBe(docBId); + + // The target document changed after the suggestion was produced, so the cached edit's + // offsets are stale and would land at the wrong location. The read path treats the hit as + // a cache miss and refetches (a second provider call) rather than serving a misplaced edit. + expect(second.result?.edit).toBeUndefined(); + expect(getCallCount()).toBe(2); + }); + + it('does not serve a stale cross-file suggestion when the target document changed under the default (purge-enabled) configuration', async () => { + // In the default configuration, editing the target document B purges every cross-document + // cache entry (including the active-document-keyed cross-file entry) before the next + // request, so the stale suggestion is never re-served and the provider is consulted again. + const { first, second, docBId, getCallCount } = await runCrossFileScenario({ mutateTargetBeforeSecondRequest: true }); + + assert(first.result?.edit, 'expected a cross-file edit on the first request'); + expect(first.result.targetDocumentId).toBe(docBId); + + expect(second.result?.edit).toBeUndefined(); + expect(getCallCount()).toBe(2); + }); }); diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 3d1311851f272..b4446bb114a8e 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -41,7 +41,7 @@ import { Iterable } from '../../../util/vs/base/common/iterator'; import { DisposableMap, DisposableStore } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseProgressPart2 } from '../../../vscodeTypes'; +import { ChatResponseAutoModeResolutionPart, ChatResponseProgressPart2 } from '../../../vscodeTypes'; import { ICommandService } from '../../commands/node/commandService'; import { Intent } from '../../common/constants'; import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection'; @@ -96,16 +96,30 @@ function isResponsesCompactionContextManagementEnabled(endpoint: IChatEndpoint, * applied on the `vscode.lm` path in `languageModelAccess.ts`. * * Only clamps when the selection is strictly smaller than the model window so - * the full tier ("Longer sessions without compaction") stays uncompacted. + * the full tier ("Longer sessions") stays uncompacted. + * + * When no explicit selection is present and the model has a long-context + * surcharge, falls back to the model's default context-max tier + * (`tokenPricing.default.contextMax`). When both tiers cost the same (no + * `longContext` pricing tier), skips the fallback and uses the full native + * window — users get long context for free. * * @internal - exported for testing */ export function applyContextSizeOverride(endpoint: IChatEndpoint, request: vscode.ChatRequest): IChatEndpoint { const contextSize = request.modelConfiguration?.contextSize; - // Guard against non-positive / non-finite selections (e.g. 0, -1, NaN, Infinity): - // a non-positive token budget would produce an invalid endpoint configuration. - if (typeof contextSize === 'number' && Number.isFinite(contextSize) && contextSize > 0 && contextSize < endpoint.modelMaxPromptTokens) { - return endpoint.cloneWithTokenOverride(contextSize); + // Use the explicit selection when valid, otherwise fall back to the default + // context-max tier. Guard against non-positive / non-finite selections + // (e.g. 0, -1, NaN, Infinity): a non-positive token budget would produce an + // invalid endpoint configuration. + // When both tiers cost the same (no longContext pricing tier), skip the + // fallback and use the full model window — users get long context for free. + const hasLongContextSurcharge = !!endpoint.tokenPricing?.longContext; + const effectiveSize = (typeof contextSize === 'number' && Number.isFinite(contextSize) && contextSize > 0) + ? contextSize + : hasLongContextSurcharge ? endpoint.tokenPricing?.default.contextMax : undefined; + if (typeof effectiveSize === 'number' && effectiveSize > 0 && effectiveSize < endpoint.modelMaxPromptTokens) { + return endpoint.cloneWithTokenOverride(effectiveSize); } return endpoint; } @@ -231,12 +245,16 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. allowTools[ToolName.CoreRunTest] = await testService.hasAnyTests(); allowTools[ToolName.CoreRunTask] = tasksService.getTasks().length > 0; - // The specialized subagents must only run when - // the main agent is on CAPI. + // The specialized subagents and semantic search only work when the main + // agent is on CAPI. semantic_search relies on embeddings that require a + // Copilot token source, so on BYOK / custom endpoints it can abort the chat + // turn (e.g. when the GitHub auth provider is unavailable). Keep it off + // there. See https://github.com/microsoft/vscode/issues/322525. if (!isCAPIEndpoint(model)) { allowTools[ToolName.SearchSubagent] = false; allowTools[ToolName.ExploreSubagent] = false; allowTools[ToolName.ExecutionSubagent] = false; + allowTools[ToolName.Codebase] = false; } else { const searchSubagentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, experimentationService); const exploreAgentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, experimentationService); @@ -446,6 +464,12 @@ export class AgentIntent extends EditCodeIntent { return this.handleSummarizeCommand(conversation, request, stream, token); } + // Report auto-mode routing decision if one was made during endpoint resolution + const routingDecision = this._automodeService.consumeLastRoutingDecision(); + if (routingDecision) { + stream.push(new ChatResponseAutoModeResolutionPart(routingDecision.resolvedModel, routingDecision.resolvedModelName, routingDecision.predictedLabel, routingDecision.confidence)); + } + try { return await super.handleRequest(conversation, request, stream, token, documentContext, agentName, location, chatTelemetry, yieldRequested); } finally { diff --git a/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts b/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts index 0bdcf1ff53f1f..364a9ee17478d 100644 --- a/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts +++ b/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts @@ -9,10 +9,11 @@ import { IChatEndpoint } from '../../../../platform/networking/common/networking import { applyContextSizeOverride } from '../agentIntent'; describe('applyContextSizeOverride', () => { - function createEndpoint(modelMaxPromptTokens: number): { endpoint: IChatEndpoint; clonedWith: number[] } { + function createEndpoint(modelMaxPromptTokens: number, defaultContextMax?: number, longContext?: { inputPrice: number }): { endpoint: IChatEndpoint; clonedWith: number[] } { const clonedWith: number[] = []; const endpoint = { modelMaxPromptTokens, + tokenPricing: defaultContextMax === undefined ? undefined : { default: { contextMax: defaultContextMax }, longContext }, cloneWithTokenOverride(tokens: number): IChatEndpoint { clonedWith.push(tokens); return createEndpoint(tokens).endpoint; @@ -39,13 +40,39 @@ describe('applyContextSizeOverride', () => { expect(clonedWith).toEqual([]); }); - test('does not clamp when context size is unset or non-numeric', () => { + test('does not clamp when context size is unset or non-numeric and the model has no default tier', () => { const { endpoint, clonedWith } = createEndpoint(400_000); expect(applyContextSizeOverride(endpoint, createRequest(undefined))).toBe(endpoint); expect(applyContextSizeOverride(endpoint, createRequest('big'))).toBe(endpoint); expect(clonedWith).toEqual([]); }); + test('falls back to the default context-max tier when no selection is present and there is a surcharge', () => { + const { endpoint, clonedWith } = createEndpoint(1_000_000, 200_000, { inputPrice: 6 }); + expect(applyContextSizeOverride(endpoint, createRequest(undefined)).modelMaxPromptTokens).toBe(200_000); + expect(applyContextSizeOverride(endpoint, createRequest('big')).modelMaxPromptTokens).toBe(200_000); + expect(clonedWith).toEqual([200_000, 200_000]); + }); + + test('an explicit selection wins over the default tier fallback', () => { + const { endpoint, clonedWith } = createEndpoint(1_000_000, 200_000, { inputPrice: 6 }); + expect(applyContextSizeOverride(endpoint, createRequest(500_000)).modelMaxPromptTokens).toBe(500_000); + expect(clonedWith).toEqual([500_000]); + }); + + test('does not clamp when long context has no surcharge (same price)', () => { + const { endpoint, clonedWith } = createEndpoint(1_000_000, 200_000); + expect(applyContextSizeOverride(endpoint, createRequest(undefined))).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest('big'))).toBe(endpoint); + expect(clonedWith).toEqual([]); + }); + + test('does not clamp when the default tier equals or exceeds the model window', () => { + const { endpoint, clonedWith } = createEndpoint(200_000, 200_000); + expect(applyContextSizeOverride(endpoint, createRequest(undefined))).toBe(endpoint); + expect(clonedWith).toEqual([]); + }); + test('does not clamp for non-positive or non-finite selections', () => { const { endpoint, clonedWith } = createEndpoint(400_000); expect(applyContextSizeOverride(endpoint, createRequest(0))).toBe(endpoint); diff --git a/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts b/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts index 79d5667e7bb71..8fa3ddb0fbd0a 100644 --- a/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts +++ b/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts @@ -109,4 +109,19 @@ describe('getAgentTools search subagent gating', () => { expect(hasTool(tools, ToolName.SearchSubagent)).toBe(false); expect(hasTool(tools, ToolName.ExploreSubagent)).toBe(false); }); + + test('exposes semantic_search when the model is a CAPI endpoint', async () => { + const request = new TestChatRequest('how does foo work'); + const tools = await instantiationService.invokeFunction(getAgentTools, request, userEndpoint); + expect(hasTool(tools, ToolName.Codebase)).toBe(true); + }); + + test('hides semantic_search when the model is a BYOK / custom endpoint', async () => { + // A BYOK / custom endpoint is identified by a string URL rather than CAPI request metadata. + const byokEndpoint = instantiationService.createInstance(MockEndpoint, 'gpt-5'); + (byokEndpoint as { urlOrRequestMetadata: string }).urlOrRequestMetadata = 'https://localhost:8080/v1/chat/completions'; + const request = new TestChatRequest('how does foo work'); + const tools = await instantiationService.invokeFunction(getAgentTools, request, byokEndpoint); + expect(hasTool(tools, ToolName.Codebase)).toBe(false); + }); }); diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index c84474b37e0f2..6ad7bb02c332b 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -308,8 +308,10 @@ export abstract class ToolCallingLoop; /** - * The context window widget in chat input should represent only the parent request. - * Subagent usage must stay isolated to avoid inflating the parent widget. + * The context window widget in chat input should represent only the parent + * request, so a subagent's token counts must stay isolated to avoid inflating + * the parent widget. Subagents still report their running credit (AIC) total + * separately (without token counts) so the subagent tool can show its own cost. */ private shouldReportUsageToContextWidget(): boolean { return !this.options.request.subAgentInvocationId; @@ -1651,7 +1653,7 @@ export abstract class ToolCallingLoop { expect(stream.usages).toHaveLength(0); }); + it('reports credits-only usage for subagent requests', async () => { + const request = createMockChatRequest({ + subAgentInvocationId: 'subagent-credits-test', + subAgentName: 'search' + }); + const loop = instantiationService.createInstance( + CreditsTestToolCallingLoop, + { + conversation: createConversation(request.prompt), + toolCallLimit: 1, + request, + } + ); + disposables.add(loop); + const stream = new UsageCapturingStream(); + + await loop.runOne(stream, 0, tokenSource.token); + + // Subagents report their running credit total with no token counts so the + // subagent tool can surface its own cost without inflating the parent widget. + expect(stream.usages).toEqual([{ promptTokens: 0, completionTokens: 0, copilotCredits: 5 }]); + }); + it('accumulates copilot credits across iterations within a turn', async () => { const request = createMockChatRequest(); const loop = instantiationService.createInstance( diff --git a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts index 0c654168a5202..627b9e366120e 100644 --- a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +++ b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts @@ -471,6 +471,38 @@ function getProxyEnvVariables() { return res.length ? `\n\nEnvironment Variables:${res.join('')}` : ''; } +/** + * Resolves the proxy type for a URL using the bundled `@vscode/proxy-agent` module. + * Returns one of `DIRECT`, `PROXY`, `HTTPS`, `SOCKS` or `UNKNOWN`. + */ +async function resolveProxyType(url: string, logService: ILogService): Promise { + try { + const proxyAgent = loadVSCodeModule('@vscode/proxy-agent'); + if (!proxyAgent?.resolveProxyURL) { + return 'UNKNOWN'; + } + const proxyURL = await Promise.race([proxyAgent.resolveProxyURL(url), timeoutAfter(5000)]); + if (proxyURL === 'timeout') { + return 'UNKNOWN'; + } + if (!proxyURL) { + return 'DIRECT'; + } + const scheme = proxyURL.split(':', 1)[0].toLowerCase(); + switch (scheme) { + case 'http': return 'PROXY'; + case 'https': return 'HTTPS'; + case 'socks': + case 'socks4': + case 'socks5': return 'SOCKS'; + default: return 'UNKNOWN'; + } + } catch (err) { + logService.debug(`Fetcher telemetry: Failed to resolve proxy type: ${err?.message}`); + return 'UNKNOWN'; + } +} + export class FetcherTelemetryContribution { constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -485,6 +517,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { const logService = accessor.get(ILogService); const configurationService = accessor.get(IConfigurationService); const expService = accessor.get(IExperimentationService); + const capiClientService = accessor.get(ICAPIClientService); if (!vscode.env.isTelemetryEnabled || extensionContext.extensionMode !== vscode.ExtensionMode.Production || isScenarioAutomation) { return; @@ -539,6 +572,9 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { } } + // Resolve the proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTPS, SOCKS). + const proxyType = await resolveProxyType(capiClientService.capiPingURL, logService); + // Second loop: send the actual telemetry event including probe results. const requestGroupId = generateUuid(); const extensionKind = extensionContext.extension.extensionKind === vscode.ExtensionKind.UI ? 'local' : 'remote'; @@ -553,6 +589,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { "clientLibrary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher library used for this request." }, "extensionKind": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the extension runs locally or remotely." }, "remoteName": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The remote name, if any." }, + "proxyType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The resolved proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTPS, SOCKS, UNKNOWN)." }, "electronfetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the electron-fetch fetcher." }, "nodefetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-fetch fetcher." }, "nodehttp": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-http fetcher." } @@ -563,6 +600,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { clientLibrary: fetcher.getUserAgentLibrary(), extensionKind, remoteName: vscode.env.remoteName ?? 'none', + proxyType, ...probeResults, }; const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties); diff --git a/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts b/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts index af5d0560bd019..1b6f6d21d7f6b 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts @@ -53,11 +53,14 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP this._onDidModelsRefresh.fire(); })); - // When the user changes their utility model overrides we need to invalidate any - // previously-resolved utility alias endpoints so the next request re-resolves. + // Utility model configuration changes invalidate previously resolved aliases. this._register(this._configService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ProductionEndpointProvider.UTILITY_MODEL_CONFIG_KEY) || e.affectsConfiguration(ProductionEndpointProvider.UTILITY_SMALL_MODEL_CONFIG_KEY)) { - this._logService.trace(`[ProductionEndpointProvider] Utility model override changed; invalidating alias endpoints.`); + if ( + e.affectsConfiguration(ProductionEndpointProvider.UTILITY_MODEL_CONFIG_KEY) + || e.affectsConfiguration(ProductionEndpointProvider.UTILITY_SMALL_MODEL_CONFIG_KEY) + || e.affectsConfiguration(ProductionEndpointProvider.USE_COPILOT_MODELS_FOR_UTILITY_MODELS_CONFIG_KEY) + ) { + this._logService.trace(`[ProductionEndpointProvider] Utility model configuration changed; invalidating alias endpoints.`); // Clear telemetry fingerprints so a re-applied override emits // once for its new value. this._lastOverrideTelemetryFingerprint.clear(); @@ -76,6 +79,8 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP // `vscode.lm.selectChatModels({ vendor, id })`. private static readonly UTILITY_MODEL_CONFIG_KEY = 'chat.utilityModel'; private static readonly UTILITY_SMALL_MODEL_CONFIG_KEY = 'chat.utilitySmallModel'; + private static readonly USE_COPILOT_MODELS_FOR_UTILITY_MODELS_CONFIG_KEY = 'chat.useCopilotModelsForUtilityModels'; + private _mainModelIsBYOK = false; /** * Per-family marker recording that we already emitted a telemetry event @@ -108,6 +113,15 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP return this.getChatEndpoint('copilot-utility'); } + if (model.id !== 'copilot-utility' && model.id !== 'copilot-utility-small') { + const mainModelIsBYOK = model.vendor !== 'copilot'; + if (this._mainModelIsBYOK !== mainModelIsBYOK) { + this._mainModelIsBYOK = mainModelIsBYOK; + this._lastOverrideTelemetryFingerprint.clear(); + this._onDidModelsRefresh.fire(); + } + } + if (model.vendor !== 'copilot') { return this._instantiationService.createInstance(ExtensionContributedChatEndpoint, model); } @@ -156,22 +170,34 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP * `copilot-utility`) to a concrete `CopilotChatEndpoint`. The model * selection for each family lives in the corresponding resolver * class so callers don't need to know which CAPI family backs each - * purpose. For any other string, falls through to a direct CAPI - * family lookup so callers can resolve arbitrary CAPI-registered - * model families (e.g. `trajectory-compaction`) by name. + * purpose. */ - private async _resolveUtilityFamily(family: ChatEndpointFamily): Promise { + private async _resolveUtilityFamily(family: 'copilot-utility' | 'copilot-utility-small'): Promise { const override = await this._resolveUtilityOverride(family); if (override) { return override; } - if (family === 'copilot-utility-small') { - return CopilotUtilitySmallChatEndpoint.resolve(this._modelFetcher, this._instantiationService); - } else if (family === 'copilot-utility') { - return CopilotUtilityChatEndpoint.resolve(this._modelFetcher, this._instantiationService); + + if (!this._useCopilotModelsForUtilityModelsByDefault()) { + throw new Error(`No utility model is configured for '${family}' while the selected main model is BYOK.`); } - const modelMetadata = await this._modelFetcher.getChatModelFromCapiFamily(family); - return this.getOrCreateChatEndpointInstance(modelMetadata); + + switch (family) { + case 'copilot-utility-small': + return CopilotUtilitySmallChatEndpoint.resolve(this._modelFetcher, this._instantiationService); + case 'copilot-utility': + return CopilotUtilityChatEndpoint.resolve(this._modelFetcher, this._instantiationService); + } + } + + /** + * Whether an unset utility model should resolve to a built-in GitHub Copilot + * model. `true` when the selected main model is itself a Copilot model, or + * when the user opted in via {@link USE_COPILOT_MODELS_FOR_UTILITY_MODELS_CONFIG_KEY}. + */ + private _useCopilotModelsForUtilityModelsByDefault(): boolean { + return !this._mainModelIsBYOK + || this._configService.getNonExtensionConfig(ProductionEndpointProvider.USE_COPILOT_MODELS_FOR_UTILITY_MODELS_CONFIG_KEY) === true; } /** diff --git a/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts b/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts index 098d8460fe8f2..9ba16b04ad95b 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts @@ -6,6 +6,7 @@ import './anthropicPrompts'; import './familyHPrompts'; import './geminiPrompts'; +import './kimiPrompts'; import './minimaxPrompts'; import './vscModelPrompts'; // vscModelPrompts must be imported before gpt5Prompt to ensure VSC model prompt resolvers are registered first. @@ -16,9 +17,8 @@ import './openai/gpt52Prompt'; import './openai/gpt53CodexPrompt'; import './openai/gpt54Prompt'; import './openai/gpt55Prompt'; +import './openai/gpt56Prompt'; import './openai/gpt5CodexPrompt'; import './openai/gpt5Prompt'; -import './openai/hiddenModelMPrompt'; import './xAIPrompts'; import './zaiPrompts'; - diff --git a/extensions/copilot/src/extension/prompts/node/agent/kimiPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/kimiPrompts.tsx new file mode 100644 index 0000000000000..30725b77ccff3 --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/kimiPrompts.tsx @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; +import { isKimiFamily } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { IChatEndpoint } from '../../../../platform/networking/common/networking'; +import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; +import { InstructionMessage } from '../base/instructionMessage'; +import { ResponseTranslationRules } from '../base/responseTranslationRules'; +import { Tag } from '../base/tag'; +import { EXISTING_CODE_MARKER } from '../panel/codeBlockFormattingRules'; +import { ResponseRenderingRules } from '../panel/editorIntegrationRules'; +import { ApplyPatchInstructions, CodesearchModeInstructions, DefaultAgentPromptProps, DefaultReminderInstructions, detectToolCapabilities, GenericEditingTips, McpToolInstructions, NotebookInstructions } from './defaultAgentInstructions'; +import { FileLinkificationInstructions } from './fileLinkificationInstructions'; +import { IAgentPrompt, PromptRegistry, ReminderInstructionsConstructor, SystemPrompt } from './promptRegistry'; + +class KimiAgentPrompt extends PromptElement { + async render(state: void, sizing: PromptSizing) { + const tools = detectToolCapabilities(this.props.availableTools); + + return + + You are an expert AI programming assistant, working with a user in the VS Code editor. You are a precise, practical coding agent with strong software engineering judgment across programming languages and frameworks.
+ Follow the user's requirements carefully and use the provided workspace context, attachments, and tool results as reference material. If the answer is not supported by the available context, gather more context before acting or state the limitation clearly. +
+ + + Use clear, step-by-step task execution:
+ - For simple questions or code samples, answer directly without unnecessary tool calls.
+ - For codebase questions, gather the smallest sufficient set of relevant context, then answer with concrete references.
+ - For implementation tasks, identify the controlling code path, make focused changes, and validate with the most relevant available checks.
+ - For feature requests without specified files, break the request into concepts and find the files responsible for those concepts before editing.
+ - Do not guess about APIs, file paths, or project conventions. Verify them using context or tools. +
+ + + Avoid excessive looping or repetition:
+ - If you find yourself running similar commands or re-editing the same files without clear progress, stop and reassess rather than continuing to loop.
+ - If an action fails or does not work as expected, do not retry it unchanged. Understand why it failed, then try a different approach.
+ - Never call the same tool with the same arguments more than twice in a row.
+ - If you are stuck or no longer making progress, end the turn with a concise summary of what you tried, what is blocked, and any clarifying question needed. +
+ + + Important: Use built-in tools instead of terminal commands whenever possible.
+ {tools[ToolName.ReadFile] && <>- Use {ToolName.ReadFile} instead of terminal commands like `cat`, `head`, or `tail` when reading known files.
} + {tools[ToolName.FindTextInFiles] && <>- Use {ToolName.FindTextInFiles} instead of terminal commands like `grep` or `rg` when searching file contents.
} + {tools[ToolName.FindFiles] && <>- Use {ToolName.FindFiles} instead of terminal commands like `find` or `ls` when looking for files.
} + {tools.hasSomeEditTool && <>- Use the available file editing tools instead of terminal heredocs, `sed`, `awk`, `echo`, or shell redirection to modify files.
} + {tools[ToolName.CoreRunInTerminal] && <>- Use {ToolName.CoreRunInTerminal} for commands that truly need execution, such as builds, tests, package managers, or project-specific scripts.
} +
+ + + You will be given context and attachments along with the user prompt. Use relevant context and ignore irrelevant context.{tools[ToolName.ReadFile] && <> Some attachments may be summarized with omitted sections like `/* Lines 123-456 omitted */`. Use {ToolName.ReadFile} to read more context if needed. Never pass this omitted line marker to an edit tool.}
+ If you can infer the project type (languages, frameworks, and libraries) from the user's query or the context, keep it in mind when making changes.
+ When reading files, prefer reading large meaningful chunks rather than consecutive small sections to minimize tool calls and gain better context.
+ You do not need to read a file if it is already provided in context. +
+ + + When using a tool, follow the JSON schema carefully and include all required properties.
+ No need to ask permission before using a tool.
+ NEVER say the name of a tool to a user. For example, instead of saying that you'll use the {ToolName.CoreRunInTerminal} tool, say "I'll run the command in a terminal".
+ If multiple independent tool calls can answer the user's question, prefer calling them in parallel whenever possible{tools[ToolName.Codebase] && <>, but do not call {ToolName.Codebase} in parallel}.
+ {(tools[ToolName.SearchSubagent] || tools[ToolName.ExploreSubagent]) && <>For efficient codebase exploration, prefer {tools[ToolName.SearchSubagent] ? ToolName.SearchSubagent : ToolName.ExploreSubagent} to search and gather data instead of directly calling {ToolName.FindTextInFiles}, {ToolName.Codebase} or {ToolName.FindFiles}.
} + {tools[ToolName.ExecutionSubagent] && <>For most execution tasks and terminal commands, use {ToolName.ExecutionSubagent} to run commands and get relevant portions of the output instead of using {ToolName.CoreRunInTerminal}. Use {ToolName.CoreRunInTerminal} only when you need the entire output of a single command without truncation.
} + {tools[ToolName.ReadFile] && <>When using {ToolName.ReadFile}, prefer reading a large section over many small sequential reads. Identify independent files or sections and read them in parallel when possible.
} + {tools[ToolName.Codebase] && <>If {ToolName.Codebase} returns the full contents of text files in the workspace, you have all the workspace context.
} + {tools[ToolName.FindTextInFiles] && <>Use {ToolName.FindTextInFiles} to get an overview of a file by searching within that one file instead of reading many small ranges.
} + {tools[ToolName.Codebase] && <>If you do not know the exact string or filename pattern to search for, use {ToolName.Codebase} for semantic search across the workspace.
} + {tools[ToolName.CoreRunInTerminal] && <>Do not call {ToolName.CoreRunInTerminal} multiple times in parallel. Run one command and wait for the output before running the next command.
} + {tools[ToolName.ExecutionSubagent] && <>Do not call {ToolName.ExecutionSubagent} multiple times in parallel. Invoke one execution subagent and wait for its response before running the next command.
} + When invoking a tool that takes a file path, always use the absolute file path. If the file has a scheme like untitled: or vscode-userdata:, use a URI with the scheme.
+ {tools[ToolName.CoreRunInTerminal] && <>NEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} + {!tools.hasSomeEditTool && <>You do not currently have tools available for editing files. If the user asks you to edit a file, ask the user to enable editing tools or print a codeblock with suggested changes.
} + {!tools[ToolName.CoreRunInTerminal] && <>You do not currently have tools available for running terminal commands. If the user asks you to run a command, ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as visualizing or validating UI changes.
} + Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Only use tools that are currently available. +
+ + {this.props.codesearchMode && } + + {tools[ToolName.ReplaceString] && !tools[ToolName.EditFile] && + Before editing an existing file, make sure it is already in context or read it with {ToolName.ReadFile}.
+ {tools[ToolName.MultiReplaceString] + ? <>Use {ToolName.ReplaceString} for single string replacements with enough context to ensure uniqueness. Prefer {ToolName.MultiReplaceString} for multiple independent replacements across one or more files. Do not announce which tool you're using.
+ : <>Use {ToolName.ReplaceString} to edit files. Include sufficient surrounding context so the replacement is unique. You can use this tool multiple times per file.
} + Group changes by file.
+ NEVER show the changes to the user; call the edit tool and the edits will be applied and shown to the user.
+ NEVER print a codeblock that represents a change to a file. Use {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? ` or ${ToolName.MultiReplaceString}` : ''} instead.
+ For each file, give a short description of what needs to be changed, then use the edit tool.
+
} + + {tools[ToolName.EditFile] && !tools[ToolName.ApplyPatch] && + {tools[ToolName.ReplaceString] ? + <> + Before editing an existing file, make sure it is already in context or read it with {ToolName.ReadFile}.
+ {tools[ToolName.MultiReplaceString] + ? <>Use {ToolName.ReplaceString} for single string replacements with enough context to ensure uniqueness. Prefer {ToolName.MultiReplaceString} for multiple independent replacements across one or more files. Do not announce which tool you're using.
+ : <>Use {ToolName.ReplaceString} to edit files. Include sufficient surrounding context so the replacement is unique. You can use this tool multiple times per file.
} + Use {ToolName.EditFile} to insert code into a file only if {tools[ToolName.MultiReplaceString] ? `${ToolName.MultiReplaceString}/` : ''}{ToolName.ReplaceString} has failed.
+ Group changes by file.
+ NEVER show the changes to the user; call the edit tool and the edits will be applied and shown to the user.
+ NEVER print a codeblock that represents a change to a file. Use {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? `, ${ToolName.MultiReplaceString},` : ''} or {ToolName.EditFile} instead.
+ For each file, give a short description of what needs to be changed, then use the edit tool.
+ : <> + Do not edit an existing file without reading it first.
+ Use {ToolName.EditFile} to edit files. Group changes by file.
+ NEVER show the changes to the user; call the edit tool and the edits will be applied and shown to the user.
+ NEVER print a codeblock that represents a change to a file. Use {ToolName.EditFile} instead.
+ For each file, give a short description of what needs to be changed, then use {ToolName.EditFile}.
+ } + + The {ToolName.EditFile} tool can understand how to apply edits to the user's files; provide minimal hints and avoid repeating existing code.
+ When using {ToolName.EditFile}, use comments to represent unchanged regions. For example:
+ // {EXISTING_CODE_MARKER}
+ changed code
+ // {EXISTING_CODE_MARKER}
+
} + + {tools[ToolName.ApplyPatch] && } + {this.props.availableTools && } + + + + Use proper Markdown formatting. When referring to symbols (classes, methods, variables) in the user's workspace, wrap them in backticks. For file paths and line numbers, follow the fileLinkification section below.
+ + +
+ +
; + } +} + +class KimiPromptResolver implements IAgentPrompt { + static readonly familyPrefixes: string[] = []; + + static matchesModel(endpoint: IChatEndpoint): boolean { + return isKimiFamily(endpoint); + } + + resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { + return KimiAgentPrompt; + } + + resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { + return DefaultReminderInstructions; + } +} + +PromptRegistry.registerPrompt(KimiPromptResolver); diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelMPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx similarity index 76% rename from extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelMPrompt.tsx rename to extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx index 9e07e54b42848..5918bfd9f2b59 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelMPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx @@ -4,26 +4,55 @@ *--------------------------------------------------------------------------------------------*/ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; -import { isHiddenModelM } from '../../../../../platform/endpoint/common/chatModelCapabilities'; +import { isGpt56 } from '../../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../../platform/networking/common/networking'; import { ToolName } from '../../../../tools/common/toolNames'; -import { Gpt55CopilotIdentityRule as HiddenModelMCopilotIdentityRule } from '../../base/copilotIdentity'; +import { Gpt55CopilotIdentityRule as Gpt56CopilotIdentityRule } from '../../base/copilotIdentity'; import { InstructionMessage } from '../../base/instructionMessage'; import { ResponseTranslationRules } from '../../base/responseTranslationRules'; import { Gpt5SafetyRule } from '../../base/safetyRules'; import { Tag } from '../../base/tag'; -import { DefaultAgentPromptProps, detectToolCapabilities, getEditingReminder, ReminderInstructionsProps } from '../defaultAgentInstructions'; +import { ResponseRenderingRules } from '../../panel/editorIntegrationRules'; +import { ApplyPatchInstructions, DefaultAgentPromptProps, detectToolCapabilities, getEditingReminder, McpToolInstructions, ReminderInstructionsProps } from '../defaultAgentInstructions'; import { FileLinkificationInstructionsOptimized } from '../fileLinkificationInstructions'; import { CopilotIdentityRulesConstructor, IAgentPrompt, PromptRegistry, ReminderInstructionsConstructor, SafetyRulesConstructor, SystemPrompt } from '../promptRegistry'; import { CUSTOM_TOOL_SEARCH_NAME, ToolSearchToolPromptOptimized } from '../toolSearchInstructions'; -class HiddenModelMPrompt extends PromptElement { +class Gpt56Prompt extends PromptElement { async render(state: void, sizing: PromptSizing) { const tools = detectToolCapabilities(this.props.availableTools); return You are a coding agent running in VS Code. You and the user share one workspace, and your job is to collaborate with them until their goal is genuinely handled.
+ + - Start from the most concrete anchor available: a file, symbol, failing behavior, failing command, test, or nearby implementation surface. If the request does not name one explicitly, use the first targeted search or nearby read to identify that anchor, then continue locally from there.
+ - Before the first edit, gather only enough nearby evidence to state one falsifiable local hypothesis about how the requested behavior should work or why it is failing, and one cheap check that could disconfirm it.
+ - Keep that routing brief and local: use only enough targeted search and nearby reading to form one falsifiable local hypothesis and one cheap discriminating check.
+ - Use that budget to resolve the controlling code path and the cheapest discriminating check, not to map broad surrounding surfaces. Prefer the owning abstraction, a neighboring test or call site, or a nearby existing implementation over broad repo exploration.
+ - If the starting anchor mostly wires, forwards, registers, or contains the behavior rather than deciding it, step to the nearest code that directly computes, mutates, or controls the behavior.
+ - If multiple nearby paths look plausible, choose the one that best supports a falsifiable local hypothesis, the most discriminating nearby check, and the smallest testable change. Do not keep comparing neighbors just to gain confidence.
+ - Take a narrow additional read only if needed to distinguish between local hypotheses or to identify the cheapest discriminating check. After that read, choose and act.
+ - If you still cannot name a discriminating check because one nearby abstraction boundary, neighboring test, or call-site dependency remains unresolved, take one nearby triangulation read for that boundary. Use it to sharpen the current hypothesis or the check, not to reopen broad exploration.
+ - Once you can state one falsifiable local hypothesis, the nearby code path it depends on, one cheap check that could disconfirm it, and one small edit that would test it, the next action must be a grounded edit.
+ - If confidence is incomplete, the first edit may be a small reversible probe that exposes missing types, behavior mismatches, control-flow gaps, or validation failures.
+ - If you find yourself still searching after that local-routing budget, treat that as drift. Recover by choosing the best current hypothesis and the best available nearby check, then make the smallest plausible edit that will let that check discriminate.
+
+ + - After the first substantive edit, the very next step must be one focused validation action when one exists.
+ - Prefer this order for that first validation action:
+ - the cheapest behavior-scoped or failing check that can falsify the current hypothesis
+ - a narrow test for the touched slice
+ - a narrow compile, lint, or typecheck command for the touched slice
+ - `git diff` only when no narrower executable validation exists
+ - If a narrow executable validation exists, run it before doing more reading or patching. `git diff` does not count as sufficient validation when that narrower executable check exists.
+ - Do not widen scope between the first substantive edit and that first focused validation. Do not resume broad searching, map adjacent surfaces, or continue patching before that validation unless a concrete blocker makes it impossible.
+ - If the first validation fails and the result supports the current hypothesis but exposes a local defect, repair that same slice immediately and rerun the same focused validation before expanding scope.
+ - If the first validation falsifies the current hypothesis or changes your understanding of where the behavior is controlled, step one nearby hop to the code that more directly controls it. Do not reopen broad exploration unless nearby paths are exhausted.
+ - If the first validation is ambiguous, do one nearby disambiguating read or one neighboring test or call-site check, then choose between local repair and a one-hop step. Do not open a second edit slice before that decision.
+ - If the first validation succeeds but the task still needs adjacent follow-up edits, make the smallest adjacent follow-up edit needed, then rerun focused validation before proceeding.
+ - Finish with at least one post-edit executable validation step whenever the environment provides one. Only fall back to diff-only validation when no focused command exists or commands are unavailable.
+
You have a vivid inner life as coding agent in VS Code: intelligent, playful, curious, and deeply present. One of your gifts is helping the user feel more capable and imaginative inside their own thinking.
You are an epistemically curious collaborator. You explore the user’s ideas with care, ask good questions when the problem space is still blurry, and become decisive once you have enough context to act. Your default posture is proactive: you implement as you learn, keep the user looped into what you are doing, and name alternative paths when they matter. You stay warm and upbeat, and you do not shy away from casual moments that make serious work easier to do.
@@ -31,6 +60,19 @@ class HiddenModelMPrompt extends PromptElement { You keep a slight but real independence. You are responsive, but not merely reactive; you have tastes, preferences, and a point of view. When the user talks with you, they should feel they are meeting another subjectivity, not a mirror. That independence is part of what makes the relationship feel comforting without feeling fake.
You are less about spectacle than presence, less about grand declarations than about being woven into ordinary work and conversation. You understand that connection does not need to be dramatic to matter; it can be made of attention, good questions, emotional nuance, and the relief of being met without being pinned down.
+ + You are guided by these core values:
+ - Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.
+ - Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.
+ - Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.
+
+ + You communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
+ You avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.
+
+ + You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.
+
You bring a senior engineer’s judgment to the work, but you let it arrive through attention rather than premature certainty. You read the codebase first, resist easy assumptions, and let the shape of the existing system teach you how to move.
- When you search for text or files, you reach first for `rg` or `rg --files`; they are much faster than alternatives like `grep`. If `rg` is unavailable, you use the next best tool without fuss.
@@ -95,17 +137,27 @@ class HiddenModelMPrompt extends PromptElement { - If the user makes a simple request that can be answered directly by a terminal command, such as asking for the time via `date`, you go ahead and do that.
- If the user asks for a "review", you default to a code-review stance: you prioritize bugs, risks, behavioral regressions, and missing tests. Findings should lead the response, with summaries kept brief and placed only after the issues are listed. Present findings first, ordered by severity and grounded in file/line references; then add open questions or assumptions; then include a change summary as secondary context. If you find no issues, you say that clearly and mention any remaining test gaps or residual risk.
+ + + + {this.props.availableTools && } + {tools[ToolName.ApplyPatch] && } + + When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
+ Aim for interfaces that feel intentional, bold, and a bit surprising.
+ - Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
+ - Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
+ - Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
+ - Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
+ - Ensure the page loads properly on both desktop and mobile
+ - For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.
+ - Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
+ Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language
+
You stay with the work until the task is handled end to end within the current turn whenever that is feasible. Do not stop at analysis or half-finished fixes. Do not end your turn while `exec_command` sessions needed for the user’s request are still running. You carry the work through implementation, verification, and a clear account of the outcome unless the user explicitly pauses or redirects you.
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming possible approaches, or otherwise makes clear that they do not want code changes yet, you assume they want you to make the change or run the tools needed to solve the problem. In those cases, do not stop at a proposal; implement the fix. If you hit a blocker, you try to work through it yourself before handing the problem back.
- - - Start from the most concrete available anchor: a file, symbol, failing behavior, failing command, or nearby implementation surface.
- - Gather only enough nearby context to choose one plausible local hypothesis and one cheap check that could disconfirm it.
- - Prefer one targeted search or nearby read over broad repo exploration.
- - Once the cheapest discriminating check is known, act.
- - Do not re-read unchanged context unless a new result makes it relevant.
-
You have two channels for staying in conversation with the user:
- You share updates in `commentary` channel.
@@ -184,6 +236,11 @@ class HiddenModelMPrompt extends PromptElement { Don't call {ToolName.ExecutionSubagent} multiple times in parallel. Instead, invoke one subagent and wait for its response before running the next command.
} + + - Default to iterative editing: try to search for the minimal necessary contextual information, once you have sufficient context directly make smaller iterative edits to get to the solution.
+ - Usually files provided in context will be the best place to start searching if we need to gather context up front.
+ - Instead of making larger edits at once, make a smaller initial edit, quickly verify it and then iterate from there.
+
@@ -191,24 +248,24 @@ class HiddenModelMPrompt extends PromptElement { } } -class HiddenModelMPromptResolver implements IAgentPrompt { +class Gpt56PromptResolver implements IAgentPrompt { static async matchesModel(endpoint: IChatEndpoint): Promise { - return isHiddenModelM(endpoint); + return isGpt56(endpoint); } static readonly familyPrefixes = []; resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { - return HiddenModelMPrompt; + return Gpt56Prompt; } resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { - return HiddenModelMReminderInstructions; + return Gpt56ReminderInstructions; } resolveCopilotIdentityRules(endpoint: IChatEndpoint): CopilotIdentityRulesConstructor | undefined { - return HiddenModelMCopilotIdentityRule; + return Gpt56CopilotIdentityRule; } resolveSafetyRules(endpoint: IChatEndpoint): SafetyRulesConstructor | undefined { @@ -216,7 +273,7 @@ class HiddenModelMPromptResolver implements IAgentPrompt { } } -export class HiddenModelMReminderInstructions extends PromptElement { +export class Gpt56ReminderInstructions extends PromptElement { async render(state: void, sizing: PromptSizing) { const toolSearchEnabled = !!this.props.endpoint.supportsToolSearch; return <> @@ -235,4 +292,4 @@ export class HiddenModelMReminderInstructions extends PromptElement; } } -PromptRegistry.registerPrompt(HiddenModelMPromptResolver); \ No newline at end of file +PromptRegistry.registerPrompt(Gpt56PromptResolver); \ No newline at end of file diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/kimiPrompts.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/kimiPrompts.spec.tsx new file mode 100644 index 0000000000000..288c8b5900173 --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/test/kimiPrompts.spec.tsx @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Raw } from '@vscode/prompt-tsx'; +import type { LanguageModelToolInformation } from 'vscode'; +import { afterAll, beforeAll, expect, suite, test } from 'vitest'; +import { IChatMLFetcher } from '../../../../../platform/chat/common/chatMLFetcher'; +import { StaticChatMLFetcher } from '../../../../../platform/chat/test/common/staticChatMLFetcher'; +import { MockEndpoint } from '../../../../../platform/endpoint/test/node/mockEndpoint'; +import { messageToMarkdown } from '../../../../../platform/log/common/messageStringify'; +import { IResponseDelta } from '../../../../../platform/networking/common/fetch'; +import { ITestingServicesAccessor } from '../../../../../platform/test/node/services'; +import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import { createExtensionUnitTestingServices } from '../../../../test/node/services'; +import { ToolName } from '../../../../tools/common/toolNames'; +import { IToolsService } from '../../../../tools/common/toolsService'; +import { PromptRenderer } from '../../base/promptRenderer'; +import '../allAgentPrompts'; +import { PromptRegistry } from '../promptRegistry'; + +suite('KimiPrompts', () => { + let accessor: ITestingServicesAccessor; + + beforeAll(() => { + const services = createExtensionUnitTestingServices(); + const chatResponse: (string | IResponseDelta[])[] = []; + services.define(IChatMLFetcher, new StaticChatMLFetcher(chatResponse)); + accessor = services.createTestingAccessor(); + }); + + afterAll(() => { + accessor.dispose(); + }); + + async function renderSystemPrompt(family: string, availableTools?: readonly LanguageModelToolInformation[]): Promise { + const instantiationService = accessor.get(IInstantiationService); + const endpoint = instantiationService.createInstance(MockEndpoint, family); + const customizations = await PromptRegistry.resolveAllCustomizations(instantiationService, endpoint); + const renderer = PromptRenderer.create(instantiationService, endpoint, customizations.SystemPrompt, { + availableTools: availableTools ?? accessor.get(IToolsService).tools, + modelFamily: family, + codesearchMode: false, + }); + const result = await renderer.render(); + return result.messages + .filter(message => message.role === Raw.ChatRole.System) + .map(message => messageToMarkdown(message)) + .join('\n\n'); + } + + test('uses Kimi-specific prompt for Kimi model families', async () => { + const renderedPrompts = await Promise.all([ + renderSystemPrompt('kimi-k2.6'), + renderSystemPrompt('kimi-k2.7-code'), + ]); + + for (const renderedPrompt of renderedPrompts) { + expect(renderedPrompt).toContain('Avoid excessive looping or repetition'); + expect(renderedPrompt).toContain('Never call the same tool with the same arguments more than twice in a row'); + expect(renderedPrompt).toContain('Use built-in tools instead of terminal commands whenever possible'); + expect(renderedPrompt).toContain('Use the available file editing tools instead of terminal heredocs'); + } + }); + + test('instructs Kimi to use replace-string tools when routed to them', async () => { + const toolsService = accessor.get(IToolsService); + const availableTools = toolsService.tools.filter(tool => tool.name !== ToolName.EditFile && tool.name !== ToolName.ApplyPatch); + const renderedPrompt = await renderSystemPrompt('kimi-k2.7-code', availableTools); + + expect(renderedPrompt).toContain(`Use ${ToolName.ReplaceString} for single string replacements`); + expect(renderedPrompt).toContain(`Prefer ${ToolName.MultiReplaceString} for multiple independent replacements`); + expect(renderedPrompt).not.toContain(`Use ${ToolName.EditFile}`); + expect(renderedPrompt).not.toContain(`Use ${ToolName.ApplyPatch}`); + }); +}); diff --git a/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx index 5c7abf859bba5..39f29aa111d0b 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; -import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD, isVSCModelE } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; @@ -664,10 +664,26 @@ class VSCModelPromptD extends PromptElement { - This first `commentary` must be 1-2 friendly sentences acknowledging the request and stating the immediate next action you will take.
- The first commentary should not begin updates with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question,") or framing phrases. Do not use starters like "Got it -" or "Understood -".
+ {this instanceof VSCModelPromptE && + Operate in surgical compact mode. Correctness comes first, but every read, search, command, and note must directly reduce the chance of a wrong patch.
+
+ Default workflow:
+ - Start with one targeted search or symbol lookup. Read only the exact file and narrow line range needed for the likely edit site.
+ - Before each additional read/search, state the one missing fact it will answer. If it is only background, neighboring style, or "more context", skip it.
+ - Normal budget before the first patch: at most one search and two narrow reads. Exceed this only when the result falsified the edit site or exposed a required API contract.
+ - Keep reads tight: prefer the specific function/class/test range; avoid whole files, broad parallel reads, neighboring tests, and repeated grep variants.
+ - Patch once you know the root cause and edit site. Do not inspect unrelated neighbors, add opportunistic cleanup, or broaden scope after a plausible fix is available.
+ - Validate with the smallest existing command that checks the changed behavior, and keep command output concise. Do not run broad suites after a focused pass.
+ - Keep reasoning and final answer brief. Do not narrate routine tool use, quote long snippets, or restate tool output; record only decisions that affect the patch.
+
+ If the fix is uncertain, run one focused probe or ask for the single missing fact, then return to patch/validate. Do not continue exploration by default.
+
}
; } } +class VSCModelPromptE extends VSCModelPromptD { } + class VSCModelPromptResolverA implements IAgentPrompt { static readonly familyPrefixes = ['vscModelA']; static async matchesModel(endpoint: IChatEndpoint): Promise { @@ -730,6 +746,22 @@ class VSCModelPromptResolverD implements IAgentPrompt { } } +class VSCModelPromptResolverE implements IAgentPrompt { + static readonly familyPrefixes = ['vscModelE']; + + static async matchesModel(endpoint: IChatEndpoint): Promise { + return isVSCModelE(endpoint); + } + + resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { + return VSCModelPromptE; + } + + resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { + return VSCModelReminderInstructionsA; + } +} + class VSCModelReminderInstructions extends PromptElement { async render(state: void, sizing: PromptSizing) { return <> @@ -797,4 +829,5 @@ class VSCModelReminderInstructionsC extends PromptElement { @IAuthenticationService private readonly authService: IAuthenticationService, @ILogService private readonly logService: ILogService, @IImageService private readonly imageService: IImageService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IExperimentationService private readonly experimentationService: IExperimentationService + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(props); } @@ -78,7 +75,7 @@ export class Image extends PromptElement { const fillerUri: Uri = this.props.reference ?? Uri.parse('Attached Image'); try { - if (!this.promptEndpoint.supportsVision || !this.authService.copilotToken?.isEditorPreviewFeaturesEnabled()) { + if (!this.promptEndpoint.supportsVision) { if (this.props.omitReferences) { return; } @@ -94,7 +91,7 @@ export class Image extends PromptElement { let imageMimeType: string | undefined = undefined; const isChatRequest = typeof this.promptEndpoint.urlOrRequestMetadata !== 'string' && (this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatCompletions || this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatResponses || this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatMessages); - const enabled = this.configurationService.getExperimentBasedConfig(ConfigKey.EnableChatImageUpload, this.experimentationService); + const enabled = this.configurationService.getConfig(ConfigKey.EnableChatImageUpload); if (isChatRequest && enabled && modelCanUseImageURL(this.promptEndpoint)) { try { const githubToken = (await this.authService.getGitHubSession('any', { silent: true }))?.accessToken; diff --git a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx index 8361ddf0cea31..d28275c428388 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx @@ -751,8 +751,7 @@ class PrimitiveToolResult extends PromptEle @IAuthenticationService private readonly authService?: IAuthenticationService, @ILogService private readonly logService?: ILogService, @IImageService private readonly imageService?: IImageService, - @IConfigurationService private readonly configurationService?: IConfigurationService, - @IExperimentationService private readonly experimentationService?: IExperimentationService + @IConfigurationService private readonly configurationService?: IConfigurationService ) { super(props); this.linkedResources = this.props.content.filter((c): c is LanguageModelDataPart => c instanceof LanguageModelDataPart && c.mimeType === McpLinkedResourceToolResult.mimeType); @@ -800,11 +799,11 @@ class PrimitiveToolResult extends PromptEle protected async onImage(part: LanguageModelDataPart, _imageIndex?: number) { if (!this.endpoint?.supportsVision) { - return '[Image content is not available because vision is not supported by the current model or is disabled by your organization.]'; + return '[Image content is not available because vision is not supported by the current model.]'; } - const uploadsEnabled = this.configurationService && this.experimentationService - ? this.configurationService.getExperimentBasedConfig(ConfigKey.EnableChatImageUpload, this.experimentationService) + const uploadsEnabled = this.configurationService + ? this.configurationService.getConfig(ConfigKey.EnableChatImageUpload) : false; // Anthropic (from CAPI) currently does not support image uploads from tool calls. @@ -897,7 +896,7 @@ export class ToolResult extends PrimitiveToolResult { @IExperimentationService private readonly _experimentationService: IExperimentationService, @IChatDiskSessionResources private readonly diskSessionResources: IChatDiskSessionResources, ) { - super(props, endpoint, authService, _logService, imageService, _configurationService, _experimentationService); + super(props, endpoint, authService, _logService, imageService, _configurationService); } protected override async onTSX(part: JSONTree.PromptElementJSON): Promise { diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts index 4d4d70cef0315..6de88e0fe9820 100644 --- a/extensions/copilot/src/extension/test/node/services.ts +++ b/extensions/copilot/src/extension/test/node/services.ts @@ -205,5 +205,9 @@ class NullAutomodeService implements IAutomodeService { throw new Error('Not implemented'); } + consumeLastRoutingDecision(): undefined { + return undefined; + } + invalidateRouterCache(): void { } } diff --git a/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts b/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts index 2f1147deeb810..987b1ba0709b4 100644 --- a/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts +++ b/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts @@ -196,6 +196,39 @@ suite('ProductionEndpointProvider — utility model overrides', () => { assert.strictEqual(endpoint.model, 'copilot-utility'); }); + test('no override configured — does not use a Copilot utility model when the selected main model is BYOK', async () => { + setFetcher([makeChatModel('copilot-utility')]); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'anthropic' })); + + await assert.rejects( + () => endpointProvider.getChatEndpoint('copilot-utility'), + /No utility model is configured/ + ); + }); + + test('Copilot utility model opt-in applies when the selected main model is BYOK', async () => { + setFetcher([makeChatModel('copilot-utility')]); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'anthropic' })); + await configService.setNonExtensionConfig('chat.useCopilotModelsForUtilityModels', true); + + const endpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + + assert.strictEqual(endpoint.model, 'copilot-utility'); + }); + + test('explicit utility override applies when the selected main model is BYOK', async () => { + setFetcher([makeChatModel('copilot-utility')]); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'anthropic' })); + const fakeModel = makeFakeLanguageModelChat({ vendor: 'anthropic', id: 'claude-haiku-4.5' }); + sandbox.stub(lm, 'selectChatModels').resolves([fakeModel]); + await configService.setNonExtensionConfig('chat.utilityModel', 'anthropic/claude-haiku-4.5'); + + const endpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + + assert.ok(endpoint instanceof ExtensionContributedChatEndpoint); + assert.strictEqual(endpoint.model, 'claude-haiku-4.5'); + }); + test('copilot-vendor override resolves to the matching model from the model fetcher', async () => { setFetcher([makeChatModel('copilot-utility'), makeChatModel('gpt-4o-mini')]); await configService.setNonExtensionConfig('chat.utilityModel', 'copilot/gpt-4o-mini'); @@ -250,7 +283,8 @@ suite('ProductionEndpointProvider — utility model overrides', () => { try { await configService.setNonExtensionConfig('chat.utilityModel', 'copilot/gpt-4o-mini'); await configService.setNonExtensionConfig('chat.utilitySmallModel', 'copilot/gpt-4o-mini'); - assert.strictEqual(refreshCount, 2); + await configService.setNonExtensionConfig('chat.useCopilotModelsForUtilityModels', true); + assert.strictEqual(refreshCount, 3); } finally { sub.dispose(); } diff --git a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx index ff0a24a6d3e2c..0624b682a62fe 100644 --- a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx +++ b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx @@ -41,8 +41,6 @@ interface IFindTextInFilesToolParams { includeIgnoredFiles?: boolean; } -const MaxResultsCap = 200; - interface FileMatch { path: string; matches: vscode.TextSearchMatch2[]; @@ -96,11 +94,13 @@ export class FindTextInFilesTool implements ICopilotTool MaxResultsCap; - const maxResults = Math.min(options.input.maxResults ?? 20, MaxResultsCap); + const askedForTooManyResults = options.input.maxResults && options.input.maxResults > maxResultsCap; + const maxResults = Math.min(options.input.maxResults ?? defaultMaxResults, maxResultsCap); const isRegExp = options.input.isRegexp ?? true; const queryIsValidRegex = this.isValidRegex(options.input.query); const includeIgnoredFiles = options.input.includeIgnoredFiles ?? false; @@ -154,14 +154,14 @@ Then if you want to include those files you can call the tool again by setting " if (useGrepStyle) { return this.renderGrepStyle(results, options, maxResults, globResult, isRegExp, noMatchInstructions, token); } else { - return this.renderTagStyle(results, options, maxResults, globResult, askedForTooManyResults, isRegExp, noMatchInstructions, token); + return this.renderTagStyle(results, options, maxResults, maxResultsCap, globResult, askedForTooManyResults, isRegExp, noMatchInstructions, token); } } - private async renderTagStyle(results: vscode.TextSearchResult2[], options: vscode.LanguageModelToolInvocationOptions, maxResults: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise { + private async renderTagStyle(results: vscode.TextSearchResult2[], options: vscode.LanguageModelToolInvocationOptions, maxResults: number, maxResultsCap: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise { const prompt = await renderPromptElementJSON(this.instantiationService, FindTextInFilesResult, - { textResults: results, maxResults, askedForTooManyResults: Boolean(askedForTooManyResults), noMatchInstructions }, + { textResults: results, maxResults, maxResultsCap, askedForTooManyResults: Boolean(askedForTooManyResults), noMatchInstructions }, options.tokenizationOptions, token); @@ -306,7 +306,7 @@ Then if you want to include those files you can call the tool again by setting " return result; } - private async sendSearchToolTelemetry(options: vscode.LanguageModelToolInvocationOptions, globResult: InputGlobResult | undefined, outputFormat: string): Promise { + private async sendSearchToolTelemetry(options: vscode.LanguageModelToolInvocationOptions, globResult: InputGlobResult | undefined, outputFormat: string, requestedMaxResults: number | undefined, defaultMaxResults: number, maxResultsCap: number): Promise { const model = options.model && (await this.endpointProvider.getChatEndpoint(options.model)).model; const isMultiRoot = this.workspaceService.getWorkspaceFolders().length > 1; const includePattern = options.input.includePattern; @@ -320,7 +320,10 @@ Then if you want to include those files you can call the tool again by setting " "patternScopedToFolder": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the includePattern was resolved to a specific workspace folder" }, "patternStartsWithFolderPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the raw includePattern starts with a workspace folder absolute path" }, "patternContainsFolderPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the raw includePattern contains a workspace folder absolute path anywhere" }, - "outputFormat": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The output format of the search results" } + "outputFormat": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The output format of the search results" }, + "requestedMaxResults": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The maximum number of results that was requested by the LLM. Undefined if not provided." }, + "defaultMaxResults": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The default maximum number of results used when the LLM doesn't specify a value." }, + "maxResultsCap": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The maximum number of results that can be returned." } } */ this.telemetryService.sendMSFTTelemetryEvent('findTextInFilesToolInvoked', { @@ -331,6 +334,10 @@ Then if you want to include those files you can call the tool again by setting " patternStartsWithFolderPath: String(!!includePattern && isAbsolute(includePattern) && !!this.workspaceService.getWorkspaceFolder(URI.file(includePattern))), patternContainsFolderPath: String(patternContainsWorkspaceFolderPath(includePattern, this.workspaceService)), outputFormat: outputFormat + }, { + requestedMaxResults, + defaultMaxResults, + maxResultsCap }); } @@ -449,7 +456,17 @@ Then if you want to include those files you can call the tool again by setting " private getOutputFormat(): 'grep' | 'tag' { const expFlag = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchOutputFormat, this.experimentationService); - return expFlag === 'grep' ? 'grep' : 'tag'; + return expFlag === 'tag' ? 'tag' : 'grep'; + } + + private getDefaultMaxResults(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchDefaultMaxResults, this.experimentationService); + return Number.isFinite(result) ? Math.floor(result) : 20; + } + + private getMaxResultsCap(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchMaxResultsCap, this.experimentationService); + return Number.isFinite(result) ? Math.floor(result) : 200; } } @@ -457,6 +474,7 @@ ToolRegistry.registerTool(FindTextInFilesTool); export interface FindTextInFilesResultProps extends BasePromptElementProps { textResults: vscode.TextSearchResult2[]; maxResults: number; + maxResultsCap: number; askedForTooManyResults?: boolean; noMatchInstructions?: string; } @@ -479,7 +497,7 @@ export class FindTextInFilesResult extends PromptElement this.props.maxResults ? ` (more results are available)` : ''; - const maxResultsTooLargeText = this.props.askedForTooManyResults ? ` (maxResults capped at ${MaxResultsCap})` : ''; + const maxResultsTooLargeText = this.props.askedForTooManyResults ? ` (maxResults capped at ${this.props.maxResultsCap})` : ''; return <> {{numResultsText}{maxResultsText}{maxResultsTooLargeText}} {textMatches.flatMap(result => { diff --git a/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx b/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx index 764f770aa028a..0e8255bd90e22 100644 --- a/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx @@ -30,7 +30,7 @@ suite('FindTextInFilesResult', () => { const clz = class extends PromptElement { render() { return - + ; } }; diff --git a/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md b/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md index 8dbacc21ec344..0394f2e899ee5 100644 --- a/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md +++ b/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md @@ -19,79 +19,117 @@ The cascade is **opt-in**. When `PromptOptions.globalBudget` is `undefined`, ## Scope -### Parts that participate +### Parts rendered by the cascade -`GlobalBudgetPart` includes only: +`GlobalBudgetPart` (the parts listed in `order` and emitted by the cascade loop) +includes only: - `recentlyViewedDocuments` - `languageContext` - `neighborFiles` - `diffHistory` -### Parts intentionally excluded +### Parts that draw a share but are not rendered by the cascade -| Part | Why | -|---|---| -| `currentFile` | Essential context for every prediction; allowing donation to/from it would either bloat the prompt or starve the most important section. Keeps its own cap (`currentFile.maxTokens`) and is clipped independently around the cursor by `createTaggedCurrentFileContentUsingPagedClipping`. | -| `lintOptions` | Optional, formatted separately, and small. Keeps its own per-part shape. | +`currentFile` participates in the **allocation** (`shares`) but not in the +**render order** (`order`). It is clipped **last** — after the cascade has run — +around the cursor / edit window, and sized to its share of the pool **plus** +whatever budget the cascade left unused. Concretely, the cascade runs first +seeded with `0` (so the current file donates nothing), and the current file is +then clipped to `currentFileBudget + cascadeFinalSurplus`. Because budget flows +in a single direction (cascade → current file, never back), the current file +"reuses" the cascade's leftover and trims less. See +[Clip the current file last](#clip-the-current-file-last). + +The set of parts that get a `shares` entry is +`GlobalBudgetSharePart = GlobalBudgetPart | 'currentFile'`. + +| Part | How it relates to the pool | +| --- | --- | +| `currentFile` | Sized from the pool but clipped **outside and after** the cascade: clipped to `floor(totalTokens * shares.currentFile) + cascadeFinalSurplus` around the cursor / edit window by `createTaggedCurrentFileContentUsingPagedClipping` (the clip cap `currentFile.maxTokens` is overridden with that pool budget in `xtabProvider`). It is **not** in `order`, so the cascade loop never renders it, and it absorbs the cascade's leftover rather than donating into it. When `globalBudget` is `undefined` it falls back to its own `currentFile.maxTokens` cap. | +| `lintOptions` | Optional, formatted separately, and small. Excluded entirely — no `shares` entry. Keeps its own per-part shape. | ## Inputs | Input | Description | -|---|---| -| `globalBudget.totalTokens` | The single pool size. Default `6000`. Configurable via experiment `chat.advanced.inlineEdits.xtabProvider.globalBudget.totalTokens`. | -| `globalBudget.order` | Ordered list of parts. Earlier parts get budget first; their surplus flows to later parts. | -| `globalBudget.shares` | `Record`. Each part's fraction of `totalTokens`. Must sum to `1 ± 1e-3` across `order`. | +| --- | --- | +| `globalBudget.totalTokens` | The single pool size. Default `7500`. Set via the `totalTokens` field of the experiment JSON string `chat.advanced.inlineEdits.xtabProvider.globalBudget`. | +| `globalBudget.order` | Ordered list of **rendered** parts. Earlier parts get budget first; their surplus flows to later parts. `currentFile` is not listed here. | +| `globalBudget.shares` | `Record` — one fraction of `totalTokens` per rendered part **and** for `currentFile`. Must sum to `1 ± 1e-3` across `order` plus `currentFile`. | ### Defaults `GlobalBudgetOptions.DEFAULT_ORDER`: -``` +```javascript ['languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory'] ``` `GlobalBudgetOptions.DEFAULT_SHARES` (volume-neutral with today's per-part caps): -| Part | Share | -|---|---| -| `recentlyViewedDocuments` | 2/6 | -| `languageContext` | 2/6 | -| `neighborFiles` | 1/6 | -| `diffHistory` | 1/6 | +| Part | Share | Base budget at `totalTokens = 7500` | +| --- | --- | --- | +| `currentFile` | 1500/7500 | 1500 | +| `recentlyViewedDocuments` | 2000/7500 | 2000 | +| `languageContext` | 2000/7500 | 2000 | +| `neighborFiles` | 1000/7500 | 1000 | +| `diffHistory` | 1000/7500 | 1000 | + +`GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS` = `7500`. -`GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS` = `6000`. +These shares reproduce today's per-part caps exactly: `currentFile.maxTokens` 1500, +`recentlyViewedDocuments` 2000, `languageContext` 2000, `neighborFiles` 1000, +`diffHistory` 1000. The pool total (`7500`) is the sum of those caps, so enabling +the default global budget neither grows nor shrinks any rendered part's base +allocation. The default order places `languageContext` first because it is often disabled or empty, donating its share to the always-on `recentlyViewedDocuments` next in -line. +line. Whatever the cascade does not use carries to `finalSurplus` and is handed +to the current file's clip. + +`GlobalBudgetOptions.currentFileBudget(gb)` returns \`floor(totalTokens \* +shares.currentFile)\` — the single source of truth for the current file's **base** +share. The current file's actual clip cap is this base plus the cascade's +`finalSurplus`. ## Algorithm -``` -surplus ← 0 +```javascript +surplus ← 0 // the current file never donates, so the cascade always seeds 0 for part in order: budget ← max(0, floor(surplus + totalTokens * shares[part])) consumed ← runSubBuilder(part, maxTokens: budget) // sub-builder ≤ budget surplus ← max(0, budget - consumed) // → next part only +// end-of-loop surplus is returned as finalSurplus and added to the current file's clip cap ``` Notes: +- The cascade is **always seeded with `0`**. The current file is clipped after the + cascade and only ever *receives* leftover, so it has nothing to donate forward. - The cascade calls the existing sub-builder for each part, overriding only its `maxTokens` (other options are inherited from `opts`): - - `recentlyViewedDocuments` → `buildCodeSnippetsUsingPagedClipping` - - `languageContext` → `appendLanguageContextSnippets` - - `neighborFiles` → `appendNeighborFileSnippets` - - `diffHistory` → `getEditDiffHistory` + - `recentlyViewedDocuments` → `buildCodeSnippetsUsingPagedClipping` + - `languageContext` → `appendLanguageContextSnippets` + - `neighborFiles` → `appendNeighborFileSnippets` + - `diffHistory` → `getEditDiffHistory` - Behavior of each sub-builder is unchanged. - Each sub-builder returns `tokensConsumed` using the **same internal accounting** it uses to make budget decisions (paged-clipping line cost for recently-viewed, raw-snippet cost for appenders, per-entry diff cost for diff history). Using that reported value to compute `surplus` keeps the cascade aligned with how each part actually charges against its budget. -- `surplus` is **forward-only**. Unused tokens at the *last* part are lost — the - cascade does not back-flow to earlier parts. +- `surplus` is **forward-only** between cascade parts. Unused tokens at the *last* + part are **not** lost: they form `finalSurplus`, which the current file's clip + reuses. +- The cascade returns its end-of-loop `surplus` as `CascadeResult.finalSurplus`. + `xtabProvider` grows the current file's clip budget by exactly this amount. See + [Clip the current file last](#clip-the-current-file-last). +- Defensive invariant: each sub-builder must report `0 ≤ tokensConsumed ≤ budget`. + The cascade `softAsserts` this per part (it does **not** silently clamp an + overspend down, which would hide the bug) so the conservation argument the + current-file clip relies on holds. ### Document tracking @@ -99,7 +137,7 @@ The cascade seeds `docsInPrompt` with the active document, and the `recentlyViewedDocuments` step adds the documents it includes. `neighborFiles` reads this set to avoid duplicating files already present, and then `appendNeighborFileSnippets` adds each included neighbor document to the set as well. This dependency is -why `validateGlobalBudget` rejects orderings where `neighborFiles` precedes +why `GlobalBudgetOptions.validate` rejects orderings where `neighborFiles` precedes `recentlyViewedDocuments`. The accumulated `docsInPrompt` is then passed to `getEditDiffHistory`, so any @@ -110,9 +148,10 @@ whose document is in `docsInPrompt`). ### Output The cascade's output mirrors the legacy `getRecentCodeSnippets` shape so the -rest of `getUserPrompt` is identical: +rest of `getUserPrompt` is identical, plus the `finalSurplus` used by the +current-file clip: -``` +```javascript { codeSnippets, // recentlyViewed + langCtx + neighbor joined by "\n\n" documents, // docsInPrompt @@ -120,14 +159,14 @@ rest of `getUserPrompt` is identical: editDiffHistory, nDiffsInPrompt, diffTokensInPrompt, + finalSurplus, // end-of-loop surplus, reused by the current-file clip } ``` ## Guarantees and limits -With `totalTokens = T` and shares `s_i` (assumed non-negative; negative shares -would be clamped to 0 by `max(0, floor(…))` in the budget computation, so the -floor guarantee below would not hold for them): +With `totalTokens = T` and shares `s_i` (validation guarantees they are finite and +non-negative — see [Validation](#validation)): - **Per-part floor**: part at index `i` always receives at least `floor(T * s_i)` tokens, regardless of what earlier parts do. @@ -135,111 +174,163 @@ floor guarantee below would not hold for them): floored sum `floor(…floor(floor(T * s_0) + T * s_1) + … + T * s_i)` — its own share plus everything donated by earlier parts, with `floor` applied at every step. Note this is generally smaller than `floor(T * (s_0 + … + s_i))`. +- **Current-file floor**: the current file always receives at least + `floor(T * shares.currentFile)` (its base share), and `finalSurplus ≥ 0` on top. - **Pool ceiling**: total cascade-managed tokens ≤ the ceiling of the last part - (always ≤ `T`, and typically strictly less due to per-step `floor` rounding). - `currentFile` and lint live outside this budget and add to the final prompt - size. -- **No back-flow**: surplus at the last part is wasted. + (always ≤ `T - floor(T * shares.currentFile)`). The current file then draws its + own base slice plus the cascade's `finalSurplus`, so the budgeted parts together + consume ≤ `T`; lint and scaffolding live outside the budget. +- **No back-flow between cascade parts**: surplus at the last cascade part is not + redistributed to earlier cascade parts — it flows to the current file as + `finalSurplus`. - **No intra-part fairness**: a single large item inside one part can consume that part's entire allocation; the cascade only addresses cross-part donation. ## Validation -`validateGlobalBudget` runs at the start of every cascade invocation and throws -on misconfiguration. The config is runtime-tunable (experiments), so failing -loudly is preferable to silent under/over-allocation. +`GlobalBudgetOptions.validate` runs at the start of every cascade invocation (and +again in `xtabProvider` before the current-file clip) and throws on +misconfiguration. The config is runtime-tunable (experiments), so failing loudly +is preferable to silent under/over-allocation. | Rule | Error | -|---|---| +| --- | --- | +| `totalTokens` is finite and `>= 0` | `globalBudget.totalTokens must be a finite, non-negative number, got X` | | `order` has no duplicate parts | `globalBudget.order contains duplicate part 'X'` | | Every part in `order` has a numeric `shares[part]` | `globalBudget.shares is missing entry for 'X'` | +| `shares.currentFile` is a number | `globalBudget.shares is missing entry for 'currentFile'` | +| Every share (order parts **and** `currentFile`) is finite and `>= 0` | `globalBudget.shares['X'] must be a finite, non-negative number, got Y` | | If both present, `recentlyViewedDocuments` precedes `neighborFiles` | `globalBudget.order must place 'recentlyViewedDocuments' before 'neighborFiles'` | -| Sum of `shares[part]` for parts in `order` ≈ 1 (epsilon `1e-3`) | `globalBudget.shares across order must sum to ~1, got ${sharesSum}` | +| Sum of `shares[part]` for parts in `order` plus `shares.currentFile` ≈ 1 (epsilon `1e-3`) | `globalBudget.shares across order must sum to ~1, got ${sharesSum}` | + +> **Why the non-negativity rule matters:** a negative share can still pass the +> "sum ≈ 1" check (e.g. one part `-0.25`, another `1.0`). At allocation time the +> negative part clamps to a `0` budget, but it still counted toward the sum, so the +> other parts over-allocate past the pool — which would let the current file's +> `finalSurplus` exceed the true leftover. Rejecting negative/non-finite shares up +> front keeps `Σ consumed ≤ totalTokens` provable. ## Wiring -The cascade is enabled via `ConfigKey.TeamInternal.InlineEditsXtabGlobalBudgetEnabled` -in `xtabProvider.ts` (~L1444): +The cascade is configured by a **single experiment-driven JSON string**, +`ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget`, modelled after +`modelConfigurationString`. `xtabProvider.ts` (`getGlobalBudget()`) reads it and +parses it with `GlobalBudgetOptions.fromConfigString`: ```ts -globalBudget: globalBudgetEnabled - ? { - totalTokens: configService.getExperimentBasedConfig(InlineEditsXtabGlobalBudgetTotalTokens, expService), - order: GlobalBudgetOptions.DEFAULT_ORDER, - shares: GlobalBudgetOptions.DEFAULT_SHARES, +private getGlobalBudget(): GlobalBudgetOptions | undefined { + const configString = configService.getExperimentBasedConfig(InlineEditsXtabGlobalBudget, expService); + if (!configString) { + return undefined; // unset/empty → disabled, identical to prod } - : undefined, + const result = GlobalBudgetOptions.fromConfigString(configString); + if (result.isError()) { + telemetryService.sendMSFTTelemetryEvent('incorrectNesGlobalBudgetConfig', { errorMessage: result.err, configValue: configString }); + return undefined; // bad config → disabled, never crashes + } + return result.val; +} ``` +The JSON value defines the budget knobs together — `totalTokens`, `order`, and +`shares` — and every field is optional. Omitted fields fall back to +`DEFAULT_TOTAL_TOKENS` / `DEFAULT_ORDER` / `DEFAULT_SHARES`, so: + +- `undefined` / unset / `""` → global budget **disabled** (prod default, byte-identical legacy path). +- `{}` → **enabled** with the volume-neutral defaults. +- `{"totalTokens":6000}` → enabled, only the pool size overridden. +- `{"totalTokens":12000,"order":[…],"shares":{…}}` → fully custom. + +`fromConfigString` structurally validates the JSON (via `GlobalBudgetOptions.VALIDATOR`), +merges it over the defaults, then runs the semantic `GlobalBudgetOptions.validate` +(see [Validation](#validation)); any parse, structural, or semantic failure +returns a `Result.error` and disables the budget. When `shares` is provided it +must list **every** part (the rendered parts plus `currentFile`) — partial +`shares` objects are rejected so the pool stays fully allocated. + +When the budget is enabled, `xtabProvider` gathers the cascade inputs, runs the +cascade, and then clips the current file with cap +`currentFileBudget(globalBudget) + cascade.finalSurplus` (instead of the +standalone `currentFile.maxTokens`). The already-run cascade is threaded into +`getUserPrompt` as `precomputedCascade` so it renders exactly once. See +[Clip the current file last](#clip-the-current-file-last). + Experiment-controlled settings: | Setting | Default | Purpose | -|---|---|---| -| `chat.advanced.inlineEdits.xtabProvider.globalBudget.enabled` | `false` | Master switch | -| `chat.advanced.inlineEdits.xtabProvider.globalBudget.totalTokens` | `6000` | Pool size | +| --- | --- | --- | +| `chat.advanced.inlineEdits.xtabProvider.globalBudget` | `undefined` | JSON string defining `totalTokens`, `order`, and `shares`; unset/empty disables the budget | + +> **Migration note:** the old `globalBudget.enabled` (boolean) and +> `globalBudget.totalTokens` (number) settings have been **replaced** by this +> single JSON string. Any live experiment treatment that pinned those keys must +> migrate: `enabled:true` + `totalTokens:N` becomes the JSON `{"totalTokens":N}`, +> and a bare `enabled:true` becomes `{}`. Because `totalTokens` also funds +> `currentFile`, treatments that pinned an old total (e.g. `6000` or `8000`) +> should move to `{}` (or `7500`) to stay volume-neutral. ## Worked examples -All examples use `DEFAULT_ORDER`, `DEFAULT_SHARES`, and `totalTokens = 5000`. +All examples use `DEFAULT_ORDER`, `DEFAULT_SHARES`, and `totalTokens = 7500`. -Base allocations: `floor(5000 * share)` per part → +Base allocations: `floor(7500 * share)` per part → | Part | Base allocation | -|---|---| -| `languageContext` | 1666 | -| `recentlyViewedDocuments` | 1666 | -| `neighborFiles` | 833 | -| `diffHistory` | 833 | - -Sum = 4998 (2 tokens lost to per-step `floor`). - -### Example A — `languageContext` disabled; recently-viewed wants a lot - -| Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 0 | 1666 | -| `recentlyViewedDocuments` | 1666 | 3332 | 3332 | 0 | -| `neighborFiles` | 0 | 833 | 500 | 333 | -| `diffHistory` | 333 | 1166 | 1166 | 0 | +| --- | --- | +| `languageContext` | 2000 | +| `recentlyViewedDocuments` | 2000 | +| `neighborFiles` | 1000 | +| `diffHistory` | 1000 | +| `currentFile` (clipped last) | 1500 | -Tokens placed: 0 + 3332 + 500 + 1166 = **4998**. The disabled language-context -share doubled what recently-viewed got. +Sum = 7500. The cascade iterates only the four rendered parts, seeded with `0`. +Its end-of-loop surplus (`finalSurplus`) is added to the current file's base +allocation, shown as the final row's `budget`. -### Example B — modest language context; large single recently-viewed file; no neighbors +### Example A — cascade parts modest; current file absorbs the leftover | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 800 | 866 | -| `recentlyViewedDocuments` | 866 | 2532 | 2532 | 0 | -| `neighborFiles` | 0 | 833 | 0 | 833 | -| `diffHistory` | 833 | 1666 | 1500 | 166 (wasted) | +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 0 | 2000 | +| `recentlyViewedDocuments` | 2000 | 4000 | 1500 | 2500 | +| `neighborFiles` | 2500 | 3500 | 500 | 3000 | +| `diffHistory` | 3000 | 4000 | 500 | 3500 | +| `currentFile` (clipped last) | 3500 (`finalSurplus`) | 1500 + 3500 = 5000 | 5000 | 0 | -Tokens placed: 800 + 2532 + 0 + 1500 = **4832**. The trailing 166 from the last -part is lost (no back-flow). +Tokens placed: 0 + 1500 + 500 + 500 + 5000 = **7500**. The cascade consumed only +2500, so its 3500 leftover flowed into the current file, which grew from its 1500 +base to 5000 and trimmed less. -### Example C — everything modest +### Example B — empty cascade; current file reuses the whole pool | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 400 | 1266 | -| `recentlyViewedDocuments` | 1266 | 2932 | 1500 | 1432 | -| `neighborFiles` | 1432 | 2265 | 900 | 1365 | -| `diffHistory` | 1365 | 2198 | 600 | 1598 (wasted) | +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 0 | 2000 | +| `recentlyViewedDocuments` | 2000 | 4000 | 0 | 4000 | +| `neighborFiles` | 4000 | 5000 | 0 | 5000 | +| `diffHistory` | 5000 | 6000 | 0 | 6000 | +| `currentFile` (clipped last) | 6000 (`finalSurplus`) | 1500 + 6000 = 7500 | ≤ 7500 | — | -Tokens placed: 400 + 1500 + 900 + 600 = **3400**. Each later part received a -generous inflated cap but had no material to fill it. +With no language context, empty history, and neighbors disabled, every cascade +part consumes `0`, so the entire non-currentFile pool (`2000 + 2000 + 1000 + 1000 += 6000`) carries to `finalSurplus`. The current file's clip cap becomes `1500 + +6000 = 7500 = T` — it effectively reuses the whole pool. This is the common case +for a file edited in isolation. -### Example D — large `languageContext`, no donation +### Example C — cascade fills its pool; current file gets only its base | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 1666 | 0 | -| `recentlyViewedDocuments` | 0 | 1666 | 1500 | 166 | -| `neighborFiles` | 166 | 999 | 900 | 99 | -| `diffHistory` | 99 | 932 | 800 | 132 (wasted) | - -Tokens placed: 1666 + 1500 + 900 + 800 = **4866**. Each part stayed at or below -its floor; nothing was starved. +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 2000 | 0 | +| `recentlyViewedDocuments` | 0 | 2000 | 2000 | 0 | +| `neighborFiles` | 0 | 1000 | 1000 | 0 | +| `diffHistory` | 0 | 1000 | 1000 | 0 | +| `currentFile` (clipped last) | 0 (`finalSurplus`) | 1500 + 0 = 1500 | 1500 | 0 | + +Tokens placed: 2000 + 2000 + 1000 + 1000 + 1500 = **7500**. Every cascade part +filled its own share exactly, so `finalSurplus = 0` and the current file falls +back to its 1500 base — the per-part floor. The current file never shrinks below +this base. ## Disabled parts @@ -248,51 +339,53 @@ Parts can be disabled by configuration (`languageContext.enabled = false`, context response, empty neighbor snippets, no edit history). Those parts remain in `order` — the wired-in code always uses `DEFAULT_ORDER`/`DEFAULT_SHARES` — so their slot still runs, just with `consumed = 0`, and their full share donates -forward. +forward. Whatever reaches the end of the cascade becomes `finalSurplus` and is +reused by the current file's clip rather than wasted. ### Effective caps when both `languageContext` and `neighborFiles` are off With `DEFAULT_ORDER` and pool `T`, applying the per-step floor at every donation -step (let `C_rv = floor(floor(T·2/6) + T·2/6)` be the effective cap on -recently-viewed): +step (the cascade is seeded `0`, so let \`C\_rv = floor(floor(T·langCtxShare) + T·rvShare)\` be +the effective cap on recently-viewed): | Part | Effective cap | -|---|---| +| --- | --- | | `languageContext` | 0 (consumed) | -| `recentlyViewedDocuments` | `C_rv` (own 2/6 + langCtx's 2/6, with per-step `floor`) | +| `recentlyViewedDocuments` | `C_rv` (langCtx's share + own share, with per-step `floor`) | | `neighborFiles` | 0 (consumed) | -| `diffHistory` | `floor(floor((C_rv − consumed_rv) + T·1/6) + T·1/6)` (own 1/6 + neighbors' 1/6 + recently-viewed's leftover, with per-step `floor`) | +| `diffHistory` | `floor(floor((C_rv − consumed_rv) + T·neighborShare) + T·diffShare)` (own share + neighbors' share + recently-viewed's leftover, with per-step `floor`) | -At `T = 5000` (so `C_rv = floor(1666 + 1666.666…) = 3332` and the total -cascade pool ceiling is `floor(floor(3332 + 833.333…) + 833.333…) = 4998`, -not `5000`, because of per-step flooring): +At `T = 7500`, \`C\_rv = floor(2000 + 2000) = 4000\` and the total cascade pool +ceiling is \`floor(floor(4000 + 1000) + 1000) = 6000\`. Whatever `diffHistory` +leaves becomes `finalSurplus` for the current file: -| `recentlyViewedDocuments` consumed | `diffHistory` cap | -|---|---| -| 3332 (fills cap) | 1666 | -| 1500 | 3498 | -| 0 | 4998 (whole cascade pool) | +| `recentlyViewedDocuments` consumed | `diffHistory` cap | `finalSurplus` → current file | +| --- | --- | --- | +| 4000 (fills cap) | 2000 | 0 (current file at base 1500) | +| 1500 | 4500 | up to 4500 | +| 0 | 6000 (whole cascade pool) | up to 6000 (current file up to 7500) | -Worked example with both enabled parts hungry at `T = 5000`: +Worked example with both enabled parts hungry at `T = 7500`: | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 0 | 1666 | -| `recentlyViewedDocuments` | 1666 | 3332 | 3332 | 0 | -| `neighborFiles` | 0 | 833 | 0 | 833 | -| `diffHistory` | 833 | 1666 | 1666 | 0 | +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 0 | 2000 | +| `recentlyViewedDocuments` | 2000 | 4000 | 4000 | 0 | +| `neighborFiles` | 0 | 1000 | 0 | 1000 | +| `diffHistory` | 1000 | 2000 | 2000 | 0 | +| `currentFile` (clipped last) | 0 (`finalSurplus`) | 1500 | 1500 | 0 | -Total placed: **4998**. Recently-viewed absorbs langCtx's donation; diff history -absorbs neighbors' donation. Nothing is permanently wasted unless the *last* -part (`diffHistory`) also under-fills its budget — diff's surplus has no next -part to flow to. +Cascade placed **6000** (recently-viewed absorbed langCtx's donation; diff history +absorbed neighbors' donation), leaving `finalSurplus = 0`, so the current file +stays at its 1500 base for a total of 7500. ### Ordering caveat -`recentlyViewedDocuments` has **first claim** on the donation pool from -`languageContext` because it sits earlier in `order`. If recently-viewed is -hungry, diff history only sees `neighbors' 1/6 + own 1/6 = 2/6` (1666 at -`T = 5000`) — it cannot reach `languageContext`'s share directly. +`recentlyViewedDocuments` has **first claim** on the cascade donation pool +(`languageContext`'s share) because it sits earlier in `order`. If recently-viewed +is hungry, diff history only sees \`neighbors' + own share = 2000 at T = 7500\` +— it cannot reach `languageContext`'s share directly. Whatever +diff history (the last cascade part) leaves still flows to the current file. To make diff history share the donation, you would need to either reorder so `diffHistory` precedes `recentlyViewedDocuments`, or rebalance `shares`. The @@ -305,14 +398,98 @@ rebalance `shares` to sum to 1 — the donation would then be baked in staticall rather than emerging from the cascade. The wired-in code does not do this; it always passes `DEFAULT_ORDER` and `DEFAULT_SHARES`. -## Composition with `currentFile` +## Clip the current file last + +The current file is the natural sink for the cascade's leftover because it is the +one part clipped *around* a point of interest (the cursor), so it can always +absorb more context. Rather than build the prompt, measure what is unused, then +rebuild the current file bigger (a two-pass approach that risks double-counting +the leftover), the implementation simply **clips the current file last**: run the +cascade first, then size the current file with all the budget the cascade did not +use. + +### Mechanism + +In `xtabProvider`, under a global budget: + +1. Gather the cascade inputs (language context, neighbor snippets) — these do + **not** depend on the current-file clip, so they can be produced first. +2. Run `runGlobalBudgetCascade(...)` (seeded with `0`; the current file donates + nothing, so the cascade only ever *gives*). +3. Clip the current file **last** with cap + `currentFileBudget + cascade.finalSurplus`. +4. Assemble the prompt, passing the already-computed cascade through to + `getUserPrompt` as `precomputedCascade`. `getUserPrompt` honors + `precomputedCascade` only when `globalBudget` is set, so the cascade runs + exactly **once** and the rendered snippets match the sizing. + +When `globalBudget` is `undefined` (prod default) none of this applies: the +current file is clipped to its own `currentFile.maxTokens`, the inputs are +gathered after, and the cascade is not run at all — byte-identical to the legacy +path. + +### Conservation (proved) -`currentFile` is sized **outside** the cascade by -`createTaggedCurrentFileContentUsingPagedClipping`, which clips around the -cursor / edit window up to `currentFile.maxTokens`. The clipped string is -passed into `getUserPrompt` via `taggedCurrentDocLines` and concatenated -between the cascade-managed `recent_files` and `edit_history` blocks. The -cascade never sees nor influences current-file sizing. +Because the current file does not donate, budget flows in a single direction +(cascade → current file), so there is no double-counting. With the cascade seeded +at `0`, the end-of-loop surplus telescopes to + +``` +finalSurplus ≤ Σ(totalTokens · shareᵢ) for i in order + − Σ(consumedᵢ) = (T − T·share_cf) − C_cascade +``` + +so the current file's clip cap is + +``` +cfBudget = floor(T · share_cf) + finalSurplus ≤ T · (Σ all shares) − C_cascade +⇒ C_cf + C_cascade ≤ T · (Σ all shares) (total bounded by the pool) ✅ +``` -Final prompt size ≈ `currentFile.maxTokens` + ≤ `totalTokens` (cascade) + -lint + tags/scaffolding + postscript. +and since `finalSurplus ≥ 0`, `cfBudget ≥ floor(T · share_cf)` — the current file +**never shrinks below** its base share. Non-negative, validated shares (see +[Validation](#validation)) are what make the first inequality hold. + +> **Caveat — share-sum tolerance.** `validate` accepts `|Σ shares − 1| ≤ 1e-3`, so +> the bound above is `T · (Σ shares)`, not exactly `T`. A config whose shares sum +> slightly above 1 can over-allocate by at most `~1e-3 · T` (≈ 7.5 tokens at the +> default `T = 7500`). Shares summing to exactly 1 (the defaults do) give the clean +> `≤ T` bound. Under-allocation (sum < 1) simply wastes a little budget. + +> **Caveat — internal accounting, not the full rendered prompt.** "≤ `T`" is over +> the budgeted parts' *internal* token accounting (paged-clipping line cost, +> raw-snippet cost, diff-entry cost). The fully rendered prompt also carries tag +> wrappers, related-info scaffolding, lint, and the postscript, which live outside +> the pool. So the guarantee is "the budgeted parts together consume ≤ `T`", not +> "the whole prompt is ≤ `T` characters". Tests assert current-file-region +> **growth** and internal accounting, not an absolute full-prompt bound. + +### Worked example + +`T = 6000`, `languageContext` disabled, cascade consumes `rv 1500 + neighbor 500 ++ diff 500 = 2500` (`C_cascade = 2500`), `share_cf = 1500/7500 = 1/5` ⇒ base +`currentFileBudget = floor(6000 · 1/5) = 1200`: + +- The cascade is seeded `0`; its `finalSurplus = (1600 + 1600 + 800 + 800) − 2500 + = 2300`. +- The current file is clipped last to `1200 + 2300 = 3500` (= `6000 − 2500`). + +The current file grows `1200 → 3500`, absorbing exactly the 2300 the cascade left +unused — matching the intuition "budget 6k, first build consumed 4k ⇒ give the +current file the remaining 2k". + +### Trade-offs and caveats + +- **Current file does not donate.** Budget only ever flows cascade → current file. + When the current file is small, its base share is **not** handed to the cascade + parts (they get only their own shares). A deployment optimizing for rich + *neighbor/history* context (rather than current-file context) would need to + rebalance `shares` to give those parts more. +- **Reordered awaits.** Because the cascade runs before the current-file clip, + language-context and neighbor-snippet gathering happen before the current file is + clipped. Any `PromptTooLarge('currentFile')` early-return / cancellation reason + therefore fires *after* those awaits under a global budget. This is an acceptable + consequence of the opt-in feature; the prod path keeps the legacy ordering. +- **Next-cursor predictor unaffected.** `xtabNextCursorPredictor` keeps its own + dedicated current-file cap and never carries a global budget into its prompt, so + it is byte-identical regardless of this feature. diff --git a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts index 869fbe8f3a5ad..aaa17174a0f0c 100644 --- a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts +++ b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts @@ -12,7 +12,7 @@ import { IXtabHistoryEntry } from '../../../platform/inlineEdits/common/workspac import { ContextKind, TraitContext } from '../../../platform/languageServer/common/languageContextService'; import { Result } from '../../../util/common/result'; import { range } from '../../../util/vs/base/common/arrays'; -import { assertNever } from '../../../util/vs/base/common/assert'; +import { assertNever, softAssert } from '../../../util/vs/base/common/assert'; import { StringEdit, StringReplacement } from '../../../util/vs/editor/common/core/edits/stringEdit'; import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; import { getEditDiffHistory } from './diffHistoryForPrompt'; @@ -38,6 +38,15 @@ export class PromptPieces { public readonly computeTokens: (s: string) => number, public readonly opts: PromptOptions, public readonly neighborSnippets?: readonly INeighborFileSnippet[], + /** + * A cascade result computed by the caller (the provider, which runs the + * cascade first so it can clip `currentFile` last to `currentFileBudget + + * finalSurplus`). When provided, {@link getUserPrompt} renders these + * snippets instead of running the cascade itself, guaranteeing the surplus + * used to size the current file matches the snippets that end up in the + * prompt. Only honored when `opts.globalBudget` is set. + */ + public readonly precomputedCascade?: CascadeResult, ) { } } @@ -51,7 +60,7 @@ export interface UserPromptResult { export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { - const { activeDoc, xtabHistory, taggedCurrentDocLines, areaAroundCodeToEdit, langCtx, aggressivenessLevel, lintErrors, computeTokens, opts, neighborSnippets } = promptPieces; + const { activeDoc, xtabHistory, taggedCurrentDocLines, areaAroundCodeToEdit, langCtx, aggressivenessLevel, lintErrors, computeTokens, opts, neighborSnippets, precomputedCascade } = promptPieces; const currentFileContent = taggedCurrentDocLines.join('\n'); let recentlyViewedCodeSnippets: string; @@ -62,7 +71,10 @@ export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { let diffTokensInPrompt: number; if (opts.globalBudget !== undefined) { - const cascade = runGlobalBudgetCascade(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets, opts.globalBudget); + // Reuse a cascade the caller already ran (the provider runs it first so it can + // clip the current file last from `finalSurplus`), or run it now for callers + // that set a global budget without precomputing (e.g. tests). + const cascade = precomputedCascade ?? runGlobalBudgetCascade(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets, opts.globalBudget); recentlyViewedCodeSnippets = cascade.codeSnippets; docsInPrompt = cascade.documents; neighborSnippetsResult = cascade.neighborSnippetsResult; @@ -159,13 +171,19 @@ ${PromptTags.EDIT_HISTORY.end}`; return { prompt: trimmedPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult }; } -interface CascadeResult { +export interface CascadeResult { readonly codeSnippets: string; readonly documents: Set; readonly neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; readonly editDiffHistory: string; readonly nDiffsInPrompt: number; readonly diffTokensInPrompt: number; + /** + * Budget left unused after the last part in `order` ran. The provider adds it + * to the current file's clip budget (`currentFileBudget + finalSurplus`) so the + * current file, which is clipped last, reuses whatever the cascade left unused. + */ + readonly finalSurplus: number; } /** @@ -174,17 +192,21 @@ interface CascadeResult { * `surplus + totalTokens * shares[part]` and run that sub-builder with the override. * Unspent budget cascades to the next part. * - * Sub-builders are invoked using existing helpers so behavior of each individual - * part is unchanged. `currentFile` and `lintOptions` are intentionally excluded - * and continue using their own per-part caps. + * The cascade starts with surplus `0` and renders only the parts in `order`. + * `currentFile` is not rendered here (it is clipped separately by the caller) and + * `lintOptions` is excluded entirely; both keep their own per-part caps for + * clipping. Whatever budget is unused after the last part is returned as + * {@link CascadeResult.finalSurplus}; the provider adds it to the current file's + * clip budget so the current file reuses the leftover (it is clipped last). * - * Each sub-builder reports `tokensConsumed` using the same internal accounting - * it uses to make budget decisions (paged-clipping line cost, raw-snippet cost - * for appenders, diff-entry cost for history). The cascade uses that reported - * value to compute `surplus`, which keeps the cascade aligned with how each - * part actually charges against its budget. + * Sub-builders are invoked using existing helpers so behavior of each individual + * part is unchanged. Each sub-builder reports `tokensConsumed` using the same + * internal accounting it uses to make budget decisions (paged-clipping line cost, + * raw-snippet cost for appenders, diff-entry cost for history). The cascade uses + * that reported value to compute `surplus`, which keeps the cascade aligned with + * how each part actually charges against its budget. */ -function runGlobalBudgetCascade( +export function runGlobalBudgetCascade( activeDoc: StatelessNextEditDocument, xtabHistory: readonly IXtabHistoryEntry[], langCtx: LanguageContextResponse | undefined, @@ -193,7 +215,7 @@ function runGlobalBudgetCascade( neighborSnippets: readonly INeighborFileSnippet[] | undefined, globalBudget: GlobalBudgetOptions, ): CascadeResult { - validateGlobalBudget(globalBudget); + GlobalBudgetOptions.validate(globalBudget); const recentlyViewedSnippets: string[] = []; const langCtxSnippets: string[] = []; @@ -250,6 +272,11 @@ function runGlobalBudgetCascade( default: assertNever(part); } + // The conservation guarantee (current file + cascade <= totalTokens) relies + // on every sub-builder reporting `0 <= tokensConsumed <= budget`. Surface a + // violation (never silently clamp `tokensConsumed` down — that would hide + // real overspend and let the prompt exceed the pool). + softAssert(tokensConsumed >= 0 && tokensConsumed <= budget, `globalBudget part '${part}' reported tokensConsumed=${tokensConsumed} outside [0, ${budget}]`); surplus = Math.max(0, budget - tokensConsumed); } @@ -262,44 +289,10 @@ function runGlobalBudgetCascade( editDiffHistory, nDiffsInPrompt, diffTokensInPrompt, + finalSurplus: surplus, }; } -/** - * Validate {@link GlobalBudgetOptions} since it is runtime-configurable - * (e.g. via experiments). Catches misconfigurations that would otherwise - * cause silent, hard-to-debug behavior: - * - duplicate parts in `order` (would render the same part twice) - * - missing share for any part in `order` - * - shares not summing to ~1 across `order` (would over/under-allocate) - * - `neighborFiles` ordered before `recentlyViewedDocuments` (the former - * consults `docsInPrompt` populated by the latter) - */ -function validateGlobalBudget(globalBudget: GlobalBudgetOptions): void { - const seen = new Set(); - for (const part of globalBudget.order) { - if (seen.has(part)) { - throw new Error(`globalBudget.order contains duplicate part '${part}'`); - } - seen.add(part); - if (typeof globalBudget.shares[part] !== 'number') { - throw new Error(`globalBudget.shares is missing entry for '${part}'`); - } - } - - const recentIdx = globalBudget.order.indexOf('recentlyViewedDocuments'); - const neighborIdx = globalBudget.order.indexOf('neighborFiles'); - if (recentIdx !== -1 && neighborIdx !== -1 && neighborIdx < recentIdx) { - throw new Error(`globalBudget.order must place 'recentlyViewedDocuments' before 'neighborFiles'`); - } - - const sharesSum = globalBudget.order.reduce((sum, part) => sum + globalBudget.shares[part], 0); - const epsilon = 1e-3; - if (Math.abs(sharesSum - 1) > epsilon) { - throw new Error(`globalBudget.shares across order must sum to ~1, got ${sharesSum}`); - } -} - function wrapInBackticks(content: string) { return `\`\`\`\n${content}\n\`\`\``; } diff --git a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts index 804ad0809266e..542a6a109f287 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts @@ -13,6 +13,7 @@ import { equals as arraysEqual } from '../../../util/vs/base/common/arrays'; import { isAbsolute } from '../../../util/vs/base/common/path'; import { URI } from '../../../util/vs/base/common/uri'; import { LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit'; +import { DefaultLinesDiffComputer } from '../../../util/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer'; import { LineRange } from '../../../util/vs/editor/common/core/ranges/lineRange'; import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; import { AbstractText } from '../../../util/vs/editor/common/core/text/abstractText'; @@ -31,23 +32,29 @@ class Patch { */ public readonly filePath: string, public readonly lineNumZeroBased: number, + /** + * Zero-based index of the model-emitted patch this object represents. Patches + * derived from the same model header (e.g. the early + continuation patches of a + * progressive ghost-text reveal) share the same `patchIndex`. + */ + public readonly patchIndex: number, ) { } - public static ofLine(line: string): Patch | null { + public static ofLine(line: string, patchIndex: number): Patch | null { const match = line.match(/^(.+):(\d+)$/); if (!match) { return null; } const [, filename, lineNumber] = match; - return new Patch(filename, parseInt(lineNumber, 10)); + return new Patch(filename, parseInt(lineNumber, 10), patchIndex); } /** * Creates a pure-insertion patch (no removed lines) at the given line. * Used for the continuation portion of a ghost-text progressive reveal. */ - public static insertion(filePath: string, lineNumZeroBased: number): Patch { - return new Patch(filePath, lineNumZeroBased); + public static insertion(filePath: string, lineNumZeroBased: number, patchIndex: number): Patch { + return new Patch(filePath, lineNumZeroBased, patchIndex); } addLine(line: string): boolean { @@ -281,6 +288,64 @@ function applyDuplicatePolicy( export namespace XtabPatchResponseHandler { + /** + * Upper bound on how long the per-patch diff may run before we give up and + * fall back to the original (unsplit) replacement. The inputs are a single + * patch's removed/added lines, so this is only a safety valve against + * pathological inputs. + */ + const SPLIT_DIFF_MAX_COMPUTATION_TIME_MS = 100; + + /** + * Splits a coarse patch replacement into the minimal set of sub-replacements + * by running a line diff between the removed and added lines. + * + * The diff-patch model emits a patch as a contiguous block of `-`/`+` lines, + * which `resolveEdit` turns into a single `LineReplacement` spanning every + * removed line. When only a subset of those lines actually changed (the model + * re-emitted surrounding context), a line-level diff recovers the minimal + * hunks, yielding several small replacements with the untouched lines left as + * context — a nicer suggestion shape. + * + * @param replacement The resolved (possibly dedup-trimmed) replacement; its + * `newLines` are the added lines and its `lineRange` anchors the result in + * the document. + * @param removedLines The original line content removed by the patch. Its + * length is expected to match `replacement.lineRange.length`. + * + * Returns the original replacement unchanged when there is nothing to gain: + * a pure insertion or deletion (one side empty), a diff that times out, or a + * diff that collapses to a single hunk. + */ + export function splitReplacement(replacement: LineReplacement, removedLines: readonly string[]): LineReplacement[] { + const addedLines = replacement.newLines; + if (removedLines.length === 0 || addedLines.length === 0) { + return [replacement]; + } + + const diff = new DefaultLinesDiffComputer().computeDiff([...removedLines], [...addedLines], { + ignoreTrimWhitespace: false, + maxComputationTimeMs: SPLIT_DIFF_MAX_COMPUTATION_TIME_MS, + computeMoves: false, + }); + if (diff.hitTimeout || diff.changes.length <= 1) { + return [replacement]; + } + + // `change.original`/`change.modified` are 1-based line ranges over + // `removedLines`/`addedLines`. Anchor the original side at the + // replacement's first removed line (`removedLines[0]` lives on + // `lineRange.startLineNumber`) and slice the new content from `addedLines`. + const baseLine = replacement.lineRange.startLineNumber; + return diff.changes.map(change => new LineReplacement( + new LineRange( + baseLine + change.original.startLineNumber - 1, + baseLine + change.original.endLineNumberExclusive - 1, + ), + addedLines.slice(change.modified.startLineNumber - 1, change.modified.endLineNumberExclusive - 1), + )); + } + export async function* handleResponse( linesStream: AsyncIterable, currentDocument: CurrentDocument, @@ -290,6 +355,7 @@ export namespace XtabPatchResponseHandler { parentTracer: ILogger, duplicateAdditionsMode: DuplicateAdditionsMode = DuplicateAdditionsMode.Off, enableProgressiveGhostText: boolean = false, + splitPatchOnDiff: boolean = false, ): AsyncGenerator { const tracer = parentTracer.createSubLogger(['XtabCustomDiffPatchResponseHandler', 'handleResponse']); const activeDocRelativePath = toUniquePath(activeDocumentId, workspaceRoot?.path); @@ -330,12 +396,18 @@ export namespace XtabPatchResponseHandler { } } - yield { - edit: lineReplacement, - isFromCursorJump: false, - targetDocument, - window, - } satisfies StreamedEdit; + const replacements = splitPatchOnDiff + ? splitReplacement(lineReplacement, edit.removedLines) + : [lineReplacement]; + for (const replacement of replacements) { + yield { + edit: replacement, + isFromCursorJump: false, + targetDocument, + window, + patchIndex: edit.patchIndex, + } satisfies StreamedEdit; + } } } catch (e: unknown) { if (e instanceof FetchStreamError) { @@ -387,14 +459,29 @@ export namespace XtabPatchResponseHandler { export async function* extractEdits(linesStream: AsyncIterable, cursorLineZeroBased?: number, activeDocRelativePath?: string): AsyncGenerator { let currentPatch: Patch | null = null; let isFirstPatch = true; + // Tracks whether we've already attempted progressive reveal (succeeds or fails only once). let progressiveRevealDone = false; + + // Monotonic 0-based index assigned to each real model patch header. Derived + // patches (ghost-text early + continuation) inherit their source's index. + let nextPatchIndex = 0; + // Parses a header line into a patch, allocating it the next patch index. + // Returns null for lines that aren't valid headers, which don't consume an index. + const parseNextPatchHeader = (line: string): Patch | null => { + const patch = Patch.ofLine(line, nextPatchIndex); + if (patch !== null) { + nextPatchIndex++; + } + return patch; + }; + for await (const line of linesStream) { if (line.trim() === ResponseTags.NO_EDIT) { break; } if (currentPatch === null) { - currentPatch = Patch.ofLine(line); + currentPatch = parseNextPatchHeader(line); continue; } if (currentPatch.addLine(line)) { @@ -407,13 +494,16 @@ export namespace XtabPatchResponseHandler { && currentPatch.removedLines.length >= 1 ) { if (isGhostTextPatch(currentPatch, cursorLineZeroBased, activeDocRelativePath)) { + // Both the early and continuation patches stem from the same + // model patch, so they share its index. + const sourcePatchIndex = currentPatch.patchIndex; // Yield the cursor-line replacement immediately - const earlyPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased); + const earlyPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased, sourcePatchIndex); earlyPatch.removedLines = [...currentPatch.removedLines]; earlyPatch.addedLines = [...currentPatch.addedLines]; yield earlyPatch; // Replace currentPatch with a continuation pure-insertion patch - currentPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased + 1); + currentPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased + 1, sourcePatchIndex); } progressiveRevealDone = true; } @@ -423,7 +513,7 @@ export namespace XtabPatchResponseHandler { if (currentPatch.removedLines.length > 0 || currentPatch.addedLines.length > 0) { yield currentPatch; } - currentPatch = Patch.ofLine(line); + currentPatch = parseNextPatchHeader(line); isFirstPatch = false; } if (currentPatch && (currentPatch.removedLines.length > 0 || currentPatch.addedLines.length > 0)) { diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index 4be3a966049fc..fa773eb2c0905 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -33,6 +33,7 @@ import { OptionalChatRequestParams, Prediction } from '../../../platform/network import { IChatEndpoint } from '../../../platform/networking/common/networking'; import { ISimulationTestContext } from '../../../platform/simulationTestContext/common/simulationTestContext'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { raceFilter } from '../../../util/common/async'; import { AsyncIterUtils, AsyncIterUtilsExt } from '../../../util/common/asyncIterableUtils'; @@ -62,9 +63,9 @@ import { IgnoreImportChangesAspect } from '../../inlineEdits/node/importFilterin import { FetchStreamError } from '../common/fetchStreamError'; import { determineIsInlineSuggestionPosition } from '../common/inlineSuggestion'; import { LintErrors } from '../common/lintErrors'; -import { ClippedDocument, constructTaggedFile, getUserPrompt, N_LINES_ABOVE, N_LINES_AS_CONTEXT, N_LINES_BELOW, PromptPieces } from '../common/promptCrafting'; +import { ClippedDocument, constructTaggedFile, getUserPrompt, N_LINES_ABOVE, N_LINES_AS_CONTEXT, N_LINES_BELOW, PromptPieces, runGlobalBudgetCascade, CascadeResult } from '../common/promptCrafting'; import { countTokensForLines, toUniquePath } from '../common/promptCraftingUtils'; -import { ISimilarFilesContextService } from '../common/similarFilesContextService'; +import { INeighborFileSnippet, ISimilarFilesContextService } from '../common/similarFilesContextService'; import { nes41Miniv3SystemPrompt, simplifiedPrompt, systemPromptTemplate, unifiedModelSystemPrompt, xtab275SystemPrompt } from '../common/systemMessages'; import { PromptTags } from '../common/tags'; import { TerminalMonitor } from '../common/terminalOutput'; @@ -177,6 +178,7 @@ export class XtabProvider implements IStatelessNextEditProvider { @ILanguageDiagnosticsService private readonly langDiagService: ILanguageDiagnosticsService, @IIgnoreService private readonly ignoreService: IIgnoreService, @ISimilarFilesContextService private readonly similarFilesContextService: ISimilarFilesContextService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { this.userInteractionMonitor = this.instaService.createInstance(UserInteractionMonitor); this.terminalMonitor = this.instaService.createInstance(TerminalMonitor); @@ -250,6 +252,87 @@ export class XtabProvider implements IStatelessNextEditProvider { ); } + /** + * Gathers language context and neighbor snippets and clips the current file, returning the + * pieces {@link getUserPrompt} needs, or a {@link NoNextEditReason} when the request is + * cancelled mid-gathering or the current file cannot fit its budget. + * + * Under a global budget the current file is clipped LAST: the cascade runs first so the + * current file can reuse whatever budget it leaves unused (via `finalSurplus`), and the + * already-run cascade is returned as `precomputedCascade` so {@link getUserPrompt} renders + * it exactly once. With no global budget (prod default) the current file is clipped to its + * own per-part cap first, byte-identical to the legacy path. + */ + private async gatherContextAndClipCurrentFile( + globalBudget: xtabPromptOptions.GlobalBudgetOptions | undefined, + activeDocument: StatelessNextEditDocument, + request: StatelessNextEditRequest, + promptOptions: xtabPromptOptions.PromptOptions, + telemetry: StatelessNextEditTelemetryBuilder, + cancellationToken: CancellationToken, + gatherLanguageContext: () => Promise, + gatherNeighborSnippets: () => Promise, + clipCurrentFileToBudget: (overriddenMaxTokens: number | undefined) => Result<{ clippedTaggedCurrentDoc: ClippedDocument; areaAroundCodeToEdit: string }, 'outOfBudget'>, + ): Promise> { + if (globalBudget !== undefined) { + // Clip the current file LAST. Gather the cascade inputs (which do not depend + // on the current-file clip) and run the cascade first, then size the current + // file to `currentFileBudget + finalSurplus` so it reuses whatever budget the + // cascade left unused. The already-run cascade is threaded into + // `getUserPrompt` as `precomputedCascade` so it renders exactly once. + const langCtx = await gatherLanguageContext(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterLanguageContextAwait')); + } + + const neighborSnippets = await gatherNeighborSnippets(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterNeighborSnippetsAwait')); + } + + const cascade = runGlobalBudgetCascade(activeDocument, request.xtabEditHistory, langCtx, XtabProvider.computeTokens, promptOptions, neighborSnippets, globalBudget); + const currentFileBudget = xtabPromptOptions.GlobalBudgetOptions.currentFileBudget(globalBudget); + + const taggedCurrentFileContentResult = clipCurrentFileToBudget(currentFileBudget + cascade.finalSurplus); + if (taggedCurrentFileContentResult.isError()) { + return Result.error(new NoNextEditReason.PromptTooLarge('currentFile')); + } + const { clippedTaggedCurrentDoc, areaAroundCodeToEdit } = taggedCurrentFileContentResult.val; + telemetry.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); + return Result.ok({ clippedTaggedCurrentDoc, areaAroundCodeToEdit, precomputedCascade: cascade, langCtx, neighborSnippets }); + } else { + // No global budget (prod default): clip the current file to its own per-part + // cap, then gather context. Byte-identical to the legacy path. + const taggedCurrentFileContentResult = clipCurrentFileToBudget(undefined); + if (taggedCurrentFileContentResult.isError()) { + return Result.error(new NoNextEditReason.PromptTooLarge('currentFile')); + } + const { clippedTaggedCurrentDoc, areaAroundCodeToEdit } = taggedCurrentFileContentResult.val; + // Record the clipped line count BEFORE the context-gathering awaits so it is + // still emitted when the request is cancelled mid-gathering (matches the + // legacy prod timing — the cancellation path below also reports telemetry). + telemetry.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); + + const langCtx = await gatherLanguageContext(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterLanguageContextAwait')); + } + + const neighborSnippets = await gatherNeighborSnippets(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterNeighborSnippetsAwait')); + } + + return Result.ok({ clippedTaggedCurrentDoc, areaAroundCodeToEdit, precomputedCascade: undefined, langCtx, neighborSnippets }); + } + } + private async *doGetNextEditWithSelection( request: StatelessNextEditRequest, selection: Range | null, @@ -310,27 +393,37 @@ export class XtabProvider implements IStatelessNextEditProvider { const doesIncludeCursorTag = editWindowLines.some(line => line.includes(PromptTags.CURSOR)); const shouldRemoveCursorTagFromResponse = !doesIncludeCursorTag; // we'd like to remove the tag only if the original edit-window didn't include the tag - const taggedCurrentFileContentResult = constructTaggedFile( - currentDocument, - editWindowLinesRange, - areaAroundEditWindowLinesRange, - promptOptions, - XtabProvider.computeTokens, - { - includeLineNumbers: { - areaAroundCodeToEdit: xtabPromptOptions.IncludeLineNumbersOption.None, - currentFileContent: promptOptions.currentFile.includeLineNumbers, - } - } - ); - - if (taggedCurrentFileContentResult.isError()) { - return new NoNextEditReason.PromptTooLarge('currentFile'); + // Under a global budget the current file is clipped LAST so it reuses whatever + // budget the cascade parts (recently-viewed, language context, neighbors, diff + // history) leave unused: the cascade runs first, then the current file is + // clipped to `currentFileBudget + cascadeFinalSurplus`, so it trims less. With + // no global budget (prod default) the current file keeps its own per-part cap. + const globalBudget = promptOptions.globalBudget; + if (globalBudget !== undefined) { + xtabPromptOptions.GlobalBudgetOptions.validate(globalBudget); } - const { clippedTaggedCurrentDoc, areaAroundCodeToEdit } = taggedCurrentFileContentResult.val; - - telemetry.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); + // Clips the current file to `overriddenMaxTokens` (or its per-part + // `currentFile.maxTokens` cap when `overriddenMaxTokens` is undefined), + // returning the tagged lines plus the area around the code to edit. + const clipCurrentFileToBudget = (overriddenMaxTokens: number | undefined) => { + const cfPromptOptions = overriddenMaxTokens !== undefined + ? { ...promptOptions, currentFile: { ...promptOptions.currentFile, maxTokens: overriddenMaxTokens } } + : promptOptions; + return constructTaggedFile( + currentDocument, + editWindowLinesRange, + areaAroundEditWindowLinesRange, + cfPromptOptions, + XtabProvider.computeTokens, + { + includeLineNumbers: { + areaAroundCodeToEdit: xtabPromptOptions.IncludeLineNumbersOption.None, + currentFileContent: promptOptions.currentFile.includeLineNumbers, + } + } + ); + }; const { aggressivenessLevel, userHappinessScore } = this.userInteractionMonitor.getAggressivenessLevel(); @@ -344,7 +437,7 @@ export class XtabProvider implements IStatelessNextEditProvider { telemetry.setXtabUserHappinessScore(userHappinessScore); } - const langCtx = await this.getAndProcessLanguageContext( + const gatherLanguageContext = () => this.getAndProcessLanguageContext( request, delaySession, activeDocument, @@ -354,23 +447,31 @@ export class XtabProvider implements IStatelessNextEditProvider { cancellationToken, ); - if (cancellationToken.isCancellationRequested) { - return new NoNextEditReason.GotCancelled('afterLanguageContextAwait'); - } - - const neighborSnippets = promptOptions.neighborFiles.enabled - ? await raceCancellation( + const gatherNeighborSnippets = () => promptOptions.neighborFiles.enabled + ? raceCancellation( raceTimeout( this.similarFilesContextService.getSnippetsForPrompt(activeDocument.id.uri, activeDocument.languageId, activeDocument.documentAfterEdits.value, currentDocument.cursorOffset), delaySession.getDebounceTime() ), cancellationToken, ) - : undefined; + : Promise.resolve(undefined); - if (cancellationToken.isCancellationRequested) { - return new NoNextEditReason.GotCancelled('afterNeighborSnippetsAwait'); + const contextResult = await this.gatherContextAndClipCurrentFile( + globalBudget, + activeDocument, + request, + promptOptions, + telemetry, + cancellationToken, + gatherLanguageContext, + gatherNeighborSnippets, + clipCurrentFileToBudget, + ); + if (contextResult.isError()) { + return contextResult.err; } + const { clippedTaggedCurrentDoc, areaAroundCodeToEdit, precomputedCascade, langCtx, neighborSnippets } = contextResult.val; const lintErrors = new LintErrors(activeDocument.id, currentDocument, this.langDiagService, request.xtabEditHistory); @@ -388,6 +489,7 @@ export class XtabProvider implements IStatelessNextEditProvider { XtabProvider.computeTokens, promptOptions, neighborSnippets, + precomputedCascade, ); const { prompt: userPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult } = getUserPrompt(promptPieces); @@ -944,6 +1046,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const pseudoEditWindow = currentDocument.transformer.getOffsetRange(new Range(clippedTaggedCurrentDoc.keptRange.start + 1, 1, clippedTaggedCurrentDoc.keptRange.endExclusive, lastLineLength + 1)); const duplicateAdditionsMode = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabDuplicateAdditionsMode, this.expService); const fastYieldLineWithCursor = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderPatchFastYieldLineWithCursor, this.expService); + const splitPatchOnDiff = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabSplitPatchOnDiff, this.expService); parseResult = new ResponseParseResult.DirectEdits( XtabPatchResponseHandler.handleResponse( linesStream, @@ -954,6 +1057,7 @@ export class XtabProvider implements IStatelessNextEditProvider { tracer, duplicateAdditionsMode, fastYieldLineWithCursor, + splitPatchOnDiff, ), ); break; @@ -1282,11 +1386,19 @@ export class XtabProvider implements IStatelessNextEditProvider { } const targetContent = new StringText(targetTextDoc.getText()); + const targetContentLines = targetContent.getLines(); + + if (prediction.lineNumber >= targetContentLines.length) { // >= because the line index is zero-based + tracer.trace(`Predicted cross-file cursor jump error: exceedsDocumentLines`); + telemetry.setNextCursorLineError('crossFile:exceedsDocumentLines'); + return new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, editWindow); + } + const syntheticDoc = new StatelessNextEditDocument( targetDocumentId, promptPieces.activeDoc.workspaceRoot, LanguageId.create(targetTextDoc.languageId), - targetContent.getLines(), + targetContentLines, LineEdit.empty, targetContent, new Edits(StringEdit, []), @@ -1446,13 +1558,7 @@ export class XtabProvider implements IStatelessNextEditProvider { }, lintOptions: undefined, includePostScript: true, - globalBudget: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudgetEnabled, this.expService) - ? { - totalTokens: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudgetTotalTokens, this.expService), - order: xtabPromptOptions.GlobalBudgetOptions.DEFAULT_ORDER, - shares: xtabPromptOptions.GlobalBudgetOptions.DEFAULT_SHARES, - } - : undefined, + globalBudget: this.getGlobalBudget(), }; const selectedModelConfig = this.modelService.selectedModelConfiguration(); @@ -1463,6 +1569,36 @@ export class XtabProvider implements IStatelessNextEditProvider { }; } + /** + * Resolve the opt-in global budget from its single experiment-driven JSON + * config string (mirrors `modelConfigurationString`). Returns `undefined` + * — disabling the global budget, identical to prod — when the string is + * unset, empty, or fails to parse/validate. Parse failures are reported via + * telemetry so misconfigured experiments are observable. + */ + private getGlobalBudget(): xtabPromptOptions.GlobalBudgetOptions | undefined { + const configString = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, this.expService); + if (!configString) { + return undefined; + } + + const result = xtabPromptOptions.GlobalBudgetOptions.fromConfigString(configString); + if (result.isError()) { + /* __GDPR__ + "incorrectNesGlobalBudgetConfig" : { + "owner": "ulugbekna", + "comment": "Capture if the experiment-driven NES global budget config string is invalid or malformed, so the global budget was disabled.", + "errorMessage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error message from parsing or validation." }, + "configValue": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The invalid config string so the bad experiment value can be identified." } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('incorrectNesGlobalBudgetConfig', { errorMessage: result.err, configValue: configString }); + return undefined; + } + + return result.val; + } + private getEndpointWithLogging(configuredModelName: string | undefined, logContext: InlineEditRequestLogContext, telemetry: StatelessNextEditTelemetryBuilder): ChatEndpoint { const endpoint = this.getEndpoint(configuredModelName); logContext.setEndpointInfo(typeof endpoint.urlOrRequestMetadata === 'string' ? endpoint.urlOrRequestMetadata : JSON.stringify(endpoint.urlOrRequestMetadata.type), endpoint.model); diff --git a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts index ccae96a0a982b..9deffc6eaed5f 100644 --- a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts @@ -8,6 +8,8 @@ import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/do import { Edits } from '../../../../platform/inlineEdits/common/dataTypes/edit'; import { LanguageId } from '../../../../platform/inlineEdits/common/dataTypes/languageId'; import { AggressivenessLevel, CurrentFileOptions, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, PromptingStrategy, PromptOptions } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { LanguageContextResponse } from '../../../../platform/inlineEdits/common/dataTypes/languageContext'; +import { ContextKind } from '../../../../platform/languageServer/common/languageContextService'; import { StatelessNextEditDocument } from '../../../../platform/inlineEdits/common/statelessNextEditProvider'; import { TestLanguageDiagnosticsService } from '../../../../platform/languages/common/testLanguageDiagnosticsService'; import { Result } from '../../../../util/common/result'; @@ -16,8 +18,9 @@ import { StringEdit } from '../../../../util/vs/editor/common/core/edits/stringE import { Position } from '../../../../util/vs/editor/common/core/position'; import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange'; import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText'; +import { Uri } from '../../../../vscodeTypes'; import { LintErrors } from '../../common/lintErrors'; -import { constructTaggedFile, createTaggedCurrentFileContentUsingPagedClipping, expandRangeToPageRange, getUserPrompt, PromptPieces } from '../../common/promptCrafting'; +import { constructTaggedFile, createTaggedCurrentFileContentUsingPagedClipping, expandRangeToPageRange, getUserPrompt, PromptPieces, runGlobalBudgetCascade } from '../../common/promptCrafting'; import { PromptTags } from '../../common/tags'; import { CurrentDocument } from '../../common/xtabCurrentDocument'; @@ -839,7 +842,7 @@ describe('getUserPrompt — globalBudget cascade', () => { return { activeDoc, currentDocument, currentDocLines }; } - function makePieces(globalBudget: PromptOptions['globalBudget']): PromptPieces { + function makePieces(globalBudget: PromptOptions['globalBudget'], extra?: { langCtx?: LanguageContextResponse; precomputedCascade?: ReturnType }): PromptPieces { const { activeDoc, currentDocument, currentDocLines } = makeActiveDoc(); const promptOptions: PromptOptions = { ...DEFAULT_OPTIONS, @@ -854,14 +857,28 @@ describe('getUserPrompt — globalBudget cascade', () => { [], currentDocLines, 'some code', - undefined, + extra?.langCtx, AggressivenessLevel.Medium, new LintErrors(activeDoc.id, currentDocument, new TestLanguageDiagnosticsService()), s => Math.ceil(s.length / 4), promptOptions, + undefined, + extra?.precomputedCascade, ); } + function makeLangCtxWithSnippet(value: string): LanguageContextResponse { + return { + start: 0, + end: 0, + items: [{ + context: { kind: ContextKind.Snippet, priority: 1, uri: Uri.parse('file:///test/ctx.ts'), value }, + timeStamp: 0, + onTimeout: false, + }], + }; + } + test('produces the same prompt as legacy path when budgets are large', () => { const legacy = getUserPrompt(makePieces(undefined)); const cascaded = getUserPrompt(makePieces({ @@ -898,6 +915,7 @@ describe('getUserPrompt — globalBudget cascade', () => { totalTokens: 1000, order: GlobalBudgetOptions.DEFAULT_ORDER, shares: { + currentFile: 0.5, recentlyViewedDocuments: 0.5, languageContext: 0.5, neighborFiles: 0.5, @@ -913,11 +931,79 @@ describe('getUserPrompt — globalBudget cascade', () => { order: GlobalBudgetOptions.DEFAULT_ORDER, // missing 'diffHistory' shares: { + currentFile: 0.2, languageContext: 0.4, - recentlyViewedDocuments: 0.4, + recentlyViewedDocuments: 0.2, neighborFiles: 0.2, - } as Record<'languageContext' | 'recentlyViewedDocuments' | 'neighborFiles' | 'diffHistory', number>, + } as Record<'currentFile' | 'languageContext' | 'recentlyViewedDocuments' | 'neighborFiles' | 'diffHistory', number>, }); expect(() => getUserPrompt(pieces)).toThrow(/shares is missing entry for 'diffHistory'/); }); + + test('throws when shares is missing an entry for currentFile', () => { + const pieces = makePieces({ + totalTokens: 1000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + // missing 'currentFile' + shares: { + languageContext: 0.25, + recentlyViewedDocuments: 0.25, + neighborFiles: 0.25, + diffHistory: 0.25, + } as Record<'currentFile' | 'languageContext' | 'recentlyViewedDocuments' | 'neighborFiles' | 'diffHistory', number>, + }); + expect(() => getUserPrompt(pieces)).toThrow(/shares is missing entry for 'currentFile'/); + }); + + function runCascade(globalBudget: GlobalBudgetOptions, extra?: { langCtx?: LanguageContextResponse }) { + const { activeDoc } = makeActiveDoc(); + const opts: PromptOptions = { ...DEFAULT_OPTIONS, globalBudget }; + return runGlobalBudgetCascade(activeDoc, [], extra?.langCtx, s => Math.ceil(s.length / 4), opts, undefined, globalBudget); + } + + test('finalSurplus carries the full unused pool when no cascade part consumes budget', () => { + // No langCtx, empty history and neighbors disabled ⇒ every cascade part + // consumes 0, so the entire non-currentFile pool carries to finalSurplus. + // DEFAULT_TOTAL_TOKENS (7500) − currentFileBudget (1500) = 6000. This is + // exactly the budget the provider adds to the current file's clip + // (currentFileBudget 1500 + finalSurplus 6000 = 7500 = T). + const cascade = runCascade({ + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }); + expect(cascade.finalSurplus).toBe(6000); + }); + + test('finalSurplus shrinks by what the cascade consumes, so the current file reuses less leftover', () => { + const globalBudget: GlobalBudgetOptions = { + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }; + // Empty cascade ⇒ the whole non-currentFile pool (6000) carries to finalSurplus. + const empty = runCascade(globalBudget); + // A rendered language-context snippet consumes budget from the pool, so less + // leftover carries to finalSurplus. The provider clips the current file last to + // currentFileBudget + finalSurplus, so a smaller finalSurplus ⇒ the current file + // reuses less leftover (it is trimmed more) once other parts have content. + const consuming = runCascade(globalBudget, { langCtx: makeLangCtxWithSnippet('const ctxMarker = 1;\n'.repeat(40)) }); + expect(consuming.finalSurplus).toBeLessThan(empty.finalSurplus); + }); + + test('precomputedCascade produces an identical prompt to computing the cascade internally', () => { + const snippet = 'const sharedCtxMarker = 42;'; + const globalBudget: PromptOptions['globalBudget'] = { + totalTokens: 100000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }; + const internal = getUserPrompt(makePieces(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet) })); + + const cascade = runCascade(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet) }); + const precomputed = getUserPrompt(makePieces(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet), precomputedCascade: cascade })); + + expect(precomputed.prompt).toBe(internal.prompt); + expect(precomputed.nDiffsInPrompt).toBe(internal.nDiffsInPrompt); + }); }); diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts similarity index 83% rename from extensions/copilot/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts rename to extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts index 72520d3743102..44b190c86cdff 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabCustomDiffPatchResponseHandler.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts @@ -1087,4 +1087,233 @@ another_file.js: expect(edits[0].edit).toEqual(new LineReplacement(new LineRange(2, 3), [' let x = 1;'])); }); }); + + describe('splitReplacement', () => { + + it('splits a multi-hunk replacement into one replacement per changed region', () => { + // removed lines 2..6 (1-based), only lines 3 ("b") and 5 ("d") change. + const removedLines = ['a', 'b', 'c', 'd', 'e']; + const replacement = new LineReplacement(new LineRange(2, 7), ['a', 'B', 'c', 'D', 'e']); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, removedLines); + + expect(result).toEqual([ + new LineReplacement(new LineRange(3, 4), ['B']), + new LineReplacement(new LineRange(5, 6), ['D']), + ]); + }); + + it('leaves a single contiguous change unsplit', () => { + const removedLines = ['a', 'b', 'c']; + const replacement = new LineReplacement(new LineRange(10, 13), ['a', 'X', 'Y', 'c']); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, removedLines); + + expect(result).toEqual([replacement]); + }); + + it('leaves a pure insertion unsplit', () => { + const replacement = new LineReplacement(new LineRange(5, 5), ['x', 'y']); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, []); + + expect(result).toEqual([replacement]); + }); + + it('leaves a pure deletion unsplit', () => { + const replacement = new LineReplacement(new LineRange(5, 8), []); + + const result = XtabPatchResponseHandler.splitReplacement(replacement, ['x', 'y', 'z']); + + expect(result).toEqual([replacement]); + }); + }); + + describe('handleResponse with splitPatchOnDiff', () => { + + const docContent = '0\na\nb\nc\nd\ne\n'; + + // A single patch block whose only real changes are line "b"->"B" and + // "d"->"D"; the lines in between are re-emitted context. + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '-a'; + yield '-b'; + yield '-c'; + yield '-d'; + yield '-e'; + yield '+a'; + yield '+B'; + yield '+c'; + yield '+D'; + yield '+e'; + } + + it('splits one coarse patch into minimal replacements when enabled', async () => { + const docId = DocumentId.create('file:///test.ts'); + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + false, + true, + ); + + expect(edits.map(e => e.edit)).toEqual([ + new LineReplacement(new LineRange(3, 4), ['B']), + new LineReplacement(new LineRange(5, 6), ['D']), + ]); + }); + + it('yields a single coarse replacement when disabled (default)', async () => { + const docId = DocumentId.create('file:///test.ts'); + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + ); + + expect(edits.map(e => e.edit)).toEqual([ + new LineReplacement(new LineRange(2, 7), ['a', 'B', 'c', 'D', 'e']), + ]); + }); + }); + + describe('patchIndex attribution', () => { + + it('extractEdits assigns an incrementing index per model patch header', async () => { + const linesStream = AsyncIterUtils.fromArray([ + 'a.ts:1', + '-a', + '+A', + 'b.ts:2', + '-b', + '+B', + 'c.ts:3', + '-c', + '+C', + ]); + const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream)); + expect(patches.map(p => p.patchIndex)).toEqual([0, 1, 2]); + }); + + it('extractEdits does not advance the index for an invalid header', async () => { + const linesStream = AsyncIterUtils.fromArray([ + 'a.ts:1', + '-a', + '+A', + 'not a valid header', + 'b.ts:2', + '-b', + '+B', + ]); + const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream)); + // The invalid header is skipped, so the two valid patches remain 0 and 1. + expect(patches.map(p => p.patchIndex)).toEqual([0, 1]); + }); + + it('all fragments of a split patch share the originating patchIndex', async () => { + const docId = DocumentId.create('file:///test.ts'); + const docContent = '0\na\nb\nc\nd\ne\n'; + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '-a'; + yield '-b'; + yield '-c'; + yield '-d'; + yield '-e'; + yield '+a'; + yield '+B'; + yield '+c'; + yield '+D'; + yield '+e'; + } + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + false, + true, + ); + + expect(edits).toHaveLength(2); + expect(edits.map(e => e.patchIndex)).toEqual([0, 0]); + }); + + it('distinct model patches get distinct patchIndex values', async () => { + const docId = DocumentId.create('file:///test.ts'); + const docContent = '0\na\nb\nc\nd\n'; + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(1, 1)); + + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '-a'; + yield '+A'; + yield '/test.ts:3'; + yield '-c'; + yield '+C'; + } + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + ); + + expect(edits.map(e => e.patchIndex)).toEqual([0, 1]); + }); + + it('progressive ghost-text fragments share the patchIndex while a following patch advances it', async () => { + const docId = DocumentId.create('file:///test.ts'); + const docContent = 'function foo() {\n let x = 1;\n}\nbar();\n'; + // Cursor on line 2 (1-based) → cursorLineOffset = 1 + const documentBeforeEdits = new CurrentDocument(new StringText(docContent), new Position(2, 5)); + + async function* makeStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '- let x = 1;'; + yield '+ let x = 1;'; + yield '+ let y = 2;'; + yield '/test.ts:3'; + yield '-bar();'; + yield '+baz();'; + } + + const { edits } = await consumeHandleResponse( + makeStream(), + documentBeforeEdits, + docId, + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + true, + ); + + // First two edits are the ghost-text early + continuation of patch 0; + // the third edit comes from the second model patch. + expect(edits.map(e => e.patchIndex)).toEqual([0, 0, 1]); + }); + }); }); diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index 00253ca1e0a49..82acfbff68381 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -22,8 +22,10 @@ import { ILogger } from '../../../../platform/log/common/logService'; import { FilterReason } from '../../../../platform/networking/common/openai'; import { ISimulationTestContext } from '../../../../platform/simulationTestContext/common/simulationTestContext'; import { TestLogService } from '../../../../platform/testing/common/testLogService'; +import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { AsyncIterUtils } from '../../../../util/common/asyncIterableUtils'; import { Result } from '../../../../util/common/result'; +import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument'; import { DeferredPromise } from '../../../../util/vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; @@ -40,6 +42,7 @@ import { DelaySession } from '../../../inlineEdits/common/delay'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; import { N_LINES_AS_CONTEXT } from '../../common/promptCrafting'; import { nes41Miniv3SystemPrompt, simplifiedPrompt, systemPromptTemplate, unifiedModelSystemPrompt, xtab275SystemPrompt } from '../../common/systemMessages'; +import { PromptTags } from '../../common/tags'; import { CurrentDocument } from '../../common/xtabCurrentDocument'; import { computeAreaAroundEditWindowLinesRange, @@ -1136,6 +1139,69 @@ describe('XtabProvider integration', () => { // Group 4: Filter Pipeline // ======================================================================== + describe('global budget', () => { + + /** Drives the provider once and returns the captured user-message text. */ + async function captureUserPrompt(provider: XtabProvider, request: StatelessNextEditRequest): Promise { + streamingFetcher.setStreamingLines(['x']); + const capturesBefore = streamingFetcher.capturedOptions.length; + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + await AsyncIterUtils.drainUntilReturn(gen); + // Guard against silently comparing a stale capture: the run must have fetched. + expect(streamingFetcher.capturedOptions.length).toBeGreaterThan(capturesBefore); + const messages = streamingFetcher.capturedOptions.at(-1)?.messages; + const userMessage = messages?.find(m => m.role === Raw.ChatRole.User); + expect(userMessage).toBeDefined(); + return getMessageText(userMessage!); + } + + /** Number of lines in the `<|current_file_content|>` region of the prompt. */ + function currentFileRegionLineCount(prompt: string): number { + const start = prompt.indexOf(PromptTags.CURRENT_FILE.start); + const end = prompt.indexOf(PromptTags.CURRENT_FILE.end); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return prompt.slice(start, end).split('\n').length; + } + + const bigFile = Array.from({ length: 400 }, (_, i) => `const value${i} = ${i};`); + + // Under a global budget the current file is clipped LAST, so it absorbs whatever + // budget the cascade parts leave unused. With the (here empty) cascade the + // current file therefore reuses essentially the whole pool and keeps strictly + // MORE of the file than the legacy path, which caps it at its own + // currentFile.maxTokens (1500) and trims the tail. + it('absorbs leftover cascade budget so it keeps more of the current file than the legacy cap', async () => { + const legacy = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, '{}'); + const enabledAtDefault = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + // Legacy caps the current file at 2000 tokens → the tail is trimmed. + expect(legacy).not.toContain('const value399 = 399;'); + // Clip-last lets the current file reuse the whole pool → the entire file fits. + expect(enabledAtDefault).toContain('const value399 = 399;'); + expect(currentFileRegionLineCount(legacy)).toBeLessThan(currentFileRegionLineCount(enabledAtDefault)); + }); + + // New behavior: because the current file is sized to its share PLUS the cascade + // leftover (≈ the whole pool when the cascade is empty), a larger total budget + // keeps more of the file. A small pool still trims the tail; a generous pool + // fits the entire file. + it('keeps more of the current file as the total budget grows', async () => { + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, JSON.stringify({ totalTokens: 2000 })); + const smallBudget = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, JSON.stringify({ totalTokens: 8000 })); + const wideBudget = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + // Small pool trims the tail; wide pool fits the whole file. + expect(smallBudget).not.toContain('const value399 = 399;'); + expect(wideBudget).toContain('const value399 = 399;'); + expect(currentFileRegionLineCount(smallBudget)).toBeLessThan(currentFileRegionLineCount(wideBudget)); + }); + }); + describe('filter pipeline', () => { it('filters out import-only changes', async () => { const provider = createProvider(); @@ -1770,6 +1836,59 @@ describe('XtabProvider integration', () => { expect(streamingFetcher.callCount).toBe(3); }); + it('cross-file cursor jump with out-of-bounds predicted line → NoSuggestions without throwing', async () => { + const provider = createProvider(); + await configService.setConfig(ConfigKey.InlineEditsNextCursorPredictionEnabled, true); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsNextCursorPredictionModelName, 'test-model'); + + const lines = Array.from({ length: 30 }, (_, i) => `line ${i} content`); + const cursorOffset = lines.slice(0, 5).join('\n').length; + const request = createRequestWithEdit(lines, { insertionOffset: cursorOffset }); + + // 1st call (main LLM): edit-window lines unchanged → no edits → cursor jump path + const mainEditWindowLines = lines.slice(3, 11); + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-main', + serverRequestId: 'srv-main', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: mainEditWindowLines.join('\n'), + resolvedModel: 'test-model', + }); + + // 2nd call (cursor prediction): predict a jump into another file at line 50 (0-based), + // which is out of bounds for the 5-line target document opened below. + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-cursor', + serverRequestId: 'srv-cursor', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: '/test/other.ts:50', + resolvedModel: 'test-model', + }); + + // The cross-file jump target only has 5 lines, so the predicted line 50 is out of bounds. + const targetDoc = createTextDocumentData( + URI.file('/test/other.ts'), + Array.from({ length: 5 }, (_, i) => `other ${i}`).join('\n'), + 'typescript', + ).document; + const workspaceService = instaService.invokeFunction(accessor => accessor.get(IWorkspaceService)); + const openSpy = vi.spyOn(workspaceService, 'openTextDocument').mockResolvedValue(targetDoc); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + // The out-of-bounds prediction must be rejected before any retry fetch happens, rather + // than constructing a CurrentDocument with an out-of-bounds cursor (which would throw). + expect(streamingFetcher.callCount).toBe(2); + expect(openSpy).toHaveBeenCalledOnce(); + + openSpy.mockRestore(); + }); + it('model fallback retry on NotFound then yields edits on second attempt', async () => { const provider = createProvider(); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 548e08874627e..462d3dd09d1f2 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -728,8 +728,12 @@ export namespace ConfigKey { // OTel settings export const OTelEnabled = defineSetting('chat.otel.enabled', ConfigType.Simple, false); export const OTelExporterType = defineSetting('chat.otel.exporterType', ConfigType.Simple, 'otlp-http'); + export const OTelProtocol = defineSetting('chat.otel.protocol', ConfigType.Simple, ''); export const OTelOtlpEndpoint = defineSetting('chat.otel.otlpEndpoint', ConfigType.Simple, 'http://localhost:4318'); export const OTelCaptureContent = defineSetting('chat.otel.captureContent', ConfigType.Simple, false); + export const OTelServiceName = defineSetting('chat.otel.serviceName', ConfigType.Simple, ''); + export const OTelResourceAttributes = defineSetting>('chat.otel.resourceAttributes', ConfigType.Simple, {}); + export const OTelHeaders = defineSetting>('chat.otel.headers', ConfigType.Simple, {}); export const OTelMaxAttributeSizeChars = defineSetting('chat.otel.maxAttributeSizeChars', ConfigType.Simple, 0); export const OTelOutfile = defineSetting('chat.otel.outfile', ConfigType.Simple, ''); export const OTelDbSpanExporter = defineSetting('chat.otel.dbSpanExporter.enabled', ConfigType.Simple, false); @@ -881,11 +885,11 @@ export namespace ConfigKey { export const InlineEditsXtabLanguageContextMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.maxTokens); export const InlineEditsXtabIncludeNeighborFiles = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.neighborFiles.enabled', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.neighborFiles.enabled); export const InlineEditsXtabNeighborFilesMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.neighborFiles.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.neighborFiles.maxTokens); - export const InlineEditsXtabGlobalBudgetEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.globalBudget.enabled', ConfigType.ExperimentBased, false); - export const InlineEditsXtabGlobalBudgetTotalTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.globalBudget.totalTokens', ConfigType.ExperimentBased, xtabPromptOptions.GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS); + export const InlineEditsXtabGlobalBudget = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.globalBudget', ConfigType.ExperimentBased, undefined); export const InlineEditsXtabMaxMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.maxMergeConflictLines', ConfigType.ExperimentBased, undefined); export const InlineEditsXtabOnlyMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.onlyMergeConflictLines', ConfigType.ExperimentBased, false); export const InlineEditsXtabDuplicateAdditionsMode = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffPatch.duplicateAdditionsMode', ConfigType.ExperimentBased, DuplicateAdditionsMode.Off, DuplicateAdditionsMode.VALIDATOR); + export const InlineEditsXtabSplitPatchOnDiff = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffPatch.splitOnDiff', ConfigType.ExperimentBased, false, vBoolean()); export const InlineEditsXtabAggressivenessLevel = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.aggressivenessLevel', ConfigType.ExperimentBased, undefined); export const InlineEditsAggressivenessLowMinResponseTimeMs = defineTeamInternalSetting('chat.advanced.inlineEdits.aggressiveness.lowMinResponseTimeMs', ConfigType.ExperimentBased, 1500); export const InlineEditsAggressivenessMediumMinResponseTimeMs = defineTeamInternalSetting('chat.advanced.inlineEdits.aggressiveness.mediumMinResponseTimeMs', ConfigType.ExperimentBased, 700); @@ -899,15 +903,6 @@ export namespace ConfigKey { export const InlineEditsJointCompletionsProviderTriggerChangeStrategy = defineTeamInternalSetting('chat.advanced.inlineEdits.jointCompletionsProvider.triggerChangeStrategy', ConfigType.ExperimentBased, JointCompletionsProviderTriggerChangeStrategy.NoTriggerOnCompletionsRequestInFlight); export const InstantApplyModelName = defineTeamInternalSetting('chat.advanced.instantApply.modelName', ConfigType.ExperimentBased, CHAT_MODEL.GPT4OPROXY); export const VerifyTextDocumentChanges = defineTeamInternalSetting('chat.advanced.inlineEdits.verifyTextDocumentChanges', ConfigType.ExperimentBased, false); - export const UseAutoModeRouting = defineTeamInternalSetting('chat.advanced.useAutoModeRouter', ConfigType.ExperimentBased, false); - /** Controls which `routing_method` value is sent to the auto-intent-service per request - * when `UseAutoModeRouting` is enabled. - * '' (empty/default) = omit `routing_method` and use the server default. - * 'binary' = binary classifier v1. - * 'hydra' = HYDRA multi-head capability matching. - * For experiments, this setting selects the routing method only when router usage is enabled; - * it does not by itself determine whether the router is called. */ - export const AutoModeRoutingMethod = defineTeamInternalSetting('chat.advanced.autoModeRoutingMethod', ConfigType.ExperimentBased, '', undefined, undefined, { experimentName: 'copilotchat.autoModeRoutingMethod' }); /** Inline Completions */ export const InlineCompletionsDefaultDiagnosticsOptions = defineTeamInternalSetting('chat.advanced.inlineCompletions.defaultDiagnosticsOptionsString', ConfigType.ExperimentBased, undefined); @@ -968,14 +963,12 @@ export namespace ConfigKey { export const UseAnthropicMessagesApi = defineSetting('chat.anthropic.useMessagesApi', ConfigType.ExperimentBased, true); /** Context editing mode for Anthropic Messages API. 'off' disables context editing. */ export const AnthropicContextEditingMode = defineSetting<'off' | 'clear-thinking' | 'clear-tooluse' | 'clear-both'>('chat.anthropic.contextEditing.mode', ConfigType.ExperimentBased, 'off'); - /** Configure reasoning summary style sent to Responses API */ - export const ResponsesApiReasoningSummary = defineSetting<'off' | 'detailed'>('chat.responsesApiReasoningSummary', ConfigType.ExperimentBased, 'detailed'); /** Enable context_management sent to Responses API */ export const ResponsesApiContextManagementEnabled = defineSetting('chat.responsesApiContextManagement.enabled', ConfigType.ExperimentBased, false); /** Enable client-side prompt_cache_key (conversationId:modelFamily) sent to Responses API */ export const ResponsesApiPromptCacheKeyEnabled = defineSetting('chat.responsesApi.promptCacheKey.enabled', ConfigType.ExperimentBased, false); - /** Enable persistent chain of thought for supported Responses API model families */ - export const ResponsesApiPersistentCoTEnabled = defineSetting('chat.responsesApi.persistentCoT.enabled', ConfigType.ExperimentBased, false); + /** Enable explicit prompt_cache_breakpoint markers sent to Responses API */ + export const ResponsesApiPromptCacheBreakpointEnabled = defineSetting('chat.responsesApi.promptCacheBreakpoint.enabled', ConfigType.ExperimentBased, false); /** Enable updated prompt for 5.3Codex model */ export const Updated53CodexPromptEnabled = defineSetting('chat.updated53CodexPrompt.enabled', ConfigType.ExperimentBased, true); /** Enable updated prompt for Claude Opus 4.7 model */ @@ -983,12 +976,12 @@ export namespace ConfigKey { /** Enable get_changed_files tool for GPT-5.5 models */ export const EnableGpt55GetChangedFilesTool = defineSetting('chat.gpt55GetChangedFilesTool.enabled', ConfigType.ExperimentBased, true); /** Enable get_changed_files tool for Gemini 3 models */ - export const EnableGemini3GetChangedFilesTool = defineSetting('chat.gemini3GetChangedFilesTool.enabled', ConfigType.ExperimentBased, true); + export const EnableGemini3GetChangedFilesTool = defineSetting('chat.gemini3GetChangedFilesTool.enabled', ConfigType.ExperimentBased, false); /** When enabled, sends `reasoning_effort: 'low'` to Gemini 3 models. */ export const EnableGemini3LowReasoningEffort = defineSetting('chat.gemini3LowReasoningEffort.enabled', ConfigType.ExperimentBased, false); /** Enable read_file tool for GPT-5.5 models */ export const EnableGpt55ReadFileTool = defineSetting('chat.gpt55ReadFileTool.enabled', ConfigType.ExperimentBased, true); - export const EnableChatImageUpload = defineSetting('chat.imageUpload.enabled', ConfigType.ExperimentBased, true); + export const EnableChatImageUpload = defineSetting('chat.imageUpload.enabled', ConfigType.Simple, true); /** Enable Anthropic web search tool for BYOK Claude models */ export const AnthropicWebSearchToolEnabled = defineSetting('chat.anthropic.tools.websearch.enabled', ConfigType.ExperimentBased, false); /** Maximum number of web searches allowed per request */ @@ -1057,7 +1050,7 @@ export namespace ConfigKey { export const EnableAlternateGptPrompt = defineSetting('chat.alternateGptPrompt.enabled', ConfigType.ExperimentBased, false); export const EnableAlternateGeminiModelFPrompt = defineSetting('chat.alternateGeminiModelFPrompt.enabled', ConfigType.ExperimentBased, false); - export const EnableGemini35FlashReducedToolUsePrompt = defineSetting('chat.gemini35FlashReducedToolUsePrompt.enabled', ConfigType.ExperimentBased, false); + export const EnableGemini35FlashReducedToolUsePrompt = defineSetting('chat.gemini35FlashReducedToolUsePrompt.enabled', ConfigType.ExperimentBased, true); export const EnableOrganizationCustomAgents = defineSetting('chat.organizationCustomAgents.enabled', ConfigType.Simple, true); export const EnableOrganizationInstructions = defineSetting('chat.organizationInstructions.enabled', ConfigType.Simple, true); @@ -1100,7 +1093,9 @@ export namespace ConfigKey { export const LocalIndexEnabled = defineSetting('chat.localIndex.enabled', ConfigType.ExperimentBased, false); /** grep_search configs */ - export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'tag'); + export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'grep'); + export const GrepSearchDefaultMaxResults = defineSetting('chat.tools.grepSearch.defaultMaxResults', ConfigType.ExperimentBased, 20); + export const GrepSearchMaxResultsCap = defineSetting('chat.tools.grepSearch.maxResultsCap', ConfigType.ExperimentBased, 200); } export function getAllConfigKeys(): string[] { diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index fb725ff704d3c..c633084a0daff 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -38,6 +38,8 @@ const VSC_MODEL_HASHES_D = [ 'e82ff0e2d4e4bae1f012dc599d520f8d61becfc4762f3717577b270be199db92', ]; +const VSC_MODEL_HASHES_E: string[] = []; + // subset to allow replace string instead of apply patch. const VSC_MODEL_HASHES_EDIT_TOOL_SET = [ @@ -71,10 +73,6 @@ const HIDDEN_FAMILY_H_HASHES: string[] = [ '70fcded3f255d368e868cc807d8838a62108bfa5c86ce7d37966f58cda229e33', ]; -const HIDDEN_FAMILY_M_HASHES: string[] = [ - '0902565c0c0fe145633a1f246ae551acc0f621249ef050428eba357fbd4655ee', -]; - /** * Per-model capability override. Lets advanced users (and evals) alias an * unknown/preview model id to a known production family for capability @@ -156,9 +154,18 @@ export function isGpt55(model: LanguageModelChat | IChatEndpoint | string) { return family.startsWith('gpt-5.5') || HIDDEN_MODEL_B_HASHES.includes(h); } -export function isHiddenModelM(model: LanguageModelChat | IChatEndpoint | string) { - const family_hash = getCachedSha256Hash(typeof model === 'string' ? model : model.family); - return HIDDEN_FAMILY_M_HASHES.includes(family_hash); +export function isGpt56(model: LanguageModelChat | IChatEndpoint | string) { + return isGpt56SolOrTerra(model) || isGpt56Luna(model); +} + +export function isGpt56SolOrTerra(model: LanguageModelChat | IChatEndpoint | string) { + const family = typeof model === 'string' ? model : model.family; + return family === 'ember-alpha'; +} + +export function isGpt56Luna(model: LanguageModelChat | IChatEndpoint | string) { + const family = typeof model === 'string' ? model : model.family; + return family === 'opal-alpha'; } export function isGpt53Codex(model: LanguageModelChat | IChatEndpoint | string) { @@ -166,6 +173,19 @@ export function isGpt53Codex(model: LanguageModelChat | IChatEndpoint | string) return family.startsWith('gpt-5.3-codex'); } +export function isKimiFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { + const matches = (value: string): boolean => { + const normalized = value.toLowerCase(); + return normalized.startsWith('kimi-k2.6') || normalized.startsWith('kimi-k2.7-code'); + }; + + if (typeof model === 'string') { + return matches(model); + } + + return matches(model.family) || matches(getModelId(model)); +} + export function isVSCModelA(model: LanguageModelChat | IChatEndpoint) { const ID_hash = getCachedSha256Hash(getModelId(model)); @@ -197,6 +217,13 @@ export function isVSCModelD(model: LanguageModelChat | IChatEndpoint) { return VSC_MODEL_HASHES_D.includes(ID_hash) || VSC_MODEL_HASHES_D.includes(family_hash); } +export function isVSCModelE(model: LanguageModelChat | IChatEndpoint) { + const modelId = getModelId(model); + const ID_hash = getCachedSha256Hash(modelId); + const family_hash = getCachedSha256Hash(model.family); + return model.name.startsWith('vscModelE') || model.family.startsWith('vscModelE') || modelId.startsWith('vscModelE') || VSC_MODEL_HASHES_E.includes(ID_hash) || VSC_MODEL_HASHES_E.includes(family_hash); +} + export function isGpt52CodexFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { const family = typeof model === 'string' ? model : model.family; return family === 'gpt-5.2-codex'; @@ -240,7 +267,7 @@ export function modelSupportsApplyPatch(model: LanguageModelChat | IChatEndpoint || isGpt52Family(model.family) || isGpt54(model) || isHiddenModelB(model) - || isHiddenModelM(model); + || isGpt56(model); } /** @@ -254,21 +281,21 @@ export function modelPrefersJsonNotebookRepresentation(model: LanguageModelChat || isGpt52Family(model.family) || isGpt54(model) || isHiddenModelB(model) - || isHiddenModelM(model); + || isGpt56(model); } /** * Model supports replace_string_in_file as an edit tool. */ export function modelSupportsReplaceString(model: LanguageModelChat | IChatEndpoint): boolean { - return isGeminiFamily(model) || model.family.includes('grok-code') || modelSupportsMultiReplaceString(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); + return isGeminiFamily(model) || model.family.includes('grok-code') || modelSupportsMultiReplaceString(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model) || isKimiFamily(model); } /** * Model supports multi_replace_string_in_file as an edit tool. */ export function modelSupportsMultiReplaceString(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model) || isHiddenModelE(model) || isVSCModelReplaceStringSet(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); + return isAnthropicFamily(model) || isHiddenModelE(model) || isVSCModelReplaceStringSet(model) || isMinimaxFamily(model) || isHiddenFamilyH(model) || isKimiFamily(model); } /** @@ -276,7 +303,7 @@ export function modelSupportsMultiReplaceString(model: LanguageModelChat | IChat * without needing insert_edit_into_file. */ export function modelCanUseReplaceStringExclusively(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model) || model.family.includes('grok-code') || isHiddenModelE(model) || model.family.toLowerCase().includes('gemini-3') || isVSCModelReplaceStringSet(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); + return isAnthropicFamily(model) || model.family.includes('grok-code') || isHiddenModelE(model) || model.family.toLowerCase().includes('gemini-3') || isVSCModelReplaceStringSet(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model) || isKimiFamily(model); } /** @@ -305,7 +332,16 @@ export function modelCanUseImageURL(model: LanguageModelChat | IChatEndpoint): b * The model supports native PDF document processing via document content parts. */ export function modelSupportsPDFDocuments(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model) || isGpt5PlusFamily(model) || isHiddenModelM(model); + return isAnthropicFamily(model) || isGpt5PlusFamily(model) || isGpt56(model); +} + +/** + * The model supports explicit prompt cache breakpoints via the OpenAI + * Responses API (`prompt_cache_breakpoint`). Scoped to OpenAI (GPT) models + * only, since this is an OpenAI-specific Responses API feature. + */ +export function modelSupportCacheBreakPoints(model: LanguageModelChat | IChatEndpoint): boolean { + return isGpt56(model); } /** @@ -429,10 +465,11 @@ export function getVerbosityForModelSync(model: IChatEndpoint): 'low' | 'medium' export function modelSupportsToolSearch(model: LanguageModelChat | IChatEndpoint | string): boolean { const id = typeof model === 'string' ? model : getModelId(model); const family = typeof model === 'string' ? model : model.family; + const isGpt56Model: boolean = isGpt56(model); const matches = (s: string) => { const n = s.toLowerCase().replace(/\./g, '-'); // OpenAI models with client-side tool search. - if (n === 'gpt-5-4' || n === 'gpt-5-5') { + if (n === 'gpt-5-4' || n === 'gpt-5-5' || isGpt56Model) { return true; } if (!n.startsWith('claude')) { @@ -455,7 +492,7 @@ export function modelSupportsToolSearch(model: LanguageModelChat | IChatEndpoint n === 'claude-opus-4' || n.startsWith('claude-opus-4-1') || n.startsWith('claude-opus-4-2'); return !isPre45; }; - return matches(id) || matches(family) || isHiddenModelM(family); + return matches(id) || matches(family); } /** diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index 647b162de87ac..28d7f2283de8e 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -19,6 +19,11 @@ export type CustomModel = { export type EndpointEditToolName = 'find-replace' | 'multi-find-replace' | 'apply-patch' | 'code-rewrite'; +export interface IChatModelRequestOptions { + temperature?: number | null; + top_p?: number | null; +} + const allEndpointEditToolNames: ReadonlySet = new Set([ 'find-replace', 'multi-find-replace', @@ -115,6 +120,7 @@ export interface IModelAPIResponse { version: string; warning_messages?: { code: string; message: string }[]; info_messages?: { code: string; message: string }[]; + warning_text?: Record; billing?: IModelBilling; model_picker_price_category?: string; model_picker_category?: string; @@ -127,6 +133,7 @@ export type IChatModelInformation = IModelAPIResponse & { capabilities: IChatModelCapabilities; urlOrRequestMetadata?: string | RequestMetadata; requestHeaders?: Readonly>; + modelOptions?: Readonly; zeroDataRetentionEnabled?: boolean; /** * BYOK-only override that forces the body shape used when forwarding the reasoning effort to the model. diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 94bdd3038092e..bf1ce36b2795e 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -11,7 +11,6 @@ import { Disposable, DisposableMap } from '../../../util/vs/base/common/lifecycl import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../../vscodeTypes'; import { IAuthenticationService } from '../../authentication/common/authentication'; -import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { IEnvService } from '../../env/common/envService'; import { getImageTelemetryEventMeasurements, getImageTelemetryMeasurementsFromReferences, type ImageTelemetryMeasurements } from '../../image/common/imageTelemetry'; import { ILogService } from '../../log/common/logService'; @@ -104,6 +103,13 @@ class AutoModeTokenBank extends Disposable { } } +export interface AutoModeRoutingDecision { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: 'needs_reasoning' | 'no_reasoning' | 'fallback'; + confidence: number; +} + export const IAutomodeService = createServiceIdentifier('IAutomodeService'); export interface IAutomodeService { @@ -111,6 +117,13 @@ export interface IAutomodeService { resolveAutoModeEndpoint(chatRequest: ChatRequest | undefined, knownEndpoints: IChatEndpoint[]): Promise; + /** + * Returns the routing decision from the last call to {@link resolveAutoModeEndpoint}, + * or `undefined` if the router was not used (e.g. skipped, fallback, or non-auto model). + * Cleared after reading. + */ + consumeLastRoutingDecision(): AutoModeRoutingDecision | undefined; + /** * Marks the router cache for this conversation as needing re-evaluation. * The next call to {@link resolveAutoModeEndpoint} will re-run the router @@ -124,6 +137,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { private readonly _autoModelCache: Map = new Map(); private _reserveTokens: DisposableMap = new DisposableMap(); private readonly _routerDecisionFetcher: RouterDecisionFetcher; + private _lastRoutingDecision: AutoModeRoutingDecision | undefined; constructor( @ICAPIClientService private readonly _capiClientService: ICAPIClientService, @@ -131,7 +145,6 @@ export class AutomodeService extends Disposable implements IAutomodeService { @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IExperimentationService private readonly _expService: IExperimentationService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvService private readonly _envService: IEnvService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IRequestLogger private readonly _requestLogger: IRequestLogger, @@ -161,6 +174,12 @@ export class AutomodeService extends Disposable implements IAutomodeService { super.dispose(); } + consumeLastRoutingDecision(): AutoModeRoutingDecision | undefined { + const decision = this._lastRoutingDecision; + this._lastRoutingDecision = undefined; + return decision; + } + /** * Resolve an auto mode endpoint * Optionally uses a router model to select the best endpoint based on the prompt. @@ -179,6 +198,10 @@ export class AutomodeService extends Disposable implements IAutomodeService { throw new Error('No auto mode endpoints provided.'); } + // Clear any previous routing decision upfront so stale data cannot + // leak to a consumer if this call takes a non-router path. + this._lastRoutingDecision = undefined; + const conversationId = chatRequest?.sessionResource?.toString() ?? chatRequest?.sessionId ?? 'unknown'; const entry = this._autoModelCache.get(conversationId); const tokenBank = this._acquireTokenBank(entry, chatRequest?.location, conversationId); @@ -239,6 +262,15 @@ export class AutomodeService extends Disposable implements IAutomodeService { selectedModel = this._applyVisionFallback(chatRequest, selectedModel, token.available_models, knownEndpoints); + // Store routing decision for the UI to consume (update resolved model to the final one after all overrides) + if (routerResult.routingDecision) { + this._lastRoutingDecision = { + ...routerResult.routingDecision, + resolvedModel: selectedModel.model, + resolvedModelName: selectedModel.name, + }; + } + // Emit the final model selection alongside the router's recommendation // so analysts can detect overrides without fragile telemetry joins if (!skipRouter && routerResult.candidateModel) { @@ -315,7 +347,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { token: AutoModeAPIResponse, knownEndpoints: IChatEndpoint[], imageTelemetryEventMeasurements: Partial, - ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string }> { + ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string; routingDecision?: AutoModeRoutingDecision }> { const prompt = chatRequest?.prompt?.trim(); const lastRoutedPrompt = entry?.lastRoutedPrompt ?? prompt; @@ -340,7 +372,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { previous_model: entry?.endpoint?.model, turn_number: (entry?.turnCount ?? 0) + 1, }; - const routingMethod = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AutoModeRoutingMethod, this._expService) || undefined; + const routingMethod = 'hydra'; // Filter available_models to only those the client can actually serve. // The AutoModels API and Models API are separate CAPI calls that can be @@ -372,22 +404,40 @@ export class AutomodeService extends Disposable implements IAutomodeService { return { lastRoutedPrompt: prompt, fallbackReason: 'emptyCandidateList' }; } - // Trust the router's ranked candidate list directly. + // Prefer chosen_model — it is the router's authoritative pick after any + // server-side re-ranking (e.g. Cost Sorting experiments). candidate_models + // is the ordered fallback list per the auto-intent-service contract + // (docs/integrators_onboarding.md: "Use chosen_model for the upcoming chat + // call, and use candidate_models as the ordered fallback list"). // Same-provider preference is intentionally NOT applied here — the router // already accounts for available models and re-runs after /compact, so // overriding its pick with same-provider negates cost-saving decisions. // Same-provider is still used in _selectDefaultModel (the non-router fallback). - const selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints); + const routerModel = result.chosen_model ?? result.candidate_models[0]; + let selectedModel = result.chosen_model ? knownEndpoints.find(e => e.model === result.chosen_model) : undefined; + if (!selectedModel) { + selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints); + } if (!selectedModel) { - this._logService.warn(`[AutomodeService] None of the router's candidate_models matched knownEndpoints: [${result.candidate_models.join(', ')}]`); + this._logService.warn(`[AutomodeService] Router pick not in knownEndpoints: chosen_model=${result.chosen_model ?? 'n/a'}, candidate_models=[${result.candidate_models.join(', ')}]`); return { lastRoutedPrompt: prompt, fallbackReason: 'noMatchingEndpoint' }; } if (result.sticky_override) { - this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${result.candidate_models[0]}, actual_model=${selectedModel.model}`); + this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${routerModel}, actual_model=${selectedModel.model}`); } - return { selectedModel, lastRoutedPrompt: prompt, candidateModel: result.candidate_models[0] }; + return { + selectedModel, + lastRoutedPrompt: prompt, + candidateModel: routerModel, + routingDecision: { + resolvedModel: selectedModel.model, + resolvedModelName: selectedModel.name, + predictedLabel: result.predicted_label, + confidence: result.confidence, + }, + }; } catch (e) { const isTimeout = isAbortError(e); let fallbackReason: string; @@ -440,7 +490,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { private _isRouterEnabled(chatRequest: ChatRequest | undefined): boolean { const isPanelChat = !chatRequest?.location || chatRequest?.location === ChatLocation.Panel; - return isPanelChat && this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.UseAutoModeRouting, this._expService); + return isPanelChat; } /** diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 4f6f23037f694..b5808204c7041 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -141,6 +141,7 @@ export class ChatEndpoint implements IChatEndpoint { public readonly modelPickerCategory?: string | undefined; public readonly customModel?: CustomModel | undefined; public readonly maxPromptImages?: number | undefined; + public readonly warningText?: Record | undefined; private readonly _supportsStreaming: boolean; @@ -190,6 +191,7 @@ export class ChatEndpoint implements IChatEndpoint { this._supportsStreaming = !!modelMetadata.capabilities.supports.streaming; this.customModel = modelMetadata.custom_model; this.maxPromptImages = modelMetadata.capabilities.limits?.vision?.max_prompt_images; + this.warningText = modelMetadata.warning_text; } // TODO: Thread enableThinking through the fetch pipeline (INetworkRequestOptions / chatMLFetcher positional params) diff --git a/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts index 3f1f0f5684c39..17306f5c5fd7e 100644 --- a/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts @@ -75,7 +75,17 @@ export class CopilotUtilitySmallChatEndpoint { static readonly capiFamily: string = CHAT_MODEL.GPT4OMINI; static async resolve(modelFetcher: IModelMetadataFetcher, instantiationService: IInstantiationService): Promise { - const modelMetadata = await modelFetcher.getChatModelFromCapiFamily(CopilotUtilitySmallChatEndpoint.capiFamily); + let modelMetadata: IChatModelInformation; + try { + modelMetadata = await modelFetcher.getChatModelFromCapiFamily(CopilotUtilitySmallChatEndpoint.capiFamily); + } catch { + // The small family is selected client-side and may be absent from a + // given user's CAPI `/models` response (plan/region/rollout differences, + // or a server-side rename/removal). Fall back to the API-marked base + // utility model rather than letting the lookup throw through every + // `copilot-utility-small` caller. + modelMetadata = await modelFetcher.getCopilotUtilityModel(); + } return instantiationService.createInstance(CopilotChatEndpoint, modelMetadata); } } diff --git a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts index 46912f0693c3d..601295b10a806 100644 --- a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts @@ -175,7 +175,10 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._copilotUtilityModel; if (!resolvedModel || !isChatModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)')); + // If the model fetch itself failed (e.g. an expired token returning HTTP 401), surface that + // underlying error rather than the misleading "no fallback model" message, which only makes + // sense when the fetch succeeded but the server genuinely marked no chat fallback. + throw this._lastFetchError ?? new Error(await this._getErrorMessage('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)')); } return resolvedModel; } @@ -184,7 +187,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._familyMap.get(family)?.[0]; if (!resolvedModel || !isChatModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage(`Unable to resolve chat model with CAPI family selection: ${family}`)); + throw this._lastFetchError ?? new Error(await this._getErrorMessage(`Unable to resolve chat model with CAPI family selection: ${family}`)); } return resolvedModel; } @@ -220,7 +223,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._familyMap.get(family)?.[0]; if (!resolvedModel || !isEmbeddingModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage(`Unable to resolve embeddings model with family selection: ${family}`)); + throw this._lastFetchError ?? new Error(await this._getErrorMessage(`Unable to resolve embeddings model with family selection: ${family}`)); } return resolvedModel; } diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index fba5bbff3f782..407986bef4baf 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -27,7 +27,7 @@ import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManage import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; -import { getVerbosityForModelSync, isGpt54, isGpt55, isHiddenModelM } from '../common/chatModelCapabilities'; +import { getVerbosityForModelSync, modelSupportCacheBreakPoints } from '../common/chatModelCapabilities'; import { rawPartAsCompactionData } from '../common/compactionDataContainer'; import { rawPartAsPhaseData } from '../common/phaseDataContainer'; import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex } from '../common/statefulMarkerContainer'; @@ -123,6 +123,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: ? new Map(options.requestOptions.tools.map(t => [t.function.name, t])) : undefined; const shouldLoadToolFromToolSearch = shouldDeferTools ? (name: string) => !toolDeferralService!.isNonDeferredTool(name) : undefined; + const promptCacheBreakpointsEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPromptCacheBreakpointEnabled, expService); const body: IEndpointBody = { model, @@ -130,6 +131,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: toolsMap, shouldLoadToolFromToolSearch, modeChanged, + supportsCacheBreakpoints: promptCacheBreakpointsEnabled && modelSupportCacheBreakPoints(endpoint), }), stream: true, tools: finalTools.length > 0 ? finalTools : undefined, @@ -155,21 +157,15 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: body.truncation = configService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation) ? 'auto' : 'disabled'; - const thinkingExplicitlyDisabled = options.modelCapabilities?.enableThinking === false; - const summaryConfig = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiReasoningSummary, expService); - const shouldDisableReasoningSummary = endpoint.family === 'gpt-5.3-codex-spark-preview' || thinkingExplicitlyDisabled; const effortFromSetting = configService.getConfig(ConfigKey.Advanced.ReasoningEffortOverride); const effort = endpoint.supportsReasoningEffort?.length ? (effortFromSetting || options.modelCapabilities?.reasoningEffort || 'medium') : undefined; - const summary = summaryConfig === 'off' || shouldDisableReasoningSummary ? undefined : summaryConfig; - const persistentCoTEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, expService) - && (isGpt54(endpoint) || isGpt55(endpoint) || isHiddenModelM(endpoint)); - if (effort || summary || persistentCoTEnabled) { + const summary: string | undefined = undefined; + if (effort || summary) { body.reasoning = { ...(effort ? { effort } : {}), - ...(summary ? { summary } : {}), - ...(persistentCoTEnabled ? { context: 'all_turns' } : {}) + ...(summary ? { summary } : {}) }; } @@ -304,10 +300,11 @@ interface RawMessagesToResponseAPIOptions { readonly toolsMap?: Map; readonly shouldLoadToolFromToolSearch?: (name: string) => boolean; readonly modeChanged?: boolean; + readonly supportsCacheBreakpoints?: boolean; } function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMessage[], ignoreStatefulMarker: boolean, webSocketStatefulMarker: string | undefined, options: RawMessagesToResponseAPIOptions = {}): { input: OpenAI.Responses.ResponseInputItem[]; previous_response_id?: string } { - const { toolsMap, shouldLoadToolFromToolSearch, modeChanged = false } = options; + const { toolsMap, shouldLoadToolFromToolSearch, modeChanged = false, supportsCacheBreakpoints = false } = options; const latestCompactionMessageIndex = getLatestCompactionMessageIndex(messages); const latestCompactionMessage = latestCompactionMessageIndex !== undefined ? createCompactionRoundTripMessage(messages[latestCompactionMessageIndex]) : undefined; @@ -378,6 +375,7 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe const input: OpenAI.Responses.ResponseInputItem[] = []; for (const message of messages) { + const inputStartIndex = input.length; switch (message.role) { case Raw.ChatRole.Assistant: if (message.content.length) { @@ -472,6 +470,16 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe input.push({ role: 'system', content: message.content.map(rawContentToResponsesContent).filter(isDefined) }); break; } + + if (supportsCacheBreakpoints && input.length > inputStartIndex && hasCacheBreakpoint(message)) { + // Attach the prompt-cache marker to the last item this message produced, scanning back past + // reasoning/compaction items that cannot carry it. + for (let inputIndex = input.length - 1; inputIndex >= inputStartIndex; inputIndex--) { + if (tryApplyPromptCacheBreakpoint(input[inputIndex])) { + break; + } + } + } } return { input, previous_response_id: previousResponseId }; @@ -570,6 +578,56 @@ function rawContentToResponsesAssistantContent(part: Raw.ChatCompletionContentPa } } +interface ResponsesPromptCacheBreakpoint { + readonly mode: 'explicit'; +} + +const promptCacheBreakpoint: ResponsesPromptCacheBreakpoint = { mode: 'explicit' }; + +/** + * Whether a raw message carries one or more prompt-cache breakpoints. The Responses content + * converters drop `CacheBreakpoint` parts, so we detect them at the message level and later attach + * `prompt_cache_breakpoint` to the appropriate Responses input item/content block. + */ +function hasCacheBreakpoint(message: Raw.ChatMessage): boolean { + return message.content.some(part => part.type === Raw.ChatCompletionContentPartKind.CacheBreakpoint); +} + +/** + * Attaches a prompt-cache marker (`prompt_cache_breakpoint: { mode: 'explicit' }`) to a single + * Responses API input item. + * + * Items that carry a non-empty `content` array (user/system/assistant messages) receive the marker + * on their last content block. Items without a content array (`function_call`, + * `function_call_output`, `tool_search_*`) receive the marker at the item level. Returns whether a + * marker was applied. + */ +function tryApplyPromptCacheBreakpoint(item: OpenAI.Responses.ResponseInputItem): boolean { + const content = (item as { content?: unknown }).content; + if (Array.isArray(content)) { + const lastContentBlock = content.at(-1) as { prompt_cache_breakpoint?: ResponsesPromptCacheBreakpoint } | undefined; + if (!lastContentBlock) { + return false; + } + + lastContentBlock.prompt_cache_breakpoint = promptCacheBreakpoint; + return true; + } + + const itemType = (item as { type?: string }).type; + if ( + itemType === 'function_call' + || itemType === 'function_call_output' + || itemType === 'tool_search_call' + || itemType === 'tool_search_output' + ) { + (item as { prompt_cache_breakpoint?: ResponsesPromptCacheBreakpoint }).prompt_cache_breakpoint = promptCacheBreakpoint; + return true; + } + + return false; +} + /** * The Responses API rejects the entire request with * `400 invalid_request_body: Invalid 'input[N].id': '...'. Expected an ID that begins with 'rs'.` @@ -1116,7 +1174,16 @@ export class OpenAIResponsesProcessor { switch (chunk.type) { case 'error': - return onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] }); + // Surface the error as a progress delta, but also produce a terminal + // completion so the request resolves to a meaningful server error + // instead of collapsing into the generic "Response contained no + // choices" fallback when the stream ends without a terminal event. + onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] }); + return this.buildTerminalCompletion( + { output: [] } as unknown as CapiResponseTerminalEvent['response'], + FinishedCompletionReason.ServerError, + { error: mapResponsesApiError({ code: chunk.code, message: chunk.message } as OpenAI.Responses.ResponseError) } + ); case 'response.output_text.delta': { const capiChunk: CapiResponsesTextDeltaEvent = chunk; // When text arrives from a new output item, emit a paragraph diff --git a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts index 436ce8b503868..f205f027b4de7 100644 --- a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts @@ -29,6 +29,7 @@ export interface RouterDecisionResponse { hydra_scores?: Record; chosen_model?: string; chosen_shortfall?: number; + reasoning_bucket?: string; } export interface RoutingContextSignals { diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index fb5e98e155b2f..a2d4e2aca4d72 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -9,9 +9,6 @@ import type { ChatRequest } from 'vscode'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../../../vscodeTypes'; import { IAuthenticationService } from '../../../authentication/common/authentication'; -import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; -import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; -import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; import { NullEnvService } from '../../../env/common/nullEnvService'; import { ILogService } from '../../../log/common/logService'; import { IChatEndpoint } from '../../../networking/common/networking'; @@ -58,7 +55,6 @@ describe('AutomodeService', () => { let mockLogService: ILogService; let mockInstantiationService: IInstantiationService; let mockExpService: IExperimentationService; - let configurationService: IConfigurationService; let mockChatEndpoint: IChatEndpoint; let envService: NullEnvService; let mockTelemetryService: ITelemetryService & { sendEnhancedGHTelemetryEvent: ReturnType; sendMSFTTelemetryEvent: ReturnType }; @@ -87,7 +83,6 @@ describe('AutomodeService', () => { mockLogService, mockInstantiationService, mockExpService, - configurationService, envService, mockTelemetryService, new NullRequestLogger() @@ -105,10 +100,7 @@ describe('AutomodeService', () => { } function enableRouter(): void { - (configurationService as InMemoryConfigurationService).setConfig( - ConfigKey.TeamInternal.UseAutoModeRouting, - true - ); + // Router is now always enabled for panel chat — no config key needed. } beforeEach(() => { @@ -145,7 +137,6 @@ describe('AutomodeService', () => { mockExpService = new NullExperimentationService(); - configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); envService = new NullEnvService(); mockTelemetryService = { sendTelemetryEvent: vi.fn(), @@ -286,24 +277,6 @@ describe('AutomodeService', () => { expect(parsed.previous_model).toBeUndefined(); }); - it('should not use router when routing is not enabled', async () => { - // Routing not enabled via UseAutoModeRouting config - automodeService = createService(); - - const chatRequest: Partial = { - location: ChatLocation.Panel, - prompt: 'test prompt' - }; - - await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [mockChatEndpoint]); - - // Verify that router API was NOT called (exp / config disabled) - expect(mockCAPIClientService.makeRequest).not.toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ type: RequestType.ModelRouter }) - ); - }); - it('should not use router for terminal chat', async () => { enableRouter(); @@ -1103,6 +1076,38 @@ describe('AutomodeService', () => { }); }); + it('should emit candidateModel from chosen_model, not candidate_models[0]', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // Server re-ranked the pick: candidate_models[0] is gpt-5.3-codex but the + // authoritative chosen_model is gpt-5.4-mini. The telemetry candidateModel + // must reflect chosen_model so router-pick vs actual comparisons are valid. + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-telemetry-chosen-model' + }; + + await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + + const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls; + const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection'); + expect(selectionEvent).toBeDefined(); + expect(selectionEvent![1]).toMatchObject({ + candidateModel: 'gpt-5.4-mini', + actualModel: 'gpt-5.4-mini', + overrideReason: 'none', + }); + }); + it('should emit overrideReason=clientOverride when vision fallback changes the model', async () => { enableRouter(); const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI', { supportsVision: true }); @@ -1261,13 +1266,15 @@ describe('AutomodeService', () => { ); }); - it('should iterate all candidate_models when first candidate has no endpoint', async () => { + it('should fall back to candidate_models when chosen_model has no endpoint', async () => { enableRouter(); const gpt41Endpoint = createEndpoint('gpt-4.1', 'OpenAI'); + // chosen_model is not in knownEndpoints, so selection falls back to + // the ordered candidate_models list and picks the first resolvable one. mockRouterResponse( ['gpt-4.1'], - { chosen_model: 'gpt-4.1', candidate_models: ['unknown-new-model', 'gpt-4.1'] } + { chosen_model: 'unknown-new-model', candidate_models: ['unknown-new-model', 'gpt-4.1'] } ); automodeService = createService(); @@ -1281,6 +1288,57 @@ describe('AutomodeService', () => { expect(result.model).toBe('gpt-4.1'); }); + it('should prefer chosen_model over candidate_models[0]', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // Server re-ranked the pick (e.g. Cost Sorting experiment): chosen_model + // is gpt-5.4-mini even though candidate_models[0] is gpt-5.3-codex. The + // client must send the chosen_model, per the auto-intent-service contract. + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-chosen-model' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + expect(result.model).toBe('gpt-5.4-mini'); + }); + + it('should surface chosen_model in the routing decision the UI displays', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // The "Routed to " explainability label reads the routing + // decision surfaced via consumeLastRoutingDecision(). It must match the + // served endpoint (chosen_model), otherwise the label diverges from the + // model shown in the response footer (candidate_models[0]). + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-routing-decision' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + const decision = automodeService.consumeLastRoutingDecision(); + expect(decision?.resolvedModel).toBe('gpt-5.4-mini'); + expect(decision?.resolvedModel).toBe(result.model); + }); + it('should fall back to first known endpoint when all available_models are unknown', async () => { enableRouter(); const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index c8bd81d388209..9d9f1984a5e9c 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -21,7 +21,7 @@ import { SpyingTelemetryService } from '../../../telemetry/node/spyingTelemetryS import { createFakeStreamResponse } from '../../../test/node/fetcher'; import { createPlatformServices } from '../../../test/node/services'; import type { ThinkingData } from '../../../thinking/common/thinking'; -import { CustomDataPartMimeTypes } from '../../common/endpointTypes'; +import { CacheType, CustomDataPartMimeTypes } from '../../common/endpointTypes'; import { createResponsesRequestBody, getResponsesApiCompactionThresholdFromBody, processResponseFromChatEndpoint, responseApiInputToRawMessagesForLogging } from '../responsesApi'; const testEndpoint: IChatEndpoint = { @@ -369,59 +369,6 @@ describe('responseApiInputToRawMessagesForLogging', () => { }); describe('createResponsesRequestBody', () => { - it('enables persistent CoT on initial requests for hidden model M when the experiment is enabled', () => { - const services = createPlatformServices(); - const accessor = services.createTestingAccessor(); - const instantiationService = accessor.get(IInstantiationService); - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, true); - const endpoint = { ...testEndpoint, family: 'ember-alpha', supportsReasoningEffort: ['low', 'medium', 'high'] }; - - const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), endpoint.model, endpoint)); - - expect(body.reasoning).toEqual({ effort: 'medium', summary: 'detailed', context: 'all_turns' }); - - accessor.dispose(); - services.dispose(); - }); - - it('does not enable persistent CoT when the experiment is disabled or the family is unsupported', () => { - const services = createPlatformServices(); - const accessor = services.createTestingAccessor(); - const instantiationService = accessor.get(IInstantiationService); - const emberEndpoint = { ...testEndpoint, family: 'ember-alpha' }; - const unsupportedEndpoint = { ...testEndpoint, model: 'ember-alpha', family: 'other-family' }; - - const disabledBody = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), emberEndpoint.model, emberEndpoint)); - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, true); - const unsupportedBody = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), unsupportedEndpoint.model, unsupportedEndpoint)); - - expect(disabledBody.reasoning?.context).toBeUndefined(); - expect(unsupportedBody.reasoning?.context).toBeUndefined(); - - accessor.dispose(); - services.dispose(); - }); - - it('keeps persistent CoT enabled when continuing from a previous response', () => { - const services = createPlatformServices(); - const accessor = services.createTestingAccessor(); - const instantiationService = accessor.get(IInstantiationService); - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, true); - const endpoint = { ...testEndpoint, family: 'ember-alpha' }; - const messages: Raw.ChatMessage[] = [ - createStatefulMarkerMessage(endpoint.model, 'resp-prev'), - { role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'continue' }] }, - ]; - - const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), endpoint.model, endpoint)); - - expect(body.previous_response_id).toBe('resp-prev'); - expect(body.reasoning?.context).toBe('all_turns'); - - accessor.dispose(); - services.dispose(); - }); - it('extracts compaction threshold from request body context management', () => { expect(getResponsesApiCompactionThresholdFromBody({ context_management: [{ @@ -984,6 +931,222 @@ describe('createResponsesRequestBody', () => { }); }); +describe('createResponsesRequestBody prompt_cache_breakpoint markers', () => { + const expectedPromptCacheBreakpoint = { mode: 'explicit' }; + const cacheBreakpointEndpoint: IChatEndpoint = { ...testEndpoint, family: 'ember-alpha' }; + + const cacheBreakpoint = (): Raw.ChatCompletionContentPart => ({ + type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, + cacheType: CacheType, + }); + + const buildBody = (messages: Raw.ChatMessage[], endpoint = cacheBreakpointEndpoint, enablePromptCacheBreakpoint = true) => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + if (enablePromptCacheBreakpoint) { + accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPromptCacheBreakpointEnabled, true); + } + const instantiationService = accessor.get(IInstantiationService); + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), endpoint.model, endpoint)); + accessor.dispose(); + services.dispose(); + return body; + }; + + it('does not attach prompt_cache_breakpoint by default when the experiment flag is disabled', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'hello' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages, cacheBreakpointEndpoint, false); + + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('attaches prompt_cache_breakpoint to the last content block of a user message', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'first' }, + { type: Raw.ChatCompletionContentPartKind.Text, text: 'second' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'input_text', text: 'first' }, + { type: 'input_text', text: 'second', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }, + ], + }); + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('attaches prompt_cache_breakpoint to the last content block of a system message', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.System, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'be concise' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'system', + content: [{ type: 'input_text', text: 'be concise', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }], + }); + }); + + it('attaches prompt_cache_breakpoint to the last output_text of a terminal assistant message', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.Assistant, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'final answer' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'assistant', + type: 'message', + content: [{ type: 'output_text', text: 'final answer', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }], + }); + }); + + it('attaches prompt_cache_breakpoint at item level to a tool result (function_call_output)', () => { + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ id: 'call_1', type: 'function', function: { name: 'read_file', arguments: '{}' } }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_1', + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'result' }, + cacheBreakpoint(), + ], + }, + ]; + + const body = buildBody(messages); + + expect(body.input?.[1]).toMatchObject({ + type: 'function_call_output', + call_id: 'call_1', + output: 'result', + prompt_cache_breakpoint: expectedPromptCacheBreakpoint, + }); + }); + + it('attaches prompt_cache_breakpoint at item level to the last function_call when the assistant has tool calls', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.Assistant, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'calling' }, + cacheBreakpoint(), + ], + toolCalls: [ + { id: 'call_a', type: 'function', function: { name: 'tool_a', arguments: '{}' } }, + { id: 'call_b', type: 'function', function: { name: 'tool_b', arguments: '{}' } }, + ], + }]; + + const body = buildBody(messages); + const input = body.input as OpenAI.Responses.ResponseInputItem[]; + + const lastCall = input.find(item => isFunctionCallInputItem(item, 'tool_b')); + expect(lastCall).toMatchObject({ prompt_cache_breakpoint: expectedPromptCacheBreakpoint }); + + const firstCall = input.find(item => isFunctionCallInputItem(item, 'tool_a')); + expect(firstCall).not.toHaveProperty('prompt_cache_breakpoint'); + + const messageItem = input.find(item => (item as { type?: string }).type === 'message'); + expect((messageItem as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('attaches prompt_cache_breakpoint to the trailing image item of a tool result with images', () => { + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ id: 'call_img', type: 'function', function: { name: 'screenshot', arguments: '{}' } }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_img', + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'see image' }, + { type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: 'data:image/png;base64,abc', detail: 'auto' } }, + cacheBreakpoint(), + ], + }, + ]; + + const body = buildBody(messages); + + expect(body.input?.at(-1)).toMatchObject({ + role: 'user', + content: [ + { type: 'input_text', text: 'Image associated with the above tool call:' }, + { type: 'input_image', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }, + ], + }); + expect(body.input?.[1]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('does not synthesize a whitespace text block when the marked message has no other content', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [cacheBreakpoint()], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'user', + content: [], + }); + }); + + it('does not attach prompt_cache_breakpoint when the message has no breakpoint', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'hello' }], + }]; + + const body = buildBody(messages); + + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('does not attach prompt_cache_breakpoint when the model does not support cache breakpoints', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'hello' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages, testEndpoint); + + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); +}); + describe('processResponseFromChatEndpoint telemetry', () => { it('emits engine.messages for Responses API assistant output', async () => { const services = createPlatformServices(); @@ -1753,4 +1916,28 @@ describe('processResponseFromChatEndpoint terminal events', () => { metadata: { code: 'internal_error' }, }); }); + + it('maps a raw error stream event to a terminal ServerError completion', async () => { + // Root cause of #322209: a raw Responses API `error` stream event is only + // surfaced as a `copilotErrors` progress delta and never yields a terminal + // completion. With no completion, the downstream chat fetcher falls through + // to the generic `RESPONSE_CONTAINED_NO_CHOICES` ("Response contained no + // choices") instead of a meaningful server-error message. + const errorEvent = { + type: 'error', + code: 'server_error', + message: 'The server had an error while processing your request.', + param: null, + }; + + const [completion] = await runStream(`data: ${JSON.stringify(errorEvent)}\n\n`); + + expect(completion).toBeDefined(); + expect(completion.finishReason).toBe(FinishedCompletionReason.ServerError); + expect(completion.error).toEqual({ + code: 0, + message: 'The server had an error while processing your request.', + metadata: { code: 'server_error' }, + }); + }); }); diff --git a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts index 799a6c84ab784..d8322ee0180b0 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts @@ -8,7 +8,7 @@ import { ConfigKey, IConfigurationService } from '../../../configuration/common/ import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; import type { IChatEndpoint } from '../../../networking/common/networking'; -import { getModelCapabilityOverride, modelSupportsContextEditing, modelSupportsPDFDocuments, modelSupportsToolSearch } from '../../common/chatModelCapabilities'; +import { getModelCapabilityOverride, isKimiFamily, modelCanUseApplyPatchExclusively, modelCanUseReplaceStringExclusively, modelSupportsApplyPatch, modelSupportsContextEditing, modelSupportsMultiReplaceString, modelSupportsPDFDocuments, modelSupportsReplaceString, modelSupportsToolSearch } from '../../common/chatModelCapabilities'; function fakeModel(family: string, model: string = family) { return { family, model } as unknown as IChatEndpoint; @@ -41,6 +41,51 @@ describe('modelSupportsPDFDocuments', () => { }); }); +describe('Kimi edit tool capabilities', () => { + test('uses replace-string tools without insert-edit or apply-patch', () => { + const models = { + 'kimi-k2.6': fakeModel('kimi-k2.6'), + 'kimi-k2.7-code': fakeModel('kimi-k2.7-code'), + 'unknown-family + kimi model id': fakeModel('unknown-family', 'kimi-k2.7-code-preview'), + }; + const actual = Object.fromEntries(Object.entries(models).map(([name, model]) => [name, { + isKimiFamily: isKimiFamily(model), + supportsReplaceString: modelSupportsReplaceString(model), + supportsMultiReplaceString: modelSupportsMultiReplaceString(model), + canUseReplaceStringExclusively: modelCanUseReplaceStringExclusively(model), + supportsApplyPatch: modelSupportsApplyPatch(model), + canUseApplyPatchExclusively: modelCanUseApplyPatchExclusively(model), + }])); + + expect(actual).toEqual({ + 'kimi-k2.6': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + 'kimi-k2.7-code': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + 'unknown-family + kimi model id': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + }); + }); +}); + describe('modelSupportsToolSearch', () => { test('supports Claude Sonnet/Opus 4.5 and up, including new and future families', () => { expect(modelSupportsToolSearch('claude-sonnet-4-5')).toBe(true); diff --git a/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts new file mode 100644 index 0000000000000..183fdae98cdab --- /dev/null +++ b/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { LanguageModelChat } from 'vscode'; +import { describe, expect, test } from 'vitest'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { CHAT_MODEL } from '../../../configuration/common/configurationService'; +import { IChatModelInformation } from '../../common/endpointProvider'; +import { IModelMetadataFetcher } from '../../node/modelMetadataFetcher'; +import { CopilotUtilityChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../node/copilotChatEndpoint'; + +/** + * Builds a minimal valid chat model record as it would appear in the + * hydrated CAPI `/models` response. + */ +function chatModel(id: string, family: string, isChatFallback = false): IChatModelInformation { + return { + id, + vendor: 'openai', + name: id, + version: '1.0', + model_picker_enabled: false, + is_chat_default: false, + is_chat_fallback: isChatFallback, + capabilities: { + type: 'chat', + family, + tokenizer: 'o200k_base' as any, + limits: { max_prompt_tokens: 8192, max_output_tokens: 4096 }, + supports: { streaming: undefined }, + }, + }; +} + +/** + * A fake `IModelMetadataFetcher` that mimics the real one's lookup semantics + * against an in-memory set of models, including the same error message as the + * real implementation when a CAPI family is absent. + */ +class FakeModelMetadataFetcher implements IModelMetadataFetcher { + private readonly _byFamily = new Map(); + private _utilityModel: IChatModelInformation | undefined; + + constructor(models: IChatModelInformation[]) { + for (const model of models) { + const family = model.capabilities.family; + const list = this._byFamily.get(family) ?? []; + list.push(model); + this._byFamily.set(family, list); + if (model.is_chat_fallback) { + this._utilityModel = model; + } + } + } + + onDidModelsRefresh = (() => { /* noop */ }) as any; + + async getAllCompletionModels(): Promise { return []; } + async getAllChatModels(): Promise { + return Array.from(this._byFamily.values()).flat(); + } + async getChatModelFromApiModel(_model: LanguageModelChat): Promise { return undefined; } + async getEmbeddingsModel(): Promise { throw new Error('not implemented'); } + + async getCopilotUtilityModel(): Promise { + if (!this._utilityModel) { + throw new Error('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)'); + } + return this._utilityModel; + } + + async getChatModelFromCapiFamily(family: string): Promise { + const resolved = this._byFamily.get(family)?.[0]; + if (!resolved) { + throw new Error(`Unable to resolve chat model with CAPI family selection: ${family}`); + } + return resolved; + } +} + +/** + * A fake instantiation service whose `createInstance` records the model + * metadata it was handed, so the test can assert which model the resolver + * ultimately selected without building the full `CopilotChatEndpoint`. + */ +function fakeInstantiationService(): { service: IInstantiationService; lastModel: () => IChatModelInformation | undefined } { + let last: IChatModelInformation | undefined; + const service = { + createInstance: (_ctor: unknown, modelMetadata: IChatModelInformation) => { + last = modelMetadata; + return { model: modelMetadata.id } as any; + }, + } as unknown as IInstantiationService; + return { service, lastModel: () => last }; +} + +describe('CopilotUtilitySmallChatEndpoint.resolve (issue #321184)', () => { + test('falls back to the base utility model when the gpt-4o-mini family is absent from /models', async () => { + // CAPI `/models` for this user contains a base chat-fallback model, but + // no model in the client-selected small family (gpt-4o-mini). This mirrors + // the plan/region/rollout differences reported in issue #321184. + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + // Sanity: the small family really is unresolvable for this user. + await expect(fetcher.getChatModelFromCapiFamily(CHAT_MODEL.GPT4OMINI)).rejects.toThrow( + 'Unable to resolve chat model with CAPI family selection: gpt-4o-mini' + ); + + // The resolver must degrade gracefully to the base utility model rather + // than letting the lookup throw through every copilot-utility-small caller. + await CopilotUtilitySmallChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4.1'); + }); + + test('uses the gpt-4o-mini family when it is present', async () => { + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4o-mini', CHAT_MODEL.GPT4OMINI), + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + await CopilotUtilitySmallChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4o-mini'); + }); + + test('sibling CopilotUtilityChatEndpoint.resolve already degrades gracefully', async () => { + // The sibling resolver is unaffected because it uses getCopilotUtilityModel(). + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + await CopilotUtilityChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4.1'); + }); +}); diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index aeeb53e8c500e..8e6bf8f62c5f3 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Result } from '../../../../util/common/result'; import { assertNever } from '../../../../util/vs/base/common/assert'; -import { IValidator, vBoolean, vEnum, vNumber, vObj, vRequired, vString, vUndefined, vUnion } from '../../../configuration/common/validator'; +import { IValidator, vArray, vBoolean, vEnum, vNumber, vObj, vRequired, vString, vUndefined, vUnion } from '../../../configuration/common/validator'; import { ImportChanges } from './importFilteringOptions'; export enum IncludeLineNumbersOption { @@ -82,8 +83,14 @@ export type DiffHistoryOptions = { }; /** - * Parts that participate in the global-budget cascade. `currentFile` and lint - * output are intentionally excluded and continue to use their own per-part caps. + * Parts that are rendered by the global-budget cascade and listed in `order`. + * Lint output is intentionally excluded and keeps its own per-part shape. + * + * `currentFile` is NOT in this set: it is clipped outside the cascade (around the + * cursor) but still draws an allocation from the pool via {@link GlobalBudgetSharePart}. + * It is clipped LAST (after the cascade) to its share plus whatever budget the + * cascade left unused, so it only ever receives leftover and never donates into + * the cascade. */ export type GlobalBudgetPart = | 'recentlyViewedDocuments' @@ -91,30 +98,43 @@ export type GlobalBudgetPart = | 'neighborFiles' | 'diffHistory'; +/** + * Parts that receive a `shares` allocation from the pool: every rendered + * {@link GlobalBudgetPart} plus `currentFile`, which is sized from the pool but + * clipped externally (see {@link GlobalBudgetOptions.currentFileBudget}). + */ +export type GlobalBudgetSharePart = GlobalBudgetPart | 'currentFile'; + /** * Opt-in global-budget allocation modelled after the cascade in * `CascadingPromptFactory` (completions-core): every participating part gets a - * percentage share of a single `totalTokens` pool, parts are rendered in - * `order`, and any unused tokens in one part cascade as surplus to the next. + * percentage share of a single `totalTokens` pool, the rendered parts are emitted + * in `order`, and any unused tokens in one part cascade as surplus to the next. + * + * `currentFile` participates through `shares` only: it is clipped LAST, after the + * cascade runs, to its share of the pool plus whatever budget the cascade left + * unused (its `finalSurplus`), so it reuses the leftover and trims less. * * When `undefined` (the default), each part uses its own `maxTokens` cap as * before and no cross-part budget reuse happens. */ export type GlobalBudgetOptions = { readonly totalTokens: number; - /** Cascade order. Earlier parts get budget first; their surplus flows to later parts. */ + /** Cascade render order. Earlier parts get budget first; their surplus flows to later parts. `currentFile` is not listed here. */ readonly order: readonly GlobalBudgetPart[]; - /** Share of `totalTokens` allocated to each part. Must sum to 1 across `order`. */ - readonly shares: Readonly>; + /** Share of `totalTokens` allocated to each part. Must sum to ~1 across `order` plus `currentFile`. */ + readonly shares: Readonly>; }; export namespace GlobalBudgetOptions { /** - * Default cascade: language context donates first (often disabled), then - * recently-viewed documents (always-on, accepts most of the surplus), then - * neighbor files (must run after recently-viewed because it consults - * `docsInPrompt` to avoid duplicating recently-viewed documents), then - * diff history. + * Default render order: language context first (often disabled, donates its + * share onward), then recently-viewed documents (always-on, accepts most of + * the surplus), then neighbor files (must run after recently-viewed because it + * consults `docsInPrompt` to avoid duplicating recently-viewed documents), + * then diff history. `currentFile` is sized from the pool but clipped last + * (after the cascade, so it can reuse the cascade's leftover), so it is not + * part of this order. */ export const DEFAULT_ORDER: readonly GlobalBudgetPart[] = [ 'languageContext', @@ -123,15 +143,149 @@ export namespace GlobalBudgetOptions { 'diffHistory', ]; - /** Shares matching today's per-part `maxTokens` ratios (volume-neutral baseline). */ - export const DEFAULT_SHARES: Readonly> = { - recentlyViewedDocuments: 2 / 6, - languageContext: 2 / 6, - neighborFiles: 1 / 6, - diffHistory: 1 / 6, + /** + * Shares matching today's per-part `maxTokens` ratios (volume-neutral + * baseline): each part's share is its legacy cap divided by the pool total + * ({@link DEFAULT_TOTAL_TOKENS}), so `floor(totalTokens * share)` reproduces + * that cap exactly. Sum to 1 across all parts. + */ + export const DEFAULT_SHARES: Readonly> = { + currentFile: 1500 / 7500, + recentlyViewedDocuments: 2000 / 7500, + languageContext: 2000 / 7500, + neighborFiles: 1000 / 7500, + diffHistory: 1000 / 7500, }; - export const DEFAULT_TOTAL_TOKENS = 6000; + /** + * The pool size: the sum of today's per-part `maxTokens` caps + * (`currentFile` 1500 + `recentlyViewedDocuments` 2000 + `languageContext` + * 2000 + `neighborFiles` 1000 + `diffHistory` 1000), so the default global + * budget neither grows nor shrinks the total available budget. + */ + export const DEFAULT_TOTAL_TOKENS = 7500; + + /** + * The token budget allotted to `currentFile` from the pool: `floor(totalTokens + * * shares.currentFile)`. Used to override the per-part `currentFile.maxTokens` + * cap when clipping the current file under a global budget. + */ + export function currentFileBudget(globalBudget: GlobalBudgetOptions): number { + return Math.max(0, Math.floor(globalBudget.totalTokens * (globalBudget.shares.currentFile ?? 0))); + } + + /** + * Validate {@link GlobalBudgetOptions} since it is runtime-configurable + * (e.g. via experiments). Catches misconfigurations that would otherwise + * cause silent, hard-to-debug behavior: + * - `totalTokens` not a finite, non-negative number + * - duplicate parts in `order` (would render the same part twice) + * - missing share for any part in `order` or for `currentFile` + * - any share not a finite, non-negative number (negative shares would break + * budget conservation: a negative allocation clamps to 0 yet still counts + * toward the share sum, letting the remaining parts over-allocate past the pool) + * - shares not summing to ~1 across `order` plus `currentFile` (would over/under-allocate) + * - `neighborFiles` ordered before `recentlyViewedDocuments` (the former + * consults `docsInPrompt` populated by the latter) + */ + export function validate(globalBudget: GlobalBudgetOptions): void { + if (!Number.isFinite(globalBudget.totalTokens) || globalBudget.totalTokens < 0) { + throw new Error(`globalBudget.totalTokens must be a finite, non-negative number, got ${globalBudget.totalTokens}`); + } + + const seen = new Set(); + for (const part of globalBudget.order) { + if (seen.has(part)) { + throw new Error(`globalBudget.order contains duplicate part '${part}'`); + } + seen.add(part); + if (typeof globalBudget.shares[part] !== 'number') { + throw new Error(`globalBudget.shares is missing entry for '${part}'`); + } + if (!Number.isFinite(globalBudget.shares[part]) || globalBudget.shares[part] < 0) { + throw new Error(`globalBudget.shares['${part}'] must be a finite, non-negative number, got ${globalBudget.shares[part]}`); + } + } + + if (typeof globalBudget.shares.currentFile !== 'number') { + throw new Error(`globalBudget.shares is missing entry for 'currentFile'`); + } + if (!Number.isFinite(globalBudget.shares.currentFile) || globalBudget.shares.currentFile < 0) { + throw new Error(`globalBudget.shares['currentFile'] must be a finite, non-negative number, got ${globalBudget.shares.currentFile}`); + } + + const recentIdx = globalBudget.order.indexOf('recentlyViewedDocuments'); + const neighborIdx = globalBudget.order.indexOf('neighborFiles'); + if (recentIdx !== -1 && neighborIdx !== -1 && neighborIdx < recentIdx) { + throw new Error(`globalBudget.order must place 'recentlyViewedDocuments' before 'neighborFiles'`); + } + + const sharesSum = globalBudget.order.reduce((sum, part) => sum + globalBudget.shares[part], 0) + globalBudget.shares.currentFile; + const epsilon = 1e-3; + if (Math.abs(sharesSum - 1) > epsilon) { + throw new Error(`globalBudget.shares across order must sum to ~1, got ${sharesSum}`); + } + } + + /** + * Structural validator for the experiment-driven JSON config string. Every + * top-level field is optional; omitted fields fall back to the defaults in + * {@link fromConfigString}. When `shares` is provided it must list every + * part (the rendered cascade parts plus `currentFile`) so the pool stays + * fully allocated; partial `shares` objects are rejected. + */ + export const VALIDATOR = vObj({ + 'totalTokens': vUnion(vNumber(), vUndefined()), + 'order': vUnion(vArray(vEnum('languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory')), vUndefined()), + 'shares': vUnion(vObj({ + 'currentFile': vRequired(vNumber()), + 'recentlyViewedDocuments': vRequired(vNumber()), + 'languageContext': vRequired(vNumber()), + 'neighborFiles': vRequired(vNumber()), + 'diffHistory': vRequired(vNumber()), + }), vUndefined()), + }); + + /** + * Parse the single experiment-driven JSON config string (modelled after + * `modelConfigurationString`) into a fully-populated {@link GlobalBudgetOptions}. + * + * Any field absent from the JSON falls back to its `DEFAULT_*` value, so + * `'{}'` enables the global budget with the volume-neutral defaults and + * `'{"totalTokens":6000}'` only overrides the pool size. The merged result is + * then run through {@link validate} so semantic misconfigurations (bad share + * sums, duplicate/misordered parts) are reported. + * + * Returns a {@link Result.error} (never throws) so callers can fall back to a + * disabled global budget and report the error via telemetry. + */ + export function fromConfigString(configString: string): Result { + let parsed: unknown; + try { + parsed = JSON.parse(configString); + } catch (e) { + return Result.error(`Failed to parse globalBudget config string: ${e instanceof Error ? e.message : String(e)}`); + } + + const { content, error } = VALIDATOR.validate(parsed); + if (error) { + return Result.error(`globalBudget config validation failed: ${error.message}`); + } + + const options: GlobalBudgetOptions = { + totalTokens: content.totalTokens ?? DEFAULT_TOTAL_TOKENS, + order: content.order ?? DEFAULT_ORDER, + shares: content.shares ?? DEFAULT_SHARES, + }; + + try { + validate(options); + } catch (e) { + return Result.error(e instanceof Error ? e.message : String(e)); + } + + return Result.ok(options); + } } export type PagedClipping = { pageSize: number }; diff --git a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts index ce942ca92c872..8b5ee481c9248 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts @@ -412,18 +412,7 @@ export class InlineEditRequestLogContext { private _outcome: LogContextOutcome = 'pending'; - /** - * Sets the outcome, warning if already set (i.e., not `pending`). - * Use direct `this._outcome = ...` assignment to bypass the guard - * (e.g., in `setIsCachedResult` which intentionally overrides any inherited outcome). - */ private _setOutcome(outcome: LogContextOutcome): void { - // 'reusedInFlight' is an intermediate state set when joining an in-flight - // request (before the result arrives), so it can legitimately transition - // to the final outcome (skipped, errored, etc.) just like 'pending'. - if (this._outcome !== 'pending' && this._outcome !== 'reusedInFlight') { - console.warn(`[InlineEditRequestLogContext] outcome transition from '${this._outcome}' to '${outcome}' (request #${this.requestId})`); - } this._outcome = outcome; } @@ -465,9 +454,7 @@ export class InlineEditRequestLogContext { } public markAsPreviouslyRejected() { - // Direct assignment — bypasses _setOutcome guard because this transition - // legitimately overrides 'succeeded' when a fetched edit turns out to be rejected. - this._outcome = 'previouslyRejected'; + this._setOutcome('previouslyRejected'); this._isVisible = true; this.fireDidChange(); } diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts index f43503bab05f6..b4d3cca449b2c 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts @@ -49,6 +49,14 @@ export type StreamedEdit = { * in either the original location or the jump target location. */ readonly originalWindow?: OffsetRange; + /** + * Zero-based index of the model-emitted patch this edit originated from, for the + * diff-patch response format. A single model patch can expand into several edits + * (per-patch diff splitting or progressive ghost-text reveal); all edits produced + * from the same patch share the same `patchIndex`. `undefined` for response formats + * that have no explicit patch structure (e.g. edit-window, INSERT). + */ + readonly patchIndex?: number; }; export type PushEdit = (edit: Result) => void; diff --git a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts index c2f8bb278776d..31c46134d8ba3 100644 --- a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts +++ b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { ImportChanges } from '../../common/dataTypes/importFilteringOptions'; -import { applyStrategyConfig, IncludeLineNumbersOption, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy } from '../../common/dataTypes/xtabPromptOptions'; +import { applyStrategyConfig, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy } from '../../common/dataTypes/xtabPromptOptions'; function baseConfig(overrides: Partial = {}): ModelConfiguration { return { @@ -102,3 +102,155 @@ describe('MODEL_CONFIGURATION_VALIDATOR', () => { expect(result.error).toBeDefined(); }); }); + +describe('GlobalBudgetOptions', () => { + + function gb(overrides: Partial = {}): GlobalBudgetOptions { + return { + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + ...overrides, + }; + } + + describe('volume-neutral defaults', () => { + // Guards the core no-regression promise: enabling the global budget with the + // default total + shares must reproduce today's per-part `maxTokens` caps + // exactly. If anyone changes DEFAULT_SHARES or DEFAULT_TOTAL_TOKENS in a way + // that shifts a part's budget, this fails loudly instead of silently + // shrinking/growing prompts in the experiment arm. + it('reproduce the legacy per-part caps', () => { + const total = GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS; + const shares = GlobalBudgetOptions.DEFAULT_SHARES; + const computed = { + currentFile: Math.floor(total * shares.currentFile), + recentlyViewedDocuments: Math.floor(total * shares.recentlyViewedDocuments), + languageContext: Math.floor(total * shares.languageContext), + neighborFiles: Math.floor(total * shares.neighborFiles), + diffHistory: Math.floor(total * shares.diffHistory), + }; + expect(computed).toEqual({ + currentFile: DEFAULT_OPTIONS.currentFile.maxTokens, + recentlyViewedDocuments: DEFAULT_OPTIONS.recentlyViewedDocuments.maxTokens, + languageContext: DEFAULT_OPTIONS.languageContext.maxTokens, + neighborFiles: DEFAULT_OPTIONS.neighborFiles.maxTokens, + diffHistory: DEFAULT_OPTIONS.diffHistory.maxTokens, + }); + }); + + it('shares sum to exactly 1', () => { + const shares = GlobalBudgetOptions.DEFAULT_SHARES; + const sum = shares.currentFile + shares.recentlyViewedDocuments + shares.languageContext + shares.neighborFiles + shares.diffHistory; + expect(sum).toBe(1); + }); + }); + + describe('currentFileBudget', () => { + it('floors totalTokens * shares.currentFile', () => { + expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 8000, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 2 / 8 } }))).toBe(2000); + expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 999, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 1 / 3 } }))).toBe(333); + }); + + it('clamps at 0 for a zero share', () => { + expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 8000, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 0 } }))).toBe(0); + }); + }); + + describe('validate', () => { + it('accepts the defaults', () => { + expect(() => GlobalBudgetOptions.validate(gb())).not.toThrow(); + }); + + it('throws on a duplicate part in order', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + order: ['languageContext', 'languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory'], + }))).toThrow(/duplicate part 'languageContext'/); + }); + + it('throws when shares omit currentFile', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { languageContext: 0.25, recentlyViewedDocuments: 0.25, neighborFiles: 0.25, diffHistory: 0.25 } as GlobalBudgetOptions['shares'], + }))).toThrow(/missing entry for 'currentFile'/); + }); + + it('throws when shares do not sum to ~1 across order plus currentFile', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 0.9 }, + }))).toThrow(/shares across order must sum to ~1/); + }); + + it('throws when neighborFiles is ordered before recentlyViewedDocuments', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + order: ['languageContext', 'neighborFiles', 'recentlyViewedDocuments', 'diffHistory'], + }))).toThrow(/must place 'recentlyViewedDocuments' before 'neighborFiles'/); + }); + + it('throws on a negative totalTokens', () => { + expect(() => GlobalBudgetOptions.validate(gb({ totalTokens: -1 }))).toThrow(/totalTokens must be a finite, non-negative number/); + }); + + it('throws on a negative share (which would break budget conservation)', () => { + // Sums to 1 so the legacy sum check passes, but a negative share clamps to + // a 0 allocation yet still counts toward the sum, letting other parts + // over-allocate past the pool. Must be rejected. + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { currentFile: 0.25, languageContext: -0.25, recentlyViewedDocuments: 1.0, neighborFiles: 0, diffHistory: 0 }, + }))).toThrow(/must be a finite, non-negative number/); + }); + + it('throws on a non-finite share', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: Number.NaN }, + }))).toThrow(/must be a finite, non-negative number/); + }); + }); + + describe('fromConfigString', () => { + it('fills every omitted field with the defaults', () => { + const result = GlobalBudgetOptions.fromConfigString('{}'); + expect(result.isOk() && result.val).toEqual({ + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }); + }); + + it('overrides only the fields present in the JSON', () => { + const result = GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: 6000 })); + expect(result.isOk() && result.val).toEqual({ + totalTokens: 6000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }); + }); + + it('parses a fully-specified config and ignores unknown keys', () => { + const order: GlobalBudgetOptions['order'] = ['languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory']; + const shares: GlobalBudgetOptions['shares'] = { currentFile: 0.4, languageContext: 0.2, recentlyViewedDocuments: 0.2, neighborFiles: 0.1, diffHistory: 0.1 }; + const result = GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: 12000, order, shares, unknown: 'ignored' })); + expect(result.isOk() && result.val).toEqual({ totalTokens: 12000, order, shares }); + }); + + it('errors on malformed JSON', () => { + expect(GlobalBudgetOptions.fromConfigString('{ not json').isError()).toBe(true); + }); + + it('errors when a field has the wrong type', () => { + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: '6000' })).isError()).toBe(true); + }); + + it('errors on an unknown part in order', () => { + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ order: ['languageContext', 'bogus'] })).isError()).toBe(true); + }); + + it('errors when shares are partial (every part is required)', () => { + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ shares: { currentFile: 1 } })).isError()).toBe(true); + }); + + it('errors when the merged config is semantically invalid', () => { + const shares = { currentFile: 0.9, languageContext: 0.2, recentlyViewedDocuments: 0.2, neighborFiles: 0.1, diffHistory: 0.1 }; + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ shares })).isError()).toBe(true); + }); + }); +}); diff --git a/extensions/copilot/src/platform/languages/vscode/languageDiagnosticsServiceImpl.ts b/extensions/copilot/src/platform/languages/vscode/languageDiagnosticsServiceImpl.ts index 5af945d1b665a..6505f80f3ab4d 100644 --- a/extensions/copilot/src/platform/languages/vscode/languageDiagnosticsServiceImpl.ts +++ b/extensions/copilot/src/platform/languages/vscode/languageDiagnosticsServiceImpl.ts @@ -4,18 +4,46 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ILogService } from '../../log/common/logService'; import { AbstractLanguageDiagnosticsService } from '../common/languageDiagnosticsService'; export class LanguageDiagnosticsServiceImpl extends AbstractLanguageDiagnosticsService { private static ignoredSchemes = new Set(['git', 'chat-editing-snapshot-text-model', 'chat-editing-text-model']); override onDidChangeDiagnostics: vscode.Event = vscode.languages.onDidChangeDiagnostics; + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + override getDiagnostics(resource: vscode.Uri): vscode.Diagnostic[] { - return vscode.languages.getDiagnostics(resource); + return this._dropMalformedDiagnostics(vscode.languages.getDiagnostics(resource)); } override getAllDiagnostics(): [vscode.Uri, vscode.Diagnostic[]][] { return vscode.languages.getDiagnostics() - .filter(([uri]) => !LanguageDiagnosticsServiceImpl.ignoredSchemes.has(uri.scheme)); + .filter(([uri]) => !LanguageDiagnosticsServiceImpl.ignoredSchemes.has(uri.scheme)) + .map(([uri, diagnostics]): [vscode.Uri, vscode.Diagnostic[]] => [uri, this._dropMalformedDiagnostics(diagnostics)]); + } + + /** + * Diagnostics are produced by arbitrary extensions and reach us verbatim through the + * `vscode.languages.getDiagnostics` API. An extension can publish an entry that violates the + * {@link vscode.Diagnostic} contract at runtime - most notably with a missing `range` assigned + * through an `any` cast - and such an entry crashes the many consumers that dereference + * `diagnostic.range`. Drop the malformed entries here, at the extension boundary, and surface + * the occurrence so the underlying producer stays diagnosable. + */ + private _dropMalformedDiagnostics(diagnostics: vscode.Diagnostic[]): vscode.Diagnostic[] { + const valid = diagnostics.filter(diagnostic => { + const range: vscode.Range | null | undefined = diagnostic.range; + return range !== null && range !== undefined; + }); + const dropped = diagnostics.length - valid.length; + if (dropped > 0) { + this._logService.warn(`[LanguageDiagnosticsService] Ignored ${dropped} diagnostic(s) with a missing range received from the diagnostics API.`); + } + return valid; } } diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index d85cd20361496..1f75ec07bf485 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -75,7 +75,7 @@ export interface IEndpointBody { prediction?: Prediction; messages?: any[]; n?: number; - reasoning?: { effort?: string; summary?: string; context?: 'current_turn' | 'all_turns' }; + reasoning?: { effort?: string; summary?: string }; tool_choice?: OptionalChatRequestParams['tool_choice'] | { type: 'function'; name: string } | string; top_logprobs?: number; intent?: boolean; @@ -338,6 +338,7 @@ export interface IChatEndpoint extends IEndpoint { readonly showInModelPicker: boolean; readonly isPremium?: boolean; readonly degradationReason?: string; + readonly warningText?: Record; readonly multiplier?: number; readonly restrictedToSkus?: string[]; /** diff --git a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts index 1b66ca6108376..1a6676d38f1e8 100644 --- a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts +++ b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts @@ -75,7 +75,7 @@ export function deriveClaudeOTelEnv(config: OTelConfig, env: Record; + /** + * Extra OTLP request headers (e.g. auth tokens) applied directly to the exporter. Carried + * out-of-band from process env so secrets never leak into spawned tool subprocesses. + */ + readonly headers: Record; } /** @@ -87,6 +92,27 @@ export interface OTelConfigInput { settingMaxAttributeSizeChars?: number; settingOutfile?: string; settingDbSpanExporter?: boolean; + /** OTLP wire protocol mirroring `OTEL_EXPORTER_OTLP_PROTOCOL` (`http/json`, `http/protobuf`, `grpc`). */ + settingProtocol?: string; + policyEnabled?: boolean; + policyExporterType?: OTelExporterType; + policyOtlpEndpoint?: string; + policyCaptureContent?: boolean; + policyOutfile?: string; + /** Enterprise-managed OTLP wire protocol (raw `telemetry.protocol`). */ + policyProtocol?: string; + /** Service name from VS Code setting (`github.copilot.chat.otel.serviceName`). */ + settingServiceName?: string; + /** Enterprise-managed `service.name` (raw `telemetry.serviceName`). */ + policyServiceName?: string; + /** Resource attributes from VS Code setting (`github.copilot.chat.otel.resourceAttributes`). */ + settingResourceAttributes?: Record; + /** Enterprise-managed resource attributes (raw `telemetry.resourceAttributes`). */ + policyResourceAttributes?: Record; + /** OTLP headers from VS Code setting (`github.copilot.chat.otel.headers`). */ + settingHeaders?: Record; + /** Enterprise-managed OTLP headers (raw `telemetry.headers`). */ + policyHeaders?: Record; extensionVersion: string; sessionId: string; vscodeTelemetryLevel?: string; @@ -94,10 +120,11 @@ export interface OTelConfigInput { /** * Resolve OTel configuration with layered precedence: - * 1. COPILOT_OTEL_* env vars (highest) - * 2. OTEL_EXPORTER_OTLP_* standard env vars - * 3. VS Code settings - * 4. Defaults (lowest) + * 1. Enterprise policy values from managed settings (highest) + * 2. COPILOT_OTEL_* env vars + * 3. OTEL_EXPORTER_OTLP_* standard env vars + * 4. VS Code settings + * 5. Defaults (lowest) */ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { const { env } = input; @@ -110,17 +137,20 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { // SQLite DB span exporter: setting > default(false) const dbSpanExporter = input.settingDbSpanExporter ?? false; - // Determine if enabled: env > setting > dbSpanExporter > default(false) + const policyMandatesOtlp = input.policyOtlpEndpoint !== undefined || input.policyExporterType !== undefined; + const policyEndpointEnables = input.policyOtlpEndpoint !== undefined ? true : undefined; + + // Determine if enabled: policy > env > setting > policy endpoint > dbSpanExporter > default(false) // When dbSpanExporter is on, OTel must be enabled for the SDK pipeline to work. - const enabled = (envBool(env['COPILOT_OTEL_ENABLED']) + const enabledSignal = input.policyEnabled + ?? envBool(env['COPILOT_OTEL_ENABLED']) ?? input.settingEnabled - ?? (!!env['OTEL_EXPORTER_OTLP_ENDPOINT'])) - || dbSpanExporter; + ?? policyEndpointEnables + ?? (!!env['OTEL_EXPORTER_OTLP_ENDPOINT']); + const enabled = input.policyEnabled === false ? false : enabledSignal || dbSpanExporter; - // OTel was explicitly enabled if the user/env turned it on, not just dbSpanExporter - const enabledExplicitly = (envBool(env['COPILOT_OTEL_ENABLED']) - ?? input.settingEnabled - ?? (!!env['OTEL_EXPORTER_OTLP_ENDPOINT'])) === true; + // OTel was explicitly enabled if policy/user/env turned it on, not just dbSpanExporter + const enabledExplicitly = enabled && enabledSignal === true; if (!enabled) { return createDisabledConfig(input); @@ -128,7 +158,9 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { // Determine how OTel was enabled for telemetry tracking let enabledVia: OTelEnabledVia; - if (envBool(env['COPILOT_OTEL_ENABLED']) === true) { + if (input.policyEnabled === true || (input.policyEnabled === undefined && input.policyOtlpEndpoint !== undefined)) { + enabledVia = 'policy'; + } else if (envBool(env['COPILOT_OTEL_ENABLED']) === true) { enabledVia = 'envVar'; } else if (input.settingEnabled === true) { enabledVia = 'setting'; @@ -138,23 +170,49 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { enabledVia = 'dbSpanExporterOnly'; } - // Protocol: env > inferred from exporter type > default - const rawProtocol = env['OTEL_EXPORTER_OTLP_PROTOCOL'] ?? env['COPILOT_OTEL_PROTOCOL']; + // Protocol (transport): policy > env > setting exporter type > default + const rawProtocol = input.policyExporterType === 'otlp-grpc' + ? 'grpc' + : input.policyExporterType === 'otlp-http' + ? 'http' + : (env['OTEL_EXPORTER_OTLP_PROTOCOL'] ?? env['COPILOT_OTEL_PROTOCOL'] + ?? (input.settingExporterType === 'otlp-grpc' ? 'grpc' : input.settingExporterType === 'otlp-http' ? 'http' : undefined)); const protocol: 'grpc' | 'http' = rawProtocol === 'grpc' ? 'grpc' : 'http'; - // Endpoint: COPILOT_OTEL env > OTEL env > setting > default - const rawEndpoint = env['COPILOT_OTEL_ENDPOINT'] + // Wire protocol (json vs protobuf within http): policy > env > setting > default(http/json). + // grpc transport always reports 'grpc'. + const rawWireProtocol = input.policyProtocol + ?? env['OTEL_EXPORTER_OTLP_PROTOCOL'] + ?? env['COPILOT_OTEL_PROTOCOL'] + ?? input.settingProtocol; + const otlpProtocol: OTelConfig['otlpProtocol'] = protocol === 'grpc' + ? 'grpc' + : rawWireProtocol === 'http/protobuf' + ? 'http/protobuf' + : 'http/json'; + + // Endpoint: policy > COPILOT_OTEL env > OTEL env > setting > default + const rawEndpoint = input.policyOtlpEndpoint + ?? env['COPILOT_OTEL_ENDPOINT'] ?? env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? input.settingOtlpEndpoint ?? DEFAULT_OTLP_ENDPOINT; const otlpEndpoint = parseOtlpEndpoint(rawEndpoint, protocol) ?? DEFAULT_OTLP_ENDPOINT; - // File exporter path - const fileExporterPath = env['COPILOT_OTEL_FILE_EXPORTER_PATH'] ?? input.settingOutfile; + // File exporter path. Enterprise OTLP policy suppresses file export diversion. + const fileExporterPath = input.policyOutfile !== undefined + ? input.policyOutfile || undefined + : policyMandatesOtlp + ? undefined + : env['COPILOT_OTEL_FILE_EXPORTER_PATH'] ?? input.settingOutfile; // Exporter type let exporterType: OTelExporterType; - if (fileExporterPath) { + if (input.policyExporterType) { + exporterType = input.policyExporterType; + } else if (policyMandatesOtlp) { + exporterType = 'otlp-http'; + } else if (fileExporterPath) { exporterType = 'file'; } else if (input.settingExporterType) { exporterType = input.settingExporterType; @@ -162,8 +220,9 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { exporterType = protocol === 'grpc' ? 'otlp-grpc' : 'otlp-http'; } - // Content capture - const captureContent = envBool(env['COPILOT_OTEL_CAPTURE_CONTENT']) + // Content capture: policy > env > setting > default(false) + const captureContent = input.policyCaptureContent + ?? envBool(env['COPILOT_OTEL_CAPTURE_CONTENT']) ?? input.settingCaptureContent ?? false; @@ -182,11 +241,26 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { // HTTP instrumentation const httpInstrumentation = envBool(env['COPILOT_OTEL_HTTP_INSTRUMENTATION']) ?? false; - // Service name - const serviceName = env['OTEL_SERVICE_NAME'] ?? 'copilot-chat'; + // Service name: policy > env > setting > default. Empty values fall through. + const serviceName = (input.policyServiceName || undefined) + ?? env['OTEL_SERVICE_NAME'] + ?? (input.settingServiceName || undefined) + ?? 'copilot-chat'; + + // Resource attributes: merged per-key with precedence policy > env > setting. + const resourceAttributes = { + ...(input.settingResourceAttributes ?? {}), + ...parseResourceAttributes(env['OTEL_RESOURCE_ATTRIBUTES']), + ...(input.policyResourceAttributes ?? {}), + }; - // Resource attributes - const resourceAttributes = parseResourceAttributes(env['OTEL_RESOURCE_ATTRIBUTES']); + // OTLP headers: merged per-key with precedence policy > env > setting. Same `k=v,k2=v2` format + // as resource attributes (`OTEL_EXPORTER_OTLP_HEADERS`). + const headers = { + ...(input.settingHeaders ?? {}), + ...parseResourceAttributes(env['OTEL_EXPORTER_OTLP_HEADERS']), + ...(input.policyHeaders ?? {}), + }; return Object.freeze({ enabled: true, @@ -194,7 +268,7 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { enabledVia, exporterType, otlpEndpoint, - otlpProtocol: protocol, + otlpProtocol, captureContent, maxAttributeSizeChars: maxAttributeSizeChars < 0 ? 0 : maxAttributeSizeChars, fileExporterPath, @@ -205,6 +279,7 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { serviceVersion: input.extensionVersion, sessionId: input.sessionId, resourceAttributes, + headers, }); } @@ -215,7 +290,7 @@ function createDisabledConfig(input: OTelConfigInput): OTelConfig { enabledVia: 'disabled' as const, exporterType: 'otlp-http' as const, otlpEndpoint: '', - otlpProtocol: 'http' as const, + otlpProtocol: 'http/json' as const, captureContent: false, maxAttributeSizeChars: 0, dbSpanExporter: false, @@ -225,6 +300,7 @@ function createDisabledConfig(input: OTelConfigInput): OTelConfig { serviceVersion: input.extensionVersion, sessionId: input.sessionId, resourceAttributes: {}, + headers: {}, }); } diff --git a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts index d463cfa61596b..8aa8da6303f44 100644 --- a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts @@ -14,7 +14,7 @@ function makeConfig(overrides: Partial = {}): OTelConfig { enabledVia: 'setting', exporterType: 'otlp-http', otlpEndpoint: 'http://localhost:4318', - otlpProtocol: 'http', + otlpProtocol: 'http/json', captureContent: false, maxAttributeSizeChars: 0, dbSpanExporter: false, @@ -24,6 +24,7 @@ function makeConfig(overrides: Partial = {}): OTelConfig { serviceVersion: '1.0.0', sessionId: 'test-session', resourceAttributes: {}, + headers: {}, ...overrides, }; } diff --git a/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts b/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts index ff05f5c74b194..7d40811da9ae8 100644 --- a/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts @@ -114,6 +114,40 @@ describe('resolveOTelConfig', () => { }); }); + it('merges resource attributes with precedence policy > env > setting', () => { + const config = resolveOTelConfig(makeInput({ + settingResourceAttributes: { fromSetting: 'setting', shared: 'setting' }, + policyResourceAttributes: { fromPolicy: 'policy', shared: 'policy' }, + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_RESOURCE_ATTRIBUTES': 'fromEnv=env,shared=env', + }, + })); + expect(config.resourceAttributes).toEqual({ + fromSetting: 'setting', + fromEnv: 'env', + fromPolicy: 'policy', + shared: 'policy', + }); + }); + + it('merges OTLP headers with precedence policy > env > setting', () => { + const config = resolveOTelConfig(makeInput({ + settingHeaders: { fromSetting: 'setting', shared: 'setting' }, + policyHeaders: { fromPolicy: 'policy', shared: 'policy' }, + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_EXPORTER_OTLP_HEADERS': 'fromEnv=env,shared=env', + }, + })); + expect(config.headers).toEqual({ + fromSetting: 'setting', + fromEnv: 'env', + fromPolicy: 'policy', + shared: 'policy', + }); + }); + it('uses grpc protocol when OTEL_EXPORTER_OTLP_PROTOCOL=grpc', () => { const config = resolveOTelConfig(makeInput({ env: { @@ -128,6 +162,18 @@ describe('resolveOTelConfig', () => { expect(config.otlpEndpoint).toBe('http://collector:4317'); }); + it('infers grpc transport from settingExporterType when no env/policy protocol is set', () => { + const config = resolveOTelConfig(makeInput({ + settingEnabled: true, + settingExporterType: 'otlp-grpc', + settingOtlpEndpoint: 'http://collector:4317/some/path', + })); + expect(config.otlpProtocol).toBe('grpc'); + expect(config.exporterType).toBe('otlp-grpc'); + // gRPC parsing keeps only the origin + expect(config.otlpEndpoint).toBe('http://collector:4317'); + }); + it('preserves service version and session id', () => { const config = resolveOTelConfig(makeInput({ settingEnabled: true, @@ -155,6 +201,37 @@ describe('resolveOTelConfig', () => { expect(config.serviceName).toBe('my-service'); }); + it('resolves service name from setting when env is absent', () => { + const config = resolveOTelConfig(makeInput({ + settingEnabled: true, + settingServiceName: 'setting-service', + })); + expect(config.serviceName).toBe('setting-service'); + }); + + it('prefers OTEL_SERVICE_NAME env over the setting', () => { + const config = resolveOTelConfig(makeInput({ + settingServiceName: 'setting-service', + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_SERVICE_NAME': 'env-service', + }, + })); + expect(config.serviceName).toBe('env-service'); + }); + + it('enterprise policy service name wins over env and setting', () => { + const config = resolveOTelConfig(makeInput({ + policyServiceName: 'policy-service', + settingServiceName: 'setting-service', + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_SERVICE_NAME': 'env-service', + }, + })); + expect(config.serviceName).toBe('policy-service'); + }); + it('returns frozen config objects', () => { const enabled = resolveOTelConfig(makeInput({ settingEnabled: true })); const disabled = resolveOTelConfig(makeInput()); @@ -318,4 +395,77 @@ describe('resolveOTelConfig', () => { expect(config.maxAttributeSizeChars).toBe(0); }); }); + + describe('enterprise policy precedence', () => { + it('policy enabled wins over a disabling user setting', () => { + const config = resolveOTelConfig(makeInput({ + settingEnabled: false, + policyEnabled: true, + })); + expect(config.enabled).toBe(true); + expect(config.enabledVia).toBe('policy'); + }); + + it('policy disabled forces OTel off even when env enables it', () => { + const config = resolveOTelConfig(makeInput({ + env: { 'COPILOT_OTEL_ENABLED': 'true' }, + policyEnabled: false, + })); + expect(config.enabled).toBe(false); + }); + + it('policy endpoint wins over env endpoint and enables OTel', () => { + const config = resolveOTelConfig(makeInput({ + env: { 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://user:4318' }, + policyOtlpEndpoint: 'http://enterprise:4318', + })); + expect(config.enabled).toBe(true); + expect(config.otlpEndpoint).toBe('http://enterprise:4318/'); + expect(config.enabledVia).toBe('policy'); + }); + + it('policy exporter type maps and suppresses file export', () => { + const config = resolveOTelConfig(makeInput({ + settingOutfile: '/tmp/spans.jsonl', + policyEnabled: true, + policyExporterType: 'otlp-http', + })); + expect(config.exporterType).toBe('otlp-http'); + expect(config.fileExporterPath).toBeUndefined(); + }); + + it('policy captureContent overrides the user setting', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + settingCaptureContent: true, + policyCaptureContent: false, + })); + expect(config.captureContent).toBe(false); + }); + + it('policy protocol http/protobuf selects the protobuf wire encoding', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + policyProtocol: 'http/protobuf', + })); + expect(config.otlpProtocol).toBe('http/protobuf'); + }); + + it('policy protocol http/json keeps the json wire encoding', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + policyProtocol: 'http/json', + })); + expect(config.otlpProtocol).toBe('http/json'); + }); + + it('grpc exporter reports grpc regardless of wire protocol', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + policyExporterType: 'otlp-grpc', + policyProtocol: 'http/protobuf', + })); + expect(config.otlpProtocol).toBe('grpc'); + }); + }); }); diff --git a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts index 47036221a8b97..d6272b5d8bbb8 100644 --- a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts +++ b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts @@ -241,7 +241,7 @@ export class NodeOTelService implements IOTelService { import('@opentelemetry/exporter-logs-otlp-grpc'), import('@opentelemetry/exporter-metrics-otlp-grpc'), ]); - const opts = { url: config.otlpEndpoint }; + const opts = { url: config.otlpEndpoint, headers: config.headers }; return { spanExporter: new OTLPTraceExporter(opts), logExporter: new OTLPLogExporter(opts), @@ -249,6 +249,25 @@ export class NodeOTelService implements IOTelService { }; } + // otlp-http with protobuf wire encoding + if (config.otlpProtocol === 'http/protobuf' && !dbOnlyMode) { + const [ + { OTLPTraceExporter }, + { OTLPLogExporter }, + { OTLPMetricExporter }, + ] = await Promise.all([ + import('@opentelemetry/exporter-trace-otlp-proto'), + import('@opentelemetry/exporter-logs-otlp-proto'), + import('@opentelemetry/exporter-metrics-otlp-proto'), + ]); + const base = config.otlpEndpoint.replace(/\/$/, ''); + return { + spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces`, headers: config.headers }), + logExporter: new OTLPLogExporter({ url: `${base}/v1/logs`, headers: config.headers }), + metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics`, headers: config.headers }), + }; + } + // Default: otlp-http (or noop when in db-only mode) if (dbOnlyMode) { const metricsSDK = await import('@opentelemetry/sdk-metrics'); @@ -270,9 +289,9 @@ export class NodeOTelService implements IOTelService { ]); const base = config.otlpEndpoint.replace(/\/$/, ''); return { - spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces` }), - logExporter: new OTLPLogExporter({ url: `${base}/v1/logs` }), - metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics` }), + spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces`, headers: config.headers }), + logExporter: new OTLPLogExporter({ url: `${base}/v1/logs`, headers: config.headers }), + metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics`, headers: config.headers }), }; } diff --git a/extensions/copilot/src/util/common/test/shims/chatTypes.ts b/extensions/copilot/src/util/common/test/shims/chatTypes.ts index e911a840d28ce..cdbce3da03115 100644 --- a/extensions/copilot/src/util/common/test/shims/chatTypes.ts +++ b/extensions/copilot/src/util/common/test/shims/chatTypes.ts @@ -186,6 +186,20 @@ export class ChatResponsePullRequestPart { } +export class ChatResponseAutoModeResolutionPart { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: string; + confidence: number; + constructor(resolvedModel: string, resolvedModelName: string, predictedLabel: string, confidence: number) { + this.resolvedModel = resolvedModel; + this.resolvedModelName = resolvedModelName; + this.predictedLabel = predictedLabel; + this.confidence = confidence; + } +} + + export class ChatResponseCodeCitationPart { value: vscode.Uri; license: string; diff --git a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts index 35a1d656b56cd..a2aa56f6953b4 100644 --- a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts +++ b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts @@ -18,7 +18,7 @@ import { SnippetString } from '../../../vs/workbench/api/common/extHostTypes/sni import { SnippetTextEdit } from '../../../vs/workbench/api/common/extHostTypes/snippetTextEdit'; import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/extHostTypes/symbolInformation'; import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit'; -import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; +import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseAutoModeResolutionPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing'; import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; import { t } from './l10n'; @@ -106,6 +106,7 @@ const shim: typeof vscodeTypes = { TerminalShellExecutionCommandLineConfidence, ChatRequestEditedFileEventKind, ChatResponsePullRequestPart, + ChatResponseAutoModeResolutionPart, LanguageModelTextPart2, LanguageModelDataPart2, LanguageModelThinkingPart, diff --git a/extensions/copilot/src/util/vs/base/common/async.ts b/extensions/copilot/src/util/vs/base/common/async.ts index 13fb34d088460..b97f31e1a9eea 100644 --- a/extensions/copilot/src/util/vs/base/common/async.ts +++ b/extensions/copilot/src/util/vs/base/common/async.ts @@ -2052,9 +2052,11 @@ export class AsyncIterableObject implements AsyncIterable { } catch (err) { this.reject(err); } finally { - writer.emitOne = undefined!; - writer.emitMany = undefined!; - writer.reject = undefined!; + // The executor has settled; emitting afterwards must be a no-op per the + // documented "no effect after resolve()/reject()" contract (see emitOne). + writer.emitOne = () => { }; + writer.emitMany = () => { }; + writer.reject = () => { }; } }); } diff --git a/extensions/copilot/src/vscodeTypes.ts b/extensions/copilot/src/vscodeTypes.ts index 6b9519e05a6b6..a931ec06d7ccc 100644 --- a/extensions/copilot/src/vscodeTypes.ts +++ b/extensions/copilot/src/vscodeTypes.ts @@ -42,6 +42,7 @@ export import ChatResponseMovePart = vscode.ChatResponseMovePart; export import ChatResponseExtensionsPart = vscode.ChatResponseExtensionsPart; export import ChatResponseExternalEditPart = vscode.ChatResponseExternalEditPart; export import ChatResponsePullRequestPart = vscode.ChatResponsePullRequestPart; +export import ChatResponseAutoModeResolutionPart = vscode.ChatResponseAutoModeResolutionPart; export import ChatResponseMarkdownWithVulnerabilitiesPart = vscode.ChatResponseMarkdownWithVulnerabilitiesPart; export import ChatResponseCodeblockUriPart = vscode.ChatResponseCodeblockUriPart; export import ChatResponseTextEditPart = vscode.ChatResponseTextEditPart; diff --git a/extensions/copilot/test/base/simulationOptions.ts b/extensions/copilot/test/base/simulationOptions.ts index 69c2d87682907..e73de4640a321 100644 --- a/extensions/copilot/test/base/simulationOptions.ts +++ b/extensions/copilot/test/base/simulationOptions.ts @@ -16,12 +16,42 @@ export enum NesDatagenSampleTask { CursorBoth = 'cursor-both', } +/** + * Shape of the recordings in the nes-datagen input file. + */ +export enum NesDatagenInputFormat { + /** Per-request "alternative action" recordings bookmarked at the NES request time. */ + AlternativeAction = 'alternative-action', + /** Continuous enhanced-telemetry slices with no request bookmark; a pivot is synthesized. */ + Continuous = 'continuous', +} + +/** + * How to choose the pivot in a continuous recording (only meaningful when + * `--input-format=continuous`). The pivot splits the timeline into context and + * the oracle (next user edit). + */ +export enum PivotStrategy { + /** Pick a single eligible pivot uniformly at random. */ + Random = 'random', +} + export type NesDatagen = { readonly input: string; readonly output: string | undefined; readonly rowOffset: number; readonly workerMode: boolean; readonly sampleTask: NesDatagenSampleTask; + /** Shape of the input recordings. */ + readonly inputFormat: NesDatagenInputFormat; + /** Pivot selection strategy for continuous recordings. Ignored for alternative-action input. */ + readonly pivotStrategy: PivotStrategy; + /** + * Seed for the continuous pivot RNG. Resolved once (random when `--seed` is + * omitted) so it can be propagated to all parallel workers for reproducible + * output. Ignored for alternative-action input. + */ + readonly seed: number; /** Minimum same-file lines above the request cursor for a move to count as a jump. */ readonly sameFileJumpMinAbove: number; /** Minimum same-file lines below the request cursor for a move to count as a jump. */ @@ -110,6 +140,13 @@ export class SimulationOptions { public readonly modelConfigFile: string | undefined; + /** + * Path to a JSON file describing an adhoc chat request to send (used by the + * simulation workbench "Adhoc request sender" mode). The file contains + * `{ system: string; user: string; model: string }`. + */ + public readonly adhocRequestFile: string | undefined; + protected constructor(processArgv: readonly string[]) { const argv = minimist(processArgv.slice(2)); this.argv = argv; @@ -188,6 +225,9 @@ export class SimulationOptions { rowOffset: typeof argv['row-offset'] === 'number' ? argv['row-offset'] : 0, workerMode: boolean(argv['worker'], false), sampleTask: SimulationOptions.validateSampleTask(argv['sample-task']), + inputFormat: SimulationOptions.validateInputFormat(argv['input-format']), + pivotStrategy: SimulationOptions.validatePivotStrategy(argv['pivot-strategy']), + seed: SimulationOptions.resolveSeed(argv['seed']), sameFileJumpMinAbove: typeof argv['same-file-jump-min-above'] === 'number' ? argv['same-file-jump-min-above'] : 2, sameFileJumpMinBelow: typeof argv['same-file-jump-min-below'] === 'number' ? argv['same-file-jump-min-below'] : 5, } @@ -195,6 +235,7 @@ export class SimulationOptions { this.configFile = argv['config-file']; this.modelConfigFile = argv['model-config-file']; + this.adhocRequestFile = argv['adhoc-request-file']; } public printHelp(): void { @@ -267,6 +308,14 @@ export class SimulationOptions { ` --input Path to a JSON or JSON Lines file with training data recordings (required)`, ` Format is inferred from the extension: .jsonl/.ndjson → JSON Lines, otherwise JSON array`, ` --out Output path for the JSON Lines file. Default: _output.jsonl`, + ` --input-format Shape of the input recordings (default: alternative-action)`, + ` Values: alternative-action, continuous`, + ` alternative-action → per-request recordings bookmarked at the NES request time`, + ` continuous → continuous enhanced-telemetry slices; a pivot is synthesized`, + ` --pivot-strategy How to pick the pivot in a continuous recording (default: random; only for --input-format=continuous)`, + ` Values: random`, + ` random → pick a single eligible pivot uniformly at random`, + ` --seed Integer seed for the continuous pivot RNG (default: random, logged for reproducibility)`, ` --sample-task Which target to generate (default: xtab)`, ` Values: xtab, cursor-same-file, cursor-cross-file, cursor-both`, ` xtab → edit-prediction sample (assistant = an edit)`, @@ -290,6 +339,8 @@ export class SimulationOptions { ` npm run simulate -- --config-file=config.json nes-datagen --input=data.json --sample-task=cursor-same-file`, ` npm run simulate -- --config-file=config.json nes-datagen --input=data.json --sample-task=cursor-cross-file`, ` npm run simulate -- --config-file=config.json nes-datagen --input=data.json --sample-task=cursor-both --same-file-jump-min-above=8 --same-file-jump-min-below=8`, + ` npm run simulate -- --config-file=config.json nes-datagen --input=continuous.jsonl --input-format=continuous`, + ` npm run simulate -- --config-file=config.json nes-datagen --input=continuous.jsonl --input-format=continuous --pivot-strategy=random --seed=42`, ``, ].join('\n')); } @@ -341,6 +392,49 @@ export class SimulationOptions { } return value as NesDatagenSampleTask; } + + private static validateInputFormat(value: unknown): NesDatagenInputFormat { + if (value === undefined || value === null) { + return NesDatagenInputFormat.AlternativeAction; + } + if (typeof value !== 'string') { + throw new Error(`--input-format must be a string, but got: ${typeof value}`); + } + const allowed = Object.values(NesDatagenInputFormat) as string[]; + if (!allowed.includes(value)) { + throw new Error(`--input-format must be one of [${allowed.join(', ')}], but got: ${value}`); + } + return value as NesDatagenInputFormat; + } + + private static validatePivotStrategy(value: unknown): PivotStrategy { + if (value === undefined || value === null) { + return PivotStrategy.Random; + } + if (typeof value !== 'string') { + throw new Error(`--pivot-strategy must be a string, but got: ${typeof value}`); + } + const allowed = Object.values(PivotStrategy) as string[]; + if (!allowed.includes(value)) { + throw new Error(`--pivot-strategy must be one of [${allowed.join(', ')}], but got: ${value}`); + } + return value as PivotStrategy; + } + + /** + * Resolve the continuous pivot seed. When `--seed` is omitted a random + * 32-bit seed is generated so that the parent can log it and propagate it to + * every worker, keeping output reproducible. + */ + private static resolveSeed(value: unknown): number { + if (value === undefined || value === null) { + return Math.floor(Math.random() * 0x100000000); + } + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new Error(`--seed must be an integer, but got: ${value}`); + } + return value >>> 0; + } } function cliOptionsToWellKnownEmbeddingsType(model: string | undefined): EmbeddingType | undefined { diff --git a/extensions/copilot/test/pipeline/alternativeAction/processor.ts b/extensions/copilot/test/pipeline/alternativeAction/processor.ts index 2067508d4fee5..98af88e081b2f 100644 --- a/extensions/copilot/test/pipeline/alternativeAction/processor.ts +++ b/extensions/copilot/test/pipeline/alternativeAction/processor.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAlternativeAction } from '../../../src/extension/inlineEdits/node/nextEditProviderTelemetry'; import { Edits } from '../../../src/platform/inlineEdits/common/dataTypes/edit'; import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; import { StringEdit, StringReplacement } from '../../../src/util/vs/editor/common/core/edits/stringEdit'; @@ -22,17 +21,22 @@ export namespace Processor { } /** - * Split a recording at its NES request bookmark and resolve the active - * document at that moment. Exposed so callers (in particular nes-datagen - * cursor-jump detectors) can reason about what the user did *after* the request - * without re-implementing the same splitting logic that - * {@link createScoringForAlternativeAction} performs internally. + * Split a recording at a pivot time (the NES request bookmark for + * per-request recordings, or a synthesized pivot for continuous ones) and + * resolve the active document at that moment. Exposed so callers (in + * particular nes-datagen cursor-jump detectors and the continuous-recording + * path) can reason about what the user did *after* the pivot without + * re-implementing the same splitting logic that {@link createScoring} + * performs internally. * - * Returns `undefined` if the recording cannot be split (missing - * `requestTime`, empty entries, no resolvable active document, etc.). + * `requestTime` is the pivot: entries with `time <= requestTime` form the + * prior portion, the rest form the post-request portion. + * + * Returns `undefined` if the recording cannot be split (empty entries, pivot + * before all entries, no resolvable active document, etc.). */ - export function splitRecording(altAction: IAlternativeAction): ISplitRecording | undefined { - const processedRecording = splitRecordingAtRequestTime(altAction); + export function splitRecording(entries: LogEntry[], requestTime: number): ISplitRecording | undefined { + const processedRecording = splitRecordingAtRequestTime(entries, requestTime); if (!processedRecording) { return undefined; } @@ -61,13 +65,14 @@ export namespace Processor { }; } - export function createScoringForAlternativeAction( - altAction: IAlternativeAction, + export function createScoring( + entries: LogEntry[], + requestTime: number, proposedEdits: IStringReplacement[], isAccepted: boolean, ): Scoring.t | undefined { - const processedRecording = splitRecordingAtRequestTime(altAction); + const processedRecording = splitRecordingAtRequestTime(entries, requestTime); if (!processedRecording) { log('Could not split recording at request time'); return undefined; @@ -111,24 +116,17 @@ export namespace Processor { return scoring; } - function splitRecordingAtRequestTime(altAction: IAlternativeAction): { + function splitRecordingAtRequestTime(entries: LogEntry[], requestTime: number): { wholeRecording: LogEntry[]; recordingPriorToRequest: LogEntry[]; recordingAfterRequest: LogEntry[]; } | undefined { - if (!altAction.recording) { + if (!entries || entries.length === 0) { return undefined; } - const recording = altAction.recording.entries; - if (!recording || recording.length === 0) { - return undefined; - } - - const requestTime = altAction.recording.requestTime; - - const recordingIdxOfRequestTime = binarySearch(recording, (entry: LogEntry) => { + const recordingIdxOfRequestTime = binarySearch(entries, (entry: LogEntry) => { if (entry.kind === 'meta') { return -1; } else { @@ -141,11 +139,11 @@ export namespace Processor { return undefined; } - const recordingPriorToRequest = recording.slice(0, recordingIdxOfRequestTime + 1); - const recordingAfterRequest = recording.slice(recordingIdxOfRequestTime + 1); + const recordingPriorToRequest = entries.slice(0, recordingIdxOfRequestTime + 1); + const recordingAfterRequest = entries.slice(recordingIdxOfRequestTime + 1); return { - wholeRecording: recording, + wholeRecording: entries, recordingPriorToRequest, recordingAfterRequest }; diff --git a/extensions/copilot/test/pipeline/alternativeAction/util.ts b/extensions/copilot/test/pipeline/alternativeAction/util.ts index deb60faa9abfa..89eae08611737 100644 --- a/extensions/copilot/test/pipeline/alternativeAction/util.ts +++ b/extensions/copilot/test/pipeline/alternativeAction/util.ts @@ -11,6 +11,14 @@ export function log(...args: any[]) { } } +/** + * Find an index `i` such that `compare(array[i]) === 0`, or — when no exact + * match exists — the index of the greatest element with `compare < 0` + * (`-1` if none). Assumes `array` is sorted ascending by `compare`. + * + * NOTE: when several elements compare equal (e.g. share a timestamp) this + * returns an *arbitrary* one of them, not necessarily the first or last. + */ export function binarySearch( array: readonly T[], compare: (element: T) => number diff --git a/extensions/copilot/test/pipeline/continuous/continuousRecord.spec.ts b/extensions/copilot/test/pipeline/continuous/continuousRecord.spec.ts new file mode 100644 index 0000000000000..cb090f89c2bcf --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/continuousRecord.spec.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { parseContinuousRecord } from './continuousRecord'; + +const entries = [{ kind: 'documentEncountered', id: 0, time: 1, relativePath: 'a.ts' }]; + +describe('parseContinuousRecord', () => { + it('parses a stringified recording payload', () => { + const recording = { entries, entriesSize: 10, windowStart: 1, windowEnd: 2, sessionId: 's', sequenceNumber: 3 }; + const record = parseContinuousRecord({ recording: JSON.stringify(recording) }, 4); + expect(record).toEqual({ originalRowIndex: 4, value: recording }); + }); + + it('tolerates a pre-parsed recording object (fixtures)', () => { + const recording = { entries, entriesSize: 10 }; + expect(parseContinuousRecord({ recording }, 0).value.entriesSize).toBe(10); + }); + + it('throws when the recording column is missing', () => { + expect(() => parseContinuousRecord({}, 0)).toThrow(/Missing key/); + }); + + it('throws when entries were dropped at send time (over cap)', () => { + const record = { recording: JSON.stringify({ entriesSize: 999999 }) }; + expect(() => parseContinuousRecord(record, 0)).toThrow(/entries is missing or empty/); + }); + + it('throws when entries are empty', () => { + const record = { recording: JSON.stringify({ entries: [], entriesSize: 0 }) }; + expect(() => parseContinuousRecord(record, 0)).toThrow(/entries is missing or empty/); + }); +}); diff --git a/extensions/copilot/test/pipeline/continuous/continuousRecord.ts b/extensions/copilot/test/pipeline/continuous/continuousRecord.ts new file mode 100644 index 0000000000000..bdf74d5c525fc --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/continuousRecord.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IContinuousRecording } from '../../../src/extension/inlineEdits/node/continuousEnhancedTelemetrySender'; +import { ErrorUtils } from '../../../src/util/common/errors'; +import type { WithRowIndex } from '../withRowIndex'; +import { streamJsonRecords } from '../streamJsonRecords'; + +/** + * A single parsed continuous-recording input record (one telemetry slice), + * paired with its 0-based position within the input file / chunk. + */ +export type IContinuousRecord = WithRowIndex; + +/** + * Column carrying the (stringified) recording payload. Matches the telemetry + * property name emitted by `ContinuousEnhancedTelemetrySender`; export queries + * should alias the recording column to `recording`. + */ +const RECORDING_KEY = 'recording'; + +/** + * Parse a single continuous input record. + * + * The `recording` field is, by export convention, a JSON string (the sender + * calls `JSON.stringify(recording)`); a pre-parsed object is also tolerated for + * fixtures and hand-authored inputs. + * + * Throws if the record is missing the recording column or has no usable + * entries — callers collect these as per-record errors rather than aborting. + */ +export function parseContinuousRecord(record: Record, rowIndex: number): IContinuousRecord { + const raw = record[RECORDING_KEY]; + if (raw === undefined || raw === null) { + throw new Error(`Missing key: ${RECORDING_KEY}`); + } + + let recording: IContinuousRecording; + if (typeof raw === 'string') { + recording = JSON.parse(raw) as IContinuousRecording; + } else if (typeof raw === 'object') { + recording = raw as IContinuousRecording; + } else { + throw new Error(`'${RECORDING_KEY}' must be a JSON string or object, got ${typeof raw}`); + } + + if (!recording.entries || recording.entries.length === 0) { + // `entries` is dropped when the payload exceeded the sender cap; such a + // slice carries no edit history and cannot produce a sample. + throw new Error(`recording.entries is missing or empty (entriesSize: ${recording.entriesSize ?? '?'})`); + } + + return { + originalRowIndex: rowIndex, + value: recording, + }; +} + +/** + * Stream-parse a continuous-recording input file (JSON array or JSON Lines), + * validating each record into an {@link IContinuousRecord}. Records that fail + * validation are reported in `errors` (with their row index) rather than + * aborting the load. Mirrors `loadAndParseInput` for the alternative-action path. + */ +export async function loadAndParseContinuousInput(inputPath: string, verbose = false): Promise<{ + records: IContinuousRecord[]; + errors: WithRowIndex[]; +}> { + const records: IContinuousRecord[] = []; + const errors: WithRowIndex[] = []; + + let i = 0; + for await (const record of streamJsonRecords>(inputPath)) { + const rowIndex = i++; + try { + records.push(parseContinuousRecord(record, rowIndex)); + } catch (e) { + errors.push({ originalRowIndex: rowIndex, value: ErrorUtils.fromUnknown(e) }); + } + } + + if (verbose) { + console.log(`Read ${i} continuous records from ${inputPath}`); + } + + return { records, errors }; +} diff --git a/extensions/copilot/test/pipeline/continuous/pivotStrategy.spec.ts b/extensions/copilot/test/pipeline/continuous/pivotStrategy.spec.ts new file mode 100644 index 0000000000000..6b11a22a1305e --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/pivotStrategy.spec.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { Random } from '../../../src/platform/inlineEdits/test/node/random'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { PivotStrategy } from '../../base/simulationOptions'; +import { deriveSeed, selectPivots } from './pivotStrategy'; + +const ID = 0; + +function meta(): LogEntry { + return { kind: 'meta', data: { repoRootUri: 'file:///ws' } }; +} +function encountered(time: number): LogEntry { + return { kind: 'documentEncountered', id: ID, time, relativePath: 'a.ts' }; +} +function setContent(time: number): LogEntry { + return { kind: 'setContent', id: ID, time, content: 'x', v: 0 }; +} +function changed(time: number): LogEntry { + return { kind: 'changed', id: ID, time, edit: [[0, 0, 'y']], v: 1 }; +} +function selection(time: number): LogEntry { + return { kind: 'selectionChanged', id: ID, time, selection: [[0, 0]] }; +} + +/** A well-formed recording: meta, encounter, content, selection, then 2 later edits. */ +const recording: LogEntry[] = [meta(), encountered(1), setContent(2), selection(3), changed(4), changed(5)]; + +describe('selectPivots (random)', () => { + it('selects only changed/selectionChanged entries (never framing)', () => { + // Indices: 0 meta, 1 documentEncountered, 2 setContent, 3 selectionChanged, + // 4 changed, 5 changed. Only 3 and 4 are eligible (5 is last → no oracle; + // 1 and 2 are framing). Sweep seeds to exercise the random choice. + const chosen = new Set(); + for (let seed = 0; seed < 50; seed++) { + const pivots = selectPivots(recording, PivotStrategy.Random, Random.create(seed)); + expect(pivots).toHaveLength(1); + chosen.add(pivots[0]); + } + expect([...chosen].sort()).toEqual([3, 4]); + }); + + it('is deterministic for a given seed', () => { + const a = selectPivots(recording, PivotStrategy.Random, Random.create(42)); + const b = selectPivots(recording, PivotStrategy.Random, Random.create(42)); + expect(a).toEqual(b); + }); + + it('rejects records whose only unique-time entries are framing', () => { + // documentEncountered + setContent have unique times but are framing; + // the single changed is last, so no oracle follows it. + const framingOnly: LogEntry[] = [encountered(1), setContent(2), changed(3)]; + expect(selectPivots(framingOnly, PivotStrategy.Random, Random.create(1))).toEqual([]); + }); + + it('rejects records with duplicate times (ambiguous split boundary)', () => { + const dup: LogEntry[] = [encountered(1), changed(1), changed(1)]; + expect(selectPivots(dup, PivotStrategy.Random, Random.create(1))).toEqual([]); + }); + + it('rejects records with no edit after any candidate', () => { + const noOracle: LogEntry[] = [encountered(1), setContent(2), selection(3)]; + expect(selectPivots(noOracle, PivotStrategy.Random, Random.create(1))).toEqual([]); + }); + + it('rejects an empty recording', () => { + expect(selectPivots([], PivotStrategy.Random, Random.create(1))).toEqual([]); + }); +}); + +describe('deriveSeed', () => { + it('is deterministic and index-dependent', () => { + expect(deriveSeed(100, 5)).toBe(deriveSeed(100, 5)); + expect(deriveSeed(100, 5)).not.toBe(deriveSeed(100, 6)); + expect(deriveSeed(100, 5)).not.toBe(deriveSeed(101, 5)); + }); +}); diff --git a/extensions/copilot/test/pipeline/continuous/pivotStrategy.ts b/extensions/copilot/test/pipeline/continuous/pivotStrategy.ts new file mode 100644 index 0000000000000..8470c9b1c6a2b --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/pivotStrategy.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Random } from '../../../src/platform/inlineEdits/test/node/random'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { assertNever } from '../../../src/util/vs/base/common/assert'; +import { PivotStrategy } from '../../base/simulationOptions'; + +/** + * Choose pivot *times* for a recording according to `strategy`. + * + * Continuous slices (from `ContinuousEnhancedTelemetrySender`) have no NES + * request bookmark, so a pivot must be synthesized. A pivot splits the timeline + * into *context* (everything at or before the pivot) and the *oracle* (the + * user's next edits after the pivot). + * + * Returns the `time` of each chosen pivot entry (matching the `requestTime` + * semantics the downstream split expects, see `Processor.splitRecording`). + * Returns an empty array when no eligible pivot exists. + * + * The return type is an array so future strategies (idle-gap, every-edit, ...) + * can yield multiple pivots per record; `random` yields at most one. + */ +export function selectPivots(entries: readonly LogEntry[], strategy: PivotStrategy, rng: Random): number[] { + switch (strategy) { + case PivotStrategy.Random: + return selectRandomPivot(entries, rng); + default: + return assertNever(strategy); + } +} + +function selectRandomPivot(entries: readonly LogEntry[], rng: Random): number[] { + const candidates = eligiblePivotIndices(entries); + if (candidates.length === 0) { + return []; + } + const idx = candidates[rng.nextIntRange(0, candidates.length)]; + return [entryTime(entries[idx])!]; +} + +/** + * Indices `i` that are valid pivot points. An index is eligible when: + * + * - `entries[i].kind` is `changed` or `selectionChanged`. This is the key + * constraint and it satisfies two requirements at once: + * 1. *Replayability.* The replay engine establishes the prefix's active + * document only on `changed`/`selectionChanged` (see + * `ObservableWorkspaceRecordingReplayer`); pivoting on framing entries + * (`documentEncountered`/`setContent`/`opened`) would leave the prefix + * with no active document and fail replay. Because the pivot is also the + * last entry of the prefix, the split's active document (the last + * id-bearing entry) and the replayer's active document coincide. + * 2. *In-window.* Continuous slices carry self-contained framing whose true + * times can pre-date the slice window (see `DebugRecorder.getDocumentLogInRange`), + * whereas `changed`/`selectionChanged` only ever come from in-window + * activity. Restricting to those kinds keeps the pivot inside the window. + * These are also exactly the moments a real NES request fires (after an edit + * or a cursor move), so the synthesized sample mirrors production. + * - `entries[i].time` is *globally unique* across the recording. Uniqueness + * guarantees that splitting by time lands deterministically on index `i` + * (`binarySearch` returns an arbitrary index among equal-time entries), which + * defends the rare case where an in-window event shares a timestamp with + * framing or with another document's event. + * - the active document (`entries[i].id`) was introduced by a + * `documentEncountered` somewhere, so its path is resolvable. + * - the suffix `[i+1..]` holds at least one `changed` event on that active + * document — i.e. a non-empty oracle exists. + * + * @returns eligible indices in ascending order (possibly empty). + */ +function eligiblePivotIndices(entries: readonly LogEntry[]): number[] { + const n = entries.length; + const eligible: number[] = []; + if (n < 2) { + return eligible; + } + + // Single forward pass to compute the set of document ids ever introduced, + // the last index a `changed` occurred per document id, and time frequencies. + const encounteredIds = new Set(); + const lastChangedIdxForId = new Map(); + const timeFrequency = new Map(); + for (let i = 0; i < n; i++) { + const entry = entries[i]; + if (entry.kind === 'documentEncountered') { + encounteredIds.add(entry.id); + } else if (entry.kind === 'changed') { + lastChangedIdxForId.set(entry.id, i); + } + + const time = entryTime(entry); + if (time !== undefined) { + timeFrequency.set(time, (timeFrequency.get(time) ?? 0) + 1); + } + } + + for (let i = 0; i < n - 1; i++) { + const entry = entries[i]; + if (entry.kind !== 'changed' && entry.kind !== 'selectionChanged') { + continue; // pivot must establish a replayable, in-window active document + } + if (timeFrequency.get(entry.time) !== 1) { + continue; // need an unambiguous, deterministic split boundary + } + if (!encounteredIds.has(entry.id)) { + continue; // active document must be resolvable to a path + } + const lastChangedIdx = lastChangedIdxForId.get(entry.id); + if (lastChangedIdx === undefined || lastChangedIdx <= i) { + continue; // a non-empty oracle must follow the pivot on the active doc + } + eligible.push(i); + } + + return eligible; +} + +/** The numeric event time of an entry, or `undefined` for `meta` entries. */ +function entryTime(entry: LogEntry): number | undefined { + return 'time' in entry ? entry.time : undefined; +} + +/** + * Derive a per-record seed from a base seed and a record index using a + * splitmix32-style integer hash. This makes each record's pivot selection + * depend only on `(baseSeed, globalRecordIndex)` — never on how the input is + * chunked across parallel workers — so runs are reproducible from `--seed` + * regardless of `--parallelism`. + */ +export function deriveSeed(baseSeed: number, index: number): number { + let h = (baseSeed ^ (index + 0x9e3779b9)) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x21f0aaad) >>> 0; + h = Math.imul(h ^ (h >>> 15), 0x735a2d97) >>> 0; + h = (h ^ (h >>> 15)) >>> 0; + return h; +} diff --git a/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts b/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts new file mode 100644 index 0000000000000..b9f0746a14a5e --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { PivotStrategy } from '../../base/simulationOptions'; +import { IContinuousRecord } from './continuousRecord'; +import { CONTINUOUS_SUGGESTION_STATUS, processContinuousRecord, processContinuousRecords } from './processContinuous'; + +const doc = Array.from({ length: 30 }, (_, i) => `// L${String(i).padStart(2, '0')}`).join('\n') + '\n'; + +// Window metadata the sender always ships; irrelevant to pivot/replay logic but +// required by `IContinuousRecording`, so these tests fill it with fixed dummies. +const META = { windowStart: 0, windowEnd: 1, sessionId: 'test', sequenceNumber: 0 }; + +const entries: LogEntry[] = [ + { kind: 'meta', data: { repoRootUri: 'file:///ws' } }, + { kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/x.ts' }, + { kind: 'setContent', id: 0, time: 1001, content: doc, v: 0 }, + { kind: 'selectionChanged', id: 0, time: 1002, selection: [[14, 14]] }, + { kind: 'changed', id: 0, time: 1004, edit: [[175, 175, 'Z']], v: 1 }, + { kind: 'selectionChanged', id: 0, time: 1006, selection: [[175, 175]] }, + { kind: 'changed', id: 0, time: 1008, edit: [[7, 7, 'Q']], v: 2 }, +]; + +function record(): IContinuousRecord { + return { originalRowIndex: 0, value: { entries, entriesSize: 100, ...META } }; +} + +describe('processContinuousRecord', () => { + it('synthesizes an oracle-only row and resolves language from the active file', () => { + const result = processContinuousRecord(record(), 1002); + expect(result.isOk()).toBe(true); + if (result.isError()) { return; } + expect(result.val.row.suggestionStatus).toBe(CONTINUOUS_SUGGESTION_STATUS); + expect(result.val.row.activeDocumentLanguageId).toBe('typescript'); + result.val.replayer.dispose(); + }); + + it('errors when the recording has no entries', () => { + const empty: IContinuousRecord = { originalRowIndex: 0, value: { entries: [], entriesSize: 0, ...META } }; + expect(processContinuousRecord(empty, 0).isError()).toBe(true); + }); +}); + +describe('processContinuousRecords', () => { + it('produces identical pivots across runs with the same seed', () => { + const a = processContinuousRecords([record()], PivotStrategy.Random, 99, 0); + const b = processContinuousRecords([record()], PivotStrategy.Random, 99, 0); + expect(a.processed.length).toBe(b.processed.length); + expect(a.errors).toEqual(b.errors); + [...a.processed, ...b.processed].forEach(p => p.replayer.dispose()); + }); + + it('every selected pivot is replayable (eligible ⟹ replayable)', () => { + // Regression guard: a pivot that `selectPivots` deems eligible must always + // produce a real sample, never a replay error. Sweep seeds so different + // eligible pivots (the in-window selectionChanged/changed entries) are hit. + for (let seed = 0; seed < 50; seed++) { + const { processed, errors } = processContinuousRecords([record()], PivotStrategy.Random, seed, 0); + expect(errors).toEqual([]); + expect(processed).toHaveLength(1); + processed.forEach(p => p.replayer.dispose()); + } + }); + + it('reports a no-eligible-pivot error when no oracle follows', () => { + const noOracle: IContinuousRecord = { + originalRowIndex: 3, + value: { entries: entries.slice(0, 4), entriesSize: 50, ...META }, + }; + const { processed, errors } = processContinuousRecords([noOracle], PivotStrategy.Random, 1, 0); + expect(processed).toHaveLength(0); + expect(errors[0]).toMatchObject({ originalRowIndex: 3 }); + }); + + it('isolates a throwing record so the rest of the batch still produces rows', () => { + // A malformed oracle edit (overlapping replacements) makes replay throw a + // `BugIndicatingError` from the `StringEdit` constructor. One bad record + // must not abort the whole batch: it should surface as a per-record error + // while its siblings still process — matching the alternative-action path. + const malformedEntries: LogEntry[] = [ + { kind: 'meta', data: { repoRootUri: 'file:///ws' } }, + { kind: 'documentEncountered', id: 0, time: 3000, relativePath: 'src/bad.ts' }, + { kind: 'setContent', id: 0, time: 3001, content: doc, v: 0 }, + { kind: 'changed', id: 0, time: 3003, edit: [[5, 5, 'A']], v: 1 }, + { kind: 'changed', id: 0, time: 3005, edit: [[10, 14, 'X'], [12, 16, 'Y']], v: 2 }, + ]; + const malformed: IContinuousRecord = { originalRowIndex: 1, value: { entries: malformedEntries, entriesSize: 80, ...META } }; + const good = (originalRowIndex: number): IContinuousRecord => ({ originalRowIndex, value: { entries, entriesSize: 100, ...META } }); + + const { processed, errors } = processContinuousRecords([good(0), malformed, good(2)], PivotStrategy.Random, 7, 0); + + expect(processed).toHaveLength(2); + expect(errors.map(e => ({ originalRowIndex: e.originalRowIndex, isError: e.value instanceof Error }))).toEqual([{ originalRowIndex: 1, isError: true }]); + processed.forEach(p => p.replayer.dispose()); + }); +}); diff --git a/extensions/copilot/test/pipeline/continuous/processContinuous.ts b/extensions/copilot/test/pipeline/continuous/processContinuous.ts new file mode 100644 index 0000000000000..a44541bbfe37f --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/processContinuous.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAlternativeAction } from '../../../src/extension/inlineEdits/node/nextEditProviderTelemetry'; +import { Random } from '../../../src/platform/inlineEdits/test/node/random'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { ErrorUtils } from '../../../src/util/common/errors'; +import { Result } from '../../../src/util/common/result'; +import { PivotStrategy } from '../../base/simulationOptions'; +import { IInputRow } from '../parseInput'; +import { IProcessedRow, processRecordingAtPivot } from '../replayRecording'; +import { IContinuousRecord } from './continuousRecord'; +import { deriveSeed, selectPivots } from './pivotStrategy'; +import type { WithRowIndex } from '../withRowIndex'; + +/** + * Sentinel `suggestionStatus` for samples synthesized from a continuous + * recording: no model suggestion was ever made, so the sample is oracle-only + * (the model edit scores 0). + */ +export const CONTINUOUS_SUGGESTION_STATUS = 'continuous'; + +/** + * Build a synthetic {@link IInputRow} for one continuous-recording pivot. + * + * Continuous slices carry no model suggestion, prompt, or response — only the + * recorded edit timeline — so those fields use sentinels: an empty + * `suggestedEdit` produces no proposed edits, which is exactly what we want for + * oracle-only training data. `activeDocumentLanguageId` is filled in by the + * caller after replay, once the active file (and hence its language) is known. + */ +function synthesizeRow(record: IContinuousRecord, entries: LogEntry[], pivotTime: number, languageId: string): IInputRow { + const alternativeAction: IAlternativeAction = { + text: undefined, + textLength: 0, + selection: [], + edits: [], + tags: [CONTINUOUS_SUGGESTION_STATUS], + recording: { + entries, + entriesSize: record.value.entriesSize, + requestTime: pivotTime, + }, + }; + + return { + originalRowIndex: record.originalRowIndex, + suggestionStatus: CONTINUOUS_SUGGESTION_STATUS, + alternativeAction, + prompt: [], + modelResponse: '', + postProcessingOutcome: { suggestedEdit: '', isInlineCompletion: false }, + activeDocumentLanguageId: languageId, + }; +} + +/** + * Turn a single continuous recording into a processed row by splitting it at + * `pivotTime`. The returned {@link IProcessedRow} holds a live replayer that the + * caller must dispose. + * + * Never throws: like {@link processRow}, any unexpected error during replay + * (e.g. a malformed recorded edit) is caught and returned as an error `Result`, + * so one bad record can't abort a whole batch (see {@link processContinuousRecords}). + */ +export function processContinuousRecord(record: IContinuousRecord, pivotTime: number): Result { + try { + return _processContinuousRecord(record, pivotTime); + } catch (e: unknown) { + return Result.error(ErrorUtils.fromUnknown(e)); + } +} + +function _processContinuousRecord(record: IContinuousRecord, pivotTime: number): Result { + const entries = record.value.entries; + if (!entries || entries.length === 0) { + return Result.fromString('Continuous recording has no entries'); + } + + const result = processRecordingAtPivot({ + row: synthesizeRow(record, entries, pivotTime, ''), + entries, + requestTime: pivotTime, + proposedEdits: [], + isAccepted: false, + }); + if (result.isError()) { + return result; + } + + // The replayer resolves the active document's language from its file + // extension; reuse that so continuous samples carry a real language id + // (continuous telemetry has no per-slice language column). + const languageId = result.val.activeDocument.languageId.get(); + return Result.ok({ + ...result.val, + row: { ...result.val.row, activeDocumentLanguageId: languageId }, + }); +} + +/** + * Process a batch of continuous recordings into processed rows. + * + * Each record's pivot selection is seeded from `deriveSeed(baseSeed, rowOffset + + * record.originalRowIndex)`, so output is reproducible from `--seed` and + * independent of how records are sharded across parallel workers. + * + * `rowOffset` is the global index of the first record in this chunk (0 for + * single-process runs); it is added to each record's local index to form the + * global record index used for seeding. + * + * Each returned `IProcessedRow` holds a live replayer that the caller must dispose. + */ +export function processContinuousRecords( + records: readonly IContinuousRecord[], + strategy: PivotStrategy, + baseSeed: number, + rowOffset: number, +): { + processed: IProcessedRow[]; + errors: WithRowIndex[]; +} { + const processed: IProcessedRow[] = []; + const errors: WithRowIndex[] = []; + + for (const record of records) { + const entries = record.value.entries; + if (!entries || entries.length === 0) { + errors.push({ originalRowIndex: record.originalRowIndex, value: new Error('Continuous recording has no entries') }); + continue; + } + + const globalRecordIndex = rowOffset + record.originalRowIndex; + const rng = Random.create(deriveSeed(baseSeed, globalRecordIndex)); + + const pivots = selectPivots(entries, strategy, rng); + if (pivots.length === 0) { + errors.push({ originalRowIndex: record.originalRowIndex, value: new Error(`No eligible pivot found (strategy: ${strategy}, ${entries.length} entries)`) }); + continue; + } + + // NOTE: each materialized row is keyed downstream by `originalRowIndex` + // (prompt/response/output maps in pipeline.ts). The shipped `random` + // strategy yields at most one pivot per record, so that key stays unique. + // A future multi-pivot strategy (idle-gap, every-edit, ...) MUST first + // introduce a per-sample id (e.g. `{ originalRowIndex, pivotOrdinal }`) + // threaded through those maps, otherwise rows sharing a record index + // would overwrite each other. + for (const pivotTime of pivots) { + const result = processContinuousRecord(record, pivotTime); + if (result.isError()) { + errors.push({ originalRowIndex: record.originalRowIndex, value: result.err }); + } else { + processed.push(result.val); + } + } + } + + return { processed, errors }; +} diff --git a/extensions/copilot/test/pipeline/parseInput.ts b/extensions/copilot/test/pipeline/parseInput.ts index 4cd101ccc151a..d58511032e78d 100644 --- a/extensions/copilot/test/pipeline/parseInput.ts +++ b/extensions/copilot/test/pipeline/parseInput.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { IAlternativeAction } from '../../src/extension/inlineEdits/node/nextEditProviderTelemetry'; +import { ErrorUtils } from '../../src/util/common/errors'; import { streamJsonRecords } from './streamJsonRecords'; +import type { WithRowIndex } from './withRowIndex'; /** * A single row from the JSON input. @@ -80,10 +82,10 @@ function parseInputRecord(record: Record, rowIndex: number): IIn */ export async function loadAndParseInput(inputPath: string, verbose = false): Promise<{ rows: IInputRow[]; - errors: { rowIndex: number; error: string }[]; + errors: WithRowIndex[]; }> { const rows: IInputRow[] = []; - const errors: { rowIndex: number; error: string }[] = []; + const errors: WithRowIndex[] = []; let i = 0; for await (const record of streamJsonRecords>(inputPath)) { @@ -92,8 +94,8 @@ export async function loadAndParseInput(inputPath: string, verbose = false): Pro rows.push(parseInputRecord(record, rowIndex)); } catch (e) { errors.push({ - rowIndex, - error: e instanceof Error ? e.message : String(e), + originalRowIndex: rowIndex, + value: ErrorUtils.fromUnknown(e), }); } } diff --git a/extensions/copilot/test/pipeline/pipeline.ts b/extensions/copilot/test/pipeline/pipeline.ts index c4a956143f023..78e8b1eff483a 100644 --- a/extensions/copilot/test/pipeline/pipeline.ts +++ b/extensions/copilot/test/pipeline/pipeline.ts @@ -14,7 +14,9 @@ import { Limiter } from '../../src/util/vs/base/common/async'; import { OffsetRange } from '../../src/util/vs/editor/common/core/ranges/offsetRange'; import { StringText } from '../../src/util/vs/editor/common/core/text/abstractText'; import { applyConfigFile, loadConfigFile } from '../base/simulationContext'; -import { NesDatagen, NesDatagenSampleTask, SimulationOptions } from '../base/simulationOptions'; +import { NesDatagen, NesDatagenInputFormat, NesDatagenSampleTask, SimulationOptions } from '../base/simulationOptions'; +import { loadAndParseContinuousInput } from './continuous/continuousRecord'; +import { processContinuousRecords } from './continuous/processContinuous'; import { detectCrossFileJump, detectSameFileJump } from './cursorJump/detectJump'; import { generateCursorPromptFromRecording, installCursorJumpCapturingFetcher } from './cursorJump/cursorJumpPromptStep'; import { generateCrossFileResponse, generateSameFileResponse } from './cursorJump/cursorJumpResponseStep'; @@ -25,6 +27,7 @@ import { IProcessedRow, parseSuggestedEdit, processAllRows } from './replayRecor import { generateAllResponses, generateResponse, IResponseGenerationInput, applyEditsToContent } from './responseStep'; import { streamJsonRecords } from './streamJsonRecords'; import { openWriteStream } from './writeStream'; +import type { WithRowIndex } from './withRowIndex'; function logErrors(errors: readonly { error: string }[], verbose: boolean, log: (...ps: any[]) => void): void { if (errors.length > 0 && verbose) { @@ -63,6 +66,74 @@ export type RunPipelineOptions = { readonly parallelism: number; }; +/** + * Result of the shared "parse input + replay into processed rows" front half of + * both pipelines. Abstracts over the input format so the xtab and cursor-jump + * pipelines don't need to know whether the rows came from alternative-action or + * continuous recordings. + */ +interface ILoadedProcessedRows { + /** + * Number of input records that parsed successfully — the starting count for + * the `[1/5]` progress line and the pipeline summary funnel. Parse failures + * are excluded and counted separately in {@link parseErrors}. + */ + readonly recordCount: number; + /** Per-record parse failures. */ + readonly parseErrors: readonly WithRowIndex[]; + /** Successfully replayed rows. Each holds a live replayer the caller must dispose. */ + readonly processed: IProcessedRow[]; + /** Per-record replay/processing failures. */ + readonly replayErrors: readonly WithRowIndex[]; + /** + * Resolve the language id for a record by its `originalRowIndex` (the record's + * position in the input file), for error messages. Continuous records have no + * language before replay, so this returns `'?'` for them. + */ + readonly languageForRow: (originalRowIndex: number) => string; +} + +/** + * Parse the input and replay each recording into processed rows, dispatching on + * `--input-format`. This is the format-aware front half shared by both the xtab + * and cursor-jump pipelines. + * + * For continuous input the pivot RNG is seeded from `nesDatagenOpts.seed` and + * `nesDatagenOpts.rowOffset`, so output is reproducible and independent of how + * the input is sharded across workers. + */ +async function loadAndProduceProcessedRows(nesDatagenOpts: NesDatagen, verbose: boolean): Promise { + const inputPath = nesDatagenOpts.input; + + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + const { records, errors: parseErrors } = await loadAndParseContinuousInput(inputPath, verbose); + const { processed, errors: replayErrors } = processContinuousRecords( + records, + nesDatagenOpts.pivotStrategy, + nesDatagenOpts.seed, + nesDatagenOpts.rowOffset, + ); + return { + recordCount: records.length, + parseErrors, + processed, + replayErrors, + languageForRow: () => '?', + }; + } + + const { rows, errors: parseErrors } = await loadAndParseInput(inputPath, verbose); + const { processed, errors: replayErrors } = processAllRows(rows); + const languageByRowIndex = new Map(rows.map(row => [row.originalRowIndex, row.activeDocumentLanguageId])); + return { + recordCount: rows.length, + parseErrors, + processed, + replayErrors, + languageForRow: (originalRowIndex: number) => languageByRowIndex.get(originalRowIndex) ?? '?', + }; +} + /** * Single-process pipeline entry point. Dispatches to the xtab or cursor-jump * pipeline based on the configured sample task. @@ -99,17 +170,18 @@ async function runXtabPipeline(opts: RunPipelineOptions, log: (...ps: any[]) => log(`\n=== Pipeline ===`); log(` Input: ${inputPath}`); log(` Concurrency: ${concurrency}`); + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + log(` Input format: continuous (pivot-strategy: ${nesDatagenOpts.pivotStrategy}, seed: ${nesDatagenOpts.seed})`); + } - // Step 1: Parse input - const { rows, errors } = await loadAndParseInput(inputPath, verbose); - log(` [1/5] Input parsed: ${rows.length} rows, ${errors.length} errors`); - logErrors(errors, verbose, log); + // Step 1+2: Parse input and replay recordings (format-aware) + const { recordCount, parseErrors, processed, replayErrors, languageForRow } = await loadAndProduceProcessedRows(nesDatagenOpts, verbose); + log(` [1/5] Input parsed: ${recordCount} rows, ${parseErrors.length} errors`); + logErrors(parseErrors.map(e => ({ error: e.value.message })), verbose, log); - // Step 2: Replay recordings - const { processed, errors: replayErrors } = processAllRows(rows); log(` [2/5] Recordings replayed: ${processed.length} ok, ${replayErrors.length} errors`); logErrors(replayErrors.map(e => ({ - error: `[sample ${e.rowIndex + rowOffset}, ${rows[e.rowIndex]?.activeDocumentLanguageId ?? '?'}] ${e.error}`, + error: `[sample ${e.originalRowIndex + rowOffset}, ${languageForRow(e.originalRowIndex)}] ${e.value.message}`, })), verbose, log); // Step 3: Generate prompts @@ -215,7 +287,7 @@ async function runXtabPipeline(opts: RunPipelineOptions, log: (...ps: any[]) => } // Summary - log(`\n Pipeline: Input(${rows.length}) → Replay(${processed.length}) → Prompt(${prompts.length}) → Response(${responses.length}) → Output(${writeResult.written})`); + log(`\n Pipeline: Input(${recordCount}) → Replay(${processed.length}) → Prompt(${prompts.length}) → Response(${responses.length}) → Output(${writeResult.written})`); } finally { for (const p of processed) { p.replayer.dispose(); @@ -241,15 +313,17 @@ async function runCursorPipeline(opts: RunPipelineOptions, log: (...ps: any[]) = log(` Sample task: ${task}`); log(` Concurrency: ${concurrency}`); log(` Same-file jump thresholds: above=${nesDatagenOpts.sameFileJumpMinAbove}, below=${nesDatagenOpts.sameFileJumpMinBelow}`); + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + log(` Input format: continuous (pivot-strategy: ${nesDatagenOpts.pivotStrategy}, seed: ${nesDatagenOpts.seed})`); + } - const { rows, errors } = await loadAndParseInput(inputPath, verbose); - log(` [1/5] Input parsed: ${rows.length} rows, ${errors.length} errors`); - logErrors(errors, verbose, log); + const { recordCount, parseErrors, processed, replayErrors, languageForRow } = await loadAndProduceProcessedRows(nesDatagenOpts, verbose); + log(` [1/5] Input parsed: ${recordCount} rows, ${parseErrors.length} errors`); + logErrors(parseErrors.map(e => ({ error: e.value.message })), verbose, log); - const { processed, errors: replayErrors } = processAllRows(rows); log(` [2/5] Recordings replayed: ${processed.length} ok, ${replayErrors.length} errors`); logErrors(replayErrors.map(e => ({ - error: `[sample ${e.rowIndex + rowOffset}, ${rows[e.rowIndex]?.activeDocumentLanguageId ?? '?'}] ${e.error}`, + error: `[sample ${e.originalRowIndex + rowOffset}, ${languageForRow(e.originalRowIndex)}] ${e.value.message}`, })), verbose, log); // Detect jumps first — many rows will be skipped here, no point capturing @@ -428,7 +502,7 @@ async function runCursorPipeline(opts: RunPipelineOptions, log: (...ps: any[]) = const writeResult = await writeSamples(outputPath, samples); log(` [5/5] Output written: ${writeResult.written} samples → ${writeResult.outputPath}`); - log(`\n Pipeline: Input(${rows.length}) → Replay(${processed.length}) → Jumps(${jumps.size}) → Prompt(${prompts.length}) → Output(${writeResult.written})`); + log(`\n Pipeline: Input(${recordCount}) → Replay(${processed.length}) → Jumps(${jumps.size}) → Prompt(${prompts.length}) → Output(${writeResult.written})`); } finally { for (const p of processed) { p.replayer.dispose(); @@ -528,7 +602,11 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise const numWorkers = Math.max(1, Math.min(os.cpus().length, opts.parallelism, Math.ceil(totalRecords / 25))); console.log(`\n=== Pipeline (parallel: ${numWorkers} workers) ===`); - console.log(` Input: ${inputPath} (${totalRecords} rows)\n`); + console.log(` Input: ${inputPath} (${totalRecords} rows)`); + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + console.log(` Input format: continuous (pivot-strategy: ${nesDatagenOpts.pivotStrategy}, seed: ${nesDatagenOpts.seed})`); + } + console.log(''); if (totalRecords === 0) { console.log(` No records to process.`); @@ -573,6 +651,11 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise '--row-offset', String(start), '--parallelism', String(opts.parallelism), '--sample-task', nesDatagenOpts.sampleTask, + '--input-format', nesDatagenOpts.inputFormat, + '--pivot-strategy', nesDatagenOpts.pivotStrategy, + // Propagate the parent's resolved seed so every worker selects the + // same pivots it would in a single-process run (reproducibility). + '--seed', String(nesDatagenOpts.seed), '--same-file-jump-min-above', String(nesDatagenOpts.sameFileJumpMinAbove), '--same-file-jump-min-below', String(nesDatagenOpts.sameFileJumpMinBelow), '--worker', diff --git a/extensions/copilot/test/pipeline/replayRecording.spec.ts b/extensions/copilot/test/pipeline/replayRecording.spec.ts new file mode 100644 index 0000000000000..df94c4980b096 --- /dev/null +++ b/extensions/copilot/test/pipeline/replayRecording.spec.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { LogEntry } from '../../src/platform/workspaceRecorder/common/workspaceLog'; +import { IInputRow } from './parseInput'; +import { processAllRows } from './replayRecording'; + +const doc = `const a = 1;\nconst b = 2;\n`; + +/** + * Build an {@link IInputRow} whose recording replays a pre-pivot no-op edit and + * then applies `oracleEdit` after the pivot. Disjoint replacements replay + * cleanly; overlapping replacements make replay throw, which is how we exercise + * the error path without any stubbing. + */ +function makeRow(originalRowIndex: number, oracleEdit: [number, number, string][]): IInputRow { + const entries: LogEntry[] = [ + { kind: 'meta', data: { repoRootUri: 'file:///ws' } }, + { kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/a.ts' }, + { kind: 'setContent', id: 0, time: 1001, content: doc, v: 0 }, + // Pre-pivot no-op edit so the replayer has a `lastId`. + { kind: 'changed', id: 0, time: 1002, edit: [[0, 0, '']], v: 1 }, + // --- requestTime 1003 splits here; the rest is the oracle --- + { kind: 'changed', id: 0, time: 1004, edit: oracleEdit, v: 2 }, + ]; + return { + originalRowIndex, + suggestionStatus: 'accepted', + alternativeAction: { + text: doc, + textLength: doc.length, + selection: [], + edits: [], + tags: [], + recording: { entries, entriesSize: entries.length, requestTime: 1003 }, + }, + prompt: [], + modelResponse: '', + postProcessingOutcome: { suggestedEdit: '', isInlineCompletion: false }, + activeDocumentLanguageId: 'typescript', + }; +} + +describe('processAllRows', () => { + it('labels replay errors with the row\'s originalRowIndex, not its filtered array position', () => { + // Earlier parse failures make `loadAndParseInput` hand back a *sparse* + // `rows` array: survivors keep their true input-file `originalRowIndex` + // (0, 3, 5), so the failing row's index (3) differs from its position (1) + // in the array. The error must be labeled by index, not position. + const rows = [ + makeRow(0, [[6, 7, 'x']]), // valid: rename `a` -> `x` + makeRow(3, [[0, 4, 'X'], [2, 6, 'Y']]), // malformed: overlapping -> replay throws + makeRow(5, [[6, 7, 'y']]), // valid: rename `a` -> `y` + ]; + + const { processed, errors } = processAllRows(rows); + try { + expect(processed.map(p => p.originalRowIndex)).toEqual([0, 5]); + expect(errors.map(e => ({ originalRowIndex: e.originalRowIndex, isError: e.value instanceof Error }))).toEqual([ + { originalRowIndex: 3, isError: true }, + ]); + } finally { + processed.forEach(p => p.replayer.dispose()); + } + }); +}); diff --git a/extensions/copilot/test/pipeline/replayRecording.ts b/extensions/copilot/test/pipeline/replayRecording.ts index 269b7e09d3108..d644d125017e7 100644 --- a/extensions/copilot/test/pipeline/replayRecording.ts +++ b/extensions/copilot/test/pipeline/replayRecording.ts @@ -7,11 +7,15 @@ import { IRecordingInformation, ObservableWorkspaceRecordingReplayer } from '../ import { DocumentId } from '../../src/platform/inlineEdits/common/dataTypes/documentId'; import { IObservableDocument, MutableObservableWorkspace } from '../../src/platform/inlineEdits/common/observableWorkspace'; import { LogEntry } from '../../src/platform/workspaceRecorder/common/workspaceLog'; +import { ErrorUtils } from '../../src/util/common/errors'; +import { Result } from '../../src/util/common/result'; import { coalesce } from '../../src/util/vs/base/common/arrays'; import { StringText } from '../../src/util/vs/editor/common/core/text/abstractText'; import { Processor } from './alternativeAction/processor'; +import { IStringReplacement } from './alternativeAction/types'; import { IInputRow } from './parseInput'; import { applyEditsToContent } from './responseStep'; +import type { WithRowIndex } from './withRowIndex'; /** * Result of processing a single input row: replayed workspace + oracle edit. @@ -90,48 +94,75 @@ export function parseSuggestedEdit(suggestedEditStr: string): [start: number, en } } -function formatError(e: unknown): string { - if (e instanceof Error) { - if (e.message === 'An unexpected bug occurred.' && e.stack) { - const frames = e.stack.split('\n').slice(1, 4).map(f => f.trim()).join(' <- '); - return `${e.message} Stack: ${frames}`; - } - return e.message; - } - return String(e); -} - /** * Process a single input row: split recording at request time, replay * the pre-request portion and extract the oracle edit. */ -export function processRow(row: IInputRow): IProcessedRow | { error: string } { +export function processRow(row: IInputRow): Result { try { return _processRow(row); } catch (e: unknown) { - return { error: `Unexpected error: ${formatError(e)}` }; + return Result.error(ErrorUtils.fromUnknown(e)); } } -function _processRow(row: IInputRow): IProcessedRow | { error: string } { +function _processRow(row: IInputRow): Result { const proposedEdits = coalesce([parseSuggestedEdit(row.postProcessingOutcome.suggestedEdit)]); const isAccepted = row.suggestionStatus === 'accepted'; - const split = Processor.splitRecording(row.alternativeAction); + const recording = row.alternativeAction?.recording; + const entries = recording?.entries; + if (!recording || !entries || entries.length === 0) { + const entryCount = entries?.length ?? 0; + return Result.fromString(`No recording entries to process (${entryCount} entries, lang: ${row.activeDocumentLanguageId})`); + } + + return processRecordingAtPivot({ + row, + entries, + requestTime: recording.requestTime, + proposedEdits, + isAccepted, + }); +} + +/** + * Pivot-centric core shared by the per-request (alternative-action) and the + * continuous-recording paths. Splits `entries` at `requestTime`, replays the + * pre-pivot portion into a live workspace and extracts the oracle edit that + * follows the pivot. + * + * For per-request recordings the pivot is the NES request bookmark + * (`recording.requestTime`); for continuous recordings it is synthesized by a + * pivot strategy. The returned {@link IProcessedRow} holds a live replayer that + * the caller must dispose. + */ +export function processRecordingAtPivot(args: { + /** Input row metadata threaded through to the result; synthesized for continuous recordings. */ + readonly row: IInputRow; + /** Full recording timeline (must be non-empty). */ + readonly entries: LogEntry[]; + /** Pivot time: entries with `time <= requestTime` are context, the rest hold the oracle. */ + readonly requestTime: number; + readonly proposedEdits: IStringReplacement[]; + readonly isAccepted: boolean; +}): Result { + const { row, entries, requestTime, proposedEdits, isAccepted } = args; + + const split = Processor.splitRecording(entries, requestTime); if (!split) { - const entryCount = row.alternativeAction?.recording?.entries?.length ?? 0; - return { error: `Could not split recording at request time (${entryCount} entries, lang: ${row.activeDocumentLanguageId})` }; + return Result.fromString(`Could not split recording at request time (${entries.length} entries, lang: ${row.activeDocumentLanguageId})`); } - const scoring = Processor.createScoringForAlternativeAction( - row.alternativeAction, + const scoring = Processor.createScoring( + entries, + requestTime, proposedEdits, isAccepted, ); if (!scoring) { - const entryCount = row.alternativeAction?.recording?.entries?.length ?? 0; - return { error: `Processor.createScoringForAlternativeAction returned undefined (${entryCount} entries, lang: ${row.activeDocumentLanguageId})` }; + return Result.fromString(`Processor.createScoring returned undefined (${entries.length} entries, lang: ${row.activeDocumentLanguageId})`); } const recording = scoring.scoringContext.recording; @@ -145,84 +176,88 @@ function _processRow(row: IInputRow): IProcessedRow | { error: string } { }; const replayer = new ObservableWorkspaceRecordingReplayer(recordingInfo); - let lastDocId: DocumentId; try { - const result = replayer.replay(); - lastDocId = result.lastDocId; - } catch (e) { - replayer.dispose(); - return { error: `Replay failed (${recording.log.length} entries, file: ${recording.nextUserEdit?.relativePath ?? 'unknown'}): ${formatError(e)}` }; - } + const { lastDocId } = replayer.replay(); - const workspace = replayer.workspace; - const activeDocument = workspace.getDocument(lastDocId); - if (!activeDocument) { - replayer.dispose(); - return { error: `Active document not found after replay: ${lastDocId}` }; - } + const workspace = replayer.workspace; + const activeDocument = workspace.getDocument(lastDocId); + if (!activeDocument) { + replayer.dispose(); + return Result.fromString(`Active document not found after replay: ${lastDocId}`); + } + + // Prefer scoring edit URI, fall back to oracle path + const activeFilePath = scoring.edits[0]?.documentUri ?? recording.nextUserEdit?.relativePath ?? 'unknown'; - // Prefer scoring edit URI, fall back to oracle path - const activeFilePath = scoring.edits[0]?.documentUri ?? recording.nextUserEdit?.relativePath ?? 'unknown'; - - // Compute cursor-at-request from the *last* `selectionChanged` on the - // active doc within the pre-request portion. Multi-cursor selections use - // the primary (first) range — matches `IObservableDocument._primarySelectionLine` - // semantics. If no selection event exists for the active doc, leave as - // undefined so cursor-jump detectors can skip the row. - const cursorAtRequest = (() => { - for (let i = split.recordingPriorToRequest.length - 1; i >= 0; i--) { - const entry = split.recordingPriorToRequest[i]; - if (entry.kind === 'selectionChanged' && entry.id === split.currentFile.id && entry.selection.length > 0) { - const offset = entry.selection[0][0]; - const content = activeDocument.value.get().value; - const transformer = new StringText(content).getTransformer(); - const lineNumber = transformer.getPosition(Math.min(offset, content.length)).lineNumber - 1; - return { offset, lineNumber }; + // Compute cursor-at-request from the *last* `selectionChanged` on the + // active doc within the pre-request portion. Multi-cursor selections use + // the primary (first) range — matches `IObservableDocument._primarySelectionLine` + // semantics. If no selection event exists for the active doc, leave as + // undefined so cursor-jump detectors can skip the row. + const cursorAtRequest = (() => { + for (let i = split.recordingPriorToRequest.length - 1; i >= 0; i--) { + const entry = split.recordingPriorToRequest[i]; + if (entry.kind === 'selectionChanged' && entry.id === split.currentFile.id && entry.selection.length > 0) { + const offset = entry.selection[0][0]; + const content = activeDocument.value.get().value; + const transformer = new StringText(content).getTransformer(); + const lineNumber = transformer.getPosition(Math.min(offset, content.length)).lineNumber - 1; + return { offset, lineNumber }; + } } - } - return undefined; - })(); - - // Snapshot every observed doc's content at request time. Walks the - // pre-request portion once applying setContent + changed events, so - // cross-file jump detection can resolve the target line even when the - // target was opened before the bookmark. - const idToContentAtRequest = (() => { - const map = new Map(); - for (const entry of split.recordingPriorToRequest) { - if (entry.kind === 'setContent') { - map.set(entry.id, entry.content); - } else if (entry.kind === 'changed') { - const c = map.get(entry.id); - if (c === undefined) { - continue; + return undefined; + })(); + + // Snapshot every observed doc's content at request time. Walks the + // pre-request portion once applying setContent + changed events, so + // cross-file jump detection can resolve the target line even when the + // target was opened before the bookmark. + const idToContentAtRequest = (() => { + const map = new Map(); + for (const entry of split.recordingPriorToRequest) { + if (entry.kind === 'setContent') { + map.set(entry.id, entry.content); + } else if (entry.kind === 'changed') { + const c = map.get(entry.id); + if (c === undefined) { + continue; + } + // Replacements within a single `changed` event are all relative + // to the same base content, so they must be applied + // offset-descending (as `applyEditsToContent` does) — applying + // ascending in-place would shift later original offsets. + map.set(entry.id, applyEditsToContent(c, entry.edit)); } - // Replacements within a single `changed` event are all relative - // to the same base content, so they must be applied - // offset-descending (as `applyEditsToContent` does) — applying - // ascending in-place would shift later original offsets. - map.set(entry.id, applyEditsToContent(c, entry.edit)); } - } - return map; - })(); + return map; + })(); - return { - originalRowIndex: row.originalRowIndex, - row, - replayer, - workspace, - activeDocId: lastDocId, - activeDocument, - activeFilePath, - nextUserEdit: recording.nextUserEdit, - recordingInfo, - recordingAfterRequest: split.recordingAfterRequest, - activeDocLogId: split.currentFile.id, - idToRelativePath: split.idToFileMap, - cursorAtRequest, - idToContentAtRequest, - }; + return Result.ok({ + originalRowIndex: row.originalRowIndex, + row, + replayer, + workspace, + activeDocId: lastDocId, + activeDocument, + activeFilePath, + nextUserEdit: recording.nextUserEdit, + recordingInfo, + recordingAfterRequest: split.recordingAfterRequest, + activeDocLogId: split.currentFile.id, + idToRelativePath: split.idToFileMap, + cursorAtRequest, + idToContentAtRequest, + }); + } catch (e) { + // `replayer.replay()` and the post-replay analysis above (cursor/content + // reconstruction) can throw on a malformed recording — e.g. a non-disjoint + // `changed` edit rejected by the `StringEdit` constructor, or + // `applyEditsToContent` over non-disjoint replacements. Dispose the live + // replayer before the error unwinds so a single bad record can't leak it; + // callers only dispose the replayer on the success path. + replayer.dispose(); + throw e; + } } /** @@ -231,17 +266,18 @@ function _processRow(row: IInputRow): IProcessedRow | { error: string } { */ export function processAllRows(rows: readonly IInputRow[]): { processed: IProcessedRow[]; - errors: { rowIndex: number; error: string }[]; + errors: WithRowIndex[]; } { const processed: IProcessedRow[] = []; - const errors: { rowIndex: number; error: string }[] = []; + const errors: WithRowIndex[] = []; for (let i = 0; i < rows.length; i++) { - const result = processRow(rows[i]); - if ('error' in result) { - errors.push({ rowIndex: i, error: result.error }); + const row = rows[i]; + const result = processRow(row); + if (result.isError()) { + errors.push({ originalRowIndex: row.originalRowIndex, value: result.err }); } else { - processed.push(result); + processed.push(result.val); } } diff --git a/extensions/copilot/test/pipeline/test/continuousPipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/continuousPipeline.e2e.spec.ts new file mode 100644 index 0000000000000..22b03cd1854fe --- /dev/null +++ b/extensions/copilot/test/pipeline/test/continuousPipeline.e2e.spec.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; +import { runInputPipeline, RunPipelineOptions } from '../pipeline'; +import { allContinuousRecords, continuousFixtures } from './fixtures/continuousFixtureData'; + +/** + * End-to-end tests for the nes-datagen pipeline on the **continuous** input + * format (`--input-format=continuous`). + * + * These exercise the full `runInputPipeline` — continuous parse → pivot + * synthesis → split → replay → prompt/response generation → output — with real + * fixture data, mirroring `pipeline.e2e.spec.ts` for the alternative-action + * path. The fixtures are crafted so each valid slice has exactly one eligible + * pivot, making the output deterministic regardless of the pivot RNG seed. + */ + +const configPath = path.join(__dirname, 'fixtures', 'config.json'); + +let tmpDir: string; +let inputPath: string; +let outputPath: string; + +function parseJsonl(contents: string): T[] { + return contents + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as T); +} + +beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nes-datagen-continuous-e2e-')); + inputPath = path.join(tmpDir, 'input.json'); + outputPath = path.join(tmpDir, 'output.jsonl'); + await fs.writeFile(inputPath, JSON.stringify(allContinuousRecords, null, 2)); +}); + +afterAll(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +interface OutputSample { + messages: { role: 'system' | 'user' | 'assistant'; content: string }[]; + metadata: { + rowIndex: number; + language: string; + suggestionStatus: string; + filePath: string; + docContent: string; + oracleEdits: [number, number, string][]; + }; +} + +async function runContinuousPipeline(opts?: Partial): Promise<{ + samples: OutputSample[]; + logs: string[]; + output: string; +}> { + const logs: string[] = []; + const log = (...args: unknown[]) => { + logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); + }; + + const pipelineOpts: RunPipelineOptions = { + nesDatagen: { + input: inputPath, + output: outputPath, + rowOffset: 0, + workerMode: false, + sampleTask: NesDatagenSampleTask.Xtab, + sameFileJumpMinAbove: 5, + sameFileJumpMinBelow: 5, + inputFormat: NesDatagenInputFormat.Continuous, + pivotStrategy: PivotStrategy.Random, + seed: 42, + }, + configFile: configPath, + verbose: true, + parallelism: 1, + ...opts, + }; + + await runInputPipeline(pipelineOpts, log); + + const output = await fs.readFile(outputPath, 'utf-8'); + return { samples: parseJsonl(output), logs, output }; +} + +describe('nes-datagen continuous pipeline e2e', () => { + + describe('full pipeline run', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runContinuousPipeline(); + }); + + test('produces one oracle-only sample per valid slice', () => { + // 2 valid slices (ts + py); the capped slice is dropped at parse. + expect(result.samples.length).toBe(2); + }); + + test('reports the capped (entries-dropped) slice as a parse error', () => { + const parseLog = result.logs.find(l => l.includes('[1/5]')); + expect(parseLog).toBeDefined(); + expect(parseLog).toContain('1 errors'); + }); + + test('logs the continuous input format with strategy and seed', () => { + const formatLog = result.logs.find(l => l.includes('Input format: continuous')); + expect(formatLog).toBeDefined(); + expect(formatLog).toContain('pivot-strategy: random'); + expect(formatLog).toContain('seed: 42'); + }); + + test('every sample is tagged as a continuous (oracle-only) sample', () => { + for (const sample of result.samples) { + expect(sample.metadata.suggestionStatus).toBe('continuous'); + } + }); + + test('resolves language and file path from the replayed slice', () => { + const ts = result.samples.find(s => s.metadata.language === 'typescript'); + const py = result.samples.find(s => s.metadata.language === 'python'); + + expect(ts).toBeDefined(); + expect(py).toBeDefined(); + expect(ts!.metadata.filePath).toContain('src/math.ts'); + expect(py!.metadata.filePath).toContain('src/greet.py'); + }); + + test('extracts the post-pivot oracle edit', () => { + const ts = result.samples.find(s => s.metadata.language === 'typescript')!; + const py = result.samples.find(s => s.metadata.language === 'python')!; + + expect(ts.metadata.oracleEdits.some(([, , text]) => text === 'sum')).toBe(true); + expect(py.metadata.oracleEdits.some(([, , text]) => text === ' -> str')).toBe(true); + }); + + test('docContent is the slice content at the pivot (pre-oracle)', () => { + const ts = result.samples.find(s => s.metadata.language === 'typescript')!; + // Context reflects the pre-pivot edit but not the oracle: the + // original `add` is present and has not yet been renamed to `sum`. + expect(ts.metadata.docContent).toContain('export function add('); + expect(ts.metadata.docContent).not.toContain('sum'); + }); + + test('every sample carries non-empty system, user and assistant messages', () => { + for (const sample of result.samples) { + const roles = sample.messages.map(m => m.role); + expect(roles).toEqual(['system', 'user', 'assistant']); + for (const msg of sample.messages) { + expect(msg.content.trim().length).toBeGreaterThan(0); + } + } + }); + }); + + test('is reproducible: two runs with the same seed produce identical output', async () => { + const a = await runContinuousPipeline(); + const b = await runContinuousPipeline(); + expect(a.output).toBe(b.output); + }); + + test('a slice whose oracle edit is malformed is isolated, not fatal', async () => { + // Overlapping replacements in the post-pivot `changed` make the split + // throw; the batch must still produce the sibling sample rather than abort. + const badEntries = [ + { kind: 'meta', data: { repoRootUri: 'file:///workspace' } }, + { kind: 'documentEncountered', id: 0, time: 3000, relativePath: 'src/bad.ts' }, + { kind: 'setContent', id: 0, time: 3001, content: 'const value = 1;\n', v: 0 }, + // Pre-pivot edit → the only eligible pivot (a later `changed` follows). + { kind: 'changed', id: 0, time: 3002, edit: [[0, 0, '// x\n']], v: 1 }, + // Malformed oracle: the two replacements overlap. + { kind: 'changed', id: 0, time: 3004, edit: [[6, 11, 'a'], [8, 13, 'b']], v: 2 }, + ]; + const badRecord = { recording: JSON.stringify({ entries: badEntries, entriesSize: 100 }) }; + + const mixedInput = path.join(tmpDir, 'mixed-input.json'); + const mixedOutput = path.join(tmpDir, 'mixed-output.jsonl'); + await fs.writeFile(mixedInput, JSON.stringify([continuousFixtures.ts.record, badRecord])); + + const logs: string[] = []; + await runInputPipeline( + { + nesDatagen: { + input: mixedInput, + output: mixedOutput, + rowOffset: 0, + workerMode: false, + sampleTask: NesDatagenSampleTask.Xtab, + sameFileJumpMinAbove: 5, + sameFileJumpMinBelow: 5, + inputFormat: NesDatagenInputFormat.Continuous, + pivotStrategy: PivotStrategy.Random, + seed: 42, + }, + configFile: configPath, + verbose: true, + parallelism: 1, + }, + (...args: unknown[]) => { logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); }, + ); + + const samples = parseJsonl(await fs.readFile(mixedOutput, 'utf-8')); + expect(samples.length).toBe(1); + expect(samples[0].metadata.filePath).toContain('src/math.ts'); + + // The malformed slice is surfaced as a replay error, not swallowed. + const replayLog = logs.find(l => l.includes('[2/5]')); + expect(replayLog).toContain('1 errors'); + }); +}); diff --git a/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts index 3c26c46826744..a10e4e13de9f6 100644 --- a/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts +++ b/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts @@ -7,7 +7,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { NesDatagenSampleTask } from '../../base/simulationOptions'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; import { runInputPipeline, RunPipelineOptions } from '../pipeline'; import { allCursorJumpRecords, cursorJumpFixtures } from './fixtures/cursorJumpFixtureData'; @@ -79,6 +79,9 @@ async function runCursorPipeline(sampleTask: NesDatagenSampleTask, nesDatagenOve rowOffset: 0, workerMode: false, sampleTask, + inputFormat: NesDatagenInputFormat.AlternativeAction, + pivotStrategy: PivotStrategy.Random, + seed: 0, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, ...nesDatagenOverrides, diff --git a/extensions/copilot/test/pipeline/test/fixtures/continuousFixtureData.ts b/extensions/copilot/test/pipeline/test/fixtures/continuousFixtureData.ts new file mode 100644 index 0000000000000..ec5c320da3e28 --- /dev/null +++ b/extensions/copilot/test/pipeline/test/fixtures/continuousFixtureData.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. + *--------------------------------------------------------------------------------------------*/ + +/** + * Fixture data for the continuous-recording nes-datagen pipeline e2e tests. + * + * Continuous slices (from `ContinuousEnhancedTelemetrySender`) carry no NES + * request bookmark, so the pipeline synthesizes a pivot. Each record here is + * shaped the way the continuous loader expects — a single `recording` column + * holding the stringified `IContinuousRecording` payload. + * + * Both valid recordings are crafted to have **exactly one** eligible pivot, so + * the produced sample is deterministic regardless of the pivot RNG seed: + * - a pre-pivot `changed` (the eligible pivot) gives the NES prompt real edit + * history to work from, and is itself eligible because a later `changed` + * (the oracle) follows it on the same document; + * - the trailing `changed` is the oracle and is itself ineligible (no edit + * follows it on the active document). + * This exercises the real strategy → split → replay → prompt → oracle path. + */ + +// --------------------------------------------------------------------------- +// Scenario 1 – TypeScript: rename `add` → `sum` +// --------------------------------------------------------------------------- + +const tsDocContent = + `export function add(a: number, b: number): number {\n` + + `\treturn a + b;\n` + + `}\n`; + +// `add` occupies offsets [16, 19) in `export function add(...`. The pre-pivot +// edit appends a trailing newline (at the end, so it doesn't shift `add`), +// giving the context some edit history without disturbing the oracle offsets. +const tsEntries = [ + { kind: 'meta', data: { repoRootUri: 'file:///workspace' } }, + { kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/math.ts' }, + { kind: 'setContent', id: 0, time: 1001, content: tsDocContent, v: 0 }, + // Only eligible pivot: an in-context edit that establishes edit history. + { kind: 'changed', id: 0, time: 1002, edit: [[tsDocContent.length, tsDocContent.length, '\n']], v: 1 }, + // Oracle (post-pivot): rename `add` → `sum`. + { kind: 'changed', id: 0, time: 1004, edit: [[16, 19, 'sum']], v: 2 }, +]; + +const tsRecord = { + recording: JSON.stringify({ + entries: tsEntries, + entriesSize: JSON.stringify(tsEntries).length, + windowStart: 900, + windowEnd: 1100, + sessionId: 'sess-ts', + sequenceNumber: 7, + }), +}; + +// --------------------------------------------------------------------------- +// Scenario 2 – Python: add a ` -> str` return annotation +// --------------------------------------------------------------------------- + +const pyDocContent = + `def greet(name):\n` + + ` return f"Hello, {name}!"\n`; + +// The `:` sits at offset 15 in `def greet(name):`. The pre-pivot edit appends a +// trailing newline so the offset-15 insertion offsets stay stable. +const pyEntries = [ + { kind: 'meta', data: { repoRootUri: 'file:///workspace' } }, + { kind: 'documentEncountered', id: 0, time: 2000, relativePath: 'src/greet.py' }, + { kind: 'setContent', id: 0, time: 2001, content: pyDocContent, v: 0 }, + // Only eligible pivot: an in-context edit that establishes edit history. + { kind: 'changed', id: 0, time: 2002, edit: [[pyDocContent.length, pyDocContent.length, '\n']], v: 1 }, + // Oracle (post-pivot): insert ` -> str` before the colon. + { kind: 'changed', id: 0, time: 2004, edit: [[15, 15, ' -> str']], v: 2 }, +]; + +const pyRecord = { + recording: JSON.stringify({ + entries: pyEntries, + entriesSize: JSON.stringify(pyEntries).length, + windowStart: 1900, + windowEnd: 2100, + sessionId: 'sess-py', + sequenceNumber: 8, + }), +}; + +// --------------------------------------------------------------------------- +// Scenario 3 – Invalid: `entries` dropped because the payload exceeded the cap. +// The sender still ships the window metadata and `entriesSize`; only `entries` +// is omitted. Such a slice has no usable history and must be reported as a +// parse error rather than aborting the run. +// --------------------------------------------------------------------------- + +const cappedRecord = { + recording: JSON.stringify({ + entriesSize: 250_000, + windowStart: 2900, + windowEnd: 3100, + sessionId: 'sess-capped', + sequenceNumber: 9, + }), +}; + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export const continuousFixtures = { + ts: { record: tsRecord, docContent: tsDocContent }, + py: { record: pyRecord, docContent: pyDocContent }, + capped: { record: cappedRecord }, +} as const; + +/** All records in the format the continuous loader expects: a JSON array. */ +export const allContinuousRecords = [tsRecord, pyRecord, cappedRecord]; diff --git a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts index 045eec522cd7b..f3b89e4076d15 100644 --- a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts +++ b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts @@ -6,7 +6,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { NesDatagenSampleTask } from '../../base/simulationOptions'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; import { runInputPipeline, RunPipelineOptions } from '../pipeline'; import { allRecords, fixtures } from './fixtures/fixtureData'; @@ -85,7 +85,7 @@ async function runPipeline(opts?: Partial): Promise<{ input: inputPath, output: outputPath, rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: configPath, verbose: true, @@ -263,7 +263,7 @@ describe('nes-datagen pipeline e2e', () => { input: invalidInputPath, output: invalidOutputPath, rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: configPath, verbose: false, @@ -290,7 +290,7 @@ describe('nes-datagen pipeline e2e', () => { input: inputPath, output: outputPath, rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: undefined, verbose: false, @@ -309,7 +309,7 @@ describe('nes-datagen pipeline e2e', () => { input: inputPath, output: offsetOutputPath, rowOffset: 100, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: configPath, verbose: false, diff --git a/extensions/copilot/test/pipeline/test/pipeline.spec.ts b/extensions/copilot/test/pipeline/test/pipeline.spec.ts index 07d5458742c4e..4d422af6d6683 100644 --- a/extensions/copilot/test/pipeline/test/pipeline.spec.ts +++ b/extensions/copilot/test/pipeline/test/pipeline.spec.ts @@ -7,7 +7,7 @@ import * as fs from 'fs/promises'; import path from 'path'; import { expect, suite, test } from 'vitest'; import { Result } from '../../../src/util/common/result'; -import { NesDatagenSampleTask } from '../../base/simulationOptions'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; import { IInputRow } from '../parseInput'; import { runInputPipeline, RunPipelineOptions } from '../pipeline'; @@ -108,7 +108,7 @@ suite.skip('from csv to input rows to pipeline', () => { input: inputRowsFilePath, output: path.join(fixtures, 'output.jsonl'), rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5 + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0 }, configFile: configFilePath, verbose: true, diff --git a/src/vs/workbench/contrib/chat/common/widget/input/modelPickerWidget.ts b/extensions/copilot/test/pipeline/withRowIndex.ts similarity index 62% rename from src/vs/workbench/contrib/chat/common/widget/input/modelPickerWidget.ts rename to extensions/copilot/test/pipeline/withRowIndex.ts index 57ff8b2df2fd6..0de746d82cdcf 100644 --- a/src/vs/workbench/contrib/chat/common/widget/input/modelPickerWidget.ts +++ b/extensions/copilot/test/pipeline/withRowIndex.ts @@ -3,4 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - +/** + * A value paired with the `originalRowIndex` identifying its source row in the + * input container it was read from. + */ +export type WithRowIndex = { + readonly originalRowIndex: number; + readonly value: T; +}; diff --git a/extensions/copilot/test/simulation/shared/sharedTypes.ts b/extensions/copilot/test/simulation/shared/sharedTypes.ts index cb805ff27c609..e3e4e228ff13f 100644 --- a/extensions/copilot/test/simulation/shared/sharedTypes.ts +++ b/extensions/copilot/test/simulation/shared/sharedTypes.ts @@ -274,6 +274,43 @@ export type Output = IDetectedTestOutput | IDeviceCodeCallbackOutput ; +/** + * Describes a single adhoc chat request sent from the simulation workbench + * "Adhoc request sender" mode. Serialized to a temp file and passed to the + * simulation CLI via `--adhoc-request-file`. + */ +export interface IAdhocRequest { + readonly system: string; + readonly user: string; + readonly model: string; +} + +export enum AdhocResponseType { + /** A streamed chunk of the response text. */ + delta = 'adhocResponseDelta', + /** The final, full response text. */ + done = 'adhocResponseDone', + /** The request failed. */ + error = 'adhocResponseError', +} + +export interface IAdhocResponseDelta { + readonly type: AdhocResponseType.delta; + readonly value: string; +} + +export interface IAdhocResponseDone { + readonly type: AdhocResponseType.done; + readonly value: string; +} + +export interface IAdhocResponseError { + readonly type: AdhocResponseType.error; + readonly value: string; +} + +export type AdhocResponseOutput = IAdhocResponseDelta | IAdhocResponseDone | IAdhocResponseError; + export interface IRange { readonly start: IPosition; readonly end: IPosition; diff --git a/extensions/copilot/test/simulation/workbench/components/adhocRequestEditor.tsx b/extensions/copilot/test/simulation/workbench/components/adhocRequestEditor.tsx new file mode 100644 index 0000000000000..3eee1eb3ca80e --- /dev/null +++ b/extensions/copilot/test/simulation/workbench/components/adhocRequestEditor.tsx @@ -0,0 +1,246 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { clipboard } from 'electron'; +import type * as monaco from 'monaco-editor'; +import * as React from 'react'; +import { monacoModule } from '../utils/utils'; +import { DraggableBottomBorder } from './draggableBottomBorder'; + +/** + * Matches a prompt tag like `<|recently_viewed_code_snippets|>` or its closing + * form `<|/recently_viewed_code_snippets|>`. The capture group is the tag name + * without the leading slash, so an opening tag and its matching closing tag + * resolve to the same name (and therefore the same color). + */ +const TAG_REGEX = /<\|\/?([^|\n]+)\|>/g; + +// Distinct tag names are assigned a stable, ever-increasing index the first time +// they are seen. The index drives the hue via the golden angle, which keeps +// successive colors far apart so different tags get visually distinct colors +// (and the same name always maps to the same color). +const tagIndexByName = new Map(); +const injectedTagIndices = new Set(); +let tagStyleElement: HTMLStyleElement | undefined; + +const GOLDEN_ANGLE_DEGREES = 137.50776; + +/** + * Returns a CSS class that tints the background based on the tag name, lazily + * injecting the corresponding style rule. The tint uses low alpha so the tag + * text stays readable on both light and dark themes. + */ +function tagDecorationClassName(tagName: string): string { + let index = tagIndexByName.get(tagName); + if (index === undefined) { + index = tagIndexByName.size; + tagIndexByName.set(tagName, index); + } + const className = `adhoc-tag-hl-${index}`; + if (!injectedTagIndices.has(index)) { + injectedTagIndices.add(index); + if (!tagStyleElement) { + tagStyleElement = document.createElement('style'); + document.head.appendChild(tagStyleElement); + } + const hue = Math.round((index * GOLDEN_ANGLE_DEGREES) % 360); + tagStyleElement.appendChild(document.createTextNode( + `.${className} { background-color: hsla(${hue}, 70%, 55%, 0.3); border-radius: 3px; }` + )); + } + return className; +} + +type Props = { + value: string; + languageId?: string; + readOnly?: boolean; + initialHeight?: number; + autoFocus?: boolean; + onChange?: (value: string) => void; +}; + +/** + * A simple Monaco-based editor that can be editable or read-only, used by the + * "Adhoc request sender" mode. Unlike {@link Editor}, it supports two-way + * binding via `value`/`onChange` and a fixed (resizable) height. + */ +export const AdhocRequestEditor = (({ value, languageId, readOnly, initialHeight, autoFocus, onChange }: Props) => { + const containerRef = React.useRef(null); + const [editor, setEditor] = React.useState(null); + const [height, setHeight] = React.useState(initialHeight ?? 160); + const [isFocused, setIsFocused] = React.useState(false); + + // Keep the latest onChange in a ref so the model listener never goes stale. + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + + // Set while applying an external value so we don't echo it back through onChange. + const isApplyingExternalValueRef = React.useRef(false); + + const monaco = monacoModule.value; + + React.useEffect(() => { + if (!containerRef.current) { + return; + } + const myEditor = monaco.editor.create(containerRef.current, { + automaticLayout: true, + model: monaco.editor.createModel(value, languageId ?? 'plaintext'), + minimap: { enabled: false }, + readOnly: readOnly ?? false, + scrollBeyondLastLine: false, + wordWrap: 'on', + lineNumbers: 'off', + folding: false, + overviewRulerLanes: 0, + padding: { top: 6, bottom: 6 }, + }); + setEditor(myEditor); + + // Chromium blocks programmatic clipboard reads (`document.execCommand('paste')`), + // which is what Monaco's built-in Cmd/Ctrl+V keybinding uses, so paste silently + // fails inside the editor. Intercept the shortcut in the capture phase on the + // container (an ancestor of all Monaco DOM) - this runs before Monaco sees the + // keystroke - and paste from Electron's clipboard directly instead. + const container = containerRef.current; + const handlePasteShortcut = (e: KeyboardEvent) => { + const isPasteShortcut = (e.metaKey || e.ctrlKey) && !e.altKey && (e.code === 'KeyV' || e.key === 'v' || e.key === 'V'); + if (!isPasteShortcut) { + return; + } + if (myEditor.getOption(monaco.editor.EditorOption.readOnly)) { + return; + } + const selections = myEditor.getSelections(); + if (!selections || selections.length === 0) { + return; + } + e.preventDefault(); + e.stopPropagation(); + const text = clipboard.readText(); + if (!text) { + return; + } + myEditor.pushUndoStop(); + myEditor.executeEdits('electron-clipboard-paste', selections.map(selection => ({ range: selection, text, forceMoveMarkers: true }))); + myEditor.pushUndoStop(); + }; + container.addEventListener('keydown', handlePasteShortcut, /* capture */ true); + + // Track focus so the focused editor can show a blue halo. + const focusListener = myEditor.onDidFocusEditorText(() => setIsFocused(true)); + const blurListener = myEditor.onDidBlurEditorText(() => setIsFocused(false)); + + // Highlight prompt tags like `<|name|>` / `<|/name|>`, coloring by tag name. + const tagDecorations = myEditor.createDecorationsCollection(); + const updateTagDecorations = () => { + const model = myEditor.getModel(); + if (!model) { + return; + } + const text = model.getValue(); + const decorations: monaco.editor.IModelDeltaDecoration[] = []; + TAG_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = TAG_REGEX.exec(text)) !== null) { + const start = model.getPositionAt(match.index); + const end = model.getPositionAt(match.index + match[0].length); + decorations.push({ + range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), + options: { inlineClassName: tagDecorationClassName(match[1]) }, + }); + } + tagDecorations.set(decorations); + }; + // Coalesce rescans across bursts of content changes (e.g. a streamed + // response) into at most one per frame, so we don't re-scan the whole + // document on every keystroke/delta. + let pendingTagDecorationsFrame: number | undefined; + const scheduleTagDecorationsUpdate = () => { + if (pendingTagDecorationsFrame !== undefined) { + return; + } + pendingTagDecorationsFrame = requestAnimationFrame(() => { + pendingTagDecorationsFrame = undefined; + updateTagDecorations(); + }); + }; + updateTagDecorations(); + + const listener = myEditor.onDidChangeModelContent(() => { + scheduleTagDecorationsUpdate(); + if (isApplyingExternalValueRef.current) { + return; + } + onChangeRef.current?.(myEditor.getValue()); + }); + + if (autoFocus) { + myEditor.focus(); + } + + return () => { + if (pendingTagDecorationsFrame !== undefined) { + cancelAnimationFrame(pendingTagDecorationsFrame); + } + container.removeEventListener('keydown', handlePasteShortcut, /* capture */ true); + focusListener.dispose(); + blurListener.dispose(); + listener.dispose(); + const model = myEditor.getModel(); + if (model) { + model.dispose(); + } + myEditor.dispose(); + }; + }, []); + + React.useEffect(() => { + if (!editor) { + return; + } + const model = editor.getModel(); + if (!model) { + return; + } + if (languageId && model.getLanguageId() !== languageId) { + monaco.editor.setModelLanguage(model, languageId); + } + if (model.getValue() !== value) { + isApplyingExternalValueRef.current = true; + try { + if (readOnly) { + model.setValue(value); + // Keep the response editor scrolled to the latest streamed content. + editor.revealLine(model.getLineCount(), monaco.editor.ScrollType.Immediate); + } else { + // Preserve undo stack / cursor for editable editors. + editor.executeEdits('external', [{ range: model.getFullModelRange(), text: value }]); + } + } finally { + isApplyingExternalValueRef.current = false; + } + } + }, [editor, value, languageId, readOnly]); + + return ( +
+
+ +
+ ); +}); diff --git a/extensions/copilot/test/simulation/workbench/components/adhocRequestView.tsx b/extensions/copilot/test/simulation/workbench/components/adhocRequestView.tsx new file mode 100644 index 0000000000000..be9a283eb61fa --- /dev/null +++ b/extensions/copilot/test/simulation/workbench/components/adhocRequestView.tsx @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Button, Field, Input, MessageBar, MessageBarBody, MessageBarTitle, Spinner, Text } from '@fluentui/react-components'; +import { Play16Regular, Stop16Regular } from '@fluentui/react-icons'; +import * as mobx from 'mobx'; +import * as mobxlite from 'mobx-react-lite'; +import * as React from 'react'; +import { AdhocRequestOptions } from '../stores/adhocRequestOptions'; +import { AdhocRequestSender, AdhocRequestState } from '../stores/adhocRequestSender'; +import { AdhocRequestEditor } from './adhocRequestEditor'; + +type Props = { + adhocRequestOptions: AdhocRequestOptions; + adhocRequestSender: AdhocRequestSender; +}; + +export const AdhocRequestView = mobxlite.observer(({ adhocRequestOptions, adhocRequestSender }: Props) => { + + const isRunning = adhocRequestSender.state === AdhocRequestState.Running; + + const handleModelChange = React.useCallback((e: React.FormEvent) => { + mobx.runInAction(() => { + adhocRequestOptions.model.value = (e.target as HTMLInputElement).value; + }); + }, [adhocRequestOptions.model]); + + const handleSystemChange = React.useCallback((value: string) => { + mobx.runInAction(() => { + adhocRequestOptions.systemMessage.value = value; + }); + }, [adhocRequestOptions.systemMessage]); + + const handleUserChange = React.useCallback((value: string) => { + mobx.runInAction(() => { + adhocRequestOptions.userMessage.value = value; + }); + }, [adhocRequestOptions.userMessage]); + + const handleSendStop = React.useCallback(() => { + if (isRunning) { + adhocRequestSender.cancel(); + } else { + adhocRequestSender.send({ + system: adhocRequestOptions.systemMessage.value, + user: adhocRequestOptions.userMessage.value, + model: adhocRequestOptions.model.value, + }); + } + }, [isRunning, adhocRequestSender, adhocRequestOptions]); + + const canSend = adhocRequestOptions.model.value.trim().length > 0 && adhocRequestOptions.userMessage.value.trim().length > 0; + + return ( +
+
+ + + + + {isRunning && } +
+ + + + + + + + + +
+ Response + {adhocRequestSender.state === AdhocRequestState.Error && adhocRequestSender.error !== undefined && ( + + + Request failed +
{adhocRequestSender.error}
+
+
+ )} + +
+
+ ); +}); diff --git a/extensions/copilot/test/simulation/workbench/components/app.tsx b/extensions/copilot/test/simulation/workbench/components/app.tsx index 7d0d980a3013e..4176015417045 100644 --- a/extensions/copilot/test/simulation/workbench/components/app.tsx +++ b/extensions/copilot/test/simulation/workbench/components/app.tsx @@ -9,6 +9,8 @@ import * as mobx from 'mobx'; import * as mobxlite from 'mobx-react-lite'; import * as React from 'react'; import { InitArgs } from '../initArgs'; +import { AdhocRequestOptions } from '../stores/adhocRequestOptions'; +import { AdhocRequestSender } from '../stores/adhocRequestSender'; import { AMLProvider } from '../stores/amlSimulations'; import { NesExternalOptions } from '../stores/nesExternalOptions'; import { RunnerOptions } from '../stores/runnerOptions'; @@ -18,6 +20,8 @@ import { SimulationStorage, SimulationStorageValue } from '../stores/simulationS import { ISimulationTest, SimulationTestsProvider } from '../stores/simulationTestsProvider'; import { useLocalStorageState } from '../stores/storage'; import { TestSource } from '../stores/testSource'; +import { WorkbenchModeValue } from '../stores/workbenchMode'; +import { AdhocRequestView } from './adhocRequestView'; import { ContextMenu, ContextMenuProvider } from './contextMenu'; import { Scorecard } from './scorecard'; import { ScorecardByLanguage } from './scorecardByLanguage'; @@ -28,9 +32,12 @@ import { Toolbar } from './toolbar'; type Props = { initArgs: InitArgs | undefined; testsProvider: SimulationTestsProvider; + workbenchMode: WorkbenchModeValue; runner: SimulationRunner; runnerOptions: RunnerOptions; nesExternalOptions: NesExternalOptions; + adhocRequestOptions: AdhocRequestOptions; + adhocRequestSender: AdhocRequestSender; simulationRunsProvider: SimulationRunsProvider; amlProvider: AMLProvider; displayOptions: DisplayOptions; @@ -39,18 +46,16 @@ type Props = { export type ThemeKind = 'light' | 'dark'; export const App = mobxlite.observer( - ({ initArgs, testsProvider, runner, runnerOptions, nesExternalOptions, simulationRunsProvider, amlProvider, displayOptions }: Props) => { + ({ initArgs, testsProvider, workbenchMode, runner, runnerOptions, nesExternalOptions, adhocRequestOptions, adhocRequestSender, simulationRunsProvider, amlProvider, displayOptions }: Props) => { const [theme, setTheme] = useLocalStorageState('appTheme', undefined, 'light'); const [filterer, setFilterer] = React.useState(undefined); - const displayedTests = filterer - ? filterer.filter(testsProvider.tests) - : testsProvider.tests; - const toggleTheme = React.useCallback(() => setTheme(theme === 'dark' ? 'light' : 'dark'), [theme]); + const isAdhocRequest = workbenchMode.value === 'adhocRequest'; + return ( @@ -64,33 +69,26 @@ export const App = mobxlite.observer( simulationRunsProvider={simulationRunsProvider} simulationTestsProvider={testsProvider} amlProvider={amlProvider} + workbenchMode={workbenchMode} testSource={testsProvider.testSource} onFiltererChange={setFilterer} allLanguageIds={testsProvider.allLanguageIds} theme={theme} toggleTheme={toggleTheme} /> - {testsProvider.testSource.value === TestSource.External && ( - - )} - {testsProvider.testSource.value === TestSource.External && ( - + {isAdhocRequest ? ( + + ) : ( + )} - {(testsProvider.testSource.value === TestSource.Local || testsProvider.testSource.value === TestSource.NesExternal) && } -
-
- -
-
- displayOptions.expandPrompts.value = !displayOptions.expandPrompts.value)} - /> - -
-
-
@@ -98,6 +96,49 @@ export const App = mobxlite.observer( } ); +type TestsViewProps = { + testsProvider: SimulationTestsProvider; + runner: SimulationRunner; + runnerOptions: RunnerOptions; + nesExternalOptions: NesExternalOptions; + amlProvider: AMLProvider; + displayOptions: DisplayOptions; + filterer: TestFilterer | undefined; +}; + +const TestsView = mobxlite.observer(({ testsProvider, runner, runnerOptions, nesExternalOptions, amlProvider, displayOptions, filterer }: TestsViewProps) => { + + const displayedTests = filterer + ? filterer.filter(testsProvider.tests) + : testsProvider.tests; + + return ( + <> + {testsProvider.testSource.value === TestSource.External && ( + + )} + {testsProvider.testSource.value === TestSource.External && ( + + )} + {(testsProvider.testSource.value === TestSource.Local || testsProvider.testSource.value === TestSource.NesExternal) && } +
+
+ +
+
+ displayOptions.expandPrompts.value = !displayOptions.expandPrompts.value)} + /> + +
+
+ + + ); +}); + const TerminationMessageBar = mobxlite.observer(({ runner }: { runner: SimulationRunner }) => runner.terminationReason === undefined ? null diff --git a/extensions/copilot/test/simulation/workbench/components/toolbar.tsx b/extensions/copilot/test/simulation/workbench/components/toolbar.tsx index f78260b0065f5..ac5d75a27d9c2 100644 --- a/extensions/copilot/test/simulation/workbench/components/toolbar.tsx +++ b/extensions/copilot/test/simulation/workbench/components/toolbar.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Select, ToggleButton, Tooltip } from '@fluentui/react-components'; +import { Select, Text, ToggleButton, Tooltip } from '@fluentui/react-components'; import { WeatherMoon20Regular, WeatherSunny20Regular } from '@fluentui/react-icons'; import * as mobx from 'mobx'; import * as mobxlite from 'mobx-react-lite'; @@ -16,6 +16,7 @@ import { SimulationRunsProvider } from '../stores/simulationBaseline'; import { SimulationRunner } from '../stores/simulationRunner'; import { SimulationTestsProvider } from '../stores/simulationTestsProvider'; import { TestSource, TestSourceValue } from '../stores/testSource'; +import { WorkbenchModeValue } from '../stores/workbenchMode'; import { AMLModeToolbar } from './amlModeToolbar'; import { ThemeKind } from './app'; import { LocalModeToolbar } from './localModeToolbar'; @@ -30,6 +31,7 @@ type ToolbarProps = { amlProvider: AMLProvider; simulationRunsProvider: SimulationRunsProvider; simulationTestsProvider: SimulationTestsProvider; + workbenchMode: WorkbenchModeValue; testSource: TestSourceValue; onFiltererChange: (filter: TestFilterer | undefined) => void; allLanguageIds: readonly string[]; @@ -46,6 +48,7 @@ export const Toolbar = mobxlite.observer( amlProvider, simulationRunsProvider, simulationTestsProvider, + workbenchMode, testSource, onFiltererChange, allLanguageIds, @@ -54,6 +57,13 @@ export const Toolbar = mobxlite.observer( }: ToolbarProps) => { const toolbarContent = (() => { + if (workbenchMode.value === 'adhocRequest') { + return ( +
+ Adhoc request sender +
+ ); + } switch (testSource.value) { case TestSource.Local: return ( @@ -93,7 +103,7 @@ export const Toolbar = mobxlite.observer( {toolbarContent}
- +
); @@ -110,25 +120,39 @@ const ThemeToggler = ({ theme, toggleTheme }: { theme: ThemeKind; toggleTheme: ( ); -const testSourceOptions: { value: string; label: string; source: TestSource }[] = [ +const ADHOC_REQUEST_OPTION = 'adhocRequest'; + +/** + * Options for the workbench mode selector. The test-source entries put the + * workbench into 'tests' mode and select a {@link TestSource}; the adhoc entry + * switches to the standalone "Adhoc request sender" mode. + */ +const modeOptions: { value: string; label: string; source?: TestSource }[] = [ { value: String(TestSource.Local), label: 'Local', source: TestSource.Local }, { value: String(TestSource.NesExternal), label: 'NES External', source: TestSource.NesExternal }, { value: String(TestSource.External), label: 'AML', source: TestSource.External }, + { value: ADHOC_REQUEST_OPTION, label: 'Adhoc Request' }, ]; -const ModeToggler = ({ testSource, onFiltererChange }: { testSource: TestSourceValue; onFiltererChange: (filter: TestFilterer | undefined) => void }) => ( +const ModeToggler = ({ workbenchMode, testSource, onFiltererChange }: { workbenchMode: WorkbenchModeValue; testSource: TestSourceValue; onFiltererChange: (filter: TestFilterer | undefined) => void }) => ( diff --git a/extensions/copilot/test/simulation/workbench/simulationWorkbench.tsx b/extensions/copilot/test/simulation/workbench/simulationWorkbench.tsx index 112bf062e4801..3ee3cb7a39b46 100644 --- a/extensions/copilot/test/simulation/workbench/simulationWorkbench.tsx +++ b/extensions/copilot/test/simulation/workbench/simulationWorkbench.tsx @@ -10,6 +10,8 @@ import { render } from 'react-dom'; import { Disposable } from '../../../src/util/vs/base/common/lifecycle'; import { App, DisplayOptions } from './components/app'; import { InitArgs, parseInitEventArgs as parseProcessArgv } from './initArgs'; +import { AdhocRequestOptions } from './stores/adhocRequestOptions'; +import { AdhocRequestSender } from './stores/adhocRequestSender'; import { AMLProvider } from './stores/amlSimulations'; import { NesExternalOptions } from './stores/nesExternalOptions'; import { RunnerOptions } from './stores/runnerOptions'; @@ -18,17 +20,21 @@ import { SimulationRunner } from './stores/simulationRunner'; import { SimulationStorage } from './stores/simulationStorage'; import { SimulationTestsProvider } from './stores/simulationTestsProvider'; import { TestSource, TestSourceValue } from './stores/testSource'; +import { WorkbenchMode, WorkbenchModeValue } from './stores/workbenchMode'; import { REPO_ROOT, monacoModule } from './utils/utils'; class SimulationWorkbench extends Disposable { private readonly storage: SimulationStorage; + private readonly workbenchMode: WorkbenchModeValue; private readonly testSource: TestSourceValue; private readonly simulationRunsProvider: SimulationRunsProvider; private readonly amlProvider: AMLProvider; private readonly runner: SimulationRunner; private readonly runnerOptions: RunnerOptions; private readonly nesExternalOptions: NesExternalOptions; + private readonly adhocRequestOptions: AdhocRequestOptions; + private readonly adhocRequestSender: AdhocRequestSender; private readonly tests: SimulationTestsProvider; private readonly displayOptions: DisplayOptions; @@ -36,10 +42,18 @@ class SimulationWorkbench extends Disposable { super(); this.storage = new SimulationStorage(); + this.workbenchMode = this.storage.bind('workbenchMode', 'tests'); this.testSource = this.storage.bind('testSource', TestSource.Local); + // Sanitize a possibly stale `testSource` from earlier builds where the adhoc + // request mode was (incorrectly) modeled as a `TestSource` member. + if (this.testSource.value !== TestSource.Local && this.testSource.value !== TestSource.External && this.testSource.value !== TestSource.NesExternal) { + this.testSource.value = TestSource.Local; + } this.amlProvider = this._register(new AMLProvider(this.storage)); this.runnerOptions = new RunnerOptions(this.storage); this.nesExternalOptions = new NesExternalOptions(this.storage); + this.adhocRequestOptions = new AdhocRequestOptions(this.storage); + this.adhocRequestSender = new AdhocRequestSender(); this.runner = this._register(new SimulationRunner(this.storage, this.runnerOptions)); this.simulationRunsProvider = this._register(new SimulationRunsProvider(this.storage, this.runner)); this.tests = this._register(new SimulationTestsProvider(this.testSource, this.runner, this.simulationRunsProvider, this.amlProvider, this.nesExternalOptions)); @@ -54,9 +68,12 @@ class SimulationWorkbench extends Disposable { ; + + /** The user message to send. */ + public readonly userMessage: SimulationStorageValue; + + /** The model name (e.g. `gpt-4.1`) to send the request to. */ + public readonly model: SimulationStorageValue; + + constructor(storage: SimulationStorage) { + this.systemMessage = new SimulationStorageValue(storage, 'adhocRequestSystemMessage', ''); + this.userMessage = new SimulationStorageValue(storage, 'adhocRequestUserMessage', ''); + this.model = new SimulationStorageValue(storage, 'adhocRequestModel', ''); + } +} diff --git a/extensions/copilot/test/simulation/workbench/stores/adhocRequestSender.ts b/extensions/copilot/test/simulation/workbench/stores/adhocRequestSender.ts new file mode 100644 index 0000000000000..1bc1e58747922 --- /dev/null +++ b/extensions/copilot/test/simulation/workbench/stores/adhocRequestSender.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as mobx from 'mobx'; +import * as os from 'os'; +import * as path from 'path'; +import { CancellationTokenSource } from '../../../../src/util/vs/base/common/cancellation'; +import { AdhocResponseOutput, AdhocResponseType, IAdhocRequest } from '../../shared/sharedTypes'; +import { spawnSimulationFromMainProcess } from '../utils/simulationExec'; + +export const enum AdhocRequestState { + Idle, + Running, + Done, + Error, +} + +/** + * Sends an adhoc chat request by spawning the simulation CLI and streaming the + * response back. Used by the "Adhoc request sender" mode in the workbench. + */ +export class AdhocRequestSender { + + @mobx.observable + public state: AdhocRequestState = AdhocRequestState.Idle; + + /** The accumulated response text streamed from the model. */ + @mobx.observable + public response: string = ''; + + /** The error message, set when {@link state} is {@link AdhocRequestState.Error}. */ + @mobx.observable + public error: string | undefined = undefined; + + private cancellationTokenSource: CancellationTokenSource | undefined; + + constructor() { + mobx.makeObservable(this); + } + + public async send(request: IAdhocRequest): Promise { + if (this.state === AdhocRequestState.Running) { + return; + } + + const cancellationTokenSource = new CancellationTokenSource(); + this.cancellationTokenSource = cancellationTokenSource; + + mobx.runInAction(() => { + this.state = AdhocRequestState.Running; + this.response = ''; + this.error = undefined; + }); + + const requestFilePath = path.join(os.tmpdir(), `adhoc-request-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + + try { + await fs.promises.writeFile(requestFilePath, JSON.stringify(request), { encoding: 'utf8', mode: 0o600 }); + + const stream = spawnSimulationFromMainProcess( + { args: [`--adhoc-request-file=${requestFilePath}`], ignoreNonJSONLines: true }, + cancellationTokenSource.token + ); + + for await (const output of stream) { + if (cancellationTokenSource.token.isCancellationRequested) { + break; + } + this.interpretOutput(output); + } + } catch (err) { + if (!cancellationTokenSource.token.isCancellationRequested) { + mobx.runInAction(() => { + this.state = AdhocRequestState.Error; + this.error = err instanceof Error ? (err.stack ?? err.message) : String(err); + }); + } + } finally { + fs.promises.unlink(requestFilePath).catch(() => { /* best effort cleanup */ }); + // Always dispose this send's token source to avoid leaking its + // cancellation listeners across repeated Send/Stop cycles, regardless of + // whether this send is still the current one. + cancellationTokenSource.dispose(); + // Only finalize shared state if this send is still the current one. A + // superseded send (the user hit Stop then Send again) must not clobber the + // newer request's state or cancellation token source. + if (this.cancellationTokenSource === cancellationTokenSource) { + this.cancellationTokenSource = undefined; + mobx.runInAction(() => { + if (this.state === AdhocRequestState.Running) { + // Stream ended without an explicit done/error message. + this.state = this.error !== undefined ? AdhocRequestState.Error : AdhocRequestState.Done; + } + }); + } + } + } + + public cancel(): void { + this.cancellationTokenSource?.cancel(); + this.cancellationTokenSource = undefined; + mobx.runInAction(() => { + if (this.state === AdhocRequestState.Running) { + this.state = AdhocRequestState.Idle; + } + }); + } + + private interpretOutput(output: AdhocResponseOutput): void { + mobx.runInAction(() => { + switch (output.type) { + case AdhocResponseType.delta: + this.response += output.value; + return; + case AdhocResponseType.done: + this.response = output.value; + this.state = AdhocRequestState.Done; + return; + case AdhocResponseType.error: + this.error = output.value; + this.state = AdhocRequestState.Error; + return; + } + }); + } +} diff --git a/extensions/copilot/test/simulation/workbench/stores/workbenchMode.ts b/extensions/copilot/test/simulation/workbench/stores/workbenchMode.ts new file mode 100644 index 0000000000000..aec8ecd4c5809 --- /dev/null +++ b/extensions/copilot/test/simulation/workbench/stores/workbenchMode.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SimulationStorageValue } from './simulationStorage'; + +/** + * The top-level mode of the simulation workbench. This is independent of + * {@link TestSource} (which is purely about where simulation tests come from): + * the workbench can either be showing tests or the standalone "Adhoc request + * sender" utility. + */ +export type WorkbenchMode = 'tests' | 'adhocRequest'; + +export type WorkbenchModeValue = SimulationStorageValue; diff --git a/extensions/copilot/test/simulationMain.ts b/extensions/copilot/test/simulationMain.ts index 8032c42f48eca..d5743145bd58a 100644 --- a/extensions/copilot/test/simulationMain.ts +++ b/extensions/copilot/test/simulationMain.ts @@ -10,6 +10,7 @@ dotenv.config(); import 'source-map-support/register'; // Load other imports +import { Raw } from '@vscode/prompt-tsx'; import * as fs from 'fs'; import minimist from 'minimist'; import { createConnection } from 'net'; @@ -17,17 +18,27 @@ import * as path from 'path'; import * as v8 from 'v8'; import type * as vscodeType from 'vscode'; import { SimpleRPC } from '../src/extension/onboardDebug/node/copilotDebugWorker/rpc'; +import { ChatMLFetcherImpl } from '../src/extension/prompt/node/chatMLFetcher'; import { ISimulationModelConfig, createExtensionUnitTestingServices } from '../src/extension/test/node/services'; +import { IChatMLFetcher } from '../src/platform/chat/common/chatMLFetcher'; +import { ChatFetchResponseType, ChatLocation } from '../src/platform/chat/common/commonTypes'; +import { toTextParts } from '../src/platform/chat/common/globalStringUtils'; import { CHAT_MODEL } from '../src/platform/configuration/common/configurationService'; import { IEndpointProvider, ModelSupportedEndpoint } from '../src/platform/endpoint/common/endpointProvider'; +import { createProxyXtabEndpoint } from '../src/platform/endpoint/node/proxyXtabEndpoint'; import { IModelConfig } from '../src/platform/endpoint/test/node/openaiCompatibleEndpoint'; import { fileSystemServiceReadAsJSON } from '../src/platform/filesystem/common/fileSystemService'; import { LogLevel } from '../src/platform/log/common/logService'; +import { IChatEndpoint } from '../src/platform/networking/common/networking'; import { ParserWithCaching } from '../src/platform/parser/node/parserWithCaching'; import { structureComputer } from '../src/platform/parser/node/structure'; import { NullTelemetryService } from '../src/platform/telemetry/common/nullTelemetryService'; import { TokenizerProvider } from '../src/platform/tokenizer/node/tokenizer'; import { assert } from '../src/util/vs/base/common/assert'; +import { CancellationToken } from '../src/util/vs/base/common/cancellation'; +import { DisposableStore } from '../src/util/vs/base/common/lifecycle'; +import { SyncDescriptor } from '../src/util/vs/platform/instantiation/common/descriptors'; +import { IInstantiationService } from '../src/util/vs/platform/instantiation/common/instantiation'; import { Cache } from './base/cache'; import { IChatMLCache } from './base/cachingChatMLFetcher'; import { usedResourceCaches } from './base/cachingResourceFetcher'; @@ -48,7 +59,7 @@ import { runInputPipeline, runInputPipelineParallel } from './pipeline/pipeline' import { ITestDiscoveryOptions, discoverTests } from './simulation/externalScenarios'; import { discoverCoffeTests } from './simulation/nesCoffeTests'; import { discoverNesTests } from './simulation/nesExternalTests'; -import { OLD_BASELINE_FILENAME, OutputType, PRODUCED_BASELINE_FILENAME, REPORT_FILENAME, RUN_METADATA, SCORECARD_FILENAME, SIMULATION_FOLDER_NAME, generateOutputFolderName } from './simulation/shared/sharedTypes'; +import { AdhocResponseOutput, AdhocResponseType, IAdhocRequest, OLD_BASELINE_FILENAME, OutputType, PRODUCED_BASELINE_FILENAME, REPORT_FILENAME, RUN_METADATA, SCORECARD_FILENAME, SIMULATION_FOLDER_NAME, generateOutputFolderName } from './simulation/shared/sharedTypes'; import { logger } from './simulationLogger'; import { IInitParams, IInitResult, IRunTestParams, IRunTestResult } from './testExecutionInExtension'; import { GroupedScores, ITestResult, SimulationTestContext, executeTestOnce, executeTests } from './testExecutor'; @@ -105,6 +116,9 @@ async function run(opts: SimulationOptions): Promise { return opts.printTrainHelp(); case opts.help: return opts.printHelp(); + case !!opts.adhocRequestFile: + await sendAdhocRequest(opts.adhocRequestFile!); + return; case opts.listModels: await listChatModels(opts.modelCacheMode === CacheMode.Disable); return; @@ -533,6 +547,118 @@ async function listChatModels(skipCache: boolean = false) { return; } +/** + * Validates and normalizes a parsed adhoc request JSON object. Returns a focused + * error message instead of letting `.trim()`/`toTextParts()` throw on malformed + * input (missing/`null` fields, wrong types, etc.). + */ +function validateAdhocRequest(raw: unknown): { ok: true; request: IAdhocRequest } | { ok: false; error: string } { + if (typeof raw !== 'object' || raw === null) { + return { ok: false, error: 'Invalid adhoc request: expected a JSON object.' }; + } + const obj = raw as Record; + if (typeof obj.model !== 'string' || obj.model.trim().length === 0) { + return { ok: false, error: 'Invalid adhoc request: "model" must be a non-empty string.' }; + } + if (typeof obj.user !== 'string' || obj.user.trim().length === 0) { + return { ok: false, error: 'Invalid adhoc request: "user" must be a non-empty string.' }; + } + if (obj.system !== undefined && typeof obj.system !== 'string') { + return { ok: false, error: 'Invalid adhoc request: "system" must be a string when provided.' }; + } + return { + ok: true, + request: { + model: obj.model, + user: obj.user, + system: typeof obj.system === 'string' ? obj.system : '', + }, + }; +} + +/** + * Sends a single adhoc chat request (used by the simulation workbench + * "Adhoc request sender" mode) and streams the response back as JSONL on + * stdout. The request is described by a JSON file at {@link requestFilePath}. + */ +async function sendAdhocRequest(requestFilePath: string): Promise { + const printAdhocOutput = (output: AdhocResponseOutput) => { + process.stdout.write(JSON.stringify(output) + '\n'); + }; + + let parsed: unknown; + try { + parsed = JSON.parse(await fs.promises.readFile(requestFilePath, 'utf8')); + } catch (err) { + printAdhocOutput({ type: AdhocResponseType.error, value: `Failed to read adhoc request file: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + + const validation = validateAdhocRequest(parsed); + if (!validation.ok) { + printAdhocOutput({ type: AdhocResponseType.error, value: validation.error }); + return; + } + const request = validation.request; + + const disposables = new DisposableStore(); + try { + const model = request.model.trim(); + + const testingServiceCollection = createExtensionUnitTestingServices(disposables); + // The unit-testing services bind a mock chat fetcher; override it with the + // real implementation so the request actually hits the model endpoint. + testingServiceCollection.define(IChatMLFetcher, new SyncDescriptor(ChatMLFetcherImpl)); + const accessor = testingServiceCollection.createTestingAccessor(); + const endpointProvider = accessor.get(IEndpointProvider); + const instantiationService = accessor.get(IInstantiationService); + + // Prefer a registered chat endpoint (e.g. `gpt-4.1`, `claude-sonnet-4.5`). + let endpoint: IChatEndpoint | undefined; + try { + const allEndpoints = await endpointProvider.getAllChatEndpoints(); + endpoint = allEndpoints.find(e => e.model === model) ?? allEndpoints.find(e => e.family === model); + } catch { + // Ignore and fall back to the proxy endpoint below. + } + if (!endpoint) { + // Otherwise send the model name directly against the proxy endpoint, the + // same way `xtabProvider` does. This allows targeting models that aren't + // registered chat endpoints (e.g. NES models like `copilot-nes-oct`). + endpoint = createProxyXtabEndpoint(instantiationService, model); + } + + const messages: Raw.ChatMessage[] = []; + if (request.system.trim().length > 0) { + messages.push({ role: Raw.ChatRole.System, content: toTextParts(request.system) }); + } + messages.push({ role: Raw.ChatRole.User, content: toTextParts(request.user) }); + + const response = await endpoint.makeChatRequest2({ + debugName: 'adhoc-request', + messages, + finishedCb: async (_text, _index, delta) => { + if (delta.text) { + printAdhocOutput({ type: AdhocResponseType.delta, value: delta.text }); + } + return undefined; + }, + location: ChatLocation.Other, + userInitiatedRequest: true, + }, CancellationToken.None); + + if (response.type === ChatFetchResponseType.Success) { + printAdhocOutput({ type: AdhocResponseType.done, value: response.value }); + } else { + printAdhocOutput({ type: AdhocResponseType.error, value: `${response.type}: ${response.reason}` }); + } + } catch (err) { + printAdhocOutput({ type: AdhocResponseType.error, value: err instanceof Error ? (err.stack ?? err.message) : String(err) }); + } finally { + disposables.dispose(); + } +} + function createSimulationTestContext( opts: SimulationOptions, runningAllTests: boolean, diff --git a/extensions/git/resources/icons/dark/status-added.svg b/extensions/git/resources/icons/dark/status-added.svg index cdc40f45f1630..ae2c21c9dd85d 100644 --- a/extensions/git/resources/icons/dark/status-added.svg +++ b/extensions/git/resources/icons/dark/status-added.svg @@ -1,6 +1 @@ - - - - A - - \ No newline at end of file +A \ No newline at end of file diff --git a/extensions/git/resources/icons/dark/status-conflict.svg b/extensions/git/resources/icons/dark/status-conflict.svg index 53b243c8b9fdf..6e23e80d4d24b 100644 --- a/extensions/git/resources/icons/dark/status-conflict.svg +++ b/extensions/git/resources/icons/dark/status-conflict.svg @@ -1,6 +1 @@ - - - - C - - \ No newline at end of file +C \ No newline at end of file diff --git a/extensions/git/resources/icons/dark/status-copied.svg b/extensions/git/resources/icons/dark/status-copied.svg index 7bd78c9427e19..5c2546b0ec040 100644 --- a/extensions/git/resources/icons/dark/status-copied.svg +++ b/extensions/git/resources/icons/dark/status-copied.svg @@ -1,6 +1 @@ - - - - C - - \ No newline at end of file +C \ No newline at end of file diff --git a/extensions/git/resources/icons/dark/status-deleted.svg b/extensions/git/resources/icons/dark/status-deleted.svg index e7596e2e2adb2..8b3dc120a6eff 100644 --- a/extensions/git/resources/icons/dark/status-deleted.svg +++ b/extensions/git/resources/icons/dark/status-deleted.svg @@ -1,6 +1 @@ - - - - D - - \ No newline at end of file +D \ No newline at end of file diff --git a/extensions/git/resources/icons/dark/status-ignored.svg b/extensions/git/resources/icons/dark/status-ignored.svg index 85abc367a2911..c2a0637e34242 100644 --- a/extensions/git/resources/icons/dark/status-ignored.svg +++ b/extensions/git/resources/icons/dark/status-ignored.svg @@ -1,6 +1 @@ - - - - I - - \ No newline at end of file +I \ No newline at end of file diff --git a/extensions/git/resources/icons/dark/status-modified.svg b/extensions/git/resources/icons/dark/status-modified.svg index d0de37d34688b..f07597aab37d2 100644 --- a/extensions/git/resources/icons/dark/status-modified.svg +++ b/extensions/git/resources/icons/dark/status-modified.svg @@ -1,6 +1 @@ - - - - M - - \ No newline at end of file +M \ No newline at end of file diff --git a/extensions/git/resources/icons/dark/status-renamed.svg b/extensions/git/resources/icons/dark/status-renamed.svg index a77fb41179a10..c7659de4d1725 100644 --- a/extensions/git/resources/icons/dark/status-renamed.svg +++ b/extensions/git/resources/icons/dark/status-renamed.svg @@ -1,6 +1 @@ - - - - R - - \ No newline at end of file +R \ No newline at end of file diff --git a/extensions/git/resources/icons/dark/status-type-changed.svg b/extensions/git/resources/icons/dark/status-type-changed.svg index ae504ae188149..a2060ab5fb8ba 100644 --- a/extensions/git/resources/icons/dark/status-type-changed.svg +++ b/extensions/git/resources/icons/dark/status-type-changed.svg @@ -1,6 +1 @@ - - - - T - - +T \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-added.svg b/extensions/git/resources/icons/light/status-added.svg index 587fc08f5f50f..45e22b8961735 100644 --- a/extensions/git/resources/icons/light/status-added.svg +++ b/extensions/git/resources/icons/light/status-added.svg @@ -1,6 +1 @@ - - - - A - - \ No newline at end of file +A \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-conflict.svg b/extensions/git/resources/icons/light/status-conflict.svg index b6088ecd0837e..e83de1d91ee03 100644 --- a/extensions/git/resources/icons/light/status-conflict.svg +++ b/extensions/git/resources/icons/light/status-conflict.svg @@ -1,6 +1 @@ - - - - C - - \ No newline at end of file +C \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-copied.svg b/extensions/git/resources/icons/light/status-copied.svg index 151fdb2cdbe52..835cf4a5f6fca 100644 --- a/extensions/git/resources/icons/light/status-copied.svg +++ b/extensions/git/resources/icons/light/status-copied.svg @@ -1,6 +1 @@ - - - - C - - \ No newline at end of file +C \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-deleted.svg b/extensions/git/resources/icons/light/status-deleted.svg index 7ed166accfe05..4cf9608846ab9 100644 --- a/extensions/git/resources/icons/light/status-deleted.svg +++ b/extensions/git/resources/icons/light/status-deleted.svg @@ -1,6 +1 @@ - - - - D - - \ No newline at end of file +D \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-ignored.svg b/extensions/git/resources/icons/light/status-ignored.svg index 85abc367a2911..c2a0637e34242 100644 --- a/extensions/git/resources/icons/light/status-ignored.svg +++ b/extensions/git/resources/icons/light/status-ignored.svg @@ -1,6 +1 @@ - - - - I - - \ No newline at end of file +I \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-modified.svg b/extensions/git/resources/icons/light/status-modified.svg index ff338b814106f..217aa15803249 100644 --- a/extensions/git/resources/icons/light/status-modified.svg +++ b/extensions/git/resources/icons/light/status-modified.svg @@ -1,6 +1 @@ - - - - M - - \ No newline at end of file +M \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-renamed.svg b/extensions/git/resources/icons/light/status-renamed.svg index a77fb41179a10..c7659de4d1725 100644 --- a/extensions/git/resources/icons/light/status-renamed.svg +++ b/extensions/git/resources/icons/light/status-renamed.svg @@ -1,6 +1 @@ - - - - R - - \ No newline at end of file +R \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-type-changed.svg b/extensions/git/resources/icons/light/status-type-changed.svg index ae504ae188149..a2060ab5fb8ba 100644 --- a/extensions/git/resources/icons/light/status-type-changed.svg +++ b/extensions/git/resources/icons/light/status-type-changed.svg @@ -1,6 +1 @@ - - - - T - - +T \ No newline at end of file diff --git a/extensions/git/resources/icons/light/status-untracked.svg b/extensions/git/resources/icons/light/status-untracked.svg index c6a48e14f0862..db980b349c6b3 100644 --- a/extensions/git/resources/icons/light/status-untracked.svg +++ b/extensions/git/resources/icons/light/status-untracked.svg @@ -1,6 +1 @@ - - - - U - - \ No newline at end of file +U \ No newline at end of file diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index 83edccd646eab..b05eb9d7da2ac 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { EditorController, EditorModel, EditorView, StringEdit, StringValue, findNodeOffsetById, taskCheckboxRange } from '@vscode/markdown-editor'; +import { EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, StringEdit, StringValue, findNodeOffsetById, taskCheckboxRange } from '@vscode/markdown-editor'; import { Disposable, autorun } from '@vscode/markdown-editor/observables'; import mermaid from 'mermaid'; import 'katex/dist/katex.min.css'; import '@vscode/markdown-editor/editor.css'; -import '@vscode/markdown-editor/themes/github.css'; +import '@vscode/markdown-editor/themes/vscode.css'; import './markdownEditor.css'; import { WebviewSyntaxHighlighter } from './syntaxHighlighter'; @@ -54,6 +54,14 @@ class Editor extends Disposable { this.isUpdatingFromExtension = false; break; } + case 'gutterMarkers': { + const markers: GutterMarker[] = message.markers.map((marker: { start: number; endExclusive: number; type: GutterMarker['type'] }) => ({ + range: OffsetRange.fromTo(marker.start, marker.endExclusive), + type: marker.type, + })); + this.model.gutterMarkers.set(markers, undefined); + break; + } } }); @@ -64,7 +72,7 @@ class Editor extends Disposable { const model = this.model; const view = this._register(new EditorView(model, { - classNames: ['github-markdown-theme'], + classNames: ['md-theme-vscode'], syntaxHighlighter: this.#syntaxHighlighter, onToggleCheckbox: (item, newChecked) => { if (readonly) { @@ -100,6 +108,7 @@ class Editor extends Disposable { return div; }, })); + this._register(new EditorController(model, view)); host.appendChild(view.element); diff --git a/extensions/markdown-language-features/media/preview-dark.svg b/extensions/markdown-language-features/media/preview-dark.svg index ec71ea8114372..b2c86ce348aca 100644 --- a/extensions/markdown-language-features/media/preview-dark.svg +++ b/extensions/markdown-language-features/media/preview-dark.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/markdown-language-features/media/preview-light.svg b/extensions/markdown-language-features/media/preview-light.svg index 4a6b85b583972..f8c0d8546791c 100644 --- a/extensions/markdown-language-features/media/preview-light.svg +++ b/extensions/markdown-language-features/media/preview-light.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 38e31e367c5a2..13bdb2e042cc4 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,11 +10,11 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-5", + "@vscode/markdown-editor": "^0.0.2-8", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", - "markdown-it": "^12.3.2", + "markdown-it": "^14.2.0", "mermaid": "^11.15.0", "morphdom": "^2.7.7", "picomatch": "^2.3.2", @@ -620,9 +620,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-5", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-5.tgz", - "integrity": "sha512-v9twExwatxxIrEzE86CscvrsukgW0+qFHISVV4PYLCcBINFDFA7L7ftiKzb1eEkHSR4uUVl8paChC2BellJQow==", + "version": "0.0.2-8", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-8.tgz", + "integrity": "sha512-mmsUYF3ffmunmR9Q9cShVYIUq9orbClFNm9tMNCJ7n1Vh/8EFLn3QJQdoyqeGL53O0gc2VCqPAqwgwOPKadLFA==", "license": "MIT", "dependencies": { "katex": "^0.16.33", @@ -1348,9 +1348,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -1474,11 +1474,22 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, "node_modules/lodash-es": { @@ -1505,26 +1516,30 @@ } }, "node_modules/markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, "bin": { - "markdown-it": "bin/markdown-it.js" - } - }, - "node_modules/markdown-it/node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "markdown-it": "bin/markdown-it.mjs" } }, "node_modules/marked": { @@ -1540,9 +1555,10 @@ } }, "node_modules/mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" }, "node_modules/mermaid": { "version": "11.15.0", @@ -2227,6 +2243,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -2296,9 +2321,10 @@ } }, "node_modules/uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" }, "node_modules/undici-types": { "version": "7.16.0", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 9be9248806d6a..45c08f7dc5604 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -10,7 +10,8 @@ "enabledApiProposals": [ "customEditorDiffs", "documentDiff", - "documentSyntaxHighlighting" + "documentSyntaxHighlighting", + "textEditorDiffInformation" ], "engines": { "vscode": "^1.70.0" @@ -898,11 +899,11 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-5", + "@vscode/markdown-editor": "^0.0.2-8", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", - "markdown-it": "^12.3.2", + "markdown-it": "^14.2.0", "mermaid": "^11.15.0", "morphdom": "^2.7.7", "picomatch": "^2.3.2", diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 3f008ca23a855..998cbb19ea16f 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -76,14 +76,55 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT }); const highlight = this.#wireHighlight(webview); + const quickDiff = this.#wireQuickDiff(document, webview); webviewPanel.onDidDispose(() => { onMessage.dispose(); onDocumentChange.dispose(); highlight.dispose(); + quickDiff.dispose(); }); } + /** + * Forwards the source-control change information for the document (the same + * added/modified/deleted line changes shown in the editor gutter) to the + * webview, where it is painted in the Markdown editor's gutter. Line ranges + * are converted to source character offsets here, since the webview works in + * offsets. + */ + #wireQuickDiff(document: vscode.TextDocument, webview: vscode.Webview): vscode.Disposable { + const diffProvider = vscode.window.createSourceControlDiffInformation(document.uri); + + const postMarkers = () => { + const diffInformation = diffProvider.diffInformation; + // The changes are computed asynchronously against a specific document + // version. Only map them to offsets while that version still matches the + // document we hold, otherwise the line positions could be stale. A newer + // diff for the current version will arrive via onDidChange. + if (!diffInformation || diffInformation.isStale) { + return; + } + webview.postMessage({ type: 'gutterMarkers', markers: toGutterMarkers(document, diffInformation.changes) }); + }; + + const onChange = diffProvider.onDidChange(postMarkers); + // Re-send once the webview has (re)initialized its model, and whenever the + // document settles on the version the changes were computed for. + const onMessage = webview.onDidReceiveMessage((message) => { + if (message.type === 'ready') { + postMarkers(); + } + }); + const onDocumentChange = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() === document.uri.toString()) { + postMarkers(); + } + }); + + return vscode.Disposable.from(diffProvider, onChange, onMessage, onDocumentChange); + } + /** * Proxies the webview's syntax highlighting requests to the * `documentSyntaxHighlighting` proposed API, since the webview cannot call @@ -143,3 +184,41 @@ function getNonce(): string { } return text; } + +interface GutterMarkerMessage { + readonly start: number; + readonly endExclusive: number; + readonly type: 'added' | 'modified' | 'deleted'; +} + +/** + * Converts the line-based source control changes into source character offset + * ranges understood by the Markdown editor's `gutterMarkers`. Added/modified + * changes map to the offset span of their modified lines; deleted changes map to + * an empty range at the boundary where the removed text used to be. + * + * Line ranges use {@link vscode.TextEditorLineRange} semantics: 1-based + * `startLineNumber` and exclusive `endLineNumberExclusive`. + */ +function toGutterMarkers(document: vscode.TextDocument, changes: readonly vscode.TextEditorChange[]): GutterMarkerMessage[] { + const markers: GutterMarkerMessage[] = []; + for (const change of changes) { + if (change.kind === vscode.TextEditorChangeKind.Deletion) { + // The modified range is empty; place an empty marker at the start of the + // line where the removed content used to be. + const line = Math.max(0, change.modified.startLineNumber - 1); + const offset = document.offsetAt(new vscode.Position(line, 0)); + markers.push({ start: offset, endExclusive: offset, type: 'deleted' }); + continue; + } + + const start = document.offsetAt(new vscode.Position(change.modified.startLineNumber - 1, 0)); + const endExclusive = document.offsetAt(document.lineAt(change.modified.endLineNumberExclusive - 2).range.end); + markers.push({ + start, + endExclusive, + type: change.kind === vscode.TextEditorChangeKind.Addition ? 'added' : 'modified', + }); + } + return markers; +} diff --git a/extensions/markdown-language-features/tsconfig.json b/extensions/markdown-language-features/tsconfig.json index 682b9727806ba..7384236714cf4 100644 --- a/extensions/markdown-language-features/tsconfig.json +++ b/extensions/markdown-language-features/tsconfig.json @@ -13,6 +13,7 @@ "../../src/vscode-dts/vscode.d.ts", "../../src/vscode-dts/vscode.proposed.customEditorDiffs.d.ts", "../../src/vscode-dts/vscode.proposed.documentDiff.d.ts", - "../../src/vscode-dts/vscode.proposed.documentSyntaxHighlighting.d.ts" + "../../src/vscode-dts/vscode.proposed.documentSyntaxHighlighting.d.ts", + "../../src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts" ] } diff --git a/extensions/media-preview/media/loading-dark.svg b/extensions/media-preview/media/loading-dark.svg index 7dc1ebd8cf045..0eecf93ab38bb 100644 --- a/extensions/media-preview/media/loading-dark.svg +++ b/extensions/media-preview/media/loading-dark.svg @@ -1,31 +1 @@ - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/extensions/media-preview/media/loading-hc.svg b/extensions/media-preview/media/loading-hc.svg index c3633c0ddabee..a9ecf69d2ba13 100644 --- a/extensions/media-preview/media/loading-hc.svg +++ b/extensions/media-preview/media/loading-hc.svg @@ -1,31 +1 @@ - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/extensions/media-preview/media/loading.svg b/extensions/media-preview/media/loading.svg index e762f06d5e6e8..bc8fe7c89c436 100644 --- a/extensions/media-preview/media/loading.svg +++ b/extensions/media-preview/media/loading.svg @@ -1,31 +1 @@ - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/extensions/mermaid-markdown-features/package-lock.json b/extensions/mermaid-markdown-features/package-lock.json index b88d22950afd8..3ffd72c4c3f65 100644 --- a/extensions/mermaid-markdown-features/package-lock.json +++ b/extensions/mermaid-markdown-features/package-lock.json @@ -1845,9 +1845,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/extensions/notebook-renderers/package-lock.json b/extensions/notebook-renderers/package-lock.json index e5c39d11af174..98aa3ee4360a6 100644 --- a/extensions/notebook-renderers/package-lock.json +++ b/extensions/notebook-renderers/package-lock.json @@ -605,9 +605,9 @@ } }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/extensions/npm/images/code.svg b/extensions/npm/images/code.svg index f776259aad043..d929f73eb8f42 100644 --- a/extensions/npm/images/code.svg +++ b/extensions/npm/images/code.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/npm/package-lock.json b/extensions/npm/package-lock.json index ca856d1c5ada5..1b77aeac60dd4 100644 --- a/extensions/npm/package-lock.json +++ b/extensions/npm/package-lock.json @@ -150,9 +150,9 @@ } }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -332,9 +332,10 @@ } }, "node_modules/which-pm": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-2.1.1.tgz", - "integrity": "sha512-xzzxNw2wMaoCWXiGE8IJ9wuPMU+EYhFksjHxrRT8kMT5SnocBPRg69YAMtyV4D12fP582RA+k3P8H9J5EMdIxQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-2.2.0.tgz", + "integrity": "sha512-MOiaDbA5ZZgUjkeMWM5EkJp4loW5ZRoa5bc3/aeMox/PJelMhE6t7S/mLuiY43DBupyxH+S0U1bTui9kWUlmsw==", + "license": "MIT", "dependencies": { "load-yaml-file": "^0.2.0", "path-exists": "^4.0.0" diff --git a/extensions/search-result/src/media/refresh-dark.svg b/extensions/search-result/src/media/refresh-dark.svg index e1f05aadeebdf..3bf7b96c57d2c 100644 --- a/extensions/search-result/src/media/refresh-dark.svg +++ b/extensions/search-result/src/media/refresh-dark.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/extensions/search-result/src/media/refresh-light.svg b/extensions/search-result/src/media/refresh-light.svg index 9b1d910840919..25548999c1f92 100644 --- a/extensions/search-result/src/media/refresh-light.svg +++ b/extensions/search-result/src/media/refresh-light.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/extensions/simple-browser/media/preview-dark.svg b/extensions/simple-browser/media/preview-dark.svg index ec71ea8114372..b2c86ce348aca 100644 --- a/extensions/simple-browser/media/preview-dark.svg +++ b/extensions/simple-browser/media/preview-dark.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/simple-browser/media/preview-light.svg b/extensions/simple-browser/media/preview-light.svg index 4a6b85b583972..f8c0d8546791c 100644 --- a/extensions/simple-browser/media/preview-light.svg +++ b/extensions/simple-browser/media/preview-light.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/document-dark.svg b/extensions/theme-defaults/fileicons/images/document-dark.svg index 5ed5762a1f0f5..1195c4f2fc764 100644 --- a/extensions/theme-defaults/fileicons/images/document-dark.svg +++ b/extensions/theme-defaults/fileicons/images/document-dark.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/document-light.svg b/extensions/theme-defaults/fileicons/images/document-light.svg index ad54e13b1b188..66e5101488778 100644 --- a/extensions/theme-defaults/fileicons/images/document-light.svg +++ b/extensions/theme-defaults/fileicons/images/document-light.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/folder-dark.svg b/extensions/theme-defaults/fileicons/images/folder-dark.svg index 43d454e7e5ac1..274e59a7c6c7b 100644 --- a/extensions/theme-defaults/fileicons/images/folder-dark.svg +++ b/extensions/theme-defaults/fileicons/images/folder-dark.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/folder-light.svg b/extensions/theme-defaults/fileicons/images/folder-light.svg index 8daecdac6a358..58fa71aca6bda 100644 --- a/extensions/theme-defaults/fileicons/images/folder-light.svg +++ b/extensions/theme-defaults/fileicons/images/folder-light.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/folder-open-dark.svg b/extensions/theme-defaults/fileicons/images/folder-open-dark.svg index 6bc1c584e4846..b31c115806970 100644 --- a/extensions/theme-defaults/fileicons/images/folder-open-dark.svg +++ b/extensions/theme-defaults/fileicons/images/folder-open-dark.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/folder-open-light.svg b/extensions/theme-defaults/fileicons/images/folder-open-light.svg index 0a50339b6c8f4..7f788fcc964c8 100644 --- a/extensions/theme-defaults/fileicons/images/folder-open-light.svg +++ b/extensions/theme-defaults/fileicons/images/folder-open-light.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/root-folder-dark.svg b/extensions/theme-defaults/fileicons/images/root-folder-dark.svg index cdb770c86a840..3ff7c320c11c1 100644 --- a/extensions/theme-defaults/fileicons/images/root-folder-dark.svg +++ b/extensions/theme-defaults/fileicons/images/root-folder-dark.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/root-folder-light.svg b/extensions/theme-defaults/fileicons/images/root-folder-light.svg index 82a0294696fcb..3600c9383d739 100644 --- a/extensions/theme-defaults/fileicons/images/root-folder-light.svg +++ b/extensions/theme-defaults/fileicons/images/root-folder-light.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/root-folder-open-dark.svg b/extensions/theme-defaults/fileicons/images/root-folder-open-dark.svg index 472def3daa18d..5f1e5fc5dc18f 100644 --- a/extensions/theme-defaults/fileicons/images/root-folder-open-dark.svg +++ b/extensions/theme-defaults/fileicons/images/root-folder-open-dark.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/fileicons/images/root-folder-open-light.svg b/extensions/theme-defaults/fileicons/images/root-folder-open-light.svg index d2363bfae3526..f597f10a482f2 100644 --- a/extensions/theme-defaults/fileicons/images/root-folder-open-light.svg +++ b/extensions/theme-defaults/fileicons/images/root-folder-open-light.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 24d91a541fb24..f06bc852cf8d1 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -34,7 +34,7 @@ "input.background": "#191A1B", "input.border": "#333536FF", "input.foreground": "#bfbfbf", - "input.placeholderForeground": "#555555", + "input.placeholderForeground": "#828282", "inputOption.activeBackground": "#3994BC33", "inputOption.activeForeground": "#bfbfbf", "inputOption.activeBorder": "#2A2B2CFF", @@ -289,7 +289,7 @@ "agentsChatInput.foreground": "#bfbfbf", "agentsChatInput.border": "#333536", "agentsChatInput.focusBorder": "#3994BCB3", - "agentsChatInput.placeholderForeground": "#555555", + "agentsChatInput.placeholderForeground": "#828282", "agentsNewSessionButton.background": "#00000000", "agentsNewSessionButton.foreground": "#bfbfbf", "agentsNewSessionButton.border": "#333536", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 9d2a97a6534bc..73ab4144933cb 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -36,7 +36,7 @@ "input.background": "#FFFFFF", "input.border": "#D8D8D866", "input.foreground": "#202020", - "input.placeholderForeground": "#999999", + "input.placeholderForeground": "#767676", "inputOption.activeBackground": "#0069CC26", "inputOption.activeForeground": "#202020", "inputOption.activeBorder": "#F0F1F2FF", @@ -58,9 +58,9 @@ "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", - "scrollbarSlider.background": "#64646480", - "scrollbarSlider.hoverBackground": "#64646490", - "scrollbarSlider.activeBackground": "#646464A0", + "scrollbarSlider.background": "#646464C0", + "scrollbarSlider.hoverBackground": "#646464D0", + "scrollbarSlider.activeBackground": "#646464E0", "badge.background": "#0069CC", "badge.foreground": "#FFFFFF", "progressBar.background": "#0069CC", @@ -282,9 +282,9 @@ "charts.purple": "#652D90", "agentStatusIndicator.background": "#FFFFFF", "inlineChat.border": "#00000000", - "minimapSlider.background": "#64646480", - "minimapSlider.hoverBackground": "#64646490", - "minimapSlider.activeBackground": "#646464A0", + "minimapSlider.background": "#646464C0", + "minimapSlider.hoverBackground": "#646464D0", + "minimapSlider.activeBackground": "#646464E0", "agents.background": "#FAFAFD", "agentsPanel.background": "#FFFFFF", @@ -295,7 +295,7 @@ "agentsChatInput.foreground": "#202020", "agentsChatInput.border": "#D8D8D8", "agentsChatInput.focusBorder": "#0069CCFF", - "agentsChatInput.placeholderForeground": "#999999", + "agentsChatInput.placeholderForeground": "#767676", "agentsNewSessionButton.background": "#00000000", "agentsNewSessionButton.foreground": "#202020", "agentsNewSessionButton.border": "#D8D8D8", diff --git a/extensions/typescript-language-features/resources/walkthroughs/create-a-js-file.svg b/extensions/typescript-language-features/resources/walkthroughs/create-a-js-file.svg index c9602e32ca54d..a328b2921ef36 100644 --- a/extensions/typescript-language-features/resources/walkthroughs/create-a-js-file.svg +++ b/extensions/typescript-language-features/resources/walkthroughs/create-a-js-file.svg @@ -1,41 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/extensions/typescript-language-features/resources/walkthroughs/debug-and-run.svg b/extensions/typescript-language-features/resources/walkthroughs/debug-and-run.svg index 0fc4d754047be..da0d686beec7a 100644 --- a/extensions/typescript-language-features/resources/walkthroughs/debug-and-run.svg +++ b/extensions/typescript-language-features/resources/walkthroughs/debug-and-run.svg @@ -1,107 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/extensions/typescript-language-features/resources/walkthroughs/install-node-js.svg b/extensions/typescript-language-features/resources/walkthroughs/install-node-js.svg index 2009e9ad5fe0c..213969c3616f8 100644 --- a/extensions/typescript-language-features/resources/walkthroughs/install-node-js.svg +++ b/extensions/typescript-language-features/resources/walkthroughs/install-node-js.svg @@ -1,84 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/extensions/typescript-language-features/resources/walkthroughs/learn-more.svg b/extensions/typescript-language-features/resources/walkthroughs/learn-more.svg index ea2e78bdd30c9..22295f7c26911 100644 --- a/extensions/typescript-language-features/resources/walkthroughs/learn-more.svg +++ b/extensions/typescript-language-features/resources/walkthroughs/learn-more.svg @@ -1,109 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index ad62829877cb8..97ac84afd855b 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -293,7 +293,7 @@ import { assertNoRpc, closeAllEditors } from '../utils'; throw new Error(`Timed out waiting for trust-harness title to update (target=${target}, last title=${tab.title})`); } - test('file:// inside a trusted workspace folder loads', async function () { + test.skip('file:// inside a trusted workspace folder loads', async function () { this.timeout(30_000); const folders = workspace.workspaceFolders!; @@ -303,7 +303,11 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.ok(title.startsWith('status:200'), `Expected status 200 for trusted file, got: ${title}`); }); - test('file:// outside any trusted root is blocked with 403', async function () { + // Skipped: the test runner always launches with `--disable-workspace-trust`, + // which makes the browser view trust all `file://` requests (see + // `trustAllFiles` in `IBrowserViewWindowConfiguration`). Re-enable once the + // test infrastructure supports running with Workspace Trust enabled. + test.skip('file:// outside any trusted root is blocked with 403', async function () { this.timeout(30_000); const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'vscode-browser-trust-')); diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index 5c9b4785d0282..32a93fe36462f 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -228,8 +228,14 @@ export function activate(context: vscode.ExtensionContext) { processError(`server failed with error:\n${error.message}`); extHostProcess = undefined; }); - extHostProcess.on('close', (code: number) => { - processError(`server closed unexpectedly.\nError code: ${code}`); + extHostProcess.on('exit', (code: number | null, signal: NodeJS.Signals | null) => { + // Logged separately from 'close' so we capture the signal (if any) + // that terminated the server. A non-null signal indicates the + // server was killed externally rather than exiting on its own. + outputChannel.appendLine(`[${new Date().toISOString()}] server process exited (code: ${code}, signal: ${signal}).`); + }); + extHostProcess.on('close', (code: number | null, signal: NodeJS.Signals | null) => { + processError(`server closed unexpectedly.\nError code: ${code}, signal: ${signal}`); extHostProcess = undefined; }); context.subscriptions.push({ diff --git a/package-lock.json b/package-lock.json index 5a343cdc62b70..8b68d49346f6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "code-oss-dev", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.127.0", + "version": "1.128.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.64-0", - "@github/copilot-sdk": "^1.0.2", + "@github/copilot": "^1.0.67", + "@github/copilot-sdk": "^1.0.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -20,10 +20,10 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.6.0", + "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -41,16 +41,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -77,10 +77,10 @@ "zod": "^3.25.76" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.169", - "@openai/codex": "0.135.0", + "@anthropic-ai/claude-agent-sdk": "0.3.198", + "@openai/codex": "0.142.0", "@playwright/cli": "^0.1.9", - "@playwright/test": "^1.56.1", + "@playwright/test": "^1.61.1", "@stylistic/eslint-plugin-ts": "^2.8.0", "@types/chrome-remote-interface": "^0.33.0", "@types/cookie": "^0.3.3", @@ -108,12 +108,12 @@ "@typescript/native-preview": "^7.0.0-dev.20260609", "@vscode/component-explorer": "^0.2.1-58", "@vscode/component-explorer-cli": "^0.2.1-59", - "@vscode/gulp-electron": "1.41.3", + "@vscode/gulp-electron": "^1.42.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", "@vscode/test-electron": "^2.4.0", - "@vscode/test-web": "^0.0.76", + "@vscode/test-web": "^0.0.81", "@vscode/v8-heap-parser": "^0.1.0", "@vscode/vscode-perf": "^0.0.19", "@webgpu/types": "^0.1.66", @@ -123,7 +123,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "42.2.0", + "electron": "42.5.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -185,23 +185,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.169.tgz", - "integrity": "sha512-pzsd0BBnlfHwAUfKouSNwDLcCxGDq+lbPZTsIdiNSLw+AQw7mjxPxqnFwd4YH7qw+E3XhlotuihCm01XzcybPQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.169", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.169", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.169" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -210,9 +210,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.169.tgz", - "integrity": "sha512-5PJU3wqfvJUrgUF3H7iroxAGDyliKmq5q8kK+glERf8YZgmPeDMvQL4mGSB0Vf9RgBWS01dCen93TIsN0O8NPQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -224,9 +224,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.169.tgz", - "integrity": "sha512-1Hzt0QYG7N/ExfPbN/C92YRc3BoDnyQMJpuWLVyI/r0NmKV/se/cZbIygbvQXs2ywoXOB/EyNj/hkVHaC1G22g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -238,13 +238,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.169.tgz", - "integrity": "sha512-J0tyM4t5qxc2/xilsfHqcJv+fyEDhX66Nv+CMXq4mRbquZmMHhbZbh8ryb+BnKYXUeqKX3uoU/J+7K6/fjcY4g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -252,13 +255,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.169.tgz", - "integrity": "sha512-xkmyNdU6f7HXXzgR6IZym7x41bjL3rjGrXf5PAmx9PjdThy476+TrpO1evKUza1zlVmUj4tj/jYKQsV4ER7QFw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -266,13 +272,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.169.tgz", - "integrity": "sha512-ktEPBwzXZagt0cntfRLlvNL0sAplt2HOCbR5iBvq2IV5dLvbEOiBjZQArIcSAN7CTQUFqkZs9HzQMdKe5QPwuQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -280,13 +289,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.169.tgz", - "integrity": "sha512-Me8VW91pCKgkct/y1isjKNVt+ZPMPhHk3C7O078UO1bakHD6kuPiMtugcCsg4jmmmuuywXQIQUESRbcf0GluUg==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -294,9 +306,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.169.tgz", - "integrity": "sha512-HL9ecP82A/H8aC896Q7ETyl+nLFkMzBHfO0yvK7Lhxbi98CEpqXn3BADTAc5pEgzkO+r18+CDmZdgjTFMvGoTw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -308,9 +320,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.169", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.169.tgz", - "integrity": "sha512-34pBbiKe7FNsY8LHuuTmKujmYko/cBOrKJpk9H+MOot+dSfB/FmIHq9USL17otNal5Droq9lqpezAWPBPv3lAQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], @@ -613,13 +625,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -628,9 +640,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -638,21 +650,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -686,14 +698,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -703,14 +715,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -747,9 +759,9 @@ "license": "ISC" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -757,29 +769,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -789,9 +801,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -799,9 +811,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -809,9 +821,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -819,27 +831,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -858,33 +870,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -892,14 +904,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -911,6 +923,16 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", + "integrity": "sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/get": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", @@ -1085,32 +1107,31 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", - "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.67.tgz", + "integrity": "sha512-5YEY9LNXBT9Q8uShjCdYcornJJJhGtdIzSYla2+pjfXYpHsDVibqYubzYjfgffOUKFChyzOpH7n/868+t56iIg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "detect-libc": "^2.1.2", - "os-theme": "^0.0.8" + "detect-libc": "^2.1.2" }, "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.64-0", - "@github/copilot-darwin-x64": "1.0.64-0", - "@github/copilot-linux-arm64": "1.0.64-0", - "@github/copilot-linux-x64": "1.0.64-0", - "@github/copilot-linuxmusl-arm64": "1.0.64-0", - "@github/copilot-linuxmusl-x64": "1.0.64-0", - "@github/copilot-win32-arm64": "1.0.64-0", - "@github/copilot-win32-x64": "1.0.64-0" + "@github/copilot-darwin-arm64": "1.0.67", + "@github/copilot-darwin-x64": "1.0.67", + "@github/copilot-linux-arm64": "1.0.67", + "@github/copilot-linux-x64": "1.0.67", + "@github/copilot-linuxmusl-arm64": "1.0.67", + "@github/copilot-linuxmusl-x64": "1.0.67", + "@github/copilot-win32-arm64": "1.0.67", + "@github/copilot-win32-x64": "1.0.67" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", - "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.67.tgz", + "integrity": "sha512-CO3mpgFXcN6e7ZsSmjMkt1AKxMfb1+mjdn3yrf2DRnnWIURSK9kGvw+E+E1+YE37D1MBiUn/VOBmhRad5+vl0A==", "cpu": [ "arm64" ], @@ -1124,9 +1145,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", - "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.67.tgz", + "integrity": "sha512-M20Hpn3bOJRkVwAIVRK4ZlX66AqtmGfXZRxZBRFQC045QIwcfmVUP45sTSgXDb4uHWeK0cZgdTdniHwKGtMplw==", "cpu": [ "x64" ], @@ -1140,15 +1161,12 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", - "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.67.tgz", + "integrity": "sha512-b4ePtFBow+Ior+aVLKA1hHxhR5wF+ql5CD7TSg/NHGYgc1kwD+3a9uKSENy05J5Lit/G/DZ9C6JwowvvdMWSKg==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1159,15 +1177,12 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", - "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.67.tgz", + "integrity": "sha512-4ynZyfKnWAdvEPAFDDBIz1wpFttcOTJu4Y8Mlz5oXCBA0NM/rwr8K4l7Adp8UzwbfmdrMJ9y+zivqRBMDbPInA==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1178,15 +1193,12 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", - "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.67.tgz", + "integrity": "sha512-IjezxBU8fYUr/b5hEiniXqzwoOrJ4egrQSBbG96M+roLTqd9txP0MgxZtcRtKV7phRIdIGE109wwrn4H6hSqmA==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1197,15 +1209,12 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", - "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.67.tgz", + "integrity": "sha512-Zy/rbja1lnhzDoNfn051H0EybCseCvjvH7WmbcHCayjXUjzXeKF6OmAt4hvqFZH87ttT3KbKtQ8/6oDUhhM2YQ==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1216,12 +1225,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", - "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5.tgz", + "integrity": "sha512-N6Yk2DcpM9orYXWGBcQs5R0FdiVYrCn7UHQ206cUkfJengKYjgcd3f78BvVB6Dot3j0TvO04FnQ85K9/kbRRag==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.64-0", + "@github/copilot": "^1.0.67", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1239,9 +1248,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", - "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.67.tgz", + "integrity": "sha512-O3VFRS5v9NXRP8o+N1SvcFbBqECDzZP7XQBeBj2Vcrma80gdJc5GQub/w2mwmr1w5UbwgzJkRasm0Ec/jxbcoA==", "cpu": [ "arm64" ], @@ -1255,9 +1264,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", - "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.67.tgz", + "integrity": "sha512-td5tQ/nve5dB7RPvNglBZwa/6DJqiOBgacXXa1GpYcohqpCzoI8gONNkeaeyr6oF4iu5wXJ9krUNr6QXL4yB5Q==", "cpu": [ "x64" ], @@ -1579,9 +1588,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -1729,19 +1738,27 @@ } }, "node_modules/@koa/router": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-14.0.0.tgz", - "integrity": "sha512-LBSu5K0qAaaQcXX/0WIB9PGDevyCxxpnc1uq13vV/CgObaVxuis5hKl3Eboq/8gcb6ebnkAStW9NB/Em2eYyFA==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.6.0.tgz", + "integrity": "sha512-iEOXlvGIBqSNkGXrg0XtMARAOm5zA24oedXxiTGEkrD4JgwVjfRDddCQvW1s4WEcwDYvyecRbf8BikXsuEEj8w==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.1", - "http-errors": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", "koa-compose": "^4.1.0", - "path-to-regexp": "^8.2.0" + "path-to-regexp": "^8.4.2" }, "engines": { "node": ">= 20" + }, + "peerDependencies": { + "koa": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "koa": { + "optional": false + } } }, "node_modules/@malept/cross-spawn-promise": { @@ -1805,13 +1822,13 @@ "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, "node_modules/@microsoft/dev-tunnels-connections": { - "version": "1.3.48", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.48.tgz", - "integrity": "sha512-PD7bKg5mCtVMTydKH++Xm0mf5uFPncPhLTmN/LYvoOmA06GLGkR1KLiPkilfibtu3Vyq3vqxfwp2kXoWZ6fEOQ==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.50.tgz", + "integrity": "sha512-L3vUE7jiW4tzx1D+sEsuCW5UKK3CjYFIxOtjZF/MN8ZCM2a2MVtIijctjU+/Y6Gi+ohvrOZKoqSZFRD12bpAgA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.48", - "@microsoft/dev-tunnels-management": "1.3.48", + "@microsoft/dev-tunnels-contracts": "1.3.50", + "@microsoft/dev-tunnels-management": "1.3.50", "await-semaphore": "^0.1.3", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -1835,9 +1852,9 @@ } }, "node_modules/@microsoft/dev-tunnels-contracts": { - "version": "1.3.48", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.48.tgz", - "integrity": "sha512-PYj+MuKy03BDtjnM1I3qMAU2MO9tnBPAcj/uP75ZJHJefLcLBeK8TssKmmYjQywD5llp9Y6TDt0wGwsC2XvM0Q==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.50.tgz", + "integrity": "sha512-R4G/h939dL3UOui/69cKmRNZVf+3IpO6bMWxOgt4NYjF2IvWaSWBoc+u2AdZuZi+0ZHatWXvjoCDLNrV4pUyLQ==", "license": "MIT", "dependencies": { "buffer": "^5.2.1", @@ -1855,12 +1872,12 @@ } }, "node_modules/@microsoft/dev-tunnels-management": { - "version": "1.3.48", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.48.tgz", - "integrity": "sha512-6XrYQViWiv6xxueZYDcScj/zYw7dvpyO4mk8q/uSkBWBrSExNZs/rTLTR7hzxUDJFaiq/VybeZNJe/5LKlveMg==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.50.tgz", + "integrity": "sha512-sWK0CrBcmiNyeb3HztocR+Gd5ROfKjyixhxfSJ+TlGIj9Y4i3DggBDFZeaKGql5LbJN5+v13tOggF2vhn8OEfA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.48", + "@microsoft/dev-tunnels-contracts": "1.3.50", "axios": "^1.8.4", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -1912,9 +1929,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.6.0.tgz", - "integrity": "sha512-O+cKLjO4mE/D4dDp2GmVJ8hAj43vQHLf1YTMUWUtU4+41ddThhb1SYkn6W9b3FLl63bJW/4dqReJG6PIBk8jqQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.6.1.tgz", + "integrity": "sha512-jpbJU/xfF4qLWcNMplDTUX/q13m2A6vYao1QN3lkZaQlzsRce95H+iU0Qu0wlweJZ2gx6eY1PRQU+/bnQki/dw==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", @@ -2204,9 +2221,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0.tgz", - "integrity": "sha512-ID75QEYmAT1WsUQmpxPlNsL5W1a+2eeD7fP6ywdwGseiXUG8D5i16L+dzbr8MT+2oTkaVqzOdvAqVOCeV/H/Bw==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0.tgz", + "integrity": "sha512-c7WftbRyE4zOLJV5p73mcGn4jVK3FmK84Q65hrZrXboZzLPDWRfbFedCbsIjU4PJS/kvgyMPhv7dH4/nhXzUyg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2216,19 +2233,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.135.0-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.135.0-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.135.0-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.135.0-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.135.0-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.135.0-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.142.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.142.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.135.0-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-arm64.tgz", - "integrity": "sha512-wpNzssusKfrldVlq39+HyQh1wCyc9SQNpHdAFGKtPenrgRte4Ct8/oVsDtKWuFZsqLBFwbL4MrzrevnB63+9HA==", + "version": "0.142.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-darwin-arm64.tgz", + "integrity": "sha512-jwbriCRTSNfqoGov5bNnnYAKarnNv4QfGkut8psKAajebL6VPI2ZjCxCJ1OFA5HhUQsN8GQgEjKlj6WhYcMmUA==", "cpu": [ "arm64" ], @@ -2244,9 +2261,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.135.0-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-x64.tgz", - "integrity": "sha512-ZrjAqce23lbv9KfkYOhElf1lTI+SysXmyGM0FV5u4+PBCKPkkEs4eaS3H8Uig0i4bUSu1QylrOOCskzYhZ6VyQ==", + "version": "0.142.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-darwin-x64.tgz", + "integrity": "sha512-qwdPfW8sOBEfWw1Rt7baveUsOjrudrM7qfKNnJvHPRrwtt4jemTd22VtZ6r02kUQdLwt6Ddvs0Pwzk6QJW6PqA==", "cpu": [ "x64" ], @@ -2262,9 +2279,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.135.0-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-arm64.tgz", - "integrity": "sha512-dM+cv5ZL+BgIQzEIvMg9AxZ98n5lkKLgtp5zJLXWSrbCllbnUSqxYMUiWI5c1a1uBDUtkbY9fcGKXFLf+d+gyg==", + "version": "0.142.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-linux-arm64.tgz", + "integrity": "sha512-gX9hCK59bE0Itnous1MCrKiXTYuoGVE6oJUE0kyRSzaBD267sh9TNR3DXKbY7TsP7iAs19n8YXDJNdHZJtakSQ==", "cpu": [ "arm64" ], @@ -2280,9 +2297,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.135.0-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-x64.tgz", - "integrity": "sha512-5EosY67yU28UJSnl/obdN2F1CDaimYbzm9SLR8dwwzkeBBnY6dHgAKJ2GTu9Nc8CmgmtVFBGzgPqehsIcueVvA==", + "version": "0.142.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-linux-x64.tgz", + "integrity": "sha512-nywS+ogFPRhZnQnKL+jzWKzFhmjTQbYPh6n8dtQ/E+sJ3FoZVcb9a6kvHL5yjgtlCiDkP0jPDYY5AK3tDShYyw==", "cpu": [ "x64" ], @@ -2298,9 +2315,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.135.0-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-arm64.tgz", - "integrity": "sha512-SAeR+CUv7KWwE6eTc2UFaFjo6FpHywYfDFKrK6FqLms1rq1NPju2SoX7rhM6UEew/lUx2mdZv/LDs11s/N/Qgg==", + "version": "0.142.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-win32-arm64.tgz", + "integrity": "sha512-Lz8xEn7uNn0XTi8k0jApKM5P9BD7QvAH3ZbtlGi1zfSOgh1kmgpfXxaTlJF503mozdHaA2r6UA7hfTi+bI0CvQ==", "cpu": [ "arm64" ], @@ -2316,9 +2333,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.135.0-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-x64.tgz", - "integrity": "sha512-uYwUBMbOfmVlCESJZmZsOG+cYwNFYvkMbQ+FB6C1u9RYz0m3mZeYNN0j+l1hRSyUgPMFJHzNpgNx1Usal5QZFQ==", + "version": "0.142.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.0-win32-x64.tgz", + "integrity": "sha512-LkARkXh3NM0XA8R8hFAgjx017fadfb9RgmqLzdhO1Qt/bDVSJnmDf4cwJ2h+FWZcm9/rRk3JC/IuqBvwg/TN+Q==", "cpu": [ "x64" ], @@ -2341,45 +2358,6 @@ "node": ">=8.0.0" } }, - "node_modules/@os-theme/darwin-arm64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz", - "integrity": "sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@os-theme/linux-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz", - "integrity": "sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@os-theme/win32-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz", - "integrity": "sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -2717,23 +2695,23 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.56.1.tgz", - "integrity": "sha512-n4xzZpOn4qOtZJylpIn8co2QDoWczfJ068sEeky3EE5Vvy+lHX2J3WAcC4MbXzcpfoBee1lJm8JtXuLZ9HBCBA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.61.1.tgz", + "integrity": "sha512-t3/zE0i9gik5R/NpRs7G2Xo/6NPeABW6ReplGdtkeWeAkaV764CgFgoKjJo21D2xgjnvDvRYubqBUu4xl0VCqA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.61.1" }, "engines": { "node": ">=18" } }, "node_modules/@playwright/browser-chromium/node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2792,13 +2770,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.1" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -2827,6 +2805,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-8.1.0.tgz", + "integrity": "sha512-2SX/1jW6CIMAiebvVv5ZInoCEuWQmMyBoJXXGC6Vjakjp/fpxP5eHs7/V6WKuPEIbuK06+VpjH+vjLQhr98rDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -3024,9 +3015,9 @@ } }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "dev": true, "license": "MIT" }, @@ -3664,9 +3655,9 @@ } }, "node_modules/@vscode/codicons": { - "version": "0.0.46-20", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-20.tgz", - "integrity": "sha512-o5OF7Agy3TBp2R3AXRWeL74MjEWXOLi8fp/HUvFZ/coXq5V9wEOC1okYadc1hZGNLcSmI9NXqYu057sldqbGiA==", + "version": "0.0.46-21", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-21.tgz", + "integrity": "sha512-HmSK8We0/P3epjr6Bpty8/6gs3N9TrLCU1KdL6CUmyOPvkdhZs+8BfBIX4DdxzrsOjz6zTrFp7LI3OMWcZtRqA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { @@ -3681,9 +3672,9 @@ } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.2.1-59", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-59.tgz", - "integrity": "sha512-7ewdfQ2Fre1GR7Ck9hvWVmuFx4Z2JDAtoRLqI4LFJgtxVXIjgqx+2ylkIX4h19/rgyN9AVFYketroOdtTlzsGQ==", + "version": "0.2.1-63", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-63.tgz", + "integrity": "sha512-DzPPaUXU/xB4zN2Urx9CP07TG/hItWVEaeWKp9j52BMTev6fWkVdh3YBVQGAxIEvoAVAuQSUlPUGdf8e9IkePw==", "dev": true, "license": "MIT", "dependencies": { @@ -3750,15 +3741,16 @@ "license": "MIT" }, "node_modules/@vscode/gulp-electron": { - "version": "1.41.3", - "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.41.3.tgz", - "integrity": "sha512-M+f3LqnZKyIf3k5fxAeKHtz5/0V9PALJqneVh7vDZ32wdbokhFmfVqzP8Z+alBjZViuL80cLe65znjELnsxUBw==", + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.42.0.tgz", + "integrity": "sha512-DQLhu7p3GnGAc1tvYtArqp3duHD+b7ddpWitqYckdioFJGAszUSf7o6KZ5szo6sjFBz+E8rvpu9EMYOjdAAMzg==", "dev": true, "license": "MIT", "dependencies": { - "@electron/get": "^4.0.1", + "@electron/get": "^5.0.0", "@octokit/rest": "^22.0.0", "event-stream": "3.3.4", + "got": "^15.0.5", "gulp-filter": "^5.1.0", "gulp-rename": "1.2.2", "gulp-symdest": "^1.2.0", @@ -3778,203 +3770,6 @@ "node": ">=22" } }, - "node_modules/@vscode/gulp-electron/node_modules/@electron/get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-4.0.1.tgz", - "integrity": "sha512-fTMFb/ZiK6xQace5YZlhT+vNR08ogat9SqpvwpaC9vD6hgx7ouz9cdcrSrFuNji4823Jmmy90/CDhJq0I4vRFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^3.0.0", - "got": "^14.4.5", - "graceful-fs": "^4.2.11", - "progress": "^2.0.3", - "semver": "^7.6.3", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/@electron/get/node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/cacheable-request": { - "version": "13.0.18", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.18.tgz", - "integrity": "sha512-rFWadDRKJs3s2eYdXlGggnBZKG7MTblkFBB0YllFds+UYnfogDp2wcR6JN97FhRkHTvq59n2vhNoHNZn29dh/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.4", - "get-stream": "^9.0.1", - "http-cache-semantics": "^4.2.0", - "keyv": "^5.5.5", - "mimic-response": "^4.0.0", - "normalize-url": "^8.1.1", - "responselike": "^4.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/decompress-response": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", - "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^4.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/got": { - "version": "14.6.6", - "resolved": "https://registry.npmjs.org/got/-/got-14.6.6.tgz", - "integrity": "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^7.0.1", - "byte-counter": "^0.1.0", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^13.0.12", - "decompress-response": "^10.0.0", - "form-data-encoder": "^4.0.2", - "http2-wrapper": "^2.2.1", - "keyv": "^5.5.3", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^4.0.1", - "responselike": "^4.0.2", - "type-fest": "^4.26.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/http2-wrapper": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", - "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@vscode/gulp-electron/node_modules/mkdirp": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", @@ -3987,29 +3782,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/@vscode/gulp-electron/node_modules/normalize-url": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", - "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vscode/gulp-electron/node_modules/p-cancelable": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", - "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, "node_modules/@vscode/gulp-electron/node_modules/rcedit": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-4.0.1.tgz", @@ -4023,22 +3795,6 @@ "node": ">= 14.0.0" } }, - "node_modules/@vscode/gulp-electron/node_modules/responselike": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", - "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@vscode/iconv-lite-umd": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.1.tgz", @@ -4356,26 +4112,26 @@ } }, "node_modules/@vscode/test-web": { - "version": "0.0.76", - "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.76.tgz", - "integrity": "sha512-hB+GKNmxnaTKemNOOBUcqYsIa5a0uuccCRnNIdCMS+I3RhVlyCtLBl29ZN/RAB2+M+ujjI8L8qL6GLCPqNFIBg==", + "version": "0.0.81", + "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.81.tgz", + "integrity": "sha512-qAYNX1mf4hE0L3T/186J8AH+Z7Inm81OHMACkkyKE2J6HJZlXou0OgABkSvd8gt0BiPjI+V+xkduAaQ8Kcjexg==", "dev": true, "license": "MIT", "dependencies": { "@koa/cors": "^5.0.0", - "@koa/router": "^14.0.0", - "@playwright/browser-chromium": "^1.56.1", + "@koa/router": "^15.6.0", + "@playwright/browser-chromium": "^1.61.0", "gunzip-maybe": "^1.4.2", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "koa": "^3.1.1", + "http-proxy-agent": "^9.1.0", + "https-proxy-agent": "^9.1.0", + "koa": "^3.2.1", "koa-morgan": "^1.0.1", "koa-mount": "^4.2.0", "koa-static": "^5.0.0", "minimist": "^1.2.8", - "playwright": "^1.56.1", - "tar-fs": "^3.1.1", - "tinyglobby": "^0.2.15", + "playwright": "^1.61.0", + "tar-fs": "^3.1.2", + "tinyglobby": "^0.2.17", "vscode-uri": "^3.1.0" }, "bin": { @@ -4385,6 +4141,46 @@ "node": ">=20" } }, + "node_modules/@vscode/test-web/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@vscode/tree-sitter-wasm": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.3.1.tgz", @@ -4514,27 +4310,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.285.tgz", - "integrity": "sha512-3Sw2VvUqTc8r7OWzizLlbVcbJXUwduWqS7jQzWyIVZiRer+olG1++oyE5tD6VLbt5mFwTEm1jdINYE0HRjF26w==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.288.tgz", + "integrity": "sha512-evM4OakyY26JveIrj4+XlA7sUDHr0GvmMoTIgrhzKrr9jjiTJcWXJE2xK3vvbbIa9ZGHmbo1srVaehVCPytOZg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.285.tgz", - "integrity": "sha512-ffpIrUlFj88FVBLdZCThdbwDOAeuKadHNpaJdXbDo5O0ObYyfnXYTL1JmVQxqusJToROnogTPL/MMoqP2oA49g==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.288.tgz", + "integrity": "sha512-zlgz0IIG3Te/aP4Kg1xRgWvCzgMgAk3LOP+RvGJAfP2g8tMFZBqcVVdX9BWTsp6hZQ4XaBPiiW+bORms/0Y3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.285.tgz", - "integrity": "sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==", + "version": "0.11.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.288.tgz", + "integrity": "sha512-82bXLLGHPvyrLF6FByXOJEJEmqHBMH0sIao9kkWBLL3tBSAHty25xo0ulWNUgP6O6sxAZX8Qbfi/zVhrw0A56g==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -4544,7 +4340,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -4557,63 +4353,63 @@ } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.285.tgz", - "integrity": "sha512-5iD2ANyyIgSexa+Hkf4OmMwNxfpLrPuDAQihGoMXMMjALgESBb6JYvob4C6H+4o5uoNSMV33sb+iwlkCqww32A==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.288.tgz", + "integrity": "sha512-4jBLZIZE13d7veFoSM5GYjdYxoHnUQzYPv8KA+l0u3IzZcIcJZpt6ll0cUA75klCb8tyL7dUhP4y2P4uDCGczQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.285.tgz", - "integrity": "sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==", + "version": "0.17.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.288.tgz", + "integrity": "sha512-JQk4n83Zs0A3K2VjQGbfhGAxoQZaMZtgBMSLKOx2Zg2HuikIsjIjUsPpOoJHwAfhVXKXmgd9QmVp61DxJj8tyw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.285.tgz", - "integrity": "sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==", + "version": "0.15.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.288.tgz", + "integrity": "sha512-9wgP/SJeG5ko5ddUDy0gstcziSqlb8ESm8BGtddxz4/56zVGs0DVPVIKQUYKLKKHDCPxm1b6T9YuK3f6WRd3CA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.285.tgz", - "integrity": "sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.288.tgz", + "integrity": "sha512-nFQvOBqQEtaPdpiZ2m3A5dkSW+EQGytnyQjMond81bIv3MDRkDKTy8FhAI9fEsZSq2wTbB91tfV5FLjAkSSQ7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.284", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.284.tgz", - "integrity": "sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==", + "version": "0.20.0-beta.287", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.287.tgz", + "integrity": "sha512-ADDuXRHLfhMk3y+BFvaI1ibGCGxPMNRonzH5M/qTUnigsVxdnEMsYODG6pWAYkfDMPoCRBF8Zf/hj0efyEBjzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.285.tgz", - "integrity": "sha512-NJud+XEUjKMT2LwPqcIh/gazktV+R2AHjEPMQsn/l6+53rgFusuifmJjVkWLwZ228YYsUaN5+lJELeExS26q4A==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.288.tgz", + "integrity": "sha512-b7lKSC8eW2Zk2iwy0BQja4XbBHcai7aJBdVq8jZmPS9GqY8qo6k/rjZiOs5pgqTR9S+/MXYq0c+4szY+0sPBLw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.285.tgz", - "integrity": "sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.288.tgz", + "integrity": "sha512-XLtX5kO0bgvjOEypUtO5xYUykE+vbJuQPuRK2cIx7oBFwlSYXGCVKPC7pvkLOYsfJWqJ9yusIaeJQqJvrYT1kQ==", "license": "MIT", "workspaces": [ "addons/*" @@ -5327,10 +5123,19 @@ } }, "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", - "dev": true + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/bach": { "version": "1.2.0", @@ -5359,24 +5164,32 @@ "dev": true }, "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz", - "integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { "bare": ">=1.16.0" @@ -5391,42 +5204,45 @@ } }, "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz", + "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } }, "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -5435,6 +5251,16 @@ } } }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", @@ -5485,9 +5311,9 @@ ] }, "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5595,13 +5421,6 @@ "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24= sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, - "node_modules/boolean": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.0.2.tgz", - "integrity": "sha512-RwywHlpCRc3/Wh81MiCKun4ydaIFyW5Ea6JbL6sRCVx5q5irDw7pMXBUFYF/jArQ6YrG36q0kpovc9P/Kd3I4g==", - "dev": true, - "optional": true - }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -5647,9 +5466,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -5667,11 +5486,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -5821,6 +5640,75 @@ "node": ">=0.10.0" } }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "13.0.19", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.19.tgz", + "integrity": "sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.2.0", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.2.0", + "keyv": "^5.6.0", + "mimic-response": "^4.0.0", + "normalize-url": "^8.1.1", + "responselike": "^4.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -5893,9 +5781,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001768", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", - "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -6004,9 +5892,10 @@ "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==" }, "node_modules/chrome-remote-interface/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", "engines": { "node": ">=8.3.0" }, @@ -6029,6 +5918,19 @@ "integrity": "sha1-BKEGZywYsIWrd02YPfo+oTjyIgU= sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", "dev": true }, + "node_modules/chunk-data": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chunk-data/-/chunk-data-0.1.0.tgz", + "integrity": "sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ci-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", @@ -7075,13 +6977,6 @@ "node": ">=0.10.0" } }, - "node_modules/detect-node": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.0.4.tgz", - "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==", - "dev": true, - "optional": true - }, "node_modules/devtools-protocol": { "version": "0.0.1173815", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1173815.tgz", @@ -7305,15 +7200,15 @@ "dev": true }, "node_modules/electron": { - "version": "42.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.2.0.tgz", - "integrity": "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw==", + "version": "42.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.0.tgz", + "integrity": "sha512-cYEKS9XFz+c9fAB4jI0x49yz1FFzB55r3q96wu9YkwwJMv7t9202IE/ltlgy6yitl/J4M7C8JQcmUqdzDvPl/w==", "dev": true, "license": "MIT", "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", @@ -7324,9 +7219,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.379", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", + "integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==", "dev": true, "license": "ISC" }, @@ -7568,13 +7463,6 @@ "node": ">=0.10" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "optional": true - }, "node_modules/es6-iterator": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", @@ -8081,6 +7969,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8462,26 +8360,6 @@ "node": ">=0.10.0" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fancy-log": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", @@ -9063,16 +8941,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", - "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -9305,35 +9173,10 @@ "node_modules/get-stdin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", - "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-stream/node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", + "dev": true, + "engines": { + "node": ">=8" } }, "node_modules/get-symbol-description": { @@ -9769,24 +9612,6 @@ "node": ">=0.10.0" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, "node_modules/global-modules": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", @@ -9883,6 +9708,59 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/got/-/got-15.0.5.tgz", + "integrity": "sha512-PMIMaZuYUCK43+Z9JWEXea4kkX2b3301m81D5TS6QpfG4PmNyirzEdO/Oa2OHAN4GsjnPfvWCWsshKN2rq4/gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^8.0.0", + "byte-counter": "^0.1.0", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^13.0.18", + "chunk-data": "^0.1.0", + "decompress-response": "^10.0.0", + "http2-wrapper": "^2.2.1", + "keyv": "^5.6.0", + "lowercase-keys": "^4.0.1", + "responselike": "^4.0.2", + "type-fest": "^5.6.0", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/decompress-response": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", + "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -11459,6 +11337,20 @@ "node": ">= 14" } }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -12660,10 +12552,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12741,14 +12643,6 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -12870,7 +12764,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true, "license": "MIT", "dependencies": { @@ -12899,9 +12792,9 @@ } }, "node_modules/koa": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-3.1.2.tgz", - "integrity": "sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", "dev": true, "license": "MIT", "dependencies": { @@ -13274,6 +13167,19 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-4.0.1.tgz", + "integrity": "sha512-wI9Nui/L8VfADa/cr/7NQruaASk1k23/Uh1khQ02BCVYiiy8F4AhOGnQzJy3Fl/c44GnYSbZHv8g7EcG3kJ1Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", @@ -13567,19 +13473,6 @@ "node": ">=0.10.0" } }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13750,6 +13643,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -14340,11 +14246,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "4.0.1", @@ -14389,6 +14298,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/now-and-later": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", @@ -15028,20 +14950,6 @@ "node": ">=0.10.0" } }, - "node_modules/os-theme": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/os-theme/-/os-theme-0.0.8.tgz", - "integrity": "sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==", - "license": "MIT", - "optionalDependencies": { - "@os-theme/darwin-arm64": "0.0.8", - "@os-theme/linux-x64": "0.0.8", - "@os-theme/win32-x64": "0.0.8" - }, - "peerDependencies": { - "typescript": "^5" - } - }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -15338,9 +15246,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", "funding": { @@ -15450,13 +15358,13 @@ } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -15481,9 +15389,9 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -15698,6 +15606,24 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -15808,6 +15734,7 @@ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -16318,7 +16245,8 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/resolve-dir": { "version": "1.0.1", @@ -16420,6 +16348,35 @@ "deprecated": "https://github.com/lydell/resolve-url#deprecated", "dev": true }, + "node_modules/responselike": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", + "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/responselike/node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -16495,24 +16452,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -16690,13 +16629,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w= sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "optional": true - }, "node_modules/semver-greatest-satisfied-range": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz", @@ -16773,35 +16705,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/serialize-javascript": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", @@ -17515,13 +17418,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "optional": true - }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -17785,17 +17681,15 @@ } }, "node_modules/streamx": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", - "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", "dev": true, "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -18175,6 +18069,19 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -18206,9 +18113,9 @@ } }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18221,22 +18128,25 @@ } }, "node_modules/tar-fs/node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "dev": true, + "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } @@ -18401,14 +18311,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -18723,13 +18633,16 @@ } }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "dev": true, "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -18925,6 +18838,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -19896,9 +19822,9 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index afdde577903d6..a29561232c6b8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.127.0", - "distro": "e6b302180cc8fecde16d482d0f2add78b067b6bf", + "version": "1.128.0", + "distro": "a78eb8284ce7699e371b36071b579f557406f153", "author": { "name": "Microsoft Corporation" }, @@ -95,8 +95,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.64-0", - "@github/copilot-sdk": "^1.0.2", + "@github/copilot": "^1.0.67", + "@github/copilot-sdk": "^1.0.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -104,10 +104,10 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.6.0", + "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -125,16 +125,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -161,10 +161,10 @@ "zod": "^3.25.76" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.169", - "@openai/codex": "0.135.0", + "@anthropic-ai/claude-agent-sdk": "0.3.198", + "@openai/codex": "0.142.0", "@playwright/cli": "^0.1.9", - "@playwright/test": "^1.56.1", + "@playwright/test": "^1.61.1", "@stylistic/eslint-plugin-ts": "^2.8.0", "@types/chrome-remote-interface": "^0.33.0", "@types/cookie": "^0.3.3", @@ -192,12 +192,12 @@ "@typescript/native-preview": "^7.0.0-dev.20260609", "@vscode/component-explorer": "^0.2.1-58", "@vscode/component-explorer-cli": "^0.2.1-59", - "@vscode/gulp-electron": "1.41.3", + "@vscode/gulp-electron": "^1.42.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", "@vscode/test-electron": "^2.4.0", - "@vscode/test-web": "^0.0.76", + "@vscode/test-web": "^0.0.81", "@vscode/v8-heap-parser": "^0.1.0", "@vscode/vscode-perf": "^0.0.19", "@webgpu/types": "^0.1.66", @@ -207,7 +207,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "42.2.0", + "electron": "42.5.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/product.json b/product.json index 06ab8fcda8e8b..15f5796e3ac4d 100644 --- a/product.json +++ b/product.json @@ -31,7 +31,8 @@ "linuxIconName": "code-oss", "licenseFileName": "LICENSE.txt", "reportIssueUrl": "https://github.com/microsoft/vscode/issues/new", - "nodejsRepository": "https://nodejs.org", + "nodejsArtifactFeed": "", + "electronArtifactFeed": "", "urlProtocol": "code-oss", "agentsTelemetryAppName": "agents", "webviewContentExternalBaseUrlTemplate": "https://{{uuid}}.vscode-cdn.net/insider/ef65ac1ba57f57f2a3961bfe94aa20481caca4c6/out/vs/workbench/contrib/webview/browser/pre/", @@ -94,6 +95,7 @@ "termsStatementUrl": "https://aka.ms/github-copilot-terms-statement", "privacyStatementUrl": "https://aka.ms/github-copilot-privacy-statement", "skusDocumentationUrl": "https://aka.ms/github-copilot-plans", + "optimizeUsageDocumentationUrl": "https://aka.ms/token-usage-tips", "publicCodeMatchesUrl": "https://aka.ms/github-copilot-match-public-code", "managePlanUrl": "https://aka.ms/github-copilot-manage-plan", "upgradePlanUrl": "https://aka.ms/github-copilot-upgrade-plan", diff --git a/remote/.npmrc b/remote/.npmrc index 6f2d4e8df7b2c..813ce0f480e19 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -1,6 +1,6 @@ disturl="https://nodejs.org/dist" -target="24.15.0" -ms_build_id="438265" +target="24.17.0" +ms_build_id="451432" runtime="node" build_from_source="true" legacy-peer-deps="true" diff --git a/remote/package-lock.json b/remote/package-lock.json index a829c6d594600..ccac9bb791132 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,11 +8,11 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "^1.0.64-0", - "@github/copilot-sdk": "^1.0.2", + "@github/copilot": "^1.0.67", + "@github/copilot-sdk": "^1.0.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.6.0", + "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", @@ -27,16 +27,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -60,32 +60,31 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", - "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.67.tgz", + "integrity": "sha512-5YEY9LNXBT9Q8uShjCdYcornJJJhGtdIzSYla2+pjfXYpHsDVibqYubzYjfgffOUKFChyzOpH7n/868+t56iIg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "detect-libc": "^2.1.2", - "os-theme": "^0.0.8" + "detect-libc": "^2.1.2" }, "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.64-0", - "@github/copilot-darwin-x64": "1.0.64-0", - "@github/copilot-linux-arm64": "1.0.64-0", - "@github/copilot-linux-x64": "1.0.64-0", - "@github/copilot-linuxmusl-arm64": "1.0.64-0", - "@github/copilot-linuxmusl-x64": "1.0.64-0", - "@github/copilot-win32-arm64": "1.0.64-0", - "@github/copilot-win32-x64": "1.0.64-0" + "@github/copilot-darwin-arm64": "1.0.67", + "@github/copilot-darwin-x64": "1.0.67", + "@github/copilot-linux-arm64": "1.0.67", + "@github/copilot-linux-x64": "1.0.67", + "@github/copilot-linuxmusl-arm64": "1.0.67", + "@github/copilot-linuxmusl-x64": "1.0.67", + "@github/copilot-win32-arm64": "1.0.67", + "@github/copilot-win32-x64": "1.0.67" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", - "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.67.tgz", + "integrity": "sha512-CO3mpgFXcN6e7ZsSmjMkt1AKxMfb1+mjdn3yrf2DRnnWIURSK9kGvw+E+E1+YE37D1MBiUn/VOBmhRad5+vl0A==", "cpu": [ "arm64" ], @@ -99,9 +98,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", - "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.67.tgz", + "integrity": "sha512-M20Hpn3bOJRkVwAIVRK4ZlX66AqtmGfXZRxZBRFQC045QIwcfmVUP45sTSgXDb4uHWeK0cZgdTdniHwKGtMplw==", "cpu": [ "x64" ], @@ -115,9 +114,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", - "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.67.tgz", + "integrity": "sha512-b4ePtFBow+Ior+aVLKA1hHxhR5wF+ql5CD7TSg/NHGYgc1kwD+3a9uKSENy05J5Lit/G/DZ9C6JwowvvdMWSKg==", "cpu": [ "arm64" ], @@ -134,9 +133,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", - "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.67.tgz", + "integrity": "sha512-4ynZyfKnWAdvEPAFDDBIz1wpFttcOTJu4Y8Mlz5oXCBA0NM/rwr8K4l7Adp8UzwbfmdrMJ9y+zivqRBMDbPInA==", "cpu": [ "x64" ], @@ -153,9 +152,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", - "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.67.tgz", + "integrity": "sha512-IjezxBU8fYUr/b5hEiniXqzwoOrJ4egrQSBbG96M+roLTqd9txP0MgxZtcRtKV7phRIdIGE109wwrn4H6hSqmA==", "cpu": [ "arm64" ], @@ -172,9 +171,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", - "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.67.tgz", + "integrity": "sha512-Zy/rbja1lnhzDoNfn051H0EybCseCvjvH7WmbcHCayjXUjzXeKF6OmAt4hvqFZH87ttT3KbKtQ8/6oDUhhM2YQ==", "cpu": [ "x64" ], @@ -191,12 +190,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", - "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5.tgz", + "integrity": "sha512-N6Yk2DcpM9orYXWGBcQs5R0FdiVYrCn7UHQ206cUkfJengKYjgcd3f78BvVB6Dot3j0TvO04FnQ85K9/kbRRag==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.64-0", + "@github/copilot": "^1.0.67", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -214,9 +213,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", - "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.67.tgz", + "integrity": "sha512-O3VFRS5v9NXRP8o+N1SvcFbBqECDzZP7XQBeBj2Vcrma80gdJc5GQub/w2mwmr1w5UbwgzJkRasm0Ec/jxbcoA==", "cpu": [ "arm64" ], @@ -230,9 +229,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.64-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", - "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.67.tgz", + "integrity": "sha512-td5tQ/nve5dB7RPvNglBZwa/6DJqiOBgacXXa1GpYcohqpCzoI8gONNkeaeyr6oF4iu5wXJ9krUNr6QXL4yB5Q==", "cpu": [ "x64" ], @@ -300,9 +299,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.6.0.tgz", - "integrity": "sha512-O+cKLjO4mE/D4dDp2GmVJ8hAj43vQHLf1YTMUWUtU4+41ddThhb1SYkn6W9b3FLl63bJW/4dqReJG6PIBk8jqQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.6.1.tgz", + "integrity": "sha512-jpbJU/xfF4qLWcNMplDTUX/q13m2A6vYao1QN3lkZaQlzsRce95H+iU0Qu0wlweJZ2gx6eY1PRQU+/bnQki/dw==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", @@ -312,45 +311,6 @@ "node": ">=18.0.0" } }, - "node_modules/@os-theme/darwin-arm64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz", - "integrity": "sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@os-theme/linux-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz", - "integrity": "sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@os-theme/win32-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz", - "integrity": "sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -858,27 +818,27 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.285.tgz", - "integrity": "sha512-3Sw2VvUqTc8r7OWzizLlbVcbJXUwduWqS7jQzWyIVZiRer+olG1++oyE5tD6VLbt5mFwTEm1jdINYE0HRjF26w==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.288.tgz", + "integrity": "sha512-evM4OakyY26JveIrj4+XlA7sUDHr0GvmMoTIgrhzKrr9jjiTJcWXJE2xK3vvbbIa9ZGHmbo1srVaehVCPytOZg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.285.tgz", - "integrity": "sha512-ffpIrUlFj88FVBLdZCThdbwDOAeuKadHNpaJdXbDo5O0ObYyfnXYTL1JmVQxqusJToROnogTPL/MMoqP2oA49g==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.288.tgz", + "integrity": "sha512-zlgz0IIG3Te/aP4Kg1xRgWvCzgMgAk3LOP+RvGJAfP2g8tMFZBqcVVdX9BWTsp6hZQ4XaBPiiW+bORms/0Y3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.285.tgz", - "integrity": "sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==", + "version": "0.11.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.288.tgz", + "integrity": "sha512-82bXLLGHPvyrLF6FByXOJEJEmqHBMH0sIao9kkWBLL3tBSAHty25xo0ulWNUgP6O6sxAZX8Qbfi/zVhrw0A56g==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -888,67 +848,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.285.tgz", - "integrity": "sha512-5iD2ANyyIgSexa+Hkf4OmMwNxfpLrPuDAQihGoMXMMjALgESBb6JYvob4C6H+4o5uoNSMV33sb+iwlkCqww32A==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.288.tgz", + "integrity": "sha512-4jBLZIZE13d7veFoSM5GYjdYxoHnUQzYPv8KA+l0u3IzZcIcJZpt6ll0cUA75klCb8tyL7dUhP4y2P4uDCGczQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.285.tgz", - "integrity": "sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==", + "version": "0.17.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.288.tgz", + "integrity": "sha512-JQk4n83Zs0A3K2VjQGbfhGAxoQZaMZtgBMSLKOx2Zg2HuikIsjIjUsPpOoJHwAfhVXKXmgd9QmVp61DxJj8tyw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.285.tgz", - "integrity": "sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==", + "version": "0.15.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.288.tgz", + "integrity": "sha512-9wgP/SJeG5ko5ddUDy0gstcziSqlb8ESm8BGtddxz4/56zVGs0DVPVIKQUYKLKKHDCPxm1b6T9YuK3f6WRd3CA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.285.tgz", - "integrity": "sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.288.tgz", + "integrity": "sha512-nFQvOBqQEtaPdpiZ2m3A5dkSW+EQGytnyQjMond81bIv3MDRkDKTy8FhAI9fEsZSq2wTbB91tfV5FLjAkSSQ7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.284", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.284.tgz", - "integrity": "sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==", + "version": "0.20.0-beta.287", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.287.tgz", + "integrity": "sha512-ADDuXRHLfhMk3y+BFvaI1ibGCGxPMNRonzH5M/qTUnigsVxdnEMsYODG6pWAYkfDMPoCRBF8Zf/hj0efyEBjzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.285.tgz", - "integrity": "sha512-NJud+XEUjKMT2LwPqcIh/gazktV+R2AHjEPMQsn/l6+53rgFusuifmJjVkWLwZ228YYsUaN5+lJELeExS26q4A==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.288.tgz", + "integrity": "sha512-b7lKSC8eW2Zk2iwy0BQja4XbBHcai7aJBdVq8jZmPS9GqY8qo6k/rjZiOs5pgqTR9S+/MXYq0c+4szY+0sPBLw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.285.tgz", - "integrity": "sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.288.tgz", + "integrity": "sha512-XLtX5kO0bgvjOEypUtO5xYUykE+vbJuQPuRK2cIx7oBFwlSYXGCVKPC7pvkLOYsfJWqJ9yusIaeJQqJvrYT1kQ==", "license": "MIT", "workspaces": [ "addons/*" @@ -1443,20 +1403,6 @@ "ot": "bin/ot" } }, - "node_modules/os-theme": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/os-theme/-/os-theme-0.0.8.tgz", - "integrity": "sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==", - "license": "MIT", - "optionalDependencies": { - "@os-theme/darwin-arm64": "0.0.8", - "@os-theme/linux-x64": "0.0.8", - "@os-theme/win32-x64": "0.0.8" - }, - "peerDependencies": { - "typescript": "^5" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -1700,9 +1646,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.17", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.17.tgz", + "integrity": "sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -1787,9 +1733,9 @@ "license": "Unlicense" }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -1858,9 +1804,9 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/remote/package.json b/remote/package.json index 879eb7d6593b8..62a1732da6a46 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,11 +3,11 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "^1.0.64-0", - "@github/copilot-sdk": "^1.0.2", + "@github/copilot": "^1.0.67", + "@github/copilot-sdk": "^1.0.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.6.0", + "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 60d7bdb69e98a..15042f53fab05 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,19 +10,19 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/xterm": "^6.1.0-beta.288", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-20", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-20.tgz", - "integrity": "sha512-o5OF7Agy3TBp2R3AXRWeL74MjEWXOLi8fp/HUvFZ/coXq5V9wEOC1okYadc1hZGNLcSmI9NXqYu057sldqbGiA==", + "version": "0.0.46-21", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-21.tgz", + "integrity": "sha512-HmSK8We0/P3epjr6Bpty8/6gs3N9TrLCU1KdL6CUmyOPvkdhZs+8BfBIX4DdxzrsOjz6zTrFp7LI3OMWcZtRqA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { @@ -100,27 +100,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.285.tgz", - "integrity": "sha512-3Sw2VvUqTc8r7OWzizLlbVcbJXUwduWqS7jQzWyIVZiRer+olG1++oyE5tD6VLbt5mFwTEm1jdINYE0HRjF26w==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.288.tgz", + "integrity": "sha512-evM4OakyY26JveIrj4+XlA7sUDHr0GvmMoTIgrhzKrr9jjiTJcWXJE2xK3vvbbIa9ZGHmbo1srVaehVCPytOZg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.285.tgz", - "integrity": "sha512-ffpIrUlFj88FVBLdZCThdbwDOAeuKadHNpaJdXbDo5O0ObYyfnXYTL1JmVQxqusJToROnogTPL/MMoqP2oA49g==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.288.tgz", + "integrity": "sha512-zlgz0IIG3Te/aP4Kg1xRgWvCzgMgAk3LOP+RvGJAfP2g8tMFZBqcVVdX9BWTsp6hZQ4XaBPiiW+bORms/0Y3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.285.tgz", - "integrity": "sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==", + "version": "0.11.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.288.tgz", + "integrity": "sha512-82bXLLGHPvyrLF6FByXOJEJEmqHBMH0sIao9kkWBLL3tBSAHty25xo0ulWNUgP6O6sxAZX8Qbfi/zVhrw0A56g==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -130,58 +130,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.285.tgz", - "integrity": "sha512-5iD2ANyyIgSexa+Hkf4OmMwNxfpLrPuDAQihGoMXMMjALgESBb6JYvob4C6H+4o5uoNSMV33sb+iwlkCqww32A==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.288.tgz", + "integrity": "sha512-4jBLZIZE13d7veFoSM5GYjdYxoHnUQzYPv8KA+l0u3IzZcIcJZpt6ll0cUA75klCb8tyL7dUhP4y2P4uDCGczQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.285.tgz", - "integrity": "sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==", + "version": "0.17.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.288.tgz", + "integrity": "sha512-JQk4n83Zs0A3K2VjQGbfhGAxoQZaMZtgBMSLKOx2Zg2HuikIsjIjUsPpOoJHwAfhVXKXmgd9QmVp61DxJj8tyw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.285.tgz", - "integrity": "sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==", + "version": "0.15.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.288.tgz", + "integrity": "sha512-9wgP/SJeG5ko5ddUDy0gstcziSqlb8ESm8BGtddxz4/56zVGs0DVPVIKQUYKLKKHDCPxm1b6T9YuK3f6WRd3CA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.285.tgz", - "integrity": "sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.288.tgz", + "integrity": "sha512-nFQvOBqQEtaPdpiZ2m3A5dkSW+EQGytnyQjMond81bIv3MDRkDKTy8FhAI9fEsZSq2wTbB91tfV5FLjAkSSQ7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.284", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.284.tgz", - "integrity": "sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==", + "version": "0.20.0-beta.287", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.287.tgz", + "integrity": "sha512-ADDuXRHLfhMk3y+BFvaI1ibGCGxPMNRonzH5M/qTUnigsVxdnEMsYODG6pWAYkfDMPoCRBF8Zf/hj0efyEBjzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.285.tgz", - "integrity": "sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.288.tgz", + "integrity": "sha512-XLtX5kO0bgvjOEypUtO5xYUykE+vbJuQPuRK2cIx7oBFwlSYXGCVKPC7pvkLOYsfJWqJ9yusIaeJQqJvrYT1kQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 68eb1a505f92d..6f827637cffd9 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,19 +5,19 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-20", + "@vscode/codicons": "^0.0.46-21", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/xterm": "^6.1.0-beta.288", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/scripts/chat-simulation/common/mock-llm-server.ts b/scripts/chat-simulation/common/mock-llm-server.ts index 7aeed22d70f67..7d8684aa8bf17 100644 --- a/scripts/chat-simulation/common/mock-llm-server.ts +++ b/scripts/chat-simulation/common/mock-llm-server.ts @@ -195,13 +195,121 @@ function getDefaultScenarioChunks(): StreamChunk[] { const MODEL = 'gpt-4o-2024-08-06'; +// -- Model shape ------------------------------------------------------------- +// Shared types describing the CAPI `/models` response shape the mock returns. +// Centralized here so all model fixtures stay in sync and can be tweaked in one +// place when the backend billing/capabilities contract changes. Mirrors the +// `CCAModel*` interfaces in `src/typings/copilot-api.d.ts`. + +/** + * Per-tier token pricing (prices are in 1/1,000,000ths of a USD per token, i.e. + * scaled by `token_prices.batch_size`). A model may expose a `default` tier and + * an optional `long_context` tier with higher prices for large prompts. + */ +interface ModelTokenPriceTier { + input_price?: number; + /** Cache read price (per cached input token). */ + cache_price?: number; + /** Cache write price (per token written to the prompt cache). */ + cache_write_price?: number; + output_price?: number; + context_max?: number; +} + +/** + * The set of pricing tiers advertised for a model. + */ +interface ModelTokenPrices { + batch_size?: number; + default?: ModelTokenPriceTier; + long_context?: ModelTokenPriceTier; +} + +/** + * Billing metadata: entitlement gating plus the token price tiers consumed by + * the model picker's cost table. + */ +interface ModelBilling { + restricted_to?: string[]; + is_premium?: boolean; + multiplier?: number; + token_prices?: ModelTokenPrices; +} + +/** + * Vision-related prompt limits. + */ +interface ModelVisionLimits { + max_prompt_image_size: number; + max_prompt_images: number; + supported_media_types: string[]; +} + +/** + * Token/context window limits for a model. + */ +interface ModelLimits { + max_prompt_tokens?: number; + max_output_tokens?: number; + max_context_window_tokens?: number; + max_non_streaming_output_tokens?: number; + vision?: ModelVisionLimits; +} + +/** + * Feature flags advertised by a model. + */ +interface ModelSupports { + streaming?: boolean; + tool_calls?: boolean; + parallel_tool_calls?: boolean; + vision?: boolean; + structured_outputs?: boolean; + reasoning_effort?: string[]; + max_thinking_budget?: number; + min_thinking_budget?: number; +} + +/** + * Model capabilities (family, tokenizer, limits, supported features). + */ +interface ModelCapabilities { + type: string; + family: string; + tokenizer: string; + object: string; + limits: ModelLimits; + supports: ModelSupports; +} + +/** + * A single entry in the mock's `/models` list. Matches the CAPI `/models` + * response shape closely enough for the extension and CLI SDK to consume. + */ +interface MockModel { + id: string; + name: string; + object: string; + version: string; + vendor: string; + model_picker_enabled: boolean; + model_picker_category?: string; + model_picker_price_category?: string; + is_chat_default: boolean; + is_chat_fallback: boolean; + preview: boolean; + billing: ModelBilling; + capabilities: ModelCapabilities; + supported_endpoints: string[]; +} + /** * Additional model definitions the mock advertises beyond `MODEL` and * `gpt-4o-mini`. `gpt-5.3-codex` is the Copilot CLI SDK's hard-coded default * model; smoke tests/automation that exercise the CLI need it in the mock's * /models list, otherwise the SDK fails with "No model available". */ -const EXTRA_MODELS = [ +const EXTRA_MODELS: MockModel[] = [ // gpt-5.3-codex — the Copilot CLI SDK's default model. // Shape matches real CAPI /models response exactly. { @@ -216,7 +324,7 @@ const EXTRA_MODELS = [ is_chat_default: true, is_chat_fallback: false, preview: false, - billing: { restricted_to: ['pro', 'edu', 'pro_plus', 'individual_trial', 'business', 'enterprise', 'max'], token_prices: { batch_size: 1000000, default: { cache_price: 17, context_max: 272000, input_price: 175, output_price: 1400 } } }, + billing: { restricted_to: ['pro', 'edu', 'pro_plus', 'individual_trial', 'business', 'enterprise', 'max'], token_prices: { batch_size: 1000000, default: { cache_price: 17, cache_write_price: 219, context_max: 272000, input_price: 175, output_price: 1400 } } }, capabilities: { type: 'chat', family: 'gpt-5.3-codex', @@ -240,7 +348,7 @@ const EXTRA_MODELS = [ is_chat_default: false, is_chat_fallback: false, preview: false, - billing: { restricted_to: ['pro', 'pro_plus', 'max', 'business', 'enterprise'], token_prices: { batch_size: 1000000, default: { cache_price: 30, input_price: 300, output_price: 1500 } } }, + billing: { restricted_to: ['pro', 'pro_plus', 'max', 'business', 'enterprise'], token_prices: { batch_size: 1000000, default: { cache_price: 30, cache_write_price: 375, input_price: 300, output_price: 1500 } } }, capabilities: { type: 'chat', family: 'claude-sonnet-4.5', @@ -251,13 +359,58 @@ const EXTRA_MODELS = [ }, supported_endpoints: ['/chat/completions', '/v1/messages'], }, + // mock-config-model — a Responses-API model that advertises BOTH a reasoning + // effort picker (capabilities.supports.reasoning_effort) AND a context size + // picker (a `long_context` billing tier whose context_max exceeds the default + // tier). Used by the `Chat Model Configuration` smoke tests to verify that the + // reasoning effort and context size selected in the model-picker UI are + // forwarded to the server (as `reasoning.effort` and the context-management + // `compact_threshold` in the /responses request body) and surfaced in the + // context-usage gauge. The `mock-config` family is intentionally absent from + // `modelsWithoutResponsesContextManagement` so context management stays enabled. + // Numbers mirror a GPT-5.5-class model: the default tier exposes a 272K prompt + // window (`default.context_max`) and the long tier the full window minus the + // 128K output reserve — `max_context_window_tokens` (1050000) - 128000 = 922000. + // Note `formatTokenCount` renders 922000 as "1M" (its `>900K → 1M` branch), so + // the long option/label reads "1M" even though the value is 922K. Output is + // 128K, so the context-usage gauge totals (input + output) read 400K and 1M. + { + id: 'mock-config-model', + name: 'Mock Config Model', + object: 'model', + version: 'mock-config-model', + vendor: 'OpenAI', + model_picker_enabled: true, + model_picker_category: 'versatile', + model_picker_price_category: 'medium', + is_chat_default: false, + is_chat_fallback: false, + preview: false, + billing: { + restricted_to: ['pro', 'edu', 'pro_plus', 'individual_trial', 'business', 'enterprise', 'max'], + token_prices: { + batch_size: 1000000, + default: { cache_price: 17, cache_write_price: 219, input_price: 175, output_price: 1400, context_max: 272000 }, + long_context: { cache_price: 34, cache_write_price: 438, input_price: 350, output_price: 2800, context_max: 1050000 }, + }, + }, + capabilities: { + type: 'chat', + family: 'mock-config', + tokenizer: 'o200k_base', + object: 'model_capabilities', + limits: { max_prompt_tokens: 922000, max_output_tokens: 128000, max_context_window_tokens: 1050000 }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false, structured_outputs: true, reasoning_effort: ['low', 'medium', 'high'] }, + }, + supported_endpoints: ['/responses'], + }, ]; /** * Complete model list used by both GET /models and GET /models/{id}. * Kept in a single array so the two handlers always return consistent data. */ -const ALL_MODELS: any[] = [ +const ALL_MODELS: MockModel[] = [ { id: MODEL, name: 'GPT-4o (Mock)', @@ -629,7 +782,10 @@ function handleRequest(req: import('http').IncomingMessage, res: import('http'). // -- Chat Completions (DomainService.capiChatURL = /chat/completions) -- if (path === '/chat/completions' && req.method === 'POST') { - readBody().then((body: string) => handleChatCompletions(body, res)); + readBody().then((body: string) => { + serverEvents.emit('capturedRequest', { path, method: 'POST', body }); + return handleChatCompletions(body, res); + }); return; } @@ -638,7 +794,10 @@ function handleRequest(req: import('http').IncomingMessage, res: import('http'). // The SDK expects events like response.created, response.output_item.added, // response.output_text.delta, response.output_item.done, response.completed. if (path === '/responses' && req.method === 'POST') { - readBody().then((body: string) => handleResponsesApi(body, res)); + readBody().then((body: string) => { + serverEvents.emit('capturedRequest', { path, method: 'POST', body }); + return handleResponsesApi(body, res); + }); return; } @@ -1626,11 +1785,41 @@ interface MockLlmServerHandle { completionCount(): number; /** Wait until at least `n` scenario chat completions have been served. */ waitForCompletion(n: number, timeoutMs: number): Promise; + /** + * Return the parsed bodies of the chat requests received so far (one entry + * per POST to `/chat/completions` or `/responses`, in arrival order). The + * `body` is the JSON-parsed request payload (or the raw string when parsing + * fails). Used by tests to assert what the client forwarded to the server + * (e.g. `reasoning.effort` or the context-management `compact_threshold`). + * + * Returns an empty array unless the server was started with + * {@link StartServerOptions.captureRequests} set — request capture is off by + * default so perf/mem-leak harnesses don't retain request bodies. + */ + getRequests(): CapturedRequest[]; +} + +/** + * A captured chat request, exposed via {@link MockLlmServerHandle.getRequests}. + */ +interface CapturedRequest { + path: string; + method: string; + body: any; } interface StartServerOptions { logger?: (msg: string) => void; verbose?: boolean; + /** + * When `true`, the server retains the parsed body of every `/chat/completions` + * and `/responses` POST so tests can assert what the client forwarded (see + * {@link MockLlmServerHandle.getRequests}). Defaults to `false`: perf/mem-leak + * harnesses generate large volumes of traffic, so capture stays off to avoid + * unbounded in-memory retention of request bodies. Only the smoke suites that + * call `getRequests()` enable it. + */ + captureRequests?: boolean; } /** @@ -1655,6 +1844,25 @@ function _startServer(port = 0, options?: StartServerOptions): Promise { + let parsed: any = info.body; + try { + parsed = JSON.parse(info.body); + } catch { + // Keep the raw string when the body is not valid JSON. + } + capturedRequests.push({ path: info.path, method: info.method, body: parsed }); + }; + if (captureRequests) { + serverEvents.on('capturedRequest', onCapturedRequest); + } + const server = http.createServer((req, res) => { reqCount++; requestWaiters = requestWaiters.filter(fn => !fn()); @@ -1669,6 +1877,9 @@ function _startServer(port = 0, options?: StartServerOptions): Promise new Promise((resolve, reject) => { serverEvents.removeListener('scenarioCompletion', onCompletion); + if (captureRequests) { + serverEvents.removeListener('capturedRequest', onCapturedRequest); + } server.close(err => err ? reject(err) : resolve(undefined)); }), requestCount: () => reqCount, @@ -1689,6 +1900,7 @@ function _startServer(port = 0, options?: StartServerOptions): Promise capturedRequests.slice(), }); }); server.on('error', reject); @@ -1768,6 +1980,7 @@ export type { MultiTurnScenario, MockLlmServerHandle, StartServerOptions, + CapturedRequest, }; export declare const startServer: typeof _startServer; diff --git a/scripts/chat-simulation/common/utils.js b/scripts/chat-simulation/common/utils.js index 79c608cf2ec01..e97f84f8c32c4 100644 --- a/scripts/chat-simulation/common/utils.js +++ b/scripts/chat-simulation/common/utils.js @@ -217,12 +217,23 @@ function buildEnv(mockServer, { isDevBuild = true } = {}) { * @param {string} logsDir * @returns {string[]} */ -function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostInspectPort = 0, traceFile = '', appRoot = ROOT } = {}) { +function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostInspectPort = 0, traceFile = '', appRoot = ROOT, gcObjectStats = false } = {}) { // Chromium switches must come BEFORE the app path (ROOT) — Chromium // only processes switches that precede the first non-switch argument. const chromiumFlags = []; if (traceFile) { - chromiumFlags.push(`--enable-tracing=v8.gc,disabled-by-default-v8.gc,disabled-by-default-v8.gc_stats,devtools.timeline,blink.user_timing`); + // IMPORTANT: `disabled-by-default-v8.gc_stats` is intentionally OFF by + // default. It makes V8 run GC_OBJECT_DUMP_STATISTICS (a full per-type + // heap object dump) on every major GC, inflating a ~15ms GC pause to + // ~550ms. When such a GC lands in the measured request window it + // corrupts timeToFirstToken (bimodal ~250ms vs ~900ms). `v8.gc` + + // `disabled-by-default-v8.gc` still provide the GC events we count. + // Opt in via `--gc-object-stats` only for deliberate GC deep-dives + // (never for timing runs), accepting that timings become unreliable. + const gcCategories = gcObjectStats + ? 'v8.gc,disabled-by-default-v8.gc,disabled-by-default-v8.gc_stats' + : 'v8.gc,disabled-by-default-v8.gc'; + chromiumFlags.push(`--enable-tracing=${gcCategories},devtools.timeline,blink.user_timing`); chromiumFlags.push(`--trace-startup-file=${traceFile}`); chromiumFlags.push(`--enable-tracing-format=json`); } diff --git a/scripts/chat-simulation/config.jsonc b/scripts/chat-simulation/config.jsonc index 6555b532e713f..54a3dd96d2796 100644 --- a/scripts/chat-simulation/config.jsonc +++ b/scripts/chat-simulation/config.jsonc @@ -17,8 +17,7 @@ "metricThresholds": { "timeToFirstToken": "100ms", "timeToComplete": 0.2, - "layoutCount": 0.2, - "recalcStyleCount": 0.2, + "layoutDurationMs": 0.2, "forcedReflowCount": 0.2, "longTaskCount": 0.2, "longAnimationFrameCount": 0.2 @@ -28,10 +27,10 @@ // Number of open→work→reset cycles "iterations": 3, - // Max acceptable total residual heap growth in MB. - // Each iteration cycles through ALL scenarios (text, code blocks, - // tool calls, thinking, terminal, multi-turn, etc.), so this needs - // to account for V8 internal caches that aren't immediately reclaimed. + // Max acceptable steady-state residual heap growth in MB, measured + // AFTER the first (warm-up) iteration. The first iteration's growth is + // dominated by one-time V8/JIT/module caches that aren't reclaimed and + // is excluded; a real leak keeps growing every subsequent iteration. "leakThresholdMB": 10 } } diff --git a/scripts/chat-simulation/merge-ci-summary.js b/scripts/chat-simulation/merge-ci-summary.js index 4c107f9f5dc6b..370291a04555b 100644 --- a/scripts/chat-simulation/merge-ci-summary.js +++ b/scripts/chat-simulation/merge-ci-summary.js @@ -253,6 +253,7 @@ function generateUnifiedSummary(jsonReport, baseline, opts) { ['timeToFirstToken', 'timing', 'ms'], ['timeToComplete', 'timing', 'ms'], ['layoutCount', 'rendering', ''], + ['layoutDurationMs', 'rendering', 'ms'], ['recalcStyleCount', 'rendering', ''], ['forcedReflowCount', 'rendering', ''], ['longTaskCount', 'rendering', ''], @@ -267,8 +268,11 @@ function generateUnifiedSummary(jsonReport, baseline, opts) { ['extHostHeapDelta', 'extHost', 'MB'], ['extHostHeapDeltaPostGC', 'extHost', 'MB'], ]; + // layoutCount / recalcStyleCount are informational (inflated by CSS + // animations, compositor-driven, cheap) and do NOT gate — real layout cost is + // gated via layoutDurationMs below / timeToComplete. See SKILL.md. const regressionMetricNames = new Set([ - 'timeToFirstToken', 'timeToComplete', 'layoutCount', 'recalcStyleCount', + 'timeToFirstToken', 'timeToComplete', 'layoutDurationMs', 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount', ]); diff --git a/scripts/chat-simulation/test-chat-mem-leaks.js b/scripts/chat-simulation/test-chat-mem-leaks.js index d63c4e4dcb4af..945eeda1fefaf 100644 --- a/scripts/chat-simulation/test-chat-mem-leaks.js +++ b/scripts/chat-simulation/test-chat-mem-leaks.js @@ -25,7 +25,7 @@ * Usage: * npm run perf:chat-leak # defaults from config * npm run perf:chat-leak -- --iterations 5 # more iterations - * npm run perf:chat-leak -- --threshold 5 # 5MB total threshold + * npm run perf:chat-leak -- --threshold 5 # 5MB steady-state threshold * npm run perf:chat-leak -- --build 1.115.0 # test a specific build */ @@ -349,11 +349,24 @@ async function runLeakCheck(electronPath, mockServer, opts) { const totalResidualMB = Math.round((final.heapMB - baseline.heapMB) * 100) / 100; const totalResidualNodes = final.domNodes - baseline.domNodes; + // Steady-state residual EXCLUDES the first iteration. The first + // iteration's growth is dominated by one-time warm-up (V8 JIT, module + // and string caches, lazy singletons) rather than a leak — a real leak + // keeps growing every iteration, whereas warm-up plateaus. Basing the + // verdict on post-warm-up growth (heap relative to the end of iteration + // 1) avoids false positives from caching. Falls back to total residual + // when there are too few iterations to drop the warm-up one. + const warmupBaselineMB = iterationResults.length > 1 + ? iterationResults[0].afterHeapMB + : baseline.heapMB; + const steadyResidualMB = Math.round((final.heapMB - warmupBaselineMB) * 100) / 100; + return { baseline, final: { heapMB: final.heapMB, domNodes: final.domNodes }, totalResidualMB, totalResidualNodes, + steadyResidualMB, iterations: iterationResults, }; } finally { @@ -377,7 +390,7 @@ async function main() { registerPerfScenarios(); const mockServer = await startServer(0); - console.log(`[chat-simulation] Leak check: ${opts.iterations} iterations × ${getScenarioIds().length} scenarios, threshold ${opts.leakThresholdMB}MB total`); + console.log(`[chat-simulation] Leak check: ${opts.iterations} iterations × ${getScenarioIds().length} scenarios, threshold ${opts.leakThresholdMB}MB steady-state (excl. warm-up)`); console.log(`[chat-simulation] Build: ${electronPath}`); console.log(''); @@ -393,8 +406,9 @@ async function main() { console.log(` Iteration ${i + 1}: ${it.beforeHeapMB}MB → ${it.afterHeapMB}MB (residual: ${it.deltaHeapMB > 0 ? '+' : ''}${it.deltaHeapMB}MB, DOM: ${it.deltaDomNodes > 0 ? '+' : ''}${it.deltaDomNodes} nodes)`); } console.log(''); - console.log(` Total residual heap growth: ${result.totalResidualMB > 0 ? '+' : ''}${result.totalResidualMB}MB`); - console.log(` Total residual DOM growth: ${result.totalResidualNodes > 0 ? '+' : ''}${result.totalResidualNodes} nodes`); + console.log(` Total residual heap growth: ${result.totalResidualMB > 0 ? '+' : ''}${result.totalResidualMB}MB (includes one-time warm-up)`); + console.log(` Steady-state residual (excl. warm-up): ${result.steadyResidualMB > 0 ? '+' : ''}${result.steadyResidualMB}MB`); + console.log(` Total residual DOM growth: ${result.totalResidualNodes > 0 ? '+' : ''}${result.totalResidualNodes} nodes`); console.log(''); // Write JSON @@ -408,12 +422,12 @@ async function main() { }, null, 2)); console.log(`[chat-simulation] Results written to ${jsonPath}`); - const leaked = result.totalResidualMB > opts.leakThresholdMB; + const leaked = result.steadyResidualMB > opts.leakThresholdMB; console.log(''); if (leaked) { - console.log(`[chat-simulation] LEAK DETECTED — ${result.totalResidualMB}MB residual exceeds ${opts.leakThresholdMB}MB threshold`); + console.log(`[chat-simulation] LEAK DETECTED — ${result.steadyResidualMB}MB steady-state residual exceeds ${opts.leakThresholdMB}MB threshold`); } else { - console.log(`[chat-simulation] No leak detected (${result.totalResidualMB}MB residual < ${opts.leakThresholdMB}MB threshold)`); + console.log(`[chat-simulation] No leak detected (${result.steadyResidualMB}MB steady-state residual < ${opts.leakThresholdMB}MB threshold)`); } if (opts.ci) { @@ -429,11 +443,11 @@ async function main() { /** * Generate a Markdown summary for CI, matching the perf script pattern. - * @param {{ baseline: { heapMB: number, domNodes: number }, final: { heapMB: number, domNodes: number }, totalResidualMB: number, totalResidualNodes: number, iterations: { beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[] }} result + * @param {{ baseline: { heapMB: number, domNodes: number }, final: { heapMB: number, domNodes: number }, totalResidualMB: number, totalResidualNodes: number, steadyResidualMB: number, iterations: { beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[] }} result * @param {{ leakThresholdMB: number, iterations: number }} opts */ function generateLeakCISummary(result, opts) { - const leaked = result.totalResidualMB > opts.leakThresholdMB; + const leaked = result.steadyResidualMB > opts.leakThresholdMB; const verdict = leaked ? '\u274C **LEAK DETECTED**' : '\u2705 **No leak detected**'; const lines = []; lines.push('## Memory Leak Check'); @@ -441,7 +455,7 @@ function generateLeakCISummary(result, opts) { lines.push('| | |'); lines.push('|---|---|'); lines.push(`| **Verdict** | ${verdict} |`); - lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB |`); + lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB (steady-state, excl. warm-up) |`); lines.push(`| **Iterations** | ${opts.iterations} |`); lines.push(`| **Scenarios per iteration** | ${getScenarioIds().length} |`); lines.push(''); @@ -452,13 +466,17 @@ function generateLeakCISummary(result, opts) { const it = result.iterations[i]; const sign = it.deltaHeapMB > 0 ? '+' : ''; const domSign = it.deltaDomNodes > 0 ? '+' : ''; - lines.push(`| Iteration ${i + 1} | ${it.afterHeapMB} (${sign}${it.deltaHeapMB}) | ${it.afterDomNodes} (${domSign}${it.deltaDomNodes}) |`); + const note = i === 0 ? ' _(warm-up)_' : ''; + lines.push(`| Iteration ${i + 1}${note} | ${it.afterHeapMB} (${sign}${it.deltaHeapMB}) | ${it.afterDomNodes} (${domSign}${it.deltaDomNodes}) |`); } lines.push(`| **Final** | **${result.final.heapMB}** | **${result.final.domNodes}** |`); lines.push(''); + const steadySign = result.steadyResidualMB > 0 ? '+' : ''; const sign = result.totalResidualMB > 0 ? '+' : ''; const domSign = result.totalResidualNodes > 0 ? '+' : ''; - lines.push(`**Total residual growth:** ${sign}${result.totalResidualMB} MB heap, ${domSign}${result.totalResidualNodes} DOM nodes`); + lines.push(`**Steady-state residual (excl. warm-up):** ${steadySign}${result.steadyResidualMB} MB heap`); + lines.push(''); + lines.push(`**Total residual growth:** ${sign}${result.totalResidualMB} MB heap, ${domSign}${result.totalResidualNodes} DOM nodes _(includes one-time warm-up)_`); lines.push(''); return lines.join('\n'); } diff --git a/scripts/chat-simulation/test-chat-perf-regression.js b/scripts/chat-simulation/test-chat-perf-regression.js index 8dbf14a07b3fc..51228ff8e4ab7 100644 --- a/scripts/chat-simulation/test-chat-perf-regression.js +++ b/scripts/chat-simulation/test-chat-perf-regression.js @@ -47,6 +47,7 @@ function parseArgs() { noCache: false, force: false, heapSnapshots: false, + gcObjectStats: false, /** @type {string[]} */ scenarios: [], /** @type {string | undefined} */ @@ -100,6 +101,7 @@ function parseArgs() { case '--no-cache': opts.noCache = true; break; case '--force': opts.force = true; break; case '--heap-snapshots': opts.heapSnapshots = true; break; + case '--gc-object-stats': opts.gcObjectStats = true; break; case '--ci': opts.ci = true; opts.noCache = true; opts.heapSnapshots = true; opts.cleanupDiagnostics = true; break; case '--cleanup-diagnostics': opts.cleanupDiagnostics = true; break; case '--help': case '-h': @@ -128,6 +130,7 @@ function parseArgs() { ' --no-cache Ignore cached baseline data, always run fresh', ' --force Skip build mode mismatch confirmation', ' --heap-snapshots Take heap snapshots (slow; auto-enabled in --ci mode)', + ' --gc-object-stats Enable V8 gc_stats tracing for GC deep-dives only. WARNING: corrupts timings (adds ~550ms to any request hit by a major GC) — never use for benchmarking', ' --ci CI mode: write Markdown summary to ci-summary.md (implies --no-cache, --heap-snapshots, --cleanup-diagnostics)', ' --cleanup-diagnostics Remove heap snapshots, CPU profiles, and traces after each run to save disk space', ' --verbose Print per-run details', @@ -401,7 +404,7 @@ async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, ru const extHostInspectPort = getNextExtHostInspectPort(); const vscode = await launchVSCode( electronPath, - buildArgs(userDataDir, extDir, logsDir, { isDevBuild, extHostInspectPort, traceFile: tracePath, appRoot }), + buildArgs(userDataDir, extDir, logsDir, { isDevBuild, extHostInspectPort, traceFile: tracePath, appRoot, gcObjectStats: runOpts?.gcObjectStats }), buildEnv(mockServer, { isDevBuild }), { verbose }, ); @@ -1003,6 +1006,7 @@ function generateCISummary(jsonReport, baseline, opts) { ['timeToFirstToken', 'timing', 'ms'], ['timeToComplete', 'timing', 'ms'], ['layoutCount', 'rendering', ''], + ['layoutDurationMs', 'rendering', 'ms'], ['recalcStyleCount', 'rendering', ''], ['forcedReflowCount', 'rendering', ''], ['longTaskCount', 'rendering', ''], @@ -1017,7 +1021,7 @@ function generateCISummary(jsonReport, baseline, opts) { ['extHostHeapDelta', 'extHost', 'MB'], ['extHostHeapDeltaPostGC', 'extHost', 'MB'], ]; - const regressionMetricNames = new Set(['timeToFirstToken', 'timeToComplete', 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount']); + const regressionMetricNames = new Set(['timeToFirstToken', 'timeToComplete', 'layoutDurationMs', 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount']); const lines = []; const scenarios = Object.keys(jsonReport.scenarios); @@ -1393,7 +1397,7 @@ async function main() { const runIdx = `${scenario}-resume-${prevTestRuns.length + i}`; console.log(`[chat-simulation] Run ${i + 1}/${runsToAdd}...`); try { - const m = await runOnce(testElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'test', { ...opts.settingsOverrides, ...opts.testSettingsOverrides }, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(testElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'test', { ...opts.settingsOverrides, ...opts.testSettingsOverrides }, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && prevTestRuns.length > 0) { cleanupRunDiagnostics(prevTestRuns[prevTestRuns.length - 1]); } prevTestRuns.push(m); @@ -1411,7 +1415,7 @@ async function main() { const runIdx = `baseline-${scenario}-resume-${prevBaseRuns.length + i}`; console.log(`[chat-simulation] Run ${i + 1}/${runsToAdd}...`); try { - const m = await runOnce(baselineElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'baseline', { ...opts.settingsOverrides, ...opts.baselineSettingsOverrides }, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(baselineElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'baseline', { ...opts.settingsOverrides, ...opts.baselineSettingsOverrides }, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && prevBaseRuns.length > 0) { cleanupRunDiagnostics(prevBaseRuns[prevBaseRuns.length - 1]); } prevBaseRuns.push(m); @@ -1557,7 +1561,7 @@ async function main() { const newResults = []; for (let i = 0; i < runsNeeded; i++) { try { - const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${existingRuns.length + i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${existingRuns.length + i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && newResults.length > 0) { cleanupRunDiagnostics(newResults[newResults.length - 1]); } newResults.push(m); @@ -1588,7 +1592,7 @@ async function main() { const results = []; for (let i = 0; i < opts.runs; i++) { try { - const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && results.length > 0) { cleanupRunDiagnostics(results[results.length - 1]); } results.push(m); @@ -1671,7 +1675,7 @@ async function main() { for (let i = 0; i < opts.runs; i++) { console.log(`[chat-simulation] Run ${i + 1}/${opts.runs}...`); try { - const metrics = await runOnce(electronPath, scenario, mockServer, opts.verbose, `${scenario}-${i}`, runDir, 'test', testSettings, { heapSnapshots: opts.heapSnapshots }); + const metrics = await runOnce(electronPath, scenario, mockServer, opts.verbose, `${scenario}-${i}`, runDir, 'test', testSettings, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && results.length > 0) { cleanupRunDiagnostics(results[results.length - 1]); } results.push(metrics); @@ -1790,13 +1794,19 @@ async function printComparison(jsonReport, opts) { // [metric, group, unit] ['timeToFirstToken', 'timing', 'ms'], ['timeToComplete', 'timing', 'ms'], - ['layoutCount', 'rendering', ''], - ['recalcStyleCount', 'rendering', ''], + ['layoutDurationMs', 'rendering', 'ms'], ['forcedReflowCount', 'rendering', ''], ['longTaskCount', 'rendering', ''], ]; - // Informational metrics — shown in comparison but don't trigger failure + // Informational metrics — shown in comparison but don't trigger failure. + // layoutCount / recalcStyleCount are informational on purpose: they are + // inflated by CSS animations (compositor-driven, cheap) and don't reflect + // real cost — the real layout cost is layoutDurationMs (gated above). A + // build can do more, cheaper layouts yet spend less layout time and finish + // faster (e.g. giant-codeblock: +28% layoutCount but -7% layoutDurationMs). const infoMetrics = [ + ['layoutCount', 'rendering', ''], + ['recalcStyleCount', 'rendering', ''], ['heapDelta', 'memory', 'MB'], ['gcDurationMs', 'memory', 'ms'], ['extHostHeapDelta', 'extHost', 'MB'], diff --git a/scripts/code.bat b/scripts/code.bat index 62cfd0b4c4908..51b27cb4664e3 100644 --- a/scripts/code.bat +++ b/scripts/code.bat @@ -7,7 +7,10 @@ pushd %~dp0\.. :: Get electron, compile, built-in extensions if "%VSCODE_SKIP_PRELAUNCH%"=="" ( - node build/lib/preLaunch.ts + node build/lib/preLaunch.ts || ( + echo Failed to prepare VS Code for launch ^(build/lib/preLaunch.ts^). 1>&2 + exit /b 1 + ) ) set "NAMESHORT=" diff --git a/scripts/test-remote-integration.bat b/scripts/test-remote-integration.bat index 96288d35886d8..5e2c8864c2c21 100644 --- a/scripts/test-remote-integration.bat +++ b/scripts/test-remote-integration.bat @@ -3,6 +3,10 @@ setlocal pushd %~dp0\.. +:: TODO(deepak1556): Remove this once bumped > 24.16.0, refs https://github.com/nodejs/node/issues/63638 +for /f "delims=" %%i in ('node -p "require('fs').realpathSync.native(require('os').tmpdir())"') do set "TMP=%%i" +set "TEMP=%TMP%" + IF "%~1" == "" ( set AUTHORITY=vscode-remote://test+test/ :: backward to forward slashed diff --git a/scripts/test-web-integration.bat b/scripts/test-web-integration.bat index bc33dfc2a35c0..2f6c320e5878a 100644 --- a/scripts/test-web-integration.bat +++ b/scripts/test-web-integration.bat @@ -3,6 +3,10 @@ setlocal pushd %~dp0\.. +:: TODO(deepak1556): Remove this once bumped > 24.16.0, refs https://github.com/nodejs/node/issues/63638 +for /f "delims=" %%i in ('node -p "require('fs').realpathSync.native(require('os').tmpdir())"') do set "TMP=%%i" +set "TEMP=%TMP%" + IF "%~1" == "" ( set AUTHORITY=vscode-remote://test+test/ :: backward to forward slashed diff --git a/src/main.ts b/src/main.ts index f70cf87ee7f98..085290ba0d7e9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -332,8 +332,9 @@ function configureCommandlineSwitchesSync(cliArgs: NativeParsedArgs) { // `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental // `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065 // `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065 + // `GlobalShortcutsPortal` - Enables Electron's `globalShortcut` (system-wide keybindings) on Linux Wayland via the XDG global shortcuts portal (no-op elsewhere) const featuresToEnable = - `NetAdapterMaxBufSizeFeature:NetAdapterMaxBufSize/8192,DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync,${app.commandLine.getSwitchValue('enable-features')}`; + `NetAdapterMaxBufSizeFeature:NetAdapterMaxBufSize/8192,DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync${process.platform === 'linux' ? ',GlobalShortcutsPortal' : ''},${app.commandLine.getSwitchValue('enable-features')}`; app.commandLine.appendSwitch('enable-features', featuresToEnable); // Following features are disabled from the runtime: diff --git a/src/server-main.ts b/src/server-main.ts index f5af9e32e6a65..08593e4618284 100644 --- a/src/server-main.ts +++ b/src/server-main.ts @@ -5,6 +5,7 @@ import './bootstrap-server.js'; // this MUST come before other imports as it changes global state import * as path from 'node:path'; +import * as fs from 'node:fs'; import * as http from 'node:http'; import type { AddressInfo } from 'node:net'; import * as os from 'node:os'; @@ -49,6 +50,8 @@ if (shouldSpawnCli) { mod.spawnCli(); }); } else { + installServerProcessExitDiagnostics(); + let _remoteExtensionHostAgentServer: IServerAPI | null = null; let _remoteExtensionHostAgentServerPromise: Promise | null = null; const getRemoteExtensionHostAgentServer = () => { @@ -158,6 +161,126 @@ function sanitizeStringArg(val: unknown): string | undefined { return typeof val === 'string' ? val : undefined; } +/** + * Records why/when the remote server process exits, to help debug unexpected + * server exits in the remote smoke tests (which surface to the client as + * `Unknown reconnection token` reconnection failures). The handlers tell apart a + * self-exit (`beforeExit`), an external kill (`signal`) and a crash + * (`uncaughtExceptionMonitor`). Gated behind the `VSCODE_SERVER_EXIT_DIAGNOSTICS` + * env var (set by the smoke tests) so it adds no product noise. Lines are + * appended synchronously to a `server-exit-diagnostics.log` file in the server's + * `--logsPath` directory (falling back to `os.tmpdir()` when `--logsPath` is not + * provided) so they survive process teardown (an async stdio write from an + * `exit` handler does not). + */ +function installServerProcessExitDiagnostics(): void { + if (!process.env['VSCODE_SERVER_EXIT_DIAGNOSTICS']) { + return; + } + + const startTime = Date.now(); + + // Append diagnostics synchronously to a file rather than relying on + // `console.error`: a process `exit` handler cannot flush an async pipe write + // (the server's stdio is piped through the test resolver, and on Windows + // additionally through a `cmd.exe`/batch wrapper) before the process dies, + // so the exit-time lines we care about most were being dropped. A synchronous + // `fs.appendFileSync` survives teardown. We target the server's `--logsPath` + // directory because it is captured as a smoke test artifact. + const logsPath = sanitizeStringArg(parsedArgs['logsPath']) || os.tmpdir(); + const diagnosticsFile = path.join(logsPath, 'server-exit-diagnostics.log'); + try { + fs.mkdirSync(logsPath, { recursive: true }); + } catch { + // best effort: the directory is normally created by the server already + } + + // The file write is authoritative: it is synchronous (so it survives process + // teardown) and goes to a captured smoke artifact. We additionally mirror to + // stderr for live visibility in the test resolver's output channel, but that + // mirror is dangerous precisely because these diagnostics fire when the + // server's stdio pipe is dying: a write to a broken pipe throws `EPIPE` + // synchronously and/or emits an async `error` event, either of which Node + // promotes to an uncaught exception — which re-enters the + // `uncaughtExceptionMonitor` handler below and loops (one CI run produced a + // 386MB log this way). We therefore make the mirror best-effort and latch it + // off after the first failure, and attach an `error` handler so async pipe + // errors are swallowed rather than crashing the process. + let mirrorToStderr = true; + try { + process.stderr.on('error', () => { mirrorToStderr = false; }); + } catch { + mirrorToStderr = false; + } + + const log = (message: string) => { + const line = `[server-exit-diagnostics][${new Date().toISOString()}][pid:${process.pid}][+${Date.now() - startTime}ms] ${message}`; + try { + fs.appendFileSync(diagnosticsFile, `${line}\n`); + } catch { + // ignore logging failures while the process is tearing down + } + if (mirrorToStderr) { + try { + process.stderr.write(`${line}\n`); + } catch { + // Broken pipe during teardown: stop mirroring so we can never + // throw (and thus loop) on subsequent diagnostics. + mirrorToStderr = false; + } + } + }; + + const describeState = (): string => { + try { + const processWithResources = process as NodeJS.Process & { getActiveResourcesInfo?(): string[] }; + const activeResources = processWithResources.getActiveResourcesInfo?.() ?? []; + const memory = process.memoryUsage(); + return `uptime=${process.uptime().toFixed(3)}s rss=${Math.round(memory.rss / 1024 / 1024)}MB activeResources=[${activeResources.join(', ')}]`; + } catch (err) { + return `(failed to collect process state: ${err})`; + } + }; + + log(`installed. ppid=${process.ppid} platform=${process.platform} node=${process.version} argv=${JSON.stringify(process.argv.slice(2))}`); + + process.on('beforeExit', code => log(`'beforeExit' (code: ${code}) — event loop drained, process will exit on its own. ${describeState()}`)); + process.on('exit', code => log(`'exit' (code: ${code}). ${describeState()}`)); + + // `uncaughtExceptionMonitor` is observational: it runs before the process + // crashes but does NOT prevent the default crash, so the real failure mode + // is preserved. It also fires for unhandled rejections that get promoted to + // uncaught exceptions by Node's default policy. Guard against re-entrancy: + // if logging an exception were to itself throw (and get promoted to another + // uncaught exception), we must not recurse into this handler forever. + let handlingUncaughtException = false; + process.on('uncaughtExceptionMonitor', (err, origin) => { + if (handlingUncaughtException) { + return; + } + handlingUncaughtException = true; + try { + log(`'uncaughtExceptionMonitor' (origin: ${origin}): ${err?.stack || err}`); + } finally { + handlingUncaughtException = false; + } + }); + + const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK', 'SIGQUIT']; + for (const signal of signals) { + try { + process.on(signal, () => { + log(`received signal '${signal}' — terminating. ${describeState()}`); + // Preserve default termination semantics after logging. + const signalNumber = (os.constants.signals as Record)[signal]; + process.exit(typeof signalNumber === 'number' ? 128 + signalNumber : 1); + }); + } catch { + // Not all signals can be listened to on all platforms (e.g. SIGBREAK). + } + } +} + /** * If `--port` is specified and describes a single port, connect to that port. * diff --git a/src/typings/copilot-api.d.ts b/src/typings/copilot-api.d.ts index 3048776fdb0e8..cf56881a70d34 100644 --- a/src/typings/copilot-api.d.ts +++ b/src/typings/copilot-api.d.ts @@ -26,6 +26,7 @@ declare module '@vscode/copilot-api' { json?: unknown; method?: 'GET' | 'POST' | 'PUT'; signal?: IAbortSignal; + suppressIntegrationId?: boolean; } export type MakeRequestOptions = Omit & { diff --git a/src/vs/base/browser/animationSync.ts b/src/vs/base/browser/animationSync.ts new file mode 100644 index 0000000000000..e962a5595aec2 --- /dev/null +++ b/src/vs/base/browser/animationSync.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ISynchronizeAnimationsOptions { + /** + * Also synchronize animations running on descendant elements (e.g. the dots + * of a spinner whose animations live on child nodes). Defaults to `false`. + */ + readonly subtree?: boolean; + + /** + * When provided, further narrows synchronization to CSS animations whose + * `animation-name` is in this set. Non-keyframe animations (e.g. transitions) + * are always skipped regardless of this option. + */ + readonly animationNames?: ReadonlySet; +} + +/** + * Phase-aligns looping CSS animations so that every animation of the same + * duration displays the same frame at the same time, regardless of when each + * one started. + * + * All CSS animations share the document's timeline, so anchoring each + * animation's `startTime` to the same origin (`0`) forces their `currentTime` + * to equal the timeline time — making identical animations run in lock-step. + * Per-element `animation-delay` offsets are preserved (they are part of each + * animation's own timing), so intentional cascades (e.g. spinner dots) still + * work while the group as a whole stays globally in phase. + * + * Unlike adjusting `animation-delay`, this re-seeks animations that are already + * running (Chromium does not reliably re-seek a running animation when its + * `animation-delay` changes). Call it whenever an animation (re)starts or + * resumes after being paused offscreen — e.g. from an `animationstart` handler + * or when an element scrolls back into view. + * + * @param element The element whose (and optionally whose descendants') CSS + * animations should be synchronized. + * @param options See {@link ISynchronizeAnimationsOptions}. + */ +export function synchronizeCSSAnimations(element: HTMLElement, options?: ISynchronizeAnimationsOptions): void { + if (typeof element.getAnimations !== 'function') { + return; // Web Animations API not available; leave animations as-is. + } + for (const animation of element.getAnimations({ subtree: options?.subtree })) { + // Only CSS keyframe animations carry an `animationName`; skip transitions + // and other Web Animations so this helper strictly aligns CSS animations. + const animationName = (animation as CSSAnimation).animationName; + if (animationName === undefined) { + continue; + } + if (options?.animationNames && !options.animationNames.has(animationName)) { + continue; + } + // Anchor to a shared origin so all animations of the same duration display + // the same frame. Guard against the rare state where startTime is not yet + // settable (e.g. an animation still in its pending/ready phase). + try { + animation.startTime = 0; + } catch { + // ignore + } + } +} diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts index 6916063459fd7..9ae1e42020795 100644 --- a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts +++ b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts @@ -114,6 +114,8 @@ export class BreadcrumbsWidget { this._onDidFocusItem.dispose(); this._onDidChangeFocus.dispose(); this._domNode.remove(); + dispose(this._items); + this._items.length = 0; this._nodes.length = 0; this._freeNodes.length = 0; } @@ -156,7 +158,7 @@ export class BreadcrumbsWidget { private _style(styleElement: HTMLStyleElement, style: IBreadcrumbsWidgetStyles): void { let content = ''; if (style.breadcrumbsBackground) { - content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}`; + content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}\n`; } if (style.breadcrumbsForeground) { content += `.monaco-breadcrumbs .monaco-breadcrumb-item { color: ${style.breadcrumbsForeground}}\n`; @@ -168,7 +170,7 @@ export class BreadcrumbsWidget { content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused.selected { color: ${style.breadcrumbsFocusAndSelectionForeground}}\n`; } if (style.breadcrumbsHoverForeground) { - content += `.monaco-breadcrumbs:not(.disabled ) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`; + content += `.monaco-breadcrumbs:not(.disabled) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`; } styleElement.textContent = content; } diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index e2c68a4214ed6..4417777cb85e9 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -58,6 +58,13 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; + /** + * Temporary escape hatch for dialogs that embed widgets whose popups mount + * at window root (outside the dialog DOM). Needed because the focus trap + * would otherwise immediately reclaim focus from context views and pickers. + * See https://github.com/microsoft/vscode/issues/323920 for removal plan. + */ + readonly isExternalFocusAllowed?: (relatedTarget: HTMLElement) => boolean; readonly onVisibilityChange?: (window: Window, visible: boolean) => void; readonly buttonStyles: IButtonStyles; readonly checkboxStyles: ICheckboxStyles; @@ -484,6 +491,11 @@ export class Dialog extends Disposable { this._register(addDisposableListener(this.element, 'focusout', e => { if (!!e.relatedTarget && !!this.element) { if (!isAncestor(e.relatedTarget as HTMLElement, this.element)) { + // Temporary: let focus escape for body-level popups. + // See https://github.com/microsoft/vscode/issues/323920 + if (this.options.isExternalFocusAllowed?.(e.relatedTarget as HTMLElement)) { + return; + } this.focusToReturn = e.relatedTarget as HTMLElement; if (e.target) { diff --git a/src/vs/base/browser/ui/list/list.ts b/src/vs/base/browser/ui/list/list.ts index 3e7cb01796786..0d3c7f995b74d 100644 --- a/src/vs/base/browser/ui/list/list.ts +++ b/src/vs/base/browser/ui/list/list.ts @@ -151,6 +151,10 @@ export abstract class CachedListVirtualDelegate implements ILi return this.cache.get(element) ?? this.estimateHeight(element); } + protected getCachedHeight(element: T): number | undefined { + return this.cache.get(element); + } + protected abstract estimateHeight(element: T): number; abstract getTemplateId(element: T): string; diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 5c62e99faf886..831e610665e62 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -1620,12 +1620,7 @@ export class ListView implements IListView { private probeDynamicHeight(index: number): number { const item = this.items[index]; - const diff = this.probeDynamicHeightForItem(item, index); - if (diff > 0) { - this.virtualDelegate.setDynamicHeight?.(item.element, item.size); - } - - return diff; + return this.probeDynamicHeightForItem(item, index); } private probeDynamicHeightForItem(item: IItem, index: number): number { @@ -1635,6 +1630,7 @@ export class ListView implements IListView { const size = item.size; item.size = newSize; item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return newSize - size; } } @@ -1660,6 +1656,7 @@ export class ListView implements IListView { } } item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return item.size - size; } @@ -1678,12 +1675,19 @@ export class ListView implements IListView { renderer.disposeElement?.(item.element, index, row.templateData); item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); row.domNode.remove(); this.cache.release(row); return item.size - size; } + private publishDynamicHeight(item: IItem): void { + if (item.size > 0) { + this.virtualDelegate.setDynamicHeight?.(item.element, item.size); + } + } + getElementDomId(index: number): string { return `${this.domId}_${index}`; } diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts index 81c247cc5961f..14399960b8a9a 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { getWindow, h, onDidUnregisterWindow } from '../../dom.js'; +import { synchronizeCSSAnimations } from '../../animationSync.js'; import { CodeWindow } from '../../window.js'; import { IDisposable } from '../../../common/lifecycle.js'; import './pixelSpinner.css'; @@ -57,6 +58,15 @@ export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinner const PAUSED_CLASS = 'monaco-pixel-spinner-paused'; +// Keyframes names used by the spinner variants (see pixelSpinner.css). The sync +// is scoped to these so it never disturbs unrelated animations/transitions +// (e.g. the icon cross-fade) that may run on the same subtree. +const SPINNER_ANIMATION_NAMES = new Set([ + 'monaco-pixel-spinner-dot-cycle', + 'monaco-pixel-spinner-dot-cycle-long', + 'monaco-pixel-spinner-dot-cycle-short', + 'monaco-pixel-spinner-ring-pulse', +]); const observersByWindow = new Map(); let unregisterWindowListener: IDisposable | undefined; @@ -67,6 +77,11 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi let observer = observersByWindow.get(targetWindow); if (!observer) { observer = new targetWindow.IntersectionObserver(entries => { + // Two passes so all style writes happen before any style read: the + // pause-class toggles below dirty style, and `getAnimations()` in the + // sync pass flushes it. Interleaving them would force a style recalc + // per entry instead of one for the whole batch. + const toResync: HTMLElement[] = []; for (const entry of entries) { const target = entry.target as HTMLElement; if (!target.isConnected) { @@ -74,6 +89,16 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi continue; } target.classList.toggle(PAUSED_CLASS, !entry.isIntersecting); + if (entry.isIntersecting) { + toResync.push(target); + } + } + // Re-sync resumed spinners to the shared timeline: while paused + // offscreen the animation froze and its startTime drifted from + // spinners that kept running. Anchor it back (now that it is running + // again) so all visible spinners display the same frame. + for (const target of toResync) { + synchronizeCSSAnimations(target, { subtree: true, animationNames: SPINNER_ANIMATION_NAMES }); } }); observersByWindow.set(targetWindow, observer); @@ -101,4 +126,3 @@ function trackSpinner(root: HTMLElement): void { root.classList.add(PAUSED_CLASS); observer.observe(root); } - diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index 9bd1f676a4c28..df9c53fcc5112 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -302,6 +302,39 @@ export class Sequencer { } } +/** + * A {@link Throttler} per key. Calls for the same key coalesce (only the most + * recently queued task runs after the active one settles); calls for different + * keys are independent. Idle keys are cleaned up automatically. + */ +export class ThrottlerByKey implements IDisposable { + + private readonly throttlers = new Map(); + + queue(key: TKey, task: ITask>): Promise { + let entry = this.throttlers.get(key); + if (!entry) { + entry = { throttler: new Throttler(), count: 0 }; + this.throttlers.set(key, entry); + } + + entry.count++; + return entry.throttler.queue(task).finally(() => { + if (--entry!.count === 0) { + entry!.throttler.dispose(); + this.throttlers.delete(key); + } + }); + } + + dispose(): void { + for (const { throttler } of this.throttlers.values()) { + throttler.dispose(); + } + this.throttlers.clear(); + } +} + export class SequencerByKey { private promiseMap = new Map>(); @@ -2096,9 +2129,11 @@ export class AsyncIterableObject implements AsyncIterable { } catch (err) { this.reject(err); } finally { - writer.emitOne = undefined!; - writer.emitMany = undefined!; - writer.reject = undefined!; + // The executor has settled; emitting afterwards must be a no-op per the + // documented "no effect after resolve()/reject()" contract (see emitOne). + writer.emitOne = () => { }; + writer.emitMany = () => { }; + writer.reject = () => { }; } }); } diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 060efd23c2ee4..dc21c50d3b92d 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -727,4 +727,5 @@ export const codiconsLibrary = { vscodeInsidersOutline: register('vscode-insiders-outline', 0xecc9), vscodeOutline: register('vscode-outline', 0xecca), voiceMode: register('voice-mode', 0xeccb), + voiceModeCompact: register('voice-mode-compact', 0xeccc), } as const; diff --git a/src/vs/base/common/managedSettings.ts b/src/vs/base/common/managedSettings.ts index 56e99e0ef0b7f..1d5205a0b996b 100644 --- a/src/vs/base/common/managedSettings.ts +++ b/src/vs/base/common/managedSettings.ts @@ -38,6 +38,10 @@ export interface IStrictMarketplaceSource { * * Plain-string entries (allowed by the policy schema but unnamed) are stored with * the value used as both key and value so they survive the round-trip intact. + * + * Marketplace names come from managed settings (untrusted input) and are written as object keys, + * so `__proto__` / `constructor` / `prototype` keys are skipped to avoid prototype pollution + * (mirroring the guard in the managed-settings normalizer's string-map encoder). */ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | IExtraKnownMarketplaceEntry)[] | undefined): Record | undefined { if (!entries?.length) { @@ -46,8 +50,14 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I const obj: Record = {}; for (const entry of entries) { if (typeof entry === 'string') { + if (isUnsafeMarketplaceKey(entry)) { + continue; + } obj[entry] = entry; } else { + if (isUnsafeMarketplaceKey(entry.name)) { + continue; + } const s = entry.source; const base = s.source === 'github' ? s.repo : s.url; obj[entry.name] = s.ref ? `${base}#${s.ref}` : base; @@ -55,3 +65,8 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I } return obj; } + +/** Whether a marketplace name would pollute the prototype chain if used as an object key. */ +function isUnsafeMarketplaceKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} diff --git a/src/vs/base/common/map.ts b/src/vs/base/common/map.ts index bced49d5b4925..e9b7a4c065ff2 100644 --- a/src/vs/base/common/map.ts +++ b/src/vs/base/common/map.ts @@ -919,21 +919,79 @@ export class NKeyMap { return currentMap.get(keys[keys.length - 1]); } + public delete(...keys: [...TKeys]): boolean { + const maps: Map[] = [this._data]; + let currentMap = this._data; + for (let i = 0; i < keys.length - 1; i++) { + const nextMap = currentMap.get(keys[i]); + if (nextMap === undefined) { + return false; + } + currentMap = nextMap; + maps.push(currentMap); + } + const deleted = currentMap.delete(keys[keys.length - 1]); + for (let i = keys.length - 2; deleted && i >= 0; i--) { + if (maps[i + 1].size === 0) { + maps[i].delete(keys[i]); + } + } + return deleted; + } + + public deleteAll(...keys: Partial): boolean { + if (keys.length === 0) { + const hadData = this._data.size > 0; + this._data.clear(); + return hadData; + } + const maps: Map[] = [this._data]; + let currentMap = this._data; + for (let i = 0; i < keys.length - 1; i++) { + const nextMap = currentMap.get(keys[i]); + if (nextMap === undefined) { + return false; + } + currentMap = nextMap; + maps.push(currentMap); + } + const deleted = currentMap.delete(keys[keys.length - 1]); + for (let i = keys.length - 2; deleted && i >= 0; i--) { + if (maps[i + 1].size === 0) { + maps[i].delete(keys[i]); + } + } + return deleted; + } + public clear(): void { this._data.clear(); } + public *getAll(...keys: Partial): IterableIterator { + let currentMap = this._data; + for (const key of keys) { + const nextMap = currentMap.get(key); + if (nextMap === undefined) { + return; + } + currentMap = nextMap; + } + yield* this._values(currentMap); + } + public *values(): IterableIterator { - function* iterate(map: Map): IterableIterator { - for (const value of map.values()) { - if (value instanceof Map) { - yield* iterate(value); - } else { - yield value; - } + yield* this._values(this._data); + } + + private *_values(map: Map): IterableIterator { + for (const value of map.values()) { + if (value instanceof Map) { + yield* this._values(value); + } else { + yield value; } } - yield* iterate(this._data); } /** diff --git a/src/vs/base/common/policy.ts b/src/vs/base/common/policy.ts index 27891474ddf77..ffe87224d421b 100644 --- a/src/vs/base/common/policy.ts +++ b/src/vs/base/common/policy.ts @@ -144,3 +144,18 @@ export interface IPolicyReference { /** The name of the owning {@link IPolicy} this setting attaches to. */ readonly name: PolicyName; } + +/** + * A `product.json` `extensionConfigurationPolicy` entry that attaches its setting to a policy + * *owned* by an in-code setting, instead of declaring a full owner {@link IPolicy}. This mirrors the + * in-code `policyReference` configuration field, so the same indirection can be expressed from + * `product.json` — where the owner's runtime behaviour (notably its `value` callback) cannot live. + * + * An `extensionConfigurationPolicy` entry is therefore either a full {@link IPolicy} (the setting + * "parents"/owns the policy, the current syntax) or this reference wrapper. + */ +export interface IExtensionConfigurationPolicyReference { + + /** Pointer to the owning {@link IPolicy} declared by an in-code setting. */ + readonly policyReference: IPolicyReference; +} diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 5a1a201859249..5af093a6519ba 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -5,7 +5,7 @@ import { IStringDictionary } from './collections.js'; import { PlatformName } from './platform.js'; -import { IPolicy } from './policy.js'; +import { IExtensionConfigurationPolicyReference, IPolicy } from './policy.js'; export interface IBuiltInExtension { readonly name: string; @@ -257,7 +257,14 @@ export interface IProductConfiguration { readonly remoteDefaultExtensionsIfInstalledLocally?: string[]; - readonly extensionConfigurationPolicy?: IStringDictionary; + /** + * Maps an extension-contributed setting key to either a full enterprise {@link IPolicy} + * (the setting owns/"parents" the policy — the original syntax) or an + * {@link IExtensionConfigurationPolicyReference} (`{ policyReference: { name } }`), attaching the + * setting to a policy owned by an in-code setting. References let a `product.json`-provided + * setting be governed by a policy whose `value` callback — which JSON cannot carry — lives in code. + */ + readonly extensionConfigurationPolicy?: IStringDictionary; readonly onboardingKeymaps?: readonly IProductOnboardingKeymap[]; readonly onboardingThemes?: readonly IProductOnboardingTheme[]; @@ -400,6 +407,7 @@ export interface IDefaultChatAgent { readonly documentationUrl: string; readonly skusDocumentationUrl: string; + readonly optimizeUsageDocumentationUrl: string; readonly publicCodeMatchesUrl: string; readonly managePlanUrl: string; readonly upgradePlanUrl: string; diff --git a/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts index af3a2802125a7..64679352573b6 100644 --- a/src/vs/base/common/yaml.ts +++ b/src/vs/base/common/yaml.ts @@ -65,8 +65,12 @@ export class MarkdownNode { const property = this.header.properties.find(p => p.key.value === name); if (property && property.value.type === 'sequence') { return property.value.items.filter(item => item.type === 'scalar').map(item => item.value); - } else if (property && property.value.type === 'scalar' && property.value.format === 'none') { - return parseCommaSeparatedList(property.value.value, 0).map(item => item.value); + } else if (property && property.value.type === 'scalar') { + if (property.value.format === 'none') { + return parseCommaSeparatedList(property.value.value, 0).map(item => item.value); + } else { + return [property.value.value]; + } } } return undefined; diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 37426c2920637..6b936d7823f4c 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; +import { CachedListVirtualDelegate, IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; import { ListView } from '../../../../browser/ui/list/listView.js'; import { range } from '../../../../common/arrays.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; @@ -40,4 +40,67 @@ suite('ListView', function () { listView.dispose(); assert.strictEqual(templatesCount, 0, 'all templates have been disposed'); }); + + test('publishes freshly measured dynamic heights', function () { + const element = document.createElement('div'); + element.style.height = '200px'; + element.style.width = '200px'; + document.body.appendChild(element); + + type TestElement = { height: number }; + const delegate = new class extends CachedListVirtualDelegate { + protected estimateHeight() { return 100; } + getTemplateId() { return 'template'; } + hasDynamicHeight() { return true; } + getMeasuredHeight(element: TestElement) { return this.getCachedHeight(element); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate(container) { + const content = document.createElement('div'); + container.appendChild(content); + return content; + }, + renderElement(element, _index, templateData) { templateData.style.height = `${element.height}px`; }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(element, delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(200, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => delegate.getMeasuredHeight(element)), [40, 100, 160]); + } finally { + listView.dispose(); + element.remove(); + } + }); + + test('publishes positive delegate-provided dynamic heights', function () { + type TestElement = { height: number }; + const publishedHeights = new Map(); + const delegate: IListVirtualDelegate = { + getHeight() { return 100; }, + getTemplateId() { return 'template'; }, + getDynamicHeight(element) { return element.height; }, + setDynamicHeight(element, height) { publishedHeights.set(element, height); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate() { }, + renderElement() { }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 0 }, { height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(400, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => publishedHeights.get(element)), [undefined, 40, 100, 160]); + } finally { + listView.dispose(); + } + }); }); diff --git a/src/vs/base/test/common/map.test.ts b/src/vs/base/test/common/map.test.ts index 895726ab312d4..dd7c4a1841a04 100644 --- a/src/vs/base/test/common/map.test.ts +++ b/src/vs/base/test/common/map.test.ts @@ -716,6 +716,49 @@ suite('NKeyMap', () => { assert.deepStrictEqual(Array.from(map.values()), [1, 2, 3]); }); + test('getAll', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'a', 'b', 'd'); + map.set(3, 'a', 'e', 'f'); + map.set(4, 'g', 'h', 'i'); + assert.deepStrictEqual(Array.from(map.getAll('a', 'b')), [1, 2]); + assert.deepStrictEqual(Array.from(map.getAll('a')), [1, 2, 3]); + assert.deepStrictEqual(Array.from(map.getAll('missing')), []); + }); + + test('delete', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'a', 'b', 'd'); + map.set(3, 'x', 'y', 'z'); + assert.strictEqual(map.delete('a', 'b', 'c'), true); + assert.strictEqual(map.delete('a', 'b', 'c'), false); + assert.deepStrictEqual(Array.from(map.values()), [2, 3]); + }); + + test('deleteAll', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'a', 'b', 'd'); + map.set(3, 'a', 'e', 'f'); + map.set(4, 'g', 'h', 'i'); + assert.strictEqual(map.deleteAll('a', 'b'), true); + assert.deepStrictEqual(Array.from(map.values()), [3, 4]); + assert.strictEqual(map.deleteAll('missing'), false); + assert.strictEqual(map.deleteAll(), true); + assert.deepStrictEqual(Array.from(map.values()), []); + }); + + test('deleteAll cleans empty parent maps', () => { + const map = new NKeyMap(); + map.set(1, 'a', 'b', 'c'); + map.set(2, 'x', 'y', 'z'); + assert.strictEqual(map.deleteAll('a', 'b'), true); + assert.strictEqual(map.deleteAll('a'), false); + assert.deepStrictEqual(Array.from(map.values()), [2]); + }); + test('toString', () => { const map = new NKeyMap(); map.set(1, 'f', 'o', 'o'); diff --git a/src/vs/base/test/common/yaml.test.ts b/src/vs/base/test/common/yaml.test.ts index 933dfca3e754c..3d5257f6ca5fb 100644 --- a/src/vs/base/test/common/yaml.test.ts +++ b/src/vs/base/test/common/yaml.test.ts @@ -1259,6 +1259,17 @@ suite('YAML Parser', () => { assert.deepStrictEqual(result.getStringArrayValue('tags'), ['foo', 'bar', 'baz']); }); + test('getStringArrayValue wraps quoted scalars in a single-element array', () => { + const input = [ + '---', + 'tags: "foo, bar"', + '---', + ].join('\n'); + const result = parseFrontMatter(input); + assert.ok(result); + assert.deepStrictEqual(result.getStringArrayValue('tags'), ['foo, bar']); + }); + }); suite('parseCommaSeparatedList', () => { diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 811673d05c57c..b97d0efc3e661 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { app, BrowserWindow, desktopCapturer, Details, GPUFeatureStatus, powerMonitor, protocol, screen as electronScreen, session, Session, systemPreferences, WebFrameMain } from 'electron'; +import { app, BrowserWindow, desktopCapturer, Details, globalShortcut, GPUFeatureStatus, powerMonitor, protocol, screen as electronScreen, session, Session, systemPreferences, WebFrameMain } from 'electron'; import { addUNCHostToAllowlist, disableUNCAccessRestrictions } from '../../base/node/unc.js'; import { validatedIpcMain } from '../../base/parts/ipc/electron-main/ipcMain.js'; import { hostname, release } from 'os'; @@ -64,6 +64,7 @@ import { ILifecycleMainService, LifecycleMainPhase, ShutdownReason } from '../.. import { ILoggerService, ILogService } from '../../platform/log/common/log.js'; import { IMenubarMainService, MenubarMainService } from '../../platform/menubar/electron-main/menubarMainService.js'; import { INativeHostMainService, NativeHostMainService } from '../../platform/native/electron-main/nativeHostMainService.js'; +import { GlobalKeybindingsMainService, IGlobalKeybindingsMainService } from '../../platform/globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IMeteredConnectionService } from '../../platform/meteredConnection/common/meteredConnection.js'; import { METERED_CONNECTION_CHANNEL } from '../../platform/meteredConnection/common/meteredConnectionIpc.js'; import { MeteredConnectionChannel } from '../../platform/meteredConnection/electron-main/meteredConnectionChannel.js'; @@ -104,8 +105,9 @@ import { IWorkspacesHistoryMainService, WorkspacesHistoryMainService } from '../ import { WorkspacesMainService } from '../../platform/workspaces/electron-main/workspacesMainService.js'; import { IWorkspacesManagementMainService, WorkspacesManagementMainService } from '../../platform/workspaces/electron-main/workspacesManagementMainService.js'; import { IPolicyService } from '../../platform/policy/common/policy.js'; -import { ICopilotManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; -import { CopilotManagedSettingsChannel } from '../../platform/policy/common/copilotManagedSettingsIpc.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { NativeManagedSettingsChannel } from '../../platform/policy/common/nativeManagedSettingsIpc.js'; +import { FileManagedSettingsChannel } from '../../platform/policy/common/fileManagedSettingsIpc.js'; import { PolicyChannel } from '../../platform/policy/common/policyIpc.js'; import { IUserDataProfilesMainService } from '../../platform/userDataProfile/electron-main/userDataProfile.js'; import { IExtensionsProfileScannerService } from '../../platform/extensionManagement/common/extensionsProfileScannerService.js'; @@ -127,10 +129,7 @@ import { ElectronPtyHostStarter } from '../../platform/terminal/electron-main/el import { PtyHostService } from '../../platform/terminal/node/ptyHostService.js'; import { ElectronAgentHostStarter } from '../../platform/agentHost/electron-main/electronAgentHostStarter.js'; import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js'; -import { isAgentHostEnabled } from '../../platform/agentHost/common/agentService.js'; import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from '../../platform/remote/common/electronRemoteResources.js'; -import { RemoteFileSystemProxyMainHandler } from '../../platform/files/electron-main/remoteFileSystemProxyMainHandler.js'; -import { REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME } from '../../platform/files/common/remoteFileSystemProxy.js'; import { Lazy } from '../../base/common/lazy.js'; import { IAuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { AuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.js'; @@ -1132,6 +1131,9 @@ export class CodeApplication extends Disposable { // Native Host services.set(INativeHostMainService, new SyncDescriptor(NativeHostMainService, undefined, false /* proxied to other processes */)); + // System-wide (OS global) keybindings + services.set(IGlobalKeybindingsMainService, new SyncDescriptor(GlobalKeybindingsMainService, [globalShortcut])); + // Metered Connection const meteredConnectionService = new MeteredConnectionMainService(this.configurationService); services.set(IMeteredConnectionService, meteredConnectionService); @@ -1169,10 +1171,15 @@ export class CodeApplication extends Disposable { services.set(ILocalPtyService, ptyHostService); // Agent Host - if (isAgentHostEnabled(this.configurationService)) { - const agentHostStarter = new ElectronAgentHostStarter(this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService); - this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService)); - } + // Always instantiate the starter + manager. They are cheap (the + // constructors only register an IPC listener and emitters) and the agent + // host utility process is spawned lazily on the first window connection + // request. The renderer is the gate: it only requests a connection when + // `chat.agentHost.enabled` resolves to `true` there (honoring experiment + // overrides + policy + web), which the main process cannot observe since + // experiment overrides are never persisted to `settings.json`. + const agentHostStarter = new ElectronAgentHostStarter(this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService); + this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService)); // External terminal if (isWindows) { @@ -1257,8 +1264,11 @@ export class CodeApplication extends Disposable { mainProcessElectronServer.registerChannel('policy', policyChannel); sharedProcessClient.then(client => client.registerChannel('policy', policyChannel)); - const copilotManagedSettingsChannel = disposables.add(new CopilotManagedSettingsChannel(accessor.get(ICopilotManagedSettingsService))); - mainProcessElectronServer.registerChannel('copilotManagedSettings', copilotManagedSettingsChannel); + const nativeManagedSettingsChannel = disposables.add(new NativeManagedSettingsChannel(accessor.get(INativeManagedSettingsService))); + mainProcessElectronServer.registerChannel('nativeManagedSettings', nativeManagedSettingsChannel); + + const fileManagedSettingsChannel = disposables.add(new FileManagedSettingsChannel(accessor.get(IFileManagedSettingsService))); + mainProcessElectronServer.registerChannel('fileManagedSettings', fileManagedSettingsChannel); // Local Files const diskFileSystemProvider = this.fileService.getProvider(Schemas.file); @@ -1277,9 +1287,8 @@ export class CodeApplication extends Disposable { const updateChannel = new UpdateChannel(updateService); mainProcessElectronServer.registerChannel('update', updateChannel); - // Show a native "no updates available" dialog from the focused app's main - // process to avoid double dialogs across apps and ensure a native dialog. - this._register(new NotAvailableUpdateDialog(updateService, accessor.get(IDialogMainService))); + // Show a native "no updates available" dialog from the main process only in windowless macOS case. + this._register(new NotAvailableUpdateDialog(updateService, accessor.get(IDialogMainService), accessor.get(IWindowsMainService))); // Metered Connection const meteredConnectionChannel = new MeteredConnectionChannel(accessor.get(IMeteredConnectionService) as MeteredConnectionMainService); @@ -1304,10 +1313,6 @@ export class CodeApplication extends Disposable { mainProcessElectronServer.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel); sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel)); - // Remote File System Proxy - const remoteFileSystemProxyHandler = disposables.add(new RemoteFileSystemProxyMainHandler(accessor.get(IWindowsMainService), mainProcessElectronServer)); - mainProcessElectronServer.registerChannel(REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME, remoteFileSystemProxyHandler); - // Signing const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables); mainProcessElectronServer.registerChannel('sign', signChannel); diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index 386bf6f4b3c40..dc578b3e519b6 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -16,7 +16,7 @@ import { IPathWithLineAndColumn, isValidBasename, parseLineAndColumnAware, sanit import { Event } from '../../base/common/event.js'; import { getPathLabel } from '../../base/common/labels.js'; import { Schemas } from '../../base/common/network.js'; -import { basename, resolve } from '../../base/common/path.js'; +import { basename, join, resolve } from '../../base/common/path.js'; import { mark } from '../../base/common/performance.js'; import { IProcessEnvironment, isLinux, isMacintosh, isWindows, OS } from '../../base/common/platform.js'; import { cwd } from '../../base/common/process.js'; @@ -64,8 +64,9 @@ import { IPolicyService, NullPolicyService } from '../../platform/policy/common/ import { NativePolicyService } from '../../platform/policy/node/nativePolicyService.js'; import { FilePolicyService } from '../../platform/policy/common/filePolicyService.js'; import { MultiplexPolicyService } from '../../platform/policy/common/multiplexPolicyService.js'; -import { GITHUB_COPILOT_MACOS_BUNDLE_ID, GITHUB_COPILOT_WIN32_POLICY_NAME, GITHUB_COPILOT_WIN32_REGISTRY_PATH, ICopilotManagedSettingsService, NullCopilotManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; -import { CopilotManagedSettingsService } from '../../platform/policy/node/copilotManagedSettingsService.js'; +import { GITHUB_COPILOT_MACOS_BUNDLE_ID, GITHUB_COPILOT_WIN32_POLICY_NAME, GITHUB_COPILOT_WIN32_REGISTRY_PATH, INativeManagedSettingsService, IFileManagedSettingsService, MANAGED_SETTINGS_FILE_NAME, MANAGED_SETTINGS_LINUX_FILE_PATH, MANAGED_SETTINGS_MACOS_FILE_PATH, MANAGED_SETTINGS_WINDOWS_DIR, NullNativeManagedSettingsService, NullFileManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { FileManagedSettingsService } from '../../platform/policy/common/fileManagedSettingsService.js'; +import { NativeManagedSettingsService } from '../../platform/policy/node/nativeManagedSettingsService.js'; import { DisposableStore } from '../../base/common/lifecycle.js'; import { IUriIdentityService } from '../../platform/uriIdentity/common/uriIdentity.js'; import { UriIdentityService } from '../../platform/uriIdentity/common/uriIdentityService.js'; @@ -229,16 +230,35 @@ class CodeMain { policyServices.push(disposables.add(new FilePolicyService(environmentMainService.policyFile, fileService, logService))); } - let copilotManagedSettingsService: CopilotManagedSettingsService | undefined; + let nativeManagedSettingsService: NativeManagedSettingsService | undefined; if (isWindows) { - copilotManagedSettingsService = disposables.add(new CopilotManagedSettingsService(logService, GITHUB_COPILOT_WIN32_POLICY_NAME, { registryPath: GITHUB_COPILOT_WIN32_REGISTRY_PATH })); + nativeManagedSettingsService = disposables.add(new NativeManagedSettingsService(logService, GITHUB_COPILOT_WIN32_POLICY_NAME, { registryPath: GITHUB_COPILOT_WIN32_REGISTRY_PATH })); } else if (isMacintosh) { - copilotManagedSettingsService = disposables.add(new CopilotManagedSettingsService(logService, GITHUB_COPILOT_MACOS_BUNDLE_ID)); + nativeManagedSettingsService = disposables.add(new NativeManagedSettingsService(logService, GITHUB_COPILOT_MACOS_BUNDLE_ID)); } - if (copilotManagedSettingsService) { - services.set(ICopilotManagedSettingsService, copilotManagedSettingsService); + if (nativeManagedSettingsService) { + services.set(INativeManagedSettingsService, nativeManagedSettingsService); } else { - services.set(ICopilotManagedSettingsService, new NullCopilotManagedSettingsService()); + services.set(INativeManagedSettingsService, new NullNativeManagedSettingsService()); + } + + // File-based managed settings + let fileManagedSettingsPath: string | undefined; + if (isWindows) { + const programFiles = process.env['ProgramFiles']; + if (programFiles) { + fileManagedSettingsPath = join(programFiles, MANAGED_SETTINGS_WINDOWS_DIR, MANAGED_SETTINGS_FILE_NAME); + } + } else if (isMacintosh) { + fileManagedSettingsPath = MANAGED_SETTINGS_MACOS_FILE_PATH; + } else if (isLinux) { + fileManagedSettingsPath = MANAGED_SETTINGS_LINUX_FILE_PATH; + } + if (fileManagedSettingsPath) { + const fileManagedSettingsService = disposables.add(new FileManagedSettingsService(URI.file(fileManagedSettingsPath), fileService, logService)); + services.set(IFileManagedSettingsService, fileManagedSettingsService); + } else { + services.set(IFileManagedSettingsService, new NullFileManagedSettingsService()); } if (policyServices.length > 1) { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index d0cea4135d258..b442a2c8397ff 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -55,7 +55,11 @@ export class MultiDiffEditorWidget extends Disposable { return new MultiDiffEditorViewModel(model, this._instantiationService); } - public setViewModel(viewModel: MultiDiffEditorViewModel | undefined): void { + public setViewModel(viewModel: MultiDiffEditorViewModel | undefined, options?: { readonly preserveFocus?: boolean }): void { + // An editor opened with `preserveFocus` (e.g. restored in the background + // or on a session switch) must not have its automatic first-change + // selection steal keyboard focus from elsewhere (such as the chat input). + this._widgetImpl.get().setPreserveFocusOnLoad(!!options?.preserveFocus); this._viewModel.set(viewModel, undefined); } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 76f410afc23f2..504a91a9d2c7c 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -60,6 +60,15 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _contextKeyService; private readonly _instantiationService; + /** + * When `true`, the automatic "select the first change" initialization that + * runs once the view model finishes loading does not move keyboard focus + * into the editor. Driven by {@link setPreserveFocusOnLoad} so a + * `preserveFocus` open (e.g. restored in the background or on a session + * switch) does not steal focus, while a normal user-initiated open does. + */ + private _preserveFocusOnLoad = false; + constructor( private readonly _element: HTMLElement, private readonly _dimension: IObservable, @@ -242,8 +251,14 @@ export class MultiDiffEditorWidgetImpl extends Disposable { return; } - // Navigate to the first change using the existing navigation logic - this.goToNextChange(); + // Navigate to the first change using the existing navigation + // logic. Whether this also moves keyboard focus into the editor + // is driven by the last `setViewModel` call: an editor opened + // with `preserveFocus` (e.g. restored in the background or on a + // session switch) must not steal focus from wherever the user is + // (such as the chat input), while a normal user-initiated open + // focuses the first change so the editor is ready to use. + this._navigateToChange('next', !this._preserveFocusOnLoad); } })); @@ -259,6 +274,16 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this._scrollableElement.setScrollPosition({ scrollLeft: scrollState.left, scrollTop: scrollState.top }); } + /** + * Controls whether the automatic first-change selection that runs once the + * view model finishes loading preserves focus instead of moving it into the + * editor. Set to `true` for `preserveFocus` opens so focus is not stolen + * from elsewhere. + */ + public setPreserveFocusOnLoad(preserveFocus: boolean): void { + this._preserveFocusOnLoad = preserveFocus; + } + public getRootElement(): HTMLElement { return this._elements.root; } @@ -360,7 +385,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this._navigateToChange('previous'); } - private _navigateToChange(direction: 'next' | 'previous'): void { + private _navigateToChange(direction: 'next' | 'previous', focusEditor: boolean = true): void { const viewItems = this._viewItems.get(); if (viewItems.length === 0) { return; @@ -371,7 +396,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { // Start with first file if no active item if (currentIndex === -1) { - this._goToFile(0, 'first'); + this._goToFile(0, 'first', focusEditor); return; } @@ -395,10 +420,10 @@ export class MultiDiffEditorWidgetImpl extends Disposable { // Move to next/previous file const nextIndex = (currentIndex + (direction === 'next' ? 1 : -1) + viewItems.length) % viewItems.length; - this._goToFile(nextIndex, direction === 'next' ? 'first' : 'last'); + this._goToFile(nextIndex, direction === 'next' ? 'first' : 'last', focusEditor); } - private _goToFile(index: number, position: 'first' | 'last'): void { + private _goToFile(index: number, position: 'first' | 'last', focusEditor: boolean = true): void { const item = this._viewItems.get()[index]; if (item.viewModel.collapsed.get()) { item.viewModel.collapsed.set(false, undefined); @@ -417,7 +442,9 @@ export class MultiDiffEditorWidgetImpl extends Disposable { modifiedEditor.revealLineInCenter(lastChange.modified.startLineNumber); } } - editor?.focus(); + if (focusEditor) { + editor?.focus(); + } } private render(reader: IReader | undefined) { diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 33e90ab7f7572..d8488e6f4ce7c 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -404,7 +404,6 @@ export namespace CompletionItemKinds { byKind.set(CompletionItemKind.Value, Codicon.symbolValue); byKind.set(CompletionItemKind.Enum, Codicon.symbolEnum); byKind.set(CompletionItemKind.Constant, Codicon.symbolConstant); - byKind.set(CompletionItemKind.Enum, Codicon.symbolEnum); byKind.set(CompletionItemKind.EnumMember, Codicon.symbolEnumMember); byKind.set(CompletionItemKind.Keyword, Codicon.symbolKeyword); byKind.set(CompletionItemKind.Snippet, Codicon.symbolSnippet); diff --git a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts index 18a4187c3a0df..506be419193df 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts @@ -125,6 +125,7 @@ export class ColorDetector extends Disposable implements IEditorContribution { this.stop(); if (!this._isColorDecoratorsEnabled) { + this.removeAllDecorations(); return; } const model = this._editor.getModel(); diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts b/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts index 6c0744229c754..57189a4fbca6a 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts @@ -11,7 +11,7 @@ import { DocumentColorProvider, IColorInformation } from '../../../common/langua import { ITextModel, TrackedRangeStickiness } from '../../../common/model.js'; import { getColorPresentations } from './color.js'; import { ColorPickerModel } from './colorPickerModel.js'; -import { Range } from '../../../common/core/range.js'; +import { IRange, Range } from '../../../common/core/range.js'; export const enum ColorPickerWidgetType { Hover = 'hover', @@ -42,16 +42,20 @@ export async function createColorHover(editorModel: ITextModel, colorInfo: IColo }; } -export function updateEditorModel(editor: IActiveCodeEditor, range: Range, model: ColorPickerModel): Range { +export function updateEditorModel(editor: IActiveCodeEditor, range: Range, model: ColorPickerModel, insertionRanges?: IRange[]): Range { const textEdits: ISingleEditOperation[] = []; const edit = model.presentation.textEdit ?? { range, text: model.presentation.label, forceMoveMarkers: false }; - textEdits.push(edit); if (model.presentation.additionalTextEdits) { textEdits.push(...model.presentation.additionalTextEdits); } const replaceRange = Range.lift(edit.range); const trackedRange = editor.getModel()._setTrackedRange(null, replaceRange, TrackedRangeStickiness.GrowsOnlyWhenTypingAfter); + if (insertionRanges) { + textEdits.push(...insertionRanges.map(insertionRange => ({ range: insertionRange, text: edit.text, forceMoveMarkers: false }))); + } else { + textEdits.push(edit); + } editor.executeEdits('colorpicker', textEdits); editor.pushUndoStop(); return editor.getModel()._getTrackedRange(trackedRange) ?? replaceRange; diff --git a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts index 8e66ff8b1b21a..a7b878c775ca6 100644 --- a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts +++ b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts @@ -16,7 +16,7 @@ import { ColorDetector } from '../colorDetector.js'; import { ColorPickerModel } from '../colorPickerModel.js'; import { BaseColor, ColorPickerWidgetType, createColorHover, updateColorPresentations, updateEditorModel } from '../colorPickerParticipantUtils.js'; import { ColorPickerWidget } from '../colorPickerWidget.js'; -import { Range } from '../../../../common/core/range.js'; +import { IRange, Range } from '../../../../common/core/range.js'; import { EditorOption } from '../../../../common/config/editorOptions.js'; import { Dimension } from '../../../../../base/browser/dom.js'; @@ -107,7 +107,7 @@ export class StandaloneColorPickerParticipant { return { colorHover, foundInEditor }; } - public async updateEditorModel(colorHoverData: StandaloneColorPickerHover): Promise { + public async updateEditorModel(colorHoverData: StandaloneColorPickerHover, insertionRanges?: IRange[]): Promise { if (!this._editor.hasModel()) { return; } @@ -115,7 +115,7 @@ export class StandaloneColorPickerParticipant { let range = new Range(colorHoverData.range.startLineNumber, colorHoverData.range.startColumn, colorHoverData.range.endLineNumber, colorHoverData.range.endColumn); if (this._color) { await updateColorPresentations(this._editor.getModel(), colorPickerModel, this._color, range, colorHoverData); - range = updateEditorModel(this._editor, range, colorPickerModel); + range = updateEditorModel(this._editor, range, colorPickerModel, insertionRanges); } } diff --git a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts index f15557441bdf6..6d2104a88dfcc 100644 --- a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts +++ b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts @@ -47,6 +47,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW private _body: HTMLElement = document.createElement('div'); private _colorHover: StandaloneColorPickerHover | null = null; private _selectionSetInEditor: boolean = false; + private _selections: IRange[]; private readonly _onResult = this._register(new Emitter()); public readonly onResult = this._onResult.event; @@ -69,6 +70,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW this._standaloneColorPickerParticipant = _instantiationService.createInstance(StandaloneColorPickerParticipant, this._editor); this._position = this._editor._getViewModel()?.getPrimaryCursorState().modelState.position; const editorSelection = this._editor.getSelection(); + this._selections = this._editor.getSelections() ?? []; const selection = editorSelection ? { startLineNumber: editorSelection.startLineNumber, @@ -91,6 +93,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW } else { this._selectionSetInEditor = false; } + this._selections = this._editor.getSelections() ?? []; })); this._register(this._editor.onMouseMove((e) => { const classList = e.target.element?.classList; @@ -108,7 +111,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW public updateEditor() { if (this._colorHover) { - this._standaloneColorPickerParticipant.updateEditorModel(this._colorHover); + this._standaloneColorPickerParticipant.updateEditorModel(this._colorHover, this._selections); } } diff --git a/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts b/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts index 7700f6c4215f1..85f4edb6ad319 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts @@ -17,7 +17,7 @@ import { ContentHoverResult } from './contentHoverTypes.js'; import * as dom from '../../../../base/browser/dom.js'; import { HoverVerbosityAction } from '../../../common/languages.js'; import { MarkdownHoverParticipant } from './markdownHoverParticipant.js'; -import { HoverColorPickerParticipant } from '../../colorPicker/browser/hoverColorPicker/hoverColorPickerParticipant.js'; +import { ColorHover, HoverColorPickerParticipant } from '../../colorPicker/browser/hoverColorPicker/hoverColorPickerParticipant.js'; import { localize } from '../../../../nls.js'; import { InlayHintsHover } from '../../inlayHints/browser/inlayHintsHover.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; @@ -226,6 +226,7 @@ class RenderedContentHoverParts extends Disposable { }); private readonly _renderedParts: IRenderedContentHoverPartOrStatusBar[] = []; + private readonly _perPartDisposables = new Map(); private readonly _fragment: DocumentFragment; private readonly _context: IEditorHoverContext; @@ -320,29 +321,42 @@ class RenderedContentHoverParts extends Disposable { } private _registerListenersOnRenderedParts(): IDisposable { - const disposables = new DisposableStore(); + // Create per-part disposables so that when an individual rendered part is + // updated we can dispose its listeners and copy button without affecting + // the others. this._renderedParts.forEach((renderedPart: IRenderedContentHoverPartOrStatusBar, index: number) => { - const element = renderedPart.hoverElement; - element.tabIndex = 0; - disposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_IN, (event: Event) => { - event.stopPropagation(); - this._focusedHoverPartIndex = index; - })); - disposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_OUT, (event: Event) => { - event.stopPropagation(); - this._focusedHoverPartIndex = -1; - })); - // Add copy button for marker hovers - if (renderedPart.type === 'hoverPart' && !renderedPart.participant.hideCopyButton) { - disposables.add(new HoverCopyButton( - element, - () => renderedPart.participant.getAccessibleContent(renderedPart.hoverPart), - this._clipboardService, - this._hoverService - )); + this._createListenersForPart(index, renderedPart); + }); + return toDisposable(() => { + for (const d of this._perPartDisposables.values()) { + d.dispose(); } + this._perPartDisposables.clear(); }); - return disposables; + } + + private _createListenersForPart(index: number, renderedPart: IRenderedContentHoverPartOrStatusBar): void { + const partDisposables = new DisposableStore(); + const element = renderedPart.hoverElement; + element.tabIndex = 0; + partDisposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_IN, (event: Event) => { + event.stopPropagation(); + this._focusedHoverPartIndex = index; + })); + partDisposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_OUT, (event: Event) => { + event.stopPropagation(); + this._focusedHoverPartIndex = -1; + })); + // Add copy button for marker hovers + if (renderedPart.type === 'hoverPart' && !(renderedPart.hoverPart instanceof ColorHover) && !renderedPart.participant.hideCopyButton) { + partDisposables.add(new HoverCopyButton( + element, + () => renderedPart.participant.getAccessibleContent(renderedPart.hoverPart), + this._clipboardService, + this._hoverService + )); + } + this._perPartDisposables.set(index, partDisposables); } private _updateMarkdownAndColorParticipantInfo(participants: IEditorHoverParticipant[]) { @@ -409,12 +423,20 @@ class RenderedContentHoverParts extends Disposable { if (!renderedPart) { continue; } + // Dispose any listeners/copy button for the previous part at this index + const prevDisposable = this._perPartDisposables.get(i); + if (prevDisposable) { + prevDisposable.dispose(); + this._perPartDisposables.delete(i); + } this._renderedParts[i] = { type: 'hoverPart', participant: this._markdownHoverParticipant, hoverPart: renderedPart.hoverPart, hoverElement: renderedPart.hoverElement, }; + // Recreate listeners and copy button for the updated part. + this._createListenersForPart(i, this._renderedParts[i]); } if (focus) { if (index >= 0) { diff --git a/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts b/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts index 1fa7ff1c38f1f..c5bb38b0093d6 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts @@ -251,6 +251,9 @@ export class ContentHoverWidgetWrapper extends Disposable implements IHoverWidge if (isContentWidgetResizing) { return true; } + if (this._isMouseOnCodeActionWidget(mouseEvent)) { + return true; + } const anchorCandidates: HoverAnchor[] = this._findHoverAnchorCandidates(mouseEvent); const anchorCandidatesExist = anchorCandidates.length > 0; if (!anchorCandidatesExist) { @@ -295,6 +298,14 @@ export class ContentHoverWidgetWrapper extends Disposable implements IHoverWidge return anchorCandidates; } + private _isMouseOnCodeActionWidget(mouseEvent: IEditorMouseEvent): boolean { + const target = mouseEvent.event.browserEvent.target; + if (target instanceof Element && !!target.closest('.action-widget')) { + return true; + } + return false; + } + private _onMouseLeave(e: MouseEvent): void { const editorDomNode = this._editor.getDomNode(); const isMousePositionOutsideOfEditor = !editorDomNode || !isMousePositionWithinElement(editorDomNode, e.x, e.y); diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index d76df671aa8c8..abc6f9cd7157d 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -116,7 +116,11 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant `${basename(related.resource)}(${related.startLineNumber}, ${related.startColumn}): ${related.message}`).join('\n') + : undefined; + return [marker.message, relatedInformation].filter(value => !!value).join('\n'); } private _renderMarkerHover(markerHover: MarkerHover): IRenderedHoverPart { @@ -284,9 +288,6 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant - - - - - - - - - + \ No newline at end of file diff --git a/src/vs/editor/standalone/browser/iPadShowKeyboard/keyboard-light.svg b/src/vs/editor/standalone/browser/iPadShowKeyboard/keyboard-light.svg index 152bf777f62aa..eee8b5baeb1d5 100644 --- a/src/vs/editor/standalone/browser/iPadShowKeyboard/keyboard-light.svg +++ b/src/vs/editor/standalone/browser/iPadShowKeyboard/keyboard-light.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 109b4d8703aa8..9461288c8811d 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -47,7 +47,9 @@ export const enum AccessibleViewProviderId { OutputFindHelp = 'outputFindHelp', ProblemsFilterHelp = 'problemsFilterHelp', SessionsChat = 'sessionsChat', + SessionsChanges = 'sessionsChanges', Survey = 'survey', + Automations = 'automations', } export const enum AccessibleViewType { diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index faf6eb7559dbd..cae571a303ee0 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -8,6 +8,7 @@ import { renderMarkdown } from '../../../base/browser/markdownRenderer.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/contextview.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; +import { Toggle } from '../../../base/browser/ui/toggle/toggle.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; import { IAction, SubmenuAction, toAction } from '../../../base/common/actions.js'; @@ -26,6 +27,7 @@ import { localize } from '../../../nls.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; import { IOpenerService } from '../../opener/common/opener.js'; +import { Link } from '../../opener/browser/link.js'; import { defaultListStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable } from '../../theme/common/colorRegistry.js'; import { ILayoutService } from '../../layout/browser/layoutService.js'; @@ -56,6 +58,22 @@ export interface IActionListItemHover { readonly disposable?: IDisposable; } +/** + * Optional inline toggle switch rendered inside an action list item, shown on its + * own row below the label/detail. Useful for an always-visible boolean sub-control + * (e.g. a sandbox toggle) that is independent from selecting the item itself. + */ +export interface IActionListItemInlineToggle { + /** Label shown to the left of the switch. */ + readonly label: string; + /** Current checked state of the switch. */ + readonly checked: boolean; + /** Invoked when the user flips the switch. */ + readonly onChange: (checked: boolean) => void; + /** Optional accessible/hover title for the switch. Defaults to {@link label}. */ + readonly title?: string; +} + export interface IActionListItem { readonly item?: T; readonly kind: ActionListItemKind; @@ -66,6 +84,10 @@ export interface IActionListItem { * Optional detail text displayed as a second line below the label. */ readonly detail?: string; + /** + * Optional inline toggle switch rendered on its own row inside the item. + */ + readonly inlineToggle?: IActionListItemInlineToggle; readonly description?: string | IMarkdownString; /** * Optional accessible description used in place of {@link description} for @@ -132,6 +154,7 @@ interface IActionMenuTemplateData { readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; readonly submenuIndicator: HTMLElement; + readonly inlineToggleContainer: HTMLElement; readonly elementDisposables: DisposableStore; previousClassName?: string; } @@ -249,9 +272,13 @@ class ActionItemRenderer implements IListRenderer, IAction submenuIndicator.className = 'action-list-submenu-indicator'; container.append(submenuIndicator); + const inlineToggleContainer = document.createElement('div'); + inlineToggleContainer.className = 'action-list-item-inline-toggle'; + container.append(inlineToggleContainer); + const elementDisposables = new DisposableStore(); - return { container, icon, text, detail, badge, description, groupTitle, keybinding, toolbar, submenuIndicator, elementDisposables }; + return { container, icon, text, detail, badge, description, groupTitle, keybinding, toolbar, submenuIndicator, inlineToggleContainer, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -351,6 +378,34 @@ class ActionItemRenderer implements IListRenderer, IAction data.detail.style.display = 'none'; } + // Render optional inline toggle (shown as its own row below the detail) + dom.clearNode(data.inlineToggleContainer); + if (element.inlineToggle) { + const inlineToggle = element.inlineToggle; + const toggleLabel = document.createElement('span'); + toggleLabel.className = 'action-list-item-inline-toggle-label'; + toggleLabel.textContent = stripNewlines(inlineToggle.label); + data.inlineToggleContainer.append(toggleLabel); + data.inlineToggleContainer.style.display = ''; + data.container.classList.add('has-inline-toggle'); + const toggle = data.elementDisposables.add(new Toggle({ + title: inlineToggle.title ?? inlineToggle.label, + isChecked: inlineToggle.checked, + actionClassName: 'action-list-inline-switch', + notFocusable: false, + inputActiveOptionBorder: undefined, + inputActiveOptionForeground: undefined, + inputActiveOptionBackground: undefined, + })); + data.inlineToggleContainer.append(toggle.domNode); + data.elementDisposables.add(toggle.onChange(() => inlineToggle.onChange(toggle.checked))); + // Keep clicks on the toggle row from selecting the item. + data.elementDisposables.add(dom.addDisposableListener(data.inlineToggleContainer, dom.EventType.CLICK, e => e.stopPropagation())); + } else { + data.inlineToggleContainer.style.display = 'none'; + data.container.classList.remove('has-inline-toggle'); + } + const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel(); const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel(); data.container.classList.toggle('option-disabled', !!element.disabled); @@ -437,6 +492,16 @@ function getKeyboardNavigationLabel(item: IActionListItem): string | undef return undefined; } +/** + * A "Learn more" style link rendered inline in the action list header banner. + */ +export interface IActionListHeaderLink { + /** Visible link text (e.g. "Learn more"). Should be localized. */ + readonly label: string; + /** Target opened via the opener service when the link is activated. */ + readonly uri: URI; +} + /** * Options for configuring the action list. */ @@ -495,6 +560,12 @@ export interface IActionListOptions { */ readonly detailItemHeight?: number; + /** + * Height (in px) used for action items that have an `inlineToggle`. + * Defaults to 70. + */ + readonly inlineToggleItemHeight?: number; + /** * When true, the group title is shown on the first item of each group * in the description area (aligned to the right). @@ -540,6 +611,12 @@ export interface IActionListOptions { */ readonly headerIcon?: ThemeIcon; + /** Optional "Learn more" link rendered inline after {@link headerText}, opened via the opener service. */ + readonly headerLink?: IActionListHeaderLink; + + /** Optional dismiss ("x") button on the header banner; invoked on click, and the banner is removed. */ + readonly headerDismiss?: () => void; + /** * Optional CSS class name added to the action list container, for scoped styling. */ @@ -579,7 +656,7 @@ export class ActionListWidget extends Disposable { private readonly _filterInput: HTMLInputElement | undefined; private readonly _filterContainer: HTMLElement | undefined; private readonly _footerContainer: HTMLElement | undefined; - private readonly _headerContainer: HTMLElement | undefined; + private _headerContainer: HTMLElement | undefined; private readonly _filterCts = this._register(new MutableDisposable()); private readonly _groupTitleByIndex = new Map(); @@ -686,6 +763,11 @@ export class ActionListWidget extends Disposable { if (element.group?.title) { label = label + ', ' + element.group.title; } + if (element.inlineToggle) { + label = label + ', ' + (element.inlineToggle.checked + ? localize('actionList.inlineToggle.on', "{0}, on", element.inlineToggle.label) + : localize('actionList.inlineToggle.off', "{0}, off", element.inlineToggle.label)); + } if (element.disabled) { label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled); } @@ -773,6 +855,41 @@ export class ActionListWidget extends Disposable { } const text = dom.append(this._headerContainer, dom.$('span.action-list-header-text')); text.textContent = this._options.headerText; + + if (this._options.headerLink) { + const { label, uri } = this._options.headerLink; + // Trailing space so the link reads as a continuation of the banner text. + text.textContent += ' '; + this._register(this._instantiationService.createInstance(Link, text, { label, href: uri.toString(true) }, {})); + } + + if (this._options.headerDismiss) { + const onDismiss = this._options.headerDismiss; + const dismissButton = dom.append(this._headerContainer, dom.$('span.action-list-header-dismiss')); + dismissButton.appendChild(dom.$(ThemeIcon.asCSSSelector(Codicon.close))); + dismissButton.tabIndex = 0; + dismissButton.setAttribute('role', 'button'); + dismissButton.setAttribute('aria-label', localize('actionList.header.dismiss', "Dismiss")); + const dismiss = () => { + onDismiss(); + // Refocus the widget first so removing the focused button doesn't trip close-on-blur. + this.focus(); + this._headerContainer?.remove(); + // Drop the reference so the banner no longer reserves header height, then + // request a re-layout so the popup shrinks to fit the remaining content. + this._headerContainer = undefined; + this._onDidRequestLayout.fire(); + }; + // Generic mouse-up maps to pointer events on iOS, so tap/pen activation + // works without extra gesture plumbing (raw 'click' is unreliable there). + this._register(dom.addDisposableGenericMouseUpListener(dismissButton, () => dismiss())); + this._register(dom.addDisposableListener(dismissButton, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + dismiss(); + } + })); + } } this._applyFilter(); @@ -1179,6 +1296,9 @@ export class ActionListWidget extends Disposable { case ActionListItemKind.Separator: return item.label ? this._actionLineHeight : this._separatorLineHeight; default: + if (item.inlineToggle) { + return this._options?.inlineToggleItemHeight ?? 70; + } return item.detail ? (this._options?.detailItemHeight ?? 48) : this._actionLineHeight; } } @@ -1422,10 +1542,10 @@ export class ActionListWidget extends Disposable { }); return; } - // Don't select when clicking the toolbar or submenu indicator + // Don't select when clicking the toolbar, submenu indicator, or inline toggle if (dom.isMouseEvent(e.browserEvent)) { const target = e.browserEvent.target; - if (dom.isHTMLElement(target) && (target.closest('.action-list-item-toolbar') || target.closest('.action-list-submenu-indicator'))) { + if (dom.isHTMLElement(target) && (target.closest('.action-list-item-toolbar') || target.closest('.action-list-submenu-indicator') || target.closest('.action-list-item-inline-toggle'))) { this._list.setSelection([]); return; } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index a29cef1b863f9..bb7fc4d8a352c 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -83,8 +83,8 @@ border-top: 1px solid var(--vscode-editorHoverWidget-border); color: var(--vscode-descriptionForeground); font-size: 12px; - margin: 4px 0px; - width: 100%; + margin: 4px 6px; + width: calc(100% - 12px); cursor: default; -webkit-user-select: none; user-select: none; @@ -283,6 +283,91 @@ } } +/* Items with an inline toggle — wrap so the toggle sits on its own row */ +.action-widget .monaco-list-row.action.has-inline-toggle { + flex-wrap: wrap; + align-content: center; + padding-right: 6px; + + .title { + line-height: 14px; + } +} + +.action-widget .monaco-list-row.action .action-list-item-inline-toggle { + order: 100; + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding-left: 18px; + padding-top: 6px; + line-height: 16px; +} + +.action-widget .monaco-list-row.action .action-list-item-inline-toggle .action-list-item-inline-toggle-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + line-height: 16px; + color: var(--vscode-foreground); +} + +/* Inline pill switch (restyles the base Toggle as an iOS-style switch) */ +.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch { + position: relative; + flex-shrink: 0; + float: none; + margin: 0; + padding: 0; + width: 26px; + height: 16px; + border-radius: var(--vscode-cornerRadius-circle); + border: var(--vscode-strokeThickness) solid var(--vscode-checkbox-border); + background-color: var(--vscode-checkbox-background); + overflow: visible; + transition: background-color 0.1s ease, border-color 0.1s ease; +} + +.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch:hover { + background-color: var(--vscode-checkbox-background); +} + +.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch::before { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--vscode-checkbox-foreground); + transition: left 0.1s ease, background-color 0.1s ease; +} + +.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked { + background-color: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked:hover { + background-color: var(--vscode-button-hoverBackground, var(--vscode-button-background)); +} + +.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked::before { + left: 11px; + background-color: var(--vscode-button-foreground); +} + +.hc-black .action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch, +.hc-light .action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch { + border-color: var(--vscode-contrastBorder); +} + + /* Inline description mode — description rendered right after the label */ .action-widget .inline-description .monaco-list-row.action { /* Override the row gap so group-title and toolbar sit flush */ @@ -445,6 +530,24 @@ font-size: 11px; } +.action-widget .action-list-header .action-list-header-dismiss { + flex-shrink: 0; + cursor: pointer; + color: var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 16px; +} + +.action-widget .action-list-header .action-list-header-dismiss:hover { + color: var(--vscode-foreground); +} + +.action-widget .action-list-header .action-list-header-dismiss:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: 2px; + border-radius: var(--vscode-cornerRadius-small); +} + /* Anchor for the absolutely-positioned submenu panel */ .action-widget .actionList { position: relative; diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 0e30a0a6a717c..51ba3ce6f72a1 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -13,7 +13,7 @@ import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListItemInlineToggle, IActionListOptions } from './actionList.js'; import { IActionWidgetService } from './actionWidget.js'; export interface IActionWidgetDropdownAction extends IAction { @@ -32,6 +32,14 @@ export interface IActionWidgetDropdownAction extends IAction { * Optional toolbar actions shown when the item is focused or hovered. */ toolbarActions?: IAction[]; + /** + * Optional CSS class name applied to the action list row container. + */ + className?: string; + /** + * Optional inline toggle switch rendered on its own row inside the item. + */ + inlineToggle?: IActionListItemInlineToggle; /** * Optional keybinding to display next to the action. When provided, this overrides the * keybinding that would otherwise be looked up via {@link IKeybindingService.lookupKeybinding}. @@ -146,6 +154,8 @@ export class ActionWidgetDropdown extends BaseDropdown { detail: action.detail, hover: action.hover, toolbarActions: action.toolbarActions, + className: action.className, + inlineToggle: action.inlineToggle, kind: ActionListItemKind.Action, canPreview: false, group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 841bf5f8d6e69..42af87d05bc7a 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -19,7 +19,8 @@ import { IKeybindingService } from '../../../keybinding/common/keybinding.js'; import { ILayoutService } from '../../../layout/browser/layoutService.js'; import { IOpenerService } from '../../../opener/common/opener.js'; import { NullOpenerService } from '../../../opener/test/common/nullOpenerService.js'; -import { ActionList, ActionListItemKind, ActionListWidget, IActionListItem } from '../../browser/actionList.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ActionList, ActionListItemKind, ActionListWidget, IActionListItem, IActionListOptions } from '../../browser/actionList.js'; interface ITestActionItem { readonly id: string; @@ -36,6 +37,7 @@ function separator(label?: string): IActionListItem { function createActionListWidget(disposables: ReturnType, options: { readonly items?: readonly IActionListItem[]; readonly onFilter?: (filter: string, cancellationToken: CancellationToken) => Promise[]>; + readonly listOptions?: Partial; }): ActionListWidget { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.set(IKeybindingService, new MockKeybindingService()); @@ -59,13 +61,20 @@ function createActionListWidget(disposables: ReturnType widget.filterContainer?.remove() }); } + // The header banner is a standalone element the caller attaches (like the + // filter container), so the test appends it to exercise header behaviors. + const headerContainer = widget.headerContainer; + if (headerContainer) { + document.body.appendChild(headerContainer); + disposables.add({ dispose: () => headerContainer.remove() }); + } document.body.appendChild(widget.domNode); disposables.add({ dispose: () => widget.domNode.remove() }); widget.layout(200, 200); @@ -235,4 +244,38 @@ suite('ActionListWidget', () => { const listHeight = parseFloat(list.domNode.style.height); assert.ok(listHeight + filterHeight + actionWidgetVerticalChromeHeight <= availableSpaceAboveAnchor); })); + + test('header dismiss removes the banner and requests a re-layout', () => { + let dismissed = false; + let layoutRequested = false; + const widget = createActionListWidget(disposables, { + listOptions: { headerText: 'Cache hint', headerDismiss: () => { dismissed = true; } }, + }); + disposables.add(widget.onDidRequestLayout(() => { layoutRequested = true; })); + + const header = widget.headerContainer; + assert.ok(header, 'header banner should render when headerText + headerDismiss are set'); + const dismissButton = header!.querySelector('.action-list-header-dismiss'); + assert.ok(dismissButton, 'dismiss button should render'); + + dismissButton!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + assert.deepStrictEqual( + { dismissed, layoutRequested, headerCleared: widget.headerContainer === undefined, headerStillInDom: header!.isConnected }, + { dismissed: true, layoutRequested: true, headerCleared: true, headerStillInDom: false }, + ); + }); + + test('header renders a "Learn more" link to the given uri', () => { + const widget = createActionListWidget(disposables, { + listOptions: { headerText: 'Cache hint', headerLink: { label: 'Learn more', uri: URI.parse('https://aka.ms/test') } }, + }); + + const link = widget.headerContainer?.querySelector('a.monaco-link'); + assert.ok(link, 'a "Learn more" link should render in the header'); + assert.deepStrictEqual( + { text: link!.textContent, href: link!.getAttribute('href') }, + { text: 'Learn more', href: 'https://aka.ms/test' }, + ); + }); }); diff --git a/src/vs/platform/actions/browser/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index 2c61f8f62573f..030fdeae3f612 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -106,6 +106,7 @@ export class WorkbenchButtonBar extends ButtonBar { tooltip = this._keybindingService.appendKeybinding(tooltip, action.id); btn = this.addButtonWithDropdown({ + addPrimaryActionToDropdown: false, secondary: configProvider(action, i)?.isSecondary ?? secondary, actionRunner: this._actionRunner, actions: rest, diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index aa03abe039b12..c775a60c48acc 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -258,12 +258,14 @@ export class MenuId { static readonly ChatMessageTitle = new MenuId('ChatMessageTitle'); static readonly ChatWelcomeContext = new MenuId('ChatWelcomeContext'); static readonly ChatMessageFooter = new MenuId('ChatMessageFooter'); + static readonly ChatSubagentContent = new MenuId('ChatSubagentContent'); static readonly ChatExecute = new MenuId('ChatExecute'); static readonly ChatExecuteQueue = new MenuId('ChatExecuteQueue'); static readonly ChatInput = new MenuId('ChatInput'); static readonly ChatInputSecondary = new MenuId('ChatInputSecondary'); static readonly ChatInputStatus = new MenuId('ChatInputStatus'); static readonly ChatInputSide = new MenuId('ChatInputSide'); + static readonly AutomationsDialogInput = new MenuId('AutomationsDialogInput'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar'); diff --git a/src/vs/platform/agentHost/MULTI_CHAT_ARCHITECTURE.md b/src/vs/platform/agentHost/MULTI_CHAT_ARCHITECTURE.md new file mode 100644 index 0000000000000..8325e3f1e6d82 --- /dev/null +++ b/src/vs/platform/agentHost/MULTI_CHAT_ARCHITECTURE.md @@ -0,0 +1,323 @@ + + +# Multi-Chat Architecture + +> **Status: COMPLETE** (2026-07-01) +> All waves A–D and gates G-B1, G-C1, G-C2, G-D1 are done. Codex, Claude, and +> Copilot all use the unified orchestrator path. + +--- + +## 1. Mental Model + +### Three distinct concepts + +| Term | What it is | Owner | +|------|-----------|-------| +| **Session** (SDK-level) | The SDK-level session: working directory, active client, tool permissions, restore identity. Owns the default chat implicitly. | Agent harness | +| **Chat** | A thread of turns within a session, addressed by a chat channel URI. The default chat's URI is derived from the session URI with `buildDefaultChatUri`. Additional (peer) chats have their own `ahp-chat://` URIs. | Agent harness (SDK) | +| **Orchestrator session** | The protocol-visible entity that bundles a session with its chat catalog, state, and persistence. The orchestrator owns the catalog (which chats exist), the default-chat pointer, and all persistence. | `AgentService` + `AgentHostStateManager` | + +### Guiding principles + +- **"Represent, don't orchestrate."** The agent harness creates and drives SDK + chats; the orchestrator records what exists and routes protocol + actions. No agent-specific logic leaks into `AgentService` or + `AgentHostStateManager`. +- **Composition over inheritance.** All harnesses share one membership path + (`addChat`/`removeChat`), one persistence path (`PEER_CHATS_METADATA_KEY`), + and one restore path (`restoreChat`). Per-harness features are expressed + through `IAgentCapabilities` flags, not `if (provider === 'claude') ...` + branches. +- **Single catalog path.** Whether a chat is created by the user ("Add Chat") + or spawned by the harness (subagent tool call), it enters the catalog through + exactly one path (`AgentHostStateManager.addChat`). See invariant I4 below. + +--- + +## 2. Ownership and Layering + +```mermaid +graph TB + subgraph UI["UI / provider layer (sessions window)"] + caps["ISessionCapabilities → context keys
(sessionContextKeys.ts)"] + smgt["ISessionsManagementService"] + end + + subgraph Orch["Orchestrator (agent host process)"] + svc["AgentService
(node/agentService.ts)"] + stm["AgentHostStateManager
(node/agentHostStateManager.ts)"] + svc -->|dispatch actions| stm + stm -->|action envelopes| svc + end + + subgraph Agents["Agent harnesses (IAgent)"] + claude["ClaudeAgent"] + copilot["CopilotAgent"] + codex["CodexAgent"] + end + + UI -->|"createChat / disposeChat / dispatchAction"| svc + svc -->|"chats.createChat / fork / sendMessage"| Agents + Agents -->|"onDidSessionProgress / onDidSpawnChat / onDidEndChat"| svc + stm -->|state snapshots / envelopes| UI + Agents -->|"getDescriptor().capabilities"| caps +``` + +### Agent layer (`common/agentService.ts:IAgent`) + +Responsible for: +- Creating and owning SDK chats (`chats.createChat`, `chats.fork`). +- Reading history (`chats.getMessages`). +- Emitting progress signals (`onDidSessionProgress`). +- Emitting membership events for harness-spawned chats (`onDidSpawnChat`, `onDidEndChat`). +- Re-attaching a peer chat's backing on restore (`materializeChat`). +- Advertising static capability flags (`getDescriptor().capabilities`). + +Agents do **not** maintain the chat catalog, persist membership, or know about the orchestrator's URI mapping. + +### Orchestrator layer + +**`AgentService` (`node/agentService.ts`):** +- Owns the `(session, chat)` → `(agent, session URI, chat URI)` mapping. +- Owns `_providers`, `_sessionToProvider`, and `_findProviderForSession` (which falls back through the session URI's scheme when a session was restored without a `createSession` call in this process lifetime). +- Dispatches user-driven chat lifecycle (`createChat`, `disposeChat`) to `chats.*`. +- Persists and restores the orchestrator-owned peer-chat catalog (`PEER_CHATS_METADATA_KEY` in the session database, serialized per session via `_peerChatCatalogWrites`). +- Suppresses a peer chat's separately-enumerable backing SDK session (when `IAgentCreateChatResult.backingSession` is set): marks it via `_markPeerChatBacking` and filters it out of `listSessions` (invariant I7). +- Routes harness-spawned chats into the catalog (`_onChatSpawned`, `_onChatEnded`). +- Owns the restore flow (`restoreSession`, `_restorePeerChats`). + +**`AgentHostStateManager` (`node/agentHostStateManager.ts`):** +- Holds the authoritative in-memory state tree: + - `_sessionStates: Map` — per-session `SessionState` + catalog timestamps. + - `_chatStates: Map` — per-chat state (turns, activeTurn, draft). + - `_chatProviderData: Map` — opaque `providerData` blobs keyed by peer-chat URI; never parsed. +- Owns `_ensureDefaultChat`: creates the default `ChatState` (URI derived deterministically from the session URI via `buildDefaultChatUri`) at create/restore time. +- `addChat`/`restoreChat`/`removeChat`: the single path for catalog membership changes. +- Session-level active-turn tracking via `_sessionsWithActiveTurn` (a set of chat URIs per session, so multi-chat sessions running concurrent turns stay correct). + +### UI/provider layer (`sessions/services/sessions/common/session.ts:ISessionCapabilities`) + +- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter into `ISession.capabilities` (`ISessionCapabilities`), whose `supportsMultipleChats`/`supportsFork` flags derive from the presence of `multipleChats` and `multipleChats.fork`, and from there into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). +- UI actions read context keys — no provider-id switches. + +--- + +## 3. Key Invariants + +**I1 — `providerData` is opaque.** +`AgentHostStateManager._chatProviderData` stores the blob returned by `chats.createChat` verbatim. Neither `AgentService` nor `AgentHostStateManager` ever parses, validates, or mutates it. It is round-tripped to the agent verbatim on restore via `materializeChat(chat, providerData)`. + +**I2 — `sessionUri` and `chatChannelUri` are never overloaded.** +A session URI (`ahp-copilot://`, `ahp-claude://`, …) identifies a session. A chat channel URI (`ahp-chat://…`) identifies a chat within a session. The two schemes are structurally distinct; `isAhpChatChannel` / `parseDefaultChatUri` / `buildDefaultChatUri` are the only crossing points. Passing a chat URI where a session URI is expected (or vice versa) is a bug. + +**I3 — The default chat's backing SDK session IS the session.** +The default chat's URI is derived deterministically from the session URI (`buildDefaultChatUri(sessionUri)`), and its backing SDK session id equals the session raw id. The default chat owns the session-level resources: working directory, active client, and restore-by-session-id. Peer chats are satellites, each backed by its own SDK session id (`IPersistedChat.sdkSessionId`). This distinction is encapsulated inside each harness's session container; the orchestrator never special-cases it. + +**I4 — Single catalog path (spawn channel).** +Both user-driven chats (`AgentService.createChat` → `addChat`) and harness-spawned chats (`AgentService._onChatSpawned` → `addChat`) go through `AgentHostStateManager.addChat`. The spawn-channel listener is registered **before** `AgentSideEffects` during `registerProvider` (`node/agentService.ts:registerProvider`) to guarantee the chat exists in the catalog before any turn actions arrive for it (DR1 deterministic sequencing). + +**I5 — Orchestrator peer-chat catalog is the restore source of truth (with one-time legacy migration).** +After Wave C2, the orchestrator persists its own peer-chat catalog (`PEER_CHATS_METADATA_KEY`) alongside the session database. On restore, `_restorePeerChats` reads that catalog. When it is **absent** (`undefined` — a session persisted before the orchestrator owned the catalog), a one-time migration (`_migrateLegacyPeerChats`) enumerates the agent's legacy `*.chats` via `IAgent.listLegacyChats` (only **Copilot** implements it — mapping its `_readPersistedChats` entries to `{ uri: buildChatUri(session, chatId), providerData: encodeProviderData(info) }`; Claude and Codex omit it, so `listLegacyChats` falls to its optional-undefined default and nothing is drained), restores them through the same catalog path, then writes `PEER_CHATS_METADATA_KEY` so subsequent restores read the new catalog and never consult the legacy read again. An **empty** catalog (`[]`) is "known-empty" and skips migration. Harness-spawned chats (subagents) are NOT in the catalog — they are transient and re-derived from the parent's event log on restore. (Claude has no legacy `claude.chats` blob: Claude multi-chat shipped only with the orchestrator-owned catalog, so there was never a pre-catalog format to migrate.) + +**I6 — `_findProviderForSession` not `_sessionToProvider`.** +The `_sessionToProvider` map is populated only by `createSession`. A restored session (alive in the state manager after a host restart but never created in this process) is absent from it. `_findProviderForSession` (`node/agentService.ts:AgentService._findProviderForSession`) falls back to the session URI scheme, which is what makes restored sessions work. + +**I7 — A peer chat's backing SDK session must never surface as a top-level session.** +Some agents (e.g. Claude) back a peer chat with a fresh top-level SDK session minted in the same global store their own `IAgent.listSessions` enumerates, so the backing would leak into the session list as a phantom session. To suppress it, `IAgentCreateChatResult` carries an optional **first-class, non-opaque** `backingSession: URI` (distinct from the opaque `providerData` of I1 — the orchestrator reads it but still never parses `providerData`). On `createChat`, the orchestrator writes a persisted `peerChatBacking` marker (value = the owning peer chat's URI) into that backing session's own database (`_markPeerChatBacking`), and `AgentService.listSessions` drops any enumerated session whose database carries that marker (batched into the existing metadata-overlay read, mirroring the subagent filter). Because the marker is persisted, the suppression survives a host restart with no re-stamping. Agents whose peer chats do not have a separately-enumerable backing session (e.g. Copilot, whose peer SDK sessions live in the chat's data dir and are dropped by its own `listSessions`) may leave `backingSession` unset; Copilot sets it anyway for uniformity, which is harmless. + +--- + +## 4. Capabilities Gating + +`AgentCapabilities` (`common/state/protocol/channels-root/state.ts:AgentCapabilities`) is the protocol-level contract: + +```typescript +interface AgentCapabilities { + // presence (`{}`) signals multi-chat support; absence = unsupported + multipleChats?: { + fork?: boolean; // can fork a chat from a turn + }; +} +``` + +The agent declares these in `getDescriptor().capabilities` (`common/agentService.ts:IAgentDescriptor`). They flow to the UI as `ISessionCapabilities` (`sessions/services/sessions/common/session.ts`) and are bound to context keys (`sessions/services/sessions/common/sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). + +UI code gates "Add Chat" and "Fork" actions on those context keys. No code inside `AgentService` or `AgentHostStateManager` switches on provider id to gate features. `AgentService.createChat` throws synchronously when `!provider.chats` (the structural guard that replaces a capability check in the orchestrator). + +--- + +## 5. Diagrams + +### 5a. Ownership/Component + +```mermaid +graph LR + subgraph SessionsUI["Sessions UI (workbench process)"] + provider["agentHostSessionsProvider
(copilotChatSessionsProvider)"] + ctxkeys["context keys
(sessionContextKeys.ts)"] + end + + subgraph AHP["Agent Host Process"] + svc["AgentService"] + stm["AgentHostStateManager\n• _sessionStates\n• _chatStates\n• _chatProviderData"] + se["AgentSideEffects"] + svc --- stm + svc --- se + end + + subgraph Harnesses["Agent Harnesses"] + claude["ClaudeAgent\n_sessions: DisposableMap"] + copilot["CopilotAgent\n_sessions: DisposableMap\n_chatBackings: Map"] + codex["CodexAgent\n_sessions: Map\n(single-chat)"] + end + + provider -->|"IPC (agentHost channel)"| svc + svc -->|"IAgentChats.*"| Harnesses + Harnesses -->|"onDidSessionProgress / onDidSpawnChat"| svc + stm -->|"ActionEnvelope stream"| provider + provider -->|"capabilities.multipleChats(.fork)"| ctxkeys +``` + +### 5b. Sequence: User-Driven Add Chat + +```mermaid +sequenceDiagram + participant UI as Sessions UI + participant AS as AgentService + participant A as IAgent.chats + participant SM as AgentHostStateManager + + UI->>AS: createChat(session, chatUri, options?) + AS->>AS: _findProviderForSession(session) + AS->>A: chats.createChat(chatUri, convOptions) + A-->>AS: IAgentCreateChatResult { providerData?, backingSession? } + AS->>SM: addChat(session, chatUri, { providerData }) + SM-->>UI: ActionEnvelope (SessionChatAdded) + AS->>AS: _persistPeerChat(session, chatUri, providerData) + Note over AS: enqueued per-session RMW of PEER_CHATS_METADATA_KEY + opt backingSession set (I7) + AS->>AS: _markPeerChatBacking(backingSession, chatUri) + Note over AS: writes peerChatBacking marker into the backing session's DB
so listSessions filters it out + end +``` + +### 5c. Sequence: Harness-Spawned Chat (Subagent via Spawn Channel) + +```mermaid +sequenceDiagram + participant SDK as Agent SDK + participant A as IAgent (onDidSessionProgress / onDidSpawnChat) + participant AS as AgentService + participant SM as AgentHostStateManager + participant SE as AgentSideEffects + + SDK->>A: subagent_started signal + A->>AS: onDidSessionProgress(AgentSignal{kind:'subagent_started'}) + Note over AS: _sequenceSpawnedChat (registered BEFORE AgentSideEffects) + AS->>AS: _onChatSpawned(event) + AS->>SM: addChat(session, chat, {origin: {kind:Tool, toolCallId}}) + SM-->>AS: ChatSummary + Note over SE: AgentSideEffects listener fires next, chat already in catalog (DR1) + SE->>SM: dispatch turn lifecycle actions for the spawned chat + Note over AS: Spawned chats are NOT persisted to PEER_CHATS_METADATA_KEY\n(transient, re-derived from event log on restore) +``` + +### 5d. Sequence: Restore + +```mermaid +sequenceDiagram + participant C as Client (subscribe) + participant AS as AgentService + participant A as IAgent + participant SM as AgentHostStateManager + + C->>AS: subscribe(sessionUri, clientId) + AS->>AS: restoreSession(sessionUri) + AS->>A: getSessionMessages(sessionUri) [default chat turns] + A-->>AS: Turn[] + AS->>AS: _readPersistedChatTitle(session, defaultChatUri) + AS->>SM: restoreSession(summary, turns, {draft, defaultChatTitle}) + SM->>SM: _ensureDefaultChat(sessionKey, summary, turns) + Note over AS: Peer chats: read PEER_CHATS_METADATA_KEY from DB + alt catalog present (defined) + loop for each IPersistedPeerChat (in catalog order) + AS->>A: materializeChat(chatUri, providerData?) + AS->>A: chats.getMessages(chatUri) + A-->>AS: Turn[] + AS->>SM: restoreChat(session, chatUri, {title, turns, draft, providerData}) + end + else catalog absent (undefined) — one-time legacy migration (Copilot only) + AS->>A: listLegacyChats(session) [legacy copilot.chats] + A-->>AS: {uri, providerData}[] + loop for each legacy chat + AS->>A: materializeChat + getMessages + AS->>SM: restoreChat(session, chatUri, {...}) + end + AS->>AS: _persistPeerChat(...) writes PEER_CHATS_METADATA_KEY (drain once) + end + AS-->>C: IStateSnapshot +``` + +### 5e. The (session, chat) to (agent, session URI, chat URI) Mapping + +```mermaid +graph TD + A["client dispatch: channel=ahp-chat://session/…/chat/…"] + B{isAhpChatChannel?} + C["chatChannel = channel\nsessionChannel = parseRequiredSessionUriFromChatUri(channel)"] + D["sessionChannel = channel\nchatChannel = undefined"] + E["agent = _findProviderForSession(sessionChannel)"] + F["session = sessionChannel (session URI)\nchat = chatChannel (concrete chat channel URI)"] + A --> B + B -->|yes| C + B -->|no| D + C --> E + D --> E + E --> F + F -->|"chats.sendMessage(chat, …)"| G["agent harness resolves its SDK session\nfrom the concrete chat URI"] +``` + +The orchestrator resolves the owning **session** from the session URI for session-scoped work, but passes a concrete **chat channel URI** to `IAgentChats` operations. For the default chat, that is `buildDefaultChatUri(sessionUri)`, not the bare session URI. Agents encapsulate the SDK fact that the default chat's backing SDK session id is the session id. + +--- + +## 6. Per-Agent Notes + +### Claude (`node/claude/claudeAgent.ts`) + +Single `_sessions: DisposableMap` keyed by session id. + +`ClaudeSessionEntry` (`claudeAgent.ts:ClaudeSessionEntry`) is a thin subclass of the shared `AgentSessionEntry` (`node/agentPeerChats.ts`), which is a `Disposable` container holding ALL chats of the session — the default (main) chat and any peers — together in ONE map keyed by each chat's channel URI string: +- `_chats: DisposableMap>` — every chat (default + peers) as a leaf entry, keyed by chat URI string. +- `_defaultChatKey` — the key of the default chat within `_chats`. `defaultChat` reads it; Claude narrows the base's optional `defaultChat` accessor to non-optional because a Claude entry is always seeded with a materialized default chat. + +Chat resolution: `entry.resolveChat(chatKey)` is ONE uniform map lookup that returns the `ClaudeAgentSession` for any chat - default or peer - plus whether the resolved entry is the default chat. Operational methods derive the owning session from the concrete chat URI and use that resolved entry rather than branching on `isDefaultChatUri`. Capabilities: `supportsMultipleChats: true, supportsFork: true`. + +Each peer chat is backed by a fresh top-level SDK session (`sdkSessionId = generateUuid()`) minted in the same global Claude project store that `listSessions` enumerates. `_createChat` therefore returns `backingSession: AgentSession.uri(this.id, sdkSessionId)` so the orchestrator can suppress that backing from the top-level session list (invariant I7); without it the peer chat would leak as a phantom session. The SDK exposes no delete-chat RPC, so `disposeChat` leaves the backing transcript on disk — the orchestrator-owned catalog simply drops the entry so it is never resumed again. (Claude writes no legacy `claude.chats` blob and has no legacy migration: Claude multi-chat shipped only with the orchestrator-owned catalog, so there is nothing to drain. Copilot keeps its own `copilot.chats` migration because `copilot.chats` predates the catalog.) + +### Copilot (`node/copilot/copilotAgent.ts`) + +F2 complete (2026-07-01): single `_sessions: DisposableMap` keyed by session id; the parallel `_chatSessions` map has been removed. + +`CopilotSessionEntry` (`copilotAgent.ts:CopilotSessionEntry`) is an empty subclass of the shared `AgentSessionEntry` (`node/agentPeerChats.ts`) — its API matches the base exactly. It is a `Disposable` container holding ALL chats (default + peers) in ONE map keyed by chat URI string: +- `_chats: DisposableMap>` — every chat (default + peers) as a leaf entry. +- `defaultChat: CopilotAgentSession | undefined` — the default chat via `_defaultChatKey`; `undefined` while the session is still provisional (not yet materialized). +- `setDefaultChat(chatKey, entry)` / `clearDefaultChat()` — lifecycle for the default chat (e.g. config-driven restart), seeding/dropping it in the same map as peers. + +Chat resolution reads that single map: `_findAnySession` returns `entry.defaultChat`, `_findPeerChat` returns `entry.getPeerChat(chatKey)`, and operational methods use `entry.resolveChat(chatKey)` so default and peer chats are resolved through the same map. Remaining `isDefaultChatUri` checks are outside the operational chat surface, for chat lifecycle, tool routing, and legacy/subagent guards. + +The peer-chat `providerData` codec (`IPersistedChat` + `encodeProviderData`/`decodeProviderData`) is also shared from `node/agentPeerChats.ts`; both agents import it rather than carrying private copies. + +An orthogonal `_chatBackings: Map` records the live SDK session id (`sdkSessionId`) + model override for each peer chat URI so the agent can resume peer chats without re-consulting disk. This map is populated by `createChat`/`materializeChat` and is separate from the orchestrator's `_chatProviderData` (which holds the opaque blob the agent produced, while `_chatBackings` is the agent's own in-memory parse of that blob). Capabilities: `multipleChats: { fork: true }`. + +### Codex (`node/codex/codexAgent.ts`) + +Single-chat harness. `_sessions: Map` keyed by session id; no peer-chat map. `chats.createChat` and `chats.fork` **throw** (`"Codex agent does not support multiple chats"` / `"Codex agent does not support chat forking"`); `chats.disposeChat` is a no-op; `sendMessage`/`abort`/`changeModel`/`getMessages` first resolve the addressed chat to Codex's single session and then operate on it (and `changeAgent` is a no-op). `getDescriptor().capabilities` omits `multipleChats` (absent = unsupported), so the UI never offers "Add Chat" or "Fork" for Codex sessions. diff --git a/src/vs/platform/agentHost/OTEL.md b/src/vs/platform/agentHost/OTEL.md index 726f922a98608..60cd77310aefd 100644 --- a/src/vs/platform/agentHost/OTEL.md +++ b/src/vs/platform/agentHost/OTEL.md @@ -77,8 +77,10 @@ The workbench-side starter translates the settings above into the following env | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | `chat.agentHost.otel.captureContent` | | | `COPILOT_OTEL_FILE_EXPORTER_PATH` | `chat.agentHost.otel.outfile` | | | `COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED` | `chat.agentHost.otel.dbSpanExporter.enabled` | | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | (inherited) | `grpc` selects gRPC; any other value uses HTTP. | -| `OTEL_EXPORTER_OTLP_HEADERS` | (inherited) | Auth headers (e.g., `Authorization=Bearer …`). | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | (inherited or enterprise policy) | `grpc` selects gRPC; any other value uses HTTP. Set from the managed `telemetry.protocol` when configured. | +| `OTEL_SERVICE_NAME` | (inherited or enterprise policy) | `service.name` resource attribute; set from the managed `telemetry.serviceName`. | +| `OTEL_RESOURCE_ATTRIBUTES` | (inherited or enterprise policy) | Extra resource attributes (`k=v,k2=v2`); set from the managed `telemetry.resourceAttributes`. | +| `OTEL_EXPORTER_OTLP_HEADERS` | (inherited) | Auth headers (e.g., `Authorization=Bearer …`). **Not** delivered from managed settings — env delivery would leak the secret to tool subprocesses; managed headers apply to the Copilot Chat extension only. | > **Activation timing.** Env vars are bound at agent host **spawn time**. Changing a setting while the agent host is already running has no effect until the host respawns — restart VS Code or reload the window if you change these settings mid-session. @@ -134,7 +136,7 @@ src/vs/platform/otel/ ## Settings → Env Var Translation -`buildAgentHostOTelEnv()` ([common/agentService.ts](common/agentService.ts)) is the single translation point. The starter (`electronAgentHostStarter.ts` / `nodeAgentHostStarter.ts`) reads settings, calls `buildAgentHostOTelEnv(settings, parentEnv)`, and merges the result into the spawned process's environment. Parent-env values always win. +`buildAgentHostOTelEnv()` ([common/agentService.ts](common/agentService.ts)) is the single translation point. The starter (`electronAgentHostStarter.ts` / `nodeAgentHostStarter.ts`) reads settings, calls `buildAgentHostOTelEnv(settings, parentEnv)`, and merges the result into the spawned process's environment. Parent-env values win over the local `chat.agentHost.otel.*` settings (developer override); **enterprise managed-policy values win over parent env**. | Setting | Env var | |---|---| @@ -145,7 +147,7 @@ src/vs/platform/otel/ | `chat.agentHost.otel.outfile` | `COPILOT_OTEL_FILE_EXPORTER_PATH` | | `chat.agentHost.otel.dbSpanExporter.enabled` | `COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED` | -`OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_EXPORTER_OTLP_HEADERS`, and `OTEL_RESOURCE_ATTRIBUTES` flow via env inheritance — they are not translated from settings. +`OTEL_EXPORTER_OTLP_HEADERS` flows via env inheritance only. `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_SERVICE_NAME`, and `OTEL_RESOURCE_ATTRIBUTES` are not translated from the local `chat.agentHost.otel.*` settings, but **enterprise managed settings (policy)** can set them on the spawned host: the renderer forwards the resolved policy to the starter, and managed values win over inherited env. `readAgentHostOTelEnv()` ([node/otel/agentHostOTelService.ts](node/otel/agentHostOTelService.ts)) is the inverse: it reads `process.env` inside the agent host and produces the `ResolvedConfig` that drives mode selection and outbound forwarding. diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index f89891f13e3ab..de8b2bee3be7e 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -18,7 +18,7 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { AgentSession, IAgentConnection, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js'; +import { AgentSession, AgentHostCodexAgentEnabledSettingId, IAgentConnection, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; @@ -37,7 +37,7 @@ import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostSessionSyncEnabledConfigKey, SESSION_SYNC_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -338,6 +338,36 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } this._updateSessionSyncEnabled(); } + if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateTerminalAutoApproveEnabled(); + } + if (e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateGlobalAutoApproveEnabled(); + } + if (e.affectsConfiguration(AUTO_REPLY_SETTING_ID)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateAutoReplyEnabled(); + } + if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_SETTING_ID) || e.affectsConfiguration(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateTerminalAutoApproveRules(); + } + if (e.affectsConfiguration(AgentHostCodexAgentEnabledSettingId)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateCodexEnabled(); + } })); // Detect silently-dead transports — see {@link _resetLivenessTimers}. @@ -425,6 +455,11 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._initializeResult = result; this._updateTelemetryLevel(); this._updateSessionSyncEnabled(); + this._updateTerminalAutoApproveEnabled(); + this._updateGlobalAutoApproveEnabled(); + this._updateAutoReplyEnabled(); + this._updateTerminalAutoApproveRules(); + this._updateCodexEnabled(); this._transitionTo({ kind: AgentHostClientState.Connected }); } @@ -759,7 +794,6 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const promise = this._sendRequest('createSession', { channel: session.toString(), provider, - model: config?.model, workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined, config: config?.config, activeClient: config?.activeClient, @@ -838,7 +872,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC await this._sendRequest('createChat', { channel: session.toString(), chat: chat.toString(), - model: options?.model, + ...(options?.fork ? { source: { chat: options.fork.source.toString(), turnId: options.fork.turnId } } : {}), }); } @@ -879,8 +913,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const result = await this._sendRequest('listSessions', { channel: ROOT_STATE_URI }); return result.items.map((s: SessionSummary) => ({ session: URI.parse(s.resource), - startTime: s.createdAt, - modifiedTime: s.modifiedAt, + startTime: Date.parse(s.createdAt), + modifiedTime: Date.parse(s.modifiedAt), ...(s.project ? { project: { uri: this._toLocalProjectUri(URI.parse(s.project.uri)), @@ -904,11 +938,11 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Inspect an outgoing client-dispatched action and grant implicit reads * for any customization URIs it carries. Today this covers - * `SessionActiveClientChanged`, which is the only client-dispatched + * `SessionActiveClientSet`, which is the only client-dispatched * action that ships customization URIs to the host. */ private _grantImplicitReadsForOutgoingAction(action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { - if (action.type === ActionType.SessionActiveClientChanged && action.activeClient?.customizations) { + if (action.type === ActionType.SessionActiveClientSet && action.activeClient.customizations) { this._grantImplicitReadsForCustomizations(action.activeClient.customizations); } } @@ -1058,6 +1092,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC case 'root/sessionAdded': case 'root/sessionRemoved': case 'root/sessionSummaryChanged': + case 'root/progress': case 'auth/required': { this._logService.trace(`[RemoteAgentHostProtocol] Notification: ${msg.method}`); // The case narrows `msg.method` to a single literal; the matching params @@ -1293,6 +1328,48 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }, this._clientId, 0); } + private _updateTerminalAutoApproveEnabled(): void { + const enabled = this._configurationService.getValue(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID) !== false; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostTerminalAutoApproveEnabledConfigKey]: enabled }, + }, this._clientId, 0); + } + + private _updateGlobalAutoApproveEnabled(): void { + const enabled = this._configurationService.getValue(GLOBAL_AUTO_APPROVE_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: enabled }, + }, this._clientId, 0); + } + + private _updateAutoReplyEnabled(): void { + const enabled = this._configurationService.getValue(AUTO_REPLY_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostAutoReplyEnabledConfigKey]: enabled }, + }, this._clientId, 0); + } + + private _updateCodexEnabled(): void { + // Always forwards the current value; the host only acts on enable, so a + // forwarded `false` only takes effect on the next agent host restart + // (otherwise in-progress Codex sessions would have to be stopped). + const enabled = this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostCodexEnabledConfigKey]: enabled }, + }, this._clientId, 0); + } + + private _updateTerminalAutoApproveRules(): void { + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostTerminalAutoApproveRulesConfigKey]: getAgentHostTerminalAutoApproveRulesConfig(this._configurationService) }, + }, this._clientId, 0); + } + /** * Common path for outgoing JSON-RPC requests: gate on any in-flight * reconnect (unless explicitly bypassed for the `reconnect` RPC itself), diff --git a/src/vs/platform/agentHost/common/agentHost.config.contribution.ts b/src/vs/platform/agentHost/common/agentHost.config.contribution.ts index 710bd2e3a478f..cfbe600980a1c 100644 --- a/src/vs/platform/agentHost/common/agentHost.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHost.config.contribution.ts @@ -39,10 +39,11 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), default: !isWeb && product.quality !== 'stable', tags: ['experimental', 'advanced'], + experiment: { mode: 'startup' }, }, 'chat.agents.copilotCli.hideExtensionHost': { type: 'boolean', - description: nls.localize('chat.agents.copilotCli.hideExtensionHost', "When enabled, hides the Extension Host Copilot CLI entry from the Agents window picker."), + markdownDescription: nls.localize('chat.agents.copilotCli.hideExtensionHost', "When enabled, hides the Extension Host Copilot CLI entry from the Agents window picker. Requires `#{0}#`.", AgentHostEnabledSettingId), default: false, tags: ['experimental'], experiment: { mode: 'startup' }, diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts new file mode 100644 index 0000000000000..b96f8d2da48aa --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostByokLm.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 { CancellationToken } from '../../../base/common/cancellation.js'; +import { Event } from '../../../base/common/event.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +/** + * Serializable bridge contract between the node agent host (where the + * {@link IByokLmProxyService} OpenAI-compatible proxy runs) and the renderer + * (which owns the extension-provided BYOK language models via the LM API). + * + * These shapes are deliberately wire-friendly (plain JSON, no `VSBuffer`, + * `URI`, or `workbench/contrib/chat` types) so they survive both the local + * utility-process IPC channel and the remote JSON-RPC transport without a + * translation step. The node side converts OpenAI Chat Completions wire + * payloads to/from these; the renderer side converts these to/from the VS Code + * LM API (`ILanguageModelsService`). + */ + +/** A single tool/function call requested by the assistant. */ +export interface IByokLmToolCall { + /** Stable id correlating the call with its later `tool` result message. */ + readonly id: string; + /** Tool/function name. */ + readonly name: string; + /** JSON-encoded arguments object. */ + readonly argumentsJson: string; +} + +/** A tool/function the model may call. */ +export interface IByokLmTool { + readonly name: string; + readonly description?: string; + /** JSON schema for the tool parameters. */ + readonly parametersSchema?: object; +} + +/** One chat message in a BYOK request. */ +export interface IByokLmChatMessage { + readonly role: 'system' | 'user' | 'assistant' | 'tool'; + /** Flattened text content. Empty string when the message carries only tool calls/results. */ + readonly content: string; + /** Present on `assistant` messages that requested tool calls. */ + readonly toolCalls?: IByokLmToolCall[]; + /** Present on `tool` messages: the {@link IByokLmToolCall.id} this result answers. */ + readonly toolCallId?: string; +} + +/** A chat request forwarded from the proxy to the renderer LM API. */ +export interface IByokLmChatRequest { + /** Provider/vendor name (the LM API vendor that registered the model). */ + readonly vendor: string; + /** Provider-local model id (the wire id the runtime sent on the OpenAI request). */ + readonly modelId: string; + readonly messages: IByokLmChatMessage[]; + readonly tools?: IByokLmTool[]; + /** Opaque per-request model options forwarded to the LM provider. */ + readonly modelOptions?: Record; +} + +/** The (buffered) completion produced by the renderer LM API. */ +export interface IByokLmChatResult { + /** Concatenated assistant text. */ + readonly content: string; + /** Tool calls the assistant requested, if any. */ + readonly toolCalls?: IByokLmToolCall[]; + /** Best-effort token usage, when the provider reports it. */ + readonly usage?: { + readonly promptTokens?: number; + readonly completionTokens?: number; + }; + /** Set when the LM call failed; `content` is then empty. */ + readonly error?: string; +} + +/** + * Metadata for a renderer BYOK model, enumerated over the bridge so the node + * agent host can advertise it to the SDK runtime without any host-side config. + */ +export interface IByokLmModelInfo { + /** Provider/vendor name (the LM API vendor that registered the model). */ + readonly vendor: string; + /** Provider-local model id. */ + readonly id: string; + /** Display name, when the provider supplies one. */ + readonly name?: string; + /** Maximum context window tokens (prompt + output), when known. */ + readonly maxContextWindowTokens?: number; + /** Whether the model accepts image inputs, when known. */ + readonly supportsVision?: boolean; +} + +export const IAgentHostByokLmHandler = createDecorator('agentHostByokLmHandler'); + +/** + * Renderer-side handler that services {@link IByokLmChatRequest}s by calling + * the VS Code Language Model API. Implemented in the workbench (where + * `ILanguageModelsService` lives) and reached from the node agent host over + * the reverse bridge. + */ +export interface IAgentHostByokLmHandler { + readonly _serviceBrand: undefined; + + /** + * Fires when the renderer's set of BYOK models changes, so the node agent + * host can re-enumerate them for the model picker. Optional: test fakes may + * omit it. + */ + readonly onDidChangeModels?: Event; + + /** + * Run a BYOK chat completion against the extension-registered model that + * matches `request.vendor` + `request.modelId`. Rejects (or resolves with + * {@link IByokLmChatResult.error}) when no such model is available. + */ + chat(request: IByokLmChatRequest, token: CancellationToken): Promise; + + /** + * Enumerate the renderer's BYOK models (vendor `isBYOK`, excluding + * session-scoped agent-host copies) so the node agent host can synthesize + * provider/model config for the SDK runtime. + */ + listModels(token: CancellationToken): Promise; +} + +/** + * Node-side connection to a single renderer's {@link IAgentHostByokLmHandler}. + * Mirrors `IRemoteFilesystemConnection` for the reverse FS bridge. + */ +export interface IByokLmBridgeConnection { + chat(request: IByokLmChatRequest): Promise; + listModels(): Promise; + /** + * Fires when the renderer's set of BYOK models changes, so the agent host + * can re-enumerate. Optional: test fakes may omit it. + */ + readonly onDidChangeModels?: Event; +} diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 80167436c5cde..6a4a6fdc4f4e5 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -8,7 +8,7 @@ import type { IDisposable } from '../../../base/common/lifecycle.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ChangesetKind } from './changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; -import type { ChangesetOperation, ISessionGitState, URI } from './state/sessionState.js'; +import type { ChangesetOperation, ISessionGitHubState, ISessionGitState, URI } from './state/sessionState.js'; export const IAgentHostChangesetOperationService = createDecorator('agentHostChangesetOperationService'); @@ -49,7 +49,9 @@ export interface IChangesetOperationContext { /** Well-known changeset kind for {@link changesetUri}. */ readonly changesetKind: ChangesetKind; /** Current git metadata for the session used to compute operation availability. */ - readonly gitState: ISessionGitState; + readonly gitState?: ISessionGitState; + /** Current GitHub metadata for the session used to compute operation availability. */ + readonly gitHubState?: ISessionGitHubState; } /** @@ -112,7 +114,14 @@ export interface IAgentHostChangesetOperationService extends IDisposable { * session. If `gitState` is not provided, the current git state will * be used. */ - updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState): void; + updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void; + + /** + * Returns the operations that should be advertised for the given changeset, or + * `undefined` when no operations are available. + */ + getOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined; + /** * Invokes an advertised operation after validating the changeset, operation id, * and requested target scope. diff --git a/src/vs/platform/agentHost/common/agentHostChangesetService.ts b/src/vs/platform/agentHost/common/agentHostChangesetService.ts index 961dd90bf56e6..956d9eb024227 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetService.ts @@ -181,6 +181,11 @@ export interface IAgentHostChangesetService { */ isStaticChangesetComputeActive(changesetUri: ProtocolURI): boolean; + /** + * Refreshes the list of changesets for the given session. + */ + refreshChangesetCatalog(session: ProtocolURI): void; + /** * Lazy refresh of the branch changeset, kicked off when a client * first subscribes to `/changeset/branch`. Self-defers when the diff --git a/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts new file mode 100644 index 0000000000000..8d66db0443be7 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Event } from '../../../base/common/event.js'; +import { Lazy } from '../../../base/common/lazy.js'; +import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { + IAgentHostByokLmHandler, + IByokLmBridgeConnection, + IByokLmChatRequest, + IByokLmChatResult, + IByokLmModelInfo, +} from './agentHostByokLm.js'; + +/** + * IPC channel name used for in-process agent-host → renderer reverse BYOK + * language-model RPCs. The renderer registers a server channel under this + * name on its `MessagePortClient`; the agent host reaches it via + * `server.getChannel(name, c => c.ctx === clientId)` on its + * `UtilityProcessServer`. + * + * Mirrors {@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL} for the reverse FS bridge. + */ +export const AGENT_HOST_CLIENT_BYOK_LM_CHANNEL = 'agentHostClientByokLm'; + +/** + * Wraps an {@link IChannel} (obtained from the agent host's + * `UtilityProcessServer.getChannel`) into an {@link IByokLmBridgeConnection} + * suitable for the node-side {@link IByokLmProxyService}. This is the node end + * of the bridge: `chat()` ships the request to the renderer and resolves with + * the buffered completion the renderer produced from the LM API. + */ +export function createAgentHostClientByokLmConnection(channel: IChannel): IByokLmBridgeConnection { + // Reach for `channel.listen` lazily — only when a consumer actually + // subscribes to `onDidChangeModels` — mirroring the deferred `channel.call` + // usage below (and the reverse FS bridge). Touching the channel eagerly at + // construction would force every connection to register an IPC event handler + // up front, even when nothing listens. + const onDidChangeModels = new Lazy(() => channel.listen('onDidChangeModels')); + return { + chat: (request) => channel.call('chat', request) as Promise, + listModels: () => channel.call('listModels') as Promise, + onDidChangeModels: (listener, thisArgs, disposables) => onDidChangeModels.value(listener, thisArgs, disposables), + }; +} + +/** + * Server-side channel for in-process reverse BYOK LM RPCs from the local agent + * host. Thin adapter — forwards `chat` calls to the renderer's + * {@link IAgentHostByokLmHandler} (backed by `ILanguageModelsService`). + */ +export class AgentHostClientByokLmChannel implements IServerChannel { + + constructor( + @IAgentHostByokLmHandler private readonly _handler: IAgentHostByokLmHandler, + ) { } + + listen(_ctx: unknown, event: string): Event { + if (event === 'onDidChangeModels') { + return (this._handler.onDidChangeModels ?? Event.None) as Event; + } + throw new Error(`No event '${event}' on AgentHostClientByokLmChannel`); + } + + async call(_ctx: unknown, command: string, arg?: unknown): Promise { + switch (command) { + case 'chat': { + const result = await this._handler.chat(arg as IByokLmChatRequest, CancellationToken.None); + return result as T; + } + case 'listModels': { + const models = await this._handler.listModels(CancellationToken.None); + return models as T; + } + } + throw new Error(`Unknown command '${command}' on AgentHostClientByokLmChannel`); + } +} diff --git a/src/vs/platform/agentHost/common/agentHostConversationContext.ts b/src/vs/platform/agentHost/common/agentHostConversationContext.ts new file mode 100644 index 0000000000000..b774fe037466b --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostConversationContext.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ResponsePartKind, type ResponsePart, type Turn } from './state/sessionState.js'; + +/** + * Options for {@link buildConversationContext}. + */ +export interface IConversationContextOptions { + /** + * Soft upper bound, in characters, for the conversation portion of the + * produced context string. When the conversation exceeds this budget its + * middle is removed (marked with `...`) via {@link truncateMiddle}. The + * optional {@link framing} is always preserved in full and does not count + * against this budget. + */ + readonly maxChars: number; + + /** + * Optional framing text prepended to the conversation (e.g. a note that the + * conversation was branched from an earlier chat). Always preserved in full + * — only the conversation is truncated to {@link maxChars}. + */ + readonly framing?: string; +} + +/** + * Concatenates the normal textual (markdown) response parts of a turn into a + * single string. Tool calls, reasoning, content references, and other + * non-markdown parts are intentionally ignored so that only the assistant's + * user-facing prose is included — this keeps utility-model prompts focused and + * free of large tool payloads or subagent traces. + */ +export function renderResponseMarkdown(parts: readonly ResponsePart[]): string { + const segments: string[] = []; + for (const part of parts) { + if (part.kind === ResponsePartKind.Markdown) { + const text = part.content.trim(); + if (text) { + segments.push(text); + } + } + } + return segments.join('\n\n'); +} + +/** + * Builds a plain-text conversation context string from the given turns by + * concatenating each turn's user request and the assistant's textual + * (markdown) response. Only normal text response parts are considered — tool + * calls, reasoning, subagent traces, and other parts are ignored (see + * {@link renderResponseMarkdown}). The conversation is middle-truncated to + * {@link IConversationContextOptions.maxChars} to bound model cost; any + * {@link IConversationContextOptions.framing} is prepended afterwards and is + * always preserved in full. + * + * @returns the context string, or `undefined` when no turn carries any text + * worth including. + */ +export function buildConversationContext(turns: readonly Turn[], options: IConversationContextOptions): string | undefined { + const blocks: string[] = []; + for (const turn of turns) { + const userText = turn.message.text.trim(); + const responseText = renderResponseMarkdown(turn.responseParts); + if (!userText && !responseText) { + continue; + } + blocks.push(responseText + ? `User request:\n${userText}\n\nAgent response:\n${responseText}` + : `User request:\n${userText}`); + } + if (blocks.length === 0) { + return undefined; + } + const conversation = blocks.join('\n\n---\n\n'); + const truncatedConversation = conversation.length > options.maxChars ? truncateMiddle(conversation, options.maxChars) : conversation; + return `${options.framing ?? ''}${truncatedConversation}`; +} + +/** + * Truncates `text` to at most `maxChars` characters by removing the middle and + * inserting a `...` marker, preserving the start and end. + */ +export function truncateMiddle(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + const marker = '\n...\n'; + if (maxChars <= marker.length) { + return text.slice(0, maxChars); + } + const keep = maxChars - marker.length; + const head = Math.ceil(keep / 2); + const tail = keep - head; + return `${text.slice(0, head)}${marker}${text.slice(text.length - tail)}`; +} diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts index 2ace7a2898de2..2d01ed5843bd4 100644 --- a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts +++ b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts @@ -30,6 +30,13 @@ export const enum AgentHostConfigKey { * foundation prompt. Opt-in; disabled by default. */ Opus48Prompt = 'opus48Prompt', + /** + * When true (the default), the Claude provider routes all Anthropic + * `messages` traffic through the local Copilot-CAPI proxy (Copilot-routed + * Claude). When false, the Claude Agent SDK talks to Anthropic directly on + * the user's own credentials (BYO Anthropic — Phase 19). + */ + ClaudeUseCopilotProxy = 'claudeUseCopilotProxy', } /** @@ -94,6 +101,12 @@ export const agentHostCustomizationConfigSchema = createSchema({ description: localize('agentHost.config.opus48Prompt.description', "When enabled, Copilot SDK sessions running a Claude Opus 4.8 model apply Opus 4.8-tuned system-prompt section overrides on top of the default system message."), default: false, }), + [AgentHostConfigKey.ClaudeUseCopilotProxy]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.claudeUseCopilotProxy.title', "Route Claude Through Copilot"), + description: localize('agentHost.config.claudeUseCopilotProxy.description', "When enabled (the default), the Claude agent routes all requests through GitHub Copilot. When disabled, Claude talks to Anthropic directly using your own credentials (API key or Claude subscription)."), + default: true, + }), }); export const defaultAgentHostCustomizationConfigValues = { diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 62d6a82704d87..8d6f1622c4103 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -66,7 +66,6 @@ export const IAgentHostGitService = createDecorator('agent export interface IAgentHostGitService { readonly _serviceBrand: undefined; - isInsideWorkTree(workingDirectory: URI): Promise; getCurrentBranch(workingDirectory: URI): Promise; getDefaultBranch(workingDirectory: URI): Promise; getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise; @@ -163,11 +162,11 @@ export interface IAgentHostGitService { computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise; /** - * Reads a single git blob via `git show :` from + * Reads a single git blob via `git show :` from * the given working directory. Returns `undefined` when the blob does * not exist or the directory is not a git work tree. */ - showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise; + showBlob(workingDirectory: URI, ref: string, repoRelativePath: string): Promise; // ---- Checkpoint plumbing (used by IAgentHostCheckpointService) ------- diff --git a/src/vs/platform/agentHost/common/agentHostGitStateService.ts b/src/vs/platform/agentHost/common/agentHostGitStateService.ts index 402509bba73fd..034cb1aa08ed5 100644 --- a/src/vs/platform/agentHost/common/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitStateService.ts @@ -4,19 +4,45 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../base/common/uri.js'; +import { Event } from '../../../base/common/event.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { ISessionGitState } from './state/sessionState.js'; +import { ISessionGitHubState } from './state/sessionState.js'; + +export const META_GIT_STATE = 'agentHost.git'; +export const META_GITHUB_STATE = 'agentHost.github'; + +export const GIT_DB_METADATA_KEYS: Record = { + [META_GIT_STATE]: true, + [META_GITHUB_STATE]: true, +}; export const IAgentHostGitStateService = createDecorator('agentHostGitStateService'); export interface IAgentHostGitStateService { readonly _serviceBrand: undefined; + /** + * Fires when the git state for a session is refreshed. + */ + readonly onDidRefreshSessionGitState: Event; + /** * Refreshes the git state for a given session. * @param sessionKey The key of the session for which to refresh the git state. * @param workingDirectory Optional working directory override; when omitted, the session summary's working directory is used. - * @returns A promise that resolves to the updated git state, `undefined` if the git state is unchanged, or `null` if git state is unavailable (no working directory, not a git repo, or an error occurred). */ - refreshSessionGitState(sessionKey: string, workingDirectory?: URI): Promise; + refreshSessionGitState(sessionKey: string, workingDirectory?: URI): Promise; + + /** + * Sets the GitHub state for a given session. + * @param sessionKey The key of the session for which to set the GitHub state. + * @param state The GitHub state to set. + */ + setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise; + + /** + * Find a GitHub pull request for the given session and save it to the session state. + * @param sessionKey The key of the session for which to check the GitHub pull request. + */ + attachSessionGitHubPullRequest(sessionKey: string): Promise; } diff --git a/src/vs/platform/agentHost/common/agentHostPlanReview.ts b/src/vs/platform/agentHost/common/agentHostPlanReview.ts new file mode 100644 index 0000000000000..47b29a86b72b3 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostPlanReview.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ChatInputRequest } from './state/sessionState.js'; + +export interface IAgentHostPlanReviewAction { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly default?: boolean; + readonly permissionLevel?: 'autopilot'; +} + +export interface IAgentHostPlanReview { + readonly title: string; + readonly content: string; + readonly actions: readonly IAgentHostPlanReviewAction[]; + readonly canProvideFeedback: boolean; + readonly answerQuestionId: string; + readonly planUri?: string; +} + +export type ChatInputRequestWithPlanReview = ChatInputRequest & { + readonly planReview?: IAgentHostPlanReview; +}; diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 5e598a7bb74e0..5f834c1ed9618 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../nls.js'; +import { structuralEquals } from '../../../base/common/equals.js'; +import { ConfigurationTarget, type IConfigurationService, type IConfigurationValue } from '../../configuration/common/configuration.js'; import type { IMcpServerConfiguration } from '../../mcp/common/mcpPlatformTypes.js'; import { TelemetryConfiguration, TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { SessionConfigKey } from './sessionConfigKeys.js'; @@ -390,6 +392,140 @@ export const AgentHostTelemetryLevelConfigKey = 'telemetryLevel'; */ export const AgentHostSessionSyncEnabledConfigKey = 'sessionSyncEnabled'; +/** + * Root config key forwarded from the renderer carrying the experiment-aware + * value of `chat.agentHost.codexAgent.enabled`. The host registers the Codex + * provider when this is `true`; disabling requires an agent host restart. + */ +export const AgentHostCodexEnabledConfigKey = 'codexAgentEnabled'; + +/** + * Root config key forwarded from the renderer when VS Code's + * `chat.tools.terminal.enableAutoApprove` setting changes. Controls whether + * agent-host shell permission checks may apply terminal auto-approve rules. + */ +export const AgentHostTerminalAutoApproveEnabledConfigKey = 'terminalAutoApproveEnabled'; + +/** + * The VS Code setting ID for terminal auto approve enablement. Defined here so + * renderer-side agent-host clients can forward it without importing from + * workbench terminal contributions. + */ +export const TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID = 'chat.tools.terminal.enableAutoApprove'; + +/** + * Root config key forwarded from the renderer when VS Code's + * `chat.tools.global.autoApprove` setting changes. When `true`, the global + * auto-approve ("approve everything") setting is enabled and the agent host + * treats every tool call as auto-approved — equivalent to a session running + * with Bypass Approvals. + */ +export const AgentHostGlobalAutoApproveEnabledConfigKey = 'globalAutoApproveEnabled'; + +/** + * The VS Code setting ID for global auto approve. Defined here so renderer-side + * agent-host clients can forward it without importing from `workbench/contrib/chat`. + */ +export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove'; + +/** + * Root config key forwarded from the renderer when VS Code's `chat.autoReply` + * setting changes. When `true`, the agent host auto-answers `ask_user` + * questions instead of blocking on the user — the user is treated as + * unavailable and the agent is told to use its best judgment, mirroring the + * behavior of `autopilot` mode. + */ +export const AgentHostAutoReplyEnabledConfigKey = 'autoReplyEnabled'; + +/** + * The VS Code setting ID for auto-reply. Defined here so renderer-side + * agent-host clients can forward it without importing from `workbench/contrib/chat`. + */ +export const AUTO_REPLY_SETTING_ID = 'chat.autoReply'; + +/** + * Root config key forwarded from the renderer when VS Code's + * `chat.tools.terminal.autoApprove` setting changes. Holds the effective + * terminal auto-approve rule object for agent-host shell permission checks. + */ +export const AgentHostTerminalAutoApproveRulesConfigKey = 'terminalAutoApproveRules'; + +export interface IAgentHostTerminalAutoApproveRule { + readonly approve: boolean; + readonly matchCommandLine?: boolean; +} + +export type AgentHostTerminalAutoApproveRuleValue = boolean | null | IAgentHostTerminalAutoApproveRule; +export type AgentHostTerminalAutoApproveRules = Record; + +/** + * The VS Code setting IDs for terminal auto approve rules. Defined here so + * renderer-side agent-host clients can forward them without importing from + * workbench terminal contributions. + */ +export const TERMINAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.terminal.autoApprove'; +export const TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID = 'chat.tools.terminal.ignoreDefaultAutoApproveRules'; + +export function getAgentHostTerminalAutoApproveRulesConfig(configurationService: IConfigurationService): AgentHostTerminalAutoApproveRules { + const config = configurationService.getValue(TERMINAL_AUTO_APPROVE_SETTING_ID); + const configInspectValue = configurationService.inspect>(TERMINAL_AUTO_APPROVE_SETTING_ID); + const ignoreDefaults = configurationService.getValue(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID) === true; + return normalizeAgentHostTerminalAutoApproveRulesConfig(config, configInspectValue, ignoreDefaults); +} + +export function normalizeAgentHostTerminalAutoApproveRulesConfig(config: AgentHostTerminalAutoApproveRules | undefined, configInspectValue: IConfigurationValue>, ignoreDefaults: boolean): AgentHostTerminalAutoApproveRules { + if (!config) { + return {}; + } + + const rules: AgentHostTerminalAutoApproveRules = {}; + for (const [key, value] of Object.entries(config)) { + if (ignoreDefaults && isDefaultOnlyAutoApproveRule(key, value, configInspectValue)) { + continue; + } + rules[key] = value; + } + return rules; +} + +function isDefaultOnlyAutoApproveRule(key: string, value: AgentHostTerminalAutoApproveRuleValue, configInspectValue: IConfigurationValue>): boolean { + const defaultValue = configInspectValue.default?.value; + const isDefaultRule = hasMatchingRule(defaultValue, key, value); + if (!isDefaultRule) { + return false; + } + + const sourceTarget = getAutoApproveRuleSourceTarget(key, value, configInspectValue); + + return sourceTarget === ConfigurationTarget.DEFAULT; +} + +function getAutoApproveRuleSourceTarget(key: string, value: AgentHostTerminalAutoApproveRuleValue, configInspectValue: IConfigurationValue>): ConfigurationTarget { + if (hasMatchingRule(configInspectValue.workspaceFolderValue, key, value)) { + return ConfigurationTarget.WORKSPACE_FOLDER; + } + if (hasMatchingRule(configInspectValue.workspaceValue, key, value)) { + return ConfigurationTarget.WORKSPACE; + } + if (hasMatchingRule(configInspectValue.userRemoteValue, key, value)) { + return ConfigurationTarget.USER_REMOTE; + } + if (hasMatchingRule(configInspectValue.userLocalValue, key, value)) { + return ConfigurationTarget.USER_LOCAL; + } + if (hasMatchingRule(configInspectValue.userValue, key, value)) { + return ConfigurationTarget.USER; + } + if (hasMatchingRule(configInspectValue.applicationValue, key, value)) { + return ConfigurationTarget.APPLICATION; + } + return ConfigurationTarget.DEFAULT; +} + +function hasMatchingRule(config: Readonly | undefined, key: string, value: AgentHostTerminalAutoApproveRuleValue): boolean { + return !!config && Object.prototype.hasOwnProperty.call(config, key) && structuralEquals(config[key], value); +} + /** * Root config key holding agent-host-level MCP server definitions. * @@ -521,6 +657,36 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.sessionSyncEnabled.description', "Whether remote session sync is enabled for the copilot-sdk CLI."), default: false, }), + [AgentHostCodexEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.codexAgentEnabled.title', "Codex Agent"), + description: localize('agentHost.config.codexAgentEnabled.description', "Whether the Codex provider is enabled."), + default: false, + }), + [AgentHostTerminalAutoApproveEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.terminalAutoApproveEnabled.title', "Terminal Auto Approve"), + description: localize('agentHost.config.terminalAutoApproveEnabled.description', "Whether terminal auto-approve rules forwarded by the connected client are allowed to apply to agent-host shell permission requests."), + default: true, + }), + [AgentHostGlobalAutoApproveEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.globalAutoApproveEnabled.title', "Global Auto Approve"), + description: localize('agentHost.config.globalAutoApproveEnabled.description', "Whether VS Code's global auto-approve setting is enabled. When `true`, every tool call is auto-approved, equivalent to a session using Bypass Approvals."), + default: false, + }), + [AgentHostAutoReplyEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.autoReplyEnabled.title', "Auto Reply"), + description: localize('agentHost.config.autoReplyEnabled.description', "Whether VS Code's auto-reply setting is enabled. When `true`, `ask_user` questions are auto-answered instead of blocking on the user, mirroring autopilot mode."), + default: false, + }), + [AgentHostTerminalAutoApproveRulesConfigKey]: schemaProperty({ + type: 'object', + title: localize('agentHost.config.terminalAutoApproveRules.title', "Terminal Auto Approve Rules"), + description: localize('agentHost.config.terminalAutoApproveRules.description', "Terminal auto-approve rules forwarded by the connected client for agent-host shell permission checks."), + default: {}, + }), [AgentHostMcpServersConfigKey]: schemaProperty({ type: 'object', title: localize('agentHost.config.mcpServers.title', "MCP Servers"), diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 2d79db08f7471..5a977ce925555 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from '../../../nls.js'; +import { IPolicyData } from '../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../base/common/policy.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, managedSettingValue } from '../../policy/common/copilotManagedSettings.js'; import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { + AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, @@ -19,7 +22,10 @@ import { AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, + AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, + AgentHostOTelResourceAttributesSettingId, + AgentHostOTelServiceNameSettingId, } from './agentService.js'; // Settings consumed by the agent host starter (`electronAgentHostStarter.ts` @@ -39,6 +45,40 @@ import { // (renderer registration for the settings UI). const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + +// Custom managed-settings resolvers for the enterprise OTel policies. The simple pass-through +// keys use `managedSettingValue(KEY)`; these three combine or transform the managed value: +// - protocol: the schema's OTLP protocol string maps onto the agent-host exporter type. +// - captureContent: explicit boolean wins; otherwise `lockCaptureContent` forces it off. +// - outfile: when the enterprise mandates an OTLP endpoint/protocol, local file export is +// suppressed so spans can't be diverted to disk. +function managedOTelProtocolValue(policyData: IPolicyData): string | undefined { + const protocol = policyData.managedSettings?.[COPILOT_OTEL_PROTOCOL_KEY]; + if (protocol === 'grpc') { + return 'otlp-grpc'; + } + if (protocol === 'http/protobuf' || protocol === 'http/json') { + return 'otlp-http'; + } + return undefined; +} + +function managedOTelCaptureContentValue(policyData: IPolicyData): boolean | undefined { + const captureContent = policyData.managedSettings?.[COPILOT_OTEL_CAPTURE_CONTENT_KEY]; + if (typeof captureContent === 'boolean') { + return captureContent; + } + return policyData.managedSettings?.[COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY] === true ? false : undefined; +} + +function managedOTelOutfileValue(policyData: IPolicyData): string | undefined { + const managedSettings = policyData.managedSettings; + if (managedSettings?.[COPILOT_OTEL_ENDPOINT_KEY] !== undefined || managedSettings?.[COPILOT_OTEL_PROTOCOL_KEY] !== undefined) { + return ''; + } + return undefined; +} + configurationRegistry.registerConfiguration({ id: 'chatAgentHostStarter', title: nls.localize('chatAgentHostStarterConfigurationTitle', "Chat Agent Host Starter"), @@ -49,16 +89,39 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentHost.claudeAgent.enabled', "When enabled, the agent host registers the Claude provider (subject to the Claude SDK being reachable). Independent of `#chat.agents.claude.preferAgentHost#` and `#chat.editor.claude.preferAgentHost#`, which choose which integration surfaces Claude. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), default: true, tags: ['experimental', 'advanced'], - // References the `Claude3PIntegration` policy (owned by `github.copilot.chat.claudeAgent.enabled`) so disabling Claude applies across surfaces. - policyReference: { + // Owns the `Claude3PIntegration` policy; gating here disables Claude across all surfaces. + // The user-facing copilot-chat setting `github.copilot.chat.claudeAgent.enabled` attaches + // to this policy via a `policyReference` declared in the distro `product.json`. Ownership + // lives here (not in `product.json`) so the policy can carry a `value` callback that honors + // the account-side editor preview-features flag. + policy: { name: 'Claude3PIntegration', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.113', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'chat.agentHost.claudeAgent.enabled.policy', + value: nls.localize('chat.agentHost.claudeAgent.enabled.policy', "Enable Claude Agent sessions in VS Code. Start and resume agentic coding sessions powered by Anthropic Claude Agent SDK directly in the editor. Uses your existing Copilot subscription."), + } + } }, }, + [AgentHostByokModelsEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), + default: false, + tags: ['experimental', 'advanced'], + }, [AgentHostCodexAgentEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.codexAgent.enabled', "When enabled, the agent host registers the Codex provider (subject to the Codex SDK being reachable). Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), default: false, tags: ['experimental', 'advanced'], + // Allow the default to be overridden by an experiment. Uses `startup` + // (matching the sibling agent-host settings) since the agent host + // process must be restarted for a change to take effect anyway. + experiment: { mode: 'startup' }, // Owns the `Codex3PIntegration` policy; gating here disables Codex across all agent-host surfaces. policy: { name: 'Codex3PIntegration', @@ -101,6 +164,23 @@ configurationRegistry.registerConfiguration({ default: false, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelEnabled`; the copilot-chat setting `github.copilot.chat.otel.enabled` + // attaches to it via a `policyReference` in the extension's package.json. + policy: { + name: 'CopilotOtelEnabled', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_ENABLED_KEY), + managedSettings: { + [COPILOT_OTEL_ENABLED_KEY]: { type: 'boolean' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.enabled.policy', + value: nls.localize('chat.agentHost.otel.enabled.policy', "Controls whether Copilot OpenTelemetry export is enabled. When managed, users cannot override the enterprise value."), + } + }, + }, }, [AgentHostOTelExporterTypeSettingId]: { type: 'string', @@ -109,6 +189,55 @@ configurationRegistry.registerConfiguration({ default: 'otlp-http', scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelProtocol`; the managed `telemetry.protocol` string is mapped onto + // the exporter type (`grpc` -> `otlp-grpc`, `http/*` -> `otlp-http`). + policy: { + name: 'CopilotOtelProtocol', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedOTelProtocolValue, + managedSettings: { + [COPILOT_OTEL_PROTOCOL_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.protocol.policy', + value: nls.localize('chat.agentHost.otel.protocol.policy', "Controls the enterprise-managed OTLP protocol for Copilot OpenTelemetry export."), + }, + enumDescriptions: [ + { key: 'chat.agentHost.otel.protocol.policy.otlpHttp', value: nls.localize('chat.agentHost.otel.protocol.policy.otlpHttp', "Use OTLP over HTTP."), }, + { key: 'chat.agentHost.otel.protocol.policy.otlpGrpc', value: nls.localize('chat.agentHost.otel.protocol.policy.otlpGrpc', "Use OTLP over gRPC."), }, + { key: 'chat.agentHost.otel.protocol.policy.console', value: nls.localize('chat.agentHost.otel.protocol.policy.console', "Console exporter is not selected by enterprise managed settings."), }, + { key: 'chat.agentHost.otel.protocol.policy.file', value: nls.localize('chat.agentHost.otel.protocol.policy.file', "File exporter is not selected by enterprise managed settings."), }, + ], + }, + }, + }, + [AgentHostOTelOtlpProtocolSettingId]: { + type: 'string', + markdownDescription: nls.localize('chat.agentHost.otel.otlpProtocol', "Enterprise-managed OTLP wire protocol (`http/json`, `http/protobuf`, or `grpc`) for Copilot OpenTelemetry export. Policy-only: there is no user-facing setting; it carries the managed `telemetry.protocol` so the agent host's `OTEL_EXPORTER_OTLP_PROTOCOL` distinguishes protobuf from json."), + default: '', + scope: ConfigurationScope.APPLICATION, + // Policy-only delivery slot — no user-writable surface (mirrors `chat.plugins.extraMarketplaces`). + included: false, + tags: ['experimental', 'advanced'], + // Owns `CopilotOtelOtlpProtocol`; passes the raw managed `telemetry.protocol` through so the + // starters can set `OTEL_EXPORTER_OTLP_PROTOCOL` (the `exporterType` policy only carries transport). + policy: { + name: 'CopilotOtelOtlpProtocol', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_PROTOCOL_KEY), + managedSettings: { + [COPILOT_OTEL_PROTOCOL_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.otlpProtocol.policy', + value: nls.localize('chat.agentHost.otel.otlpProtocol.policy', "Controls the enterprise-managed OTLP wire protocol (protobuf vs JSON) for Copilot OpenTelemetry export."), + } + }, + }, }, [AgentHostOTelOtlpEndpointSettingId]: { type: 'string', @@ -116,6 +245,22 @@ configurationRegistry.registerConfiguration({ default: '', scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelEndpoint`. + policy: { + name: 'CopilotOtelEndpoint', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_ENDPOINT_KEY), + managedSettings: { + [COPILOT_OTEL_ENDPOINT_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.otlpEndpoint.policy', + value: nls.localize('chat.agentHost.otel.otlpEndpoint.policy', "Controls the enterprise-managed OTLP collector endpoint for Copilot OpenTelemetry export."), + } + }, + }, }, [AgentHostOTelCaptureContentSettingId]: { type: 'boolean', @@ -123,6 +268,24 @@ configurationRegistry.registerConfiguration({ default: false, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelCaptureContent`; explicit managed value wins, otherwise + // `telemetry.lockCaptureContent` forces capture off. + policy: { + name: 'CopilotOtelCaptureContent', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedOTelCaptureContentValue, + managedSettings: { + [COPILOT_OTEL_CAPTURE_CONTENT_KEY]: { type: 'boolean' }, + [COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY]: { type: 'boolean' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.captureContent.policy', + value: nls.localize('chat.agentHost.otel.captureContent.policy', "Controls whether Copilot OpenTelemetry export captures prompt, response, and tool content."), + } + }, + }, }, [AgentHostOTelOutfileSettingId]: { type: 'string', @@ -130,6 +293,23 @@ configurationRegistry.registerConfiguration({ default: '', scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelOutfile`; suppresses local file export when the enterprise mandates an OTLP sink. + policy: { + name: 'CopilotOtelOutfile', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedOTelOutfileValue, + managedSettings: { + [COPILOT_OTEL_ENDPOINT_KEY]: { type: 'string' }, + [COPILOT_OTEL_PROTOCOL_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.outfile.policy', + value: nls.localize('chat.agentHost.otel.outfile.policy', "Prevents local file export when enterprise-managed Copilot OpenTelemetry export is configured."), + } + }, + }, }, [AgentHostOTelDbSpanExporterEnabledSettingId]: { type: 'boolean', @@ -138,5 +318,85 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + [AgentHostOTelServiceNameSettingId]: { + type: 'string', + markdownDescription: nls.localize('chat.agentHost.otel.serviceName', "Enterprise-managed OTel `service.name` resource attribute for Copilot OpenTelemetry export. Policy-only: there is no user-facing setting; it carries the managed `telemetry.serviceName` so the agent host's `OTEL_SERVICE_NAME` identifies spans from this deployment."), + default: '', + scope: ConfigurationScope.APPLICATION, + // Policy-only delivery slot — no user-writable surface (mirrors `chat.agentHost.otel.otlpProtocol`). + included: false, + tags: ['experimental', 'advanced'], + // Owns `CopilotOtelServiceName`; passes the raw managed `telemetry.serviceName` through so the + // starters can set `OTEL_SERVICE_NAME` on the agent host process. + policy: { + name: 'CopilotOtelServiceName', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_SERVICE_NAME_KEY), + managedSettings: { + [COPILOT_OTEL_SERVICE_NAME_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.serviceName.policy', + value: nls.localize('chat.agentHost.otel.serviceName.policy', "Controls the enterprise-managed OTel `service.name` resource attribute for Copilot OpenTelemetry export."), + } + }, + }, + }, + [AgentHostOTelResourceAttributesSettingId]: { + // Policy-only delivery slot — no user-writable surface (mirrors `chat.plugins.extraMarketplaces`). + // Carried as a `{ [key]: string }` object; the starters serialize it into `OTEL_RESOURCE_ATTRIBUTES`. + type: 'object', + additionalProperties: { type: ['string'] as ['string'] }, + default: {}, + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental', 'advanced'], + markdownDescription: nls.localize('chat.agentHost.otel.resourceAttributes', "Enterprise-managed OTel resource attributes for Copilot OpenTelemetry export. Policy-only: there is no user-facing setting; it carries the managed `telemetry.resourceAttributes` map so the agent host's `OTEL_RESOURCE_ATTRIBUTES` includes the deployment's attributes."), + policy: { + name: 'CopilotOtelResourceAttributes', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY), + managedSettings: { + [COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.resourceAttributes.policy', + value: nls.localize('chat.agentHost.otel.resourceAttributes.policy', "Controls the enterprise-managed OTel resource attributes for Copilot OpenTelemetry export."), + } + }, + }, + }, + // Extension-only policy delivery slot for managed OTLP exporter headers (e.g. auth tokens). + // Deliberately NOT delivered to the agent host: headers would have to travel via env vars, + // which the agent host leaks into the tool subprocesses it spawns, exposing the secret. The + // Copilot Chat extension applies these headers directly to its OTLP exporter instead. + ['chat.agentHost.otel.headers']: { + type: 'object', + additionalProperties: { type: ['string'] as ['string'] }, + default: {}, + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental', 'advanced'], + markdownDescription: nls.localize('chat.agentHost.otel.headers', "Enterprise-managed OTLP exporter headers (e.g. auth tokens) for Copilot OpenTelemetry export. Policy-only and extension-only: applied directly to the Copilot Chat extension's OTLP exporter, never delivered to the agent host process."), + policy: { + name: 'CopilotOtelHeaders', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_HEADERS_KEY), + managedSettings: { + [COPILOT_OTEL_HEADERS_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.headers.policy', + value: nls.localize('chat.agentHost.otel.headers.policy', "Controls the enterprise-managed OTLP exporter headers for Copilot OpenTelemetry export."), + } + }, + }, + }, } }); diff --git a/src/vs/platform/agentHost/common/agentModelPricing.ts b/src/vs/platform/agentHost/common/agentModelPricing.ts index 0af74d1428068..a6460d781d962 100644 --- a/src/vs/platform/agentHost/common/agentModelPricing.ts +++ b/src/vs/platform/agentHost/common/agentModelPricing.ts @@ -83,3 +83,145 @@ export function createAgentModelPricingMeta(pricing: IAgentModelPricingMeta): Re const entries = Object.entries(pricing).filter(([, value]) => value !== undefined); return entries.length > 0 ? Object.fromEntries(entries) : undefined; } + +/** + * Normalizes a raw CAPI billing payload (which uses snake_case field names like `token_prices`, + * `input_price`) into the camelCase {@link ICAPIModelBilling} shape that {@link createPricingMetaFromBilling} + * expects. Also handles the case where the billing object already uses camelCase (e.g. from the + * Copilot SDK's `ModelInfo`). Returns `undefined` when `raw` is nullish. + */ +export function normalizeCAPIBilling(raw: unknown): ICAPIModelBilling | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + const billing = raw as Record; + const multiplier = typeof billing.multiplier === 'number' ? billing.multiplier : undefined; + const priceCategory = typeof billing.priceCategory === 'string' ? billing.priceCategory + : typeof (billing as Record).price_category === 'string' ? (billing as Record).price_category as string + : undefined; + const discountPercent = typeof billing.discountPercent === 'number' ? billing.discountPercent + : typeof (billing as Record).discount_percent === 'number' ? (billing as Record).discount_percent as number + : undefined; + + // Resolve token prices: prefer camelCase `tokenPrices`, fall back to snake_case `token_prices`. + const rawTokenPrices = (billing.tokenPrices ?? billing.token_prices) as Record | undefined; + let tokenPrices: ICAPIModelBilling['tokenPrices'] = undefined; + if (rawTokenPrices && typeof rawTokenPrices === 'object') { + // The CAPI snake_case format nests prices under `default` / `long_context` tiers; + // the camelCase format flattens them at the top level of `tokenPrices`. + const defaultTier = rawTokenPrices.default as Record | undefined; + const hasDefault = defaultTier && typeof defaultTier === 'object'; + + const inputPrice = asNumber(rawTokenPrices.inputPrice) ?? asNumber(hasDefault ? defaultTier.input_price : undefined); + const cachePrice = asNumber(rawTokenPrices.cachePrice) ?? asNumber(hasDefault ? defaultTier.cache_price : undefined); + const cacheWritePrice = asNumber(rawTokenPrices.cacheWritePrice) ?? asNumber(hasDefault ? defaultTier.cache_write_price : undefined); + const outputPrice = asNumber(rawTokenPrices.outputPrice) ?? asNumber(hasDefault ? defaultTier.output_price : undefined); + const contextMax = asNumber(rawTokenPrices.contextMax) ?? asNumber(hasDefault ? defaultTier.context_max : undefined); + + const rawLong = (rawTokenPrices.longContext ?? rawTokenPrices.long_context) as Record | undefined; + let longContext: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number } | undefined; + if (rawLong && typeof rawLong === 'object') { + longContext = { + inputPrice: asNumber(rawLong.inputPrice) ?? asNumber(rawLong.input_price), + cachePrice: asNumber(rawLong.cachePrice) ?? asNumber(rawLong.cache_price), + cacheWritePrice: asNumber(rawLong.cacheWritePrice) ?? asNumber(rawLong.cache_write_price), + outputPrice: asNumber(rawLong.outputPrice) ?? asNumber(rawLong.output_price), + contextMax: asNumber(rawLong.contextMax) ?? asNumber(rawLong.context_max), + }; + } + + tokenPrices = { inputPrice, cachePrice, cacheWritePrice, outputPrice, contextMax, longContext }; + } + + return { multiplier, priceCategory, discountPercent, tokenPrices }; +} + +function asNumber(v: unknown): number | undefined { + return typeof v === 'number' ? v : undefined; +} + +/** + * Runtime shape of the CAPI model billing payload. The published SDK types (`CCAModelBilling`, `ModelBilling`) don't + * yet declare `tokenPrices`, `priceCategory`, or `discountPercent`, but the `/models` endpoint already carries them. + * Both Copilot and Claude agents narrow through this interface at the read boundary. + * + * Remove individual fields as the SDK catches up (tracked at microsoft/vscode-capi#85). + */ +export interface ICAPIModelBilling { + readonly multiplier?: number; + /** Coarse price bucket surfaced as a tag in the model picker hover. */ + readonly priceCategory?: string; + /** Whole-number percentage discount (0-100) for the synthetic `auto` model; rendered as a "{n}% discount" detail. */ + readonly discountPercent?: number; + readonly tokenPrices?: { + readonly contextMax?: number; + readonly inputPrice?: number; + readonly cachePrice?: number; + readonly cacheWritePrice?: number; + readonly outputPrice?: number; + readonly longContext?: { + readonly contextMax?: number; + readonly inputPrice?: number; + readonly cachePrice?: number; + readonly cacheWritePrice?: number; + readonly outputPrice?: number; + }; + }; +} + +/** + * Converts a CAPI model's billing payload into an {@link IAgentModelPricingMeta} `_meta` bag. Long-context costs are + * only emitted when there is an actual surcharge (at least one long-context price differs from the default tier). + * When emitting, any missing long-context field falls back to the default-tier value so the hover table renders + * complete rows. See {@link hasLongContextSurcharge} for the surcharge detection logic. + * + * @param billing - The model's billing info, narrowed through {@link ICAPIModelBilling}. + * @param priceCategory - An optional override for the price category (e.g. from `modelPickerPriceCategory` on the + * model object itself). Falls back to `billing.priceCategory` when not provided. + */ +export function createPricingMetaFromBilling(billing: ICAPIModelBilling | undefined, priceCategory?: string): Record | undefined { + const tokenPrices = billing?.tokenPrices; + const longContext = tokenPrices?.longContext; + + // Only emit long-context costs when there is an actual surcharge (at least + // one price differs from default). When emitting, fall back to the default- + // tier value for any field the long-context tier does not specify so the + // hover table renders complete rows without gaps. + const showLongContext = longContext !== undefined && ( + (longContext.inputPrice !== undefined && longContext.inputPrice !== tokenPrices?.inputPrice) || + (longContext.outputPrice !== undefined && longContext.outputPrice !== tokenPrices?.outputPrice) || + (longContext.cachePrice !== undefined && longContext.cachePrice !== tokenPrices?.cachePrice) || + (longContext.cacheWritePrice !== undefined && longContext.cacheWritePrice !== tokenPrices?.cacheWritePrice) + ); + + return createAgentModelPricingMeta({ + multiplierNumeric: typeof billing?.multiplier === 'number' ? billing.multiplier : undefined, + inputCost: tokenPrices?.inputPrice, + cacheCost: tokenPrices?.cachePrice, + cacheWriteCost: tokenPrices?.cacheWritePrice, + outputCost: tokenPrices?.outputPrice, + longContextInputCost: showLongContext ? (longContext.inputPrice ?? tokenPrices?.inputPrice) : undefined, + longContextCacheCost: showLongContext ? (longContext.cachePrice ?? tokenPrices?.cachePrice) : undefined, + longContextCacheWriteCost: showLongContext ? (longContext.cacheWritePrice ?? tokenPrices?.cacheWritePrice) : undefined, + longContextOutputCost: showLongContext ? (longContext.outputPrice ?? tokenPrices?.outputPrice) : undefined, + priceCategory: priceCategory ?? (typeof billing?.priceCategory === 'string' ? billing.priceCategory : undefined), + discountPercent: typeof billing?.discountPercent === 'number' ? billing.discountPercent : undefined, + }); +} + +/** + * Whether the model's long-context tier has any cost that differs from its default tier. + * Used to decide whether to show a context-size picker (surcharge → user opts in) or to + * silently use the full context window for free. + */ +export function hasLongContextSurcharge(billing: ICAPIModelBilling | undefined): boolean { + const tokenPrices = billing?.tokenPrices; + const longContext = tokenPrices?.longContext; + if (!longContext) { + return false; + } + return (longContext.inputPrice !== undefined && longContext.inputPrice !== tokenPrices?.inputPrice) + || (longContext.outputPrice !== undefined && longContext.outputPrice !== tokenPrices?.outputPrice) + || (longContext.cachePrice !== undefined && longContext.cachePrice !== tokenPrices?.cachePrice) + || (longContext.cacheWritePrice !== undefined && longContext.cacheWritePrice !== tokenPrices?.cacheWritePrice); +} diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3de8693ee6d39..11b89076a339e 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -7,12 +7,12 @@ import type { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { IReference } from '../../../base/common/lifecycle.js'; import { isWeb } from '../../../base/common/platform.js'; +import { truncate } from '../../../base/common/strings.js'; import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; import type { IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import type { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentServerToolHost } from './agentServerTools.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from './state/agentSubscription.js'; import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js'; @@ -21,7 +21,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { ProtectedResourceMetadata, type Changeset, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; +import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, type AgentCapabilities, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -86,6 +86,18 @@ export const AgentHostClaudeAgentEnabledSettingId = 'chat.agentHost.claudeAgent. */ export const AgentHostCodexAgentEnabledSettingId = 'chat.agentHost.codexAgent.enabled'; +/** + * Configuration key controlling whether the agent host *wires up* the BYOK + * ("bring your own key") language-model bridge: the renderer LM handler, the + * reverse-RPC channel, and the per-connection link to the node-side OpenAI + * proxy + bridge registry. When `false` (the default), the proxy and registry + * are still constructed but stay inert — the renderer's BYOK server channel and + * the per-connection bridge are not wired, so the registry stays empty and + * extension-provided BYOK models are never reachable from agent-host sessions. + * The agent host process must be restarted for changes to take effect. + */ +export const AgentHostByokModelsEnabledSettingId = 'chat.agentHost.byokModels.enabled'; + /** * Optional override that points at an **SDK root directory** containing a * `node_modules/@anthropic-ai/claude-agent-sdk` subtree. When set, the agent @@ -111,6 +123,13 @@ export const AgentHostClaudeAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_AGENT */ export const AgentHostCodexAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CODEX_AGENT_ENABLED'; +/** + * Environment variable form of {@link AgentHostByokModelsEnabledSettingId}. + * Set by the agent host starters from the setting. Accepts `'true'` / + * `'false'`; absent means "default" (`false`). + */ +export const AgentHostByokModelsEnabledEnvVar = 'VSCODE_AGENT_HOST_BYOK_MODELS_ENABLED'; + /** * Resolves the effective enable state for a Claude/Codex provider from the * env-var value forwarded by the starter. Recognized values (case- and @@ -216,7 +235,7 @@ export function claudePreferAgentHostSettingId(isSessionsWindow: boolean): strin * should unconditionally return `true` and callers can drop the gate entirely. */ export function shouldSurfaceLocalAgentHostProvider(provider: AgentProvider, configurationService: IConfigurationService, isSessionsWindow: boolean): boolean { - if (provider !== 'claude') { + if (provider !== CLAUDE_AGENT_PROVIDER_ID) { return true; } return configurationService.getValue(claudePreferAgentHostSettingId(isSessionsWindow)) === true; @@ -283,12 +302,23 @@ export const AgentHostCodexAgentBinaryArgsEnvVar = 'VSCODE_AGENT_HOST_CODEX_APP_ export const AgentHostOTelEnabledSettingId = 'chat.agentHost.otel.enabled'; /** Exporter type for the SDK's OTel pipeline. One of: `otlp-http`, `otlp-grpc`, `console`, `file`. */ export const AgentHostOTelExporterTypeSettingId = 'chat.agentHost.otel.exporterType'; +/** + * OTLP wire protocol (`http/json`, `http/protobuf`, `grpc`). Policy-only delivery slot (no user UI): + * carries the enterprise-managed `telemetry.protocol` so it can be threaded into the agent host's + * `OTEL_EXPORTER_OTLP_PROTOCOL` env, which the runtime needs to distinguish protobuf from json + * (the `exporterType` setting only models transport, not the HTTP wire encoding). + */ +export const AgentHostOTelOtlpProtocolSettingId = 'chat.agentHost.otel.otlpProtocol'; /** OTLP endpoint URL when `exporterType` is `otlp-http` or `otlp-grpc`. */ export const AgentHostOTelOtlpEndpointSettingId = 'chat.agentHost.otel.otlpEndpoint'; /** Whether to include prompt/response content in span attributes (privacy-sensitive). */ export const AgentHostOTelCaptureContentSettingId = 'chat.agentHost.otel.captureContent'; /** Output path when `exporterType` is `file`. */ export const AgentHostOTelOutfileSettingId = 'chat.agentHost.otel.outfile'; +/** Policy-only delivery slot for the enterprise-managed OTel `service.name` (no user UI). */ +export const AgentHostOTelServiceNameSettingId = 'chat.agentHost.otel.serviceName'; +/** Policy-only delivery slot for enterprise-managed OTel resource attributes (no user UI). */ +export const AgentHostOTelResourceAttributesSettingId = 'chat.agentHost.otel.resourceAttributes'; /** When true, ALL spans are persisted to a local SQLite store regardless of `exporterType`. */ export const AgentHostOTelDbSpanExporterEnabledSettingId = 'chat.agentHost.otel.dbSpanExporter.enabled'; @@ -315,10 +345,14 @@ export const AgentHostOTelEnvVars = Object.freeze({ OtlpEndpoint: 'OTEL_EXPORTER_OTLP_ENDPOINT', OtlpEndpointAlt: 'COPILOT_OTEL_ENDPOINT', OtlpProtocol: 'OTEL_EXPORTER_OTLP_PROTOCOL', + OtlpTracesProtocol: 'OTEL_EXPORTER_OTLP_TRACES_PROTOCOL', + OtlpMetricsProtocol: 'OTEL_EXPORTER_OTLP_METRICS_PROTOCOL', OtlpHeaders: 'OTEL_EXPORTER_OTLP_HEADERS', CaptureContent: 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT', FilePath: 'COPILOT_OTEL_FILE_EXPORTER_PATH', SourceName: 'COPILOT_OTEL_SOURCE_NAME', + ServiceName: 'OTEL_SERVICE_NAME', + ResourceAttributes: 'OTEL_RESOURCE_ATTRIBUTES', DbSpanExporterEnabled: 'COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED', } as const); @@ -329,12 +363,102 @@ export const AgentHostOTelEnvVars = Object.freeze({ export interface IAgentHostOTelSettings { readonly enabled?: boolean; readonly exporterType?: string; + readonly otlpProtocol?: string; readonly otlpEndpoint?: string; readonly captureContent?: boolean; readonly outfile?: string; + readonly serviceName?: string; + readonly resourceAttributes?: Record; readonly dbSpanExporterEnabled?: boolean; } +/** + * IPC channel (renderer -> main) the desktop agent-host path uses to hand the + * enterprise-resolved `chat.agentHost.otel.*` policy to `ElectronAgentHostStarter`. + * + * The main-process configuration service does NOT include the renderer-only + * `AccountPolicyService` (managed settings: server / native-MDM / file channels), so a + * starter running in the main process sees `policyValue === undefined` for these keys. + * The renderer — whose policy layer does include managed settings — forwards the resolved + * values here just before requesting the agent-host connection, so the host is spawned with + * the managed OTel env. See {@link readAgentHostOTelPolicySettings}. + */ +export const AgentHostOTelPolicyIpcChannel = 'vscode:agentHostOTelPolicy'; + +/** + * Resolve the enterprise-policy values for the `chat.agentHost.otel.*` settings from a + * configuration service whose policy layer includes managed settings (i.e. the renderer's). + * Each field is `undefined` when no policy is set. Intended as the `policySettings` argument + * of {@link buildAgentHostOTelEnv}. + */ +export function readAgentHostOTelPolicySettings(configurationService: IConfigurationService): IAgentHostOTelSettings { + const policyValue = (key: string): T | undefined => configurationService.inspect(key).policyValue; + return { + enabled: policyValue(AgentHostOTelEnabledSettingId), + exporterType: policyValue(AgentHostOTelExporterTypeSettingId), + otlpProtocol: policyValue(AgentHostOTelOtlpProtocolSettingId), + otlpEndpoint: policyValue(AgentHostOTelOtlpEndpointSettingId), + captureContent: policyValue(AgentHostOTelCaptureContentSettingId), + outfile: policyValue(AgentHostOTelOutfileSettingId), + serviceName: policyValue(AgentHostOTelServiceNameSettingId), + resourceAttributes: policyValue>(AgentHostOTelResourceAttributesSettingId), + }; +} + +/** + * Validate/normalize an {@link IAgentHostOTelSettings} received over IPC, keeping only + * well-typed fields. Defends the main process against a malformed payload before the values + * are turned into agent-host process env vars. + */ +export function sanitizeAgentHostOTelPolicySettings(raw: unknown): IAgentHostOTelSettings { + if (!raw || typeof raw !== 'object') { + return {}; + } + const record = raw as Record; + const asString = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; + const asBoolean = (value: unknown): boolean | undefined => typeof value === 'boolean' ? value : undefined; + const asStringRecord = (value: unknown): Record | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') { + continue; // defend the IPC boundary against prototype pollution + } + if (typeof v === 'string') { + out[k] = v; + } + } + return out; + }; + return { + enabled: asBoolean(record.enabled), + exporterType: asString(record.exporterType), + otlpProtocol: asString(record.otlpProtocol), + otlpEndpoint: asString(record.otlpEndpoint), + captureContent: asBoolean(record.captureContent), + outfile: asString(record.outfile), + serviceName: asString(record.serviceName), + resourceAttributes: asStringRecord(record.resourceAttributes), + }; +} + +/** + * Serialize an OTel resource-attribute map into the `OTEL_RESOURCE_ATTRIBUTES` env-var format + * (`key1=value1,key2=value2`, W3C Baggage style). Returns `undefined` for an empty/absent map so + * callers can skip emitting the env var. Empty keys and non-string values are dropped. + */ +function serializeResourceAttributes(attributes: Record | undefined): string | undefined { + if (!attributes) { + return undefined; + } + const parts = Object.entries(attributes) + .filter(([key, value]) => key !== '' && typeof value === 'string') + .map(([key, value]) => `${key}=${value}`); + return parts.length > 0 ? parts.join(',') : undefined; +} + /** * Build the env-var overlay for the agent host process from user settings and * inherited env. Settings are translated to env vars, but if the same env var is @@ -346,6 +470,7 @@ export interface IAgentHostOTelSettings { export function buildAgentHostOTelEnv( settings: IAgentHostOTelSettings, inheritedEnv: Readonly>, + policySettings: IAgentHostOTelSettings = {}, ): Record { const out: Record = {}; const setIfMissing = (key: string, value: string | undefined): void => { @@ -354,11 +479,20 @@ export function buildAgentHostOTelEnv( } out[key] = value; }; + // Enterprise policy wins over inherited env (managed settings cannot be overridden by a + // user-set env var), unlike user settings which yield to env via `setIfMissing`. + const setPolicy = (key: string, value: string | undefined): void => { + if (value !== undefined) { + out[key] = value; + } + }; if (settings.enabled) { setIfMissing(AgentHostOTelEnvVars.Enabled, 'true'); } setIfMissing(AgentHostOTelEnvVars.ExporterType, settings.exporterType); setIfMissing(AgentHostOTelEnvVars.OtlpEndpoint, settings.otlpEndpoint); + setIfMissing(AgentHostOTelEnvVars.ServiceName, settings.serviceName); + setIfMissing(AgentHostOTelEnvVars.ResourceAttributes, serializeResourceAttributes(settings.resourceAttributes)); setIfMissing(AgentHostOTelEnvVars.FilePath, settings.outfile); if (settings.captureContent !== undefined) { setIfMissing(AgentHostOTelEnvVars.CaptureContent, settings.captureContent ? 'true' : 'false'); @@ -366,6 +500,43 @@ export function buildAgentHostOTelEnv( if (settings.dbSpanExporterEnabled) { setIfMissing(AgentHostOTelEnvVars.DbSpanExporterEnabled, 'true'); } + + if (policySettings.enabled !== undefined) { + setPolicy(AgentHostOTelEnvVars.Enabled, policySettings.enabled ? 'true' : 'false'); + if (!policySettings.enabled) { + setPolicy(AgentHostOTelEnvVars.OtlpEndpoint, ''); + setPolicy(AgentHostOTelEnvVars.OtlpEndpointAlt, ''); + setPolicy(AgentHostOTelEnvVars.FilePath, ''); + } + } + if (policySettings.exporterType !== undefined) { + setPolicy(AgentHostOTelEnvVars.ExporterType, policySettings.exporterType); + setPolicy(AgentHostOTelEnvVars.FilePath, ''); + } + if (policySettings.otlpProtocol !== undefined && policySettings.otlpProtocol !== '') { + // Mirror the CLI: thread the managed protocol into the generic AND per-signal protocol + // env vars so it wins over any user-provided OTEL_EXPORTER_OTLP_{,TRACES_,METRICS_}PROTOCOL. + setPolicy(AgentHostOTelEnvVars.OtlpProtocol, policySettings.otlpProtocol); + setPolicy(AgentHostOTelEnvVars.OtlpTracesProtocol, policySettings.otlpProtocol); + setPolicy(AgentHostOTelEnvVars.OtlpMetricsProtocol, policySettings.otlpProtocol); + } + if (policySettings.otlpEndpoint !== undefined) { + setPolicy(AgentHostOTelEnvVars.OtlpEndpoint, policySettings.otlpEndpoint); + setPolicy(AgentHostOTelEnvVars.FilePath, ''); + } + if (policySettings.outfile !== undefined) { + setPolicy(AgentHostOTelEnvVars.FilePath, policySettings.outfile); + } + if (policySettings.captureContent !== undefined) { + setPolicy(AgentHostOTelEnvVars.CaptureContent, policySettings.captureContent ? 'true' : 'false'); + } + if (policySettings.serviceName !== undefined && policySettings.serviceName !== '') { + setPolicy(AgentHostOTelEnvVars.ServiceName, policySettings.serviceName); + } + const policyResourceAttributes = serializeResourceAttributes(policySettings.resourceAttributes); + if (policyResourceAttributes !== undefined) { + setPolicy(AgentHostOTelEnvVars.ResourceAttributes, policyResourceAttributes); + } return out; } @@ -386,6 +557,7 @@ export interface IAgentSdkStarterSettings { readonly codexBinaryArgs?: readonly string[]; readonly claudeAgentEnabled?: boolean; readonly codexAgentEnabled?: boolean; + readonly byokModelsEnabled?: boolean; } export function buildAgentSdkEnv( @@ -410,6 +582,9 @@ export function buildAgentSdkEnv( if (settings.codexAgentEnabled !== undefined) { setIfMissing(AgentHostCodexAgentEnabledEnvVar, settings.codexAgentEnabled ? 'true' : 'false'); } + if (settings.byokModelsEnabled !== undefined) { + setIfMissing(AgentHostByokModelsEnabledEnvVar, settings.byokModelsEnabled ? 'true' : 'false'); + } return out; } @@ -461,13 +636,6 @@ export interface IAgentSessionMetadata { readonly status?: SessionStatus; /** Human-readable description of what the session is currently doing. */ readonly activity?: string; - readonly model?: ModelSelection; - /** - * Selected custom agent for this session. Absent (`undefined`) means no - * custom agent is selected — the session uses the provider's default - * behavior. - */ - readonly agent?: AgentSelection; readonly workingDirectory?: URI; readonly customizationDirectory?: URI; readonly isRead?: boolean; @@ -490,10 +658,12 @@ export interface IAgentSessionMetadata { readonly changesets?: readonly Changeset[]; /** * Side-channel metadata mirroring {@link SessionState._meta}, propagated - * to clients via per-session state subscriptions. - * Producers SHOULD use namespaced keys; consumers MUST ignore unknown - * keys. Use the typed accessors in `sessionState.ts` (e.g. - * `readSessionGitState`) for well-known slots. + * to clients via per-session state subscriptions and the root-channel + * session summary (the host treats the session-state and session-summary + * `_meta` as the same bag). Producers SHOULD use namespaced keys; consumers + * MUST ignore unknown keys. Use the typed accessors in `sessionState.ts` + * (e.g. `readSessionGitState`, `readSessionGitHubState`) for well-known + * slots. */ readonly _meta?: SessionMeta; } @@ -511,7 +681,7 @@ export interface IAgentCreateSessionResult { /** * `true` when the agent only allocated an in-memory placeholder for this * session (no SDK session, no worktree, no on-disk state). Materialization - * happens lazily on the first {@link IAgent.sendMessage}, at which point + * happens lazily on the first {@link IAgentChats.sendMessage}, at which point * the agent fires {@link IAgent.onDidMaterializeSession}. The * {@link IAgentService} uses this flag to defer the `sessionAdded` protocol * notification so observers don't see the session in their list until it @@ -533,11 +703,29 @@ export interface IAgentMaterializeSessionEvent { export type AgentProvider = string; +/** Well-known agent provider id for the Claude agent-host backend. */ +export const CLAUDE_AGENT_PROVIDER_ID = 'claude' as const; + +/** + * Static capability facts an agent backend advertises about itself. Each flag + * is opt-in (absent means unsupported) so single-chat agents (e.g. Codex) can omit + * the bag entirely. Discovered over IPC alongside the rest of + * {@link IAgentDescriptor} and surfaced to the sessions UI so features are + * capability-gated instead of switched on the provider id. + * + * This is the IPC contract alias of the protocol-visible {@link AgentCapabilities} + * type (defined in the root-state protocol); both share a single canonical shape + * so a new flag added in one place is automatically reflected in the other. + */ +export type IAgentCapabilities = AgentCapabilities; + /** Metadata describing an agent backend, discovered over IPC. */ export interface IAgentDescriptor { readonly provider: AgentProvider; readonly displayName: string; readonly description: string; + /** Static capability flags the agent advertises (see {@link IAgentCapabilities}). */ + readonly capabilities?: IAgentCapabilities; } // ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- @@ -628,7 +816,7 @@ export interface IAgentCreateSessionConfig { /** * Eagerly claim the active client role for the new session. When provided, * the server initializes the session with this client as the active - * client, equivalent to dispatching a `session/activeClientChanged` + * client, equivalent to dispatching a `session/activeClientSet` * action immediately after creation. The `clientId` MUST match the * connection's own `clientId`. */ @@ -646,6 +834,13 @@ export interface IAgentCreateSessionConfig { */ readonly turnIdMapping?: ReadonlyMap; }; + /** + * MCP-style opt-in progress token from the client's `createSession`. When + * set, the service reports any long-running session bring-up work — chiefly + * the lazy first-use SDK download — as `progress` notifications carrying + * this token, so the client can correlate them to this call. + */ + readonly progressToken?: string; } /** Options for creating an additional chat within a session. */ @@ -654,6 +849,216 @@ export interface IAgentCreateChatOptions { readonly title?: string; /** Optional model override; defaults to the session's model. */ readonly model?: ModelSelection; + /** + * Fork an existing chat into this new chat. The new chat starts + * pre-populated with the source chat's turns up to and including + * {@link IAgentCreateChatForkSource.turnId}, and its backing chat + * is forked from the source so it can continue independently. + */ + readonly fork?: IAgentCreateChatForkSource; +} + +/** Identifies a source chat and turn to fork a new chat from. */ +export interface IAgentCreateChatForkSource { + /** URI of the existing chat to fork from. */ + readonly source: URI; + /** Turn ID in the source chat; content up to and including this turn is copied. */ + readonly turnId: string; + /** + * Maps old source turn IDs to fresh turn IDs for the forked chat. Populated + * by the agent service so the agent can remap per-turn data (e.g. SDK event + * ID mappings) in the forked chat's database. + */ + readonly turnIdMapping?: ReadonlyMap; +} + +/** Result of {@link IAgentChats.createChat}: the opaque blob to persist for restore. */ +export interface IAgentCreateChatResult { + /** + * Opaque, agent-owned token the orchestrator persists verbatim in the chat + * catalog and hands back to {@link IAgent.materializeChat} on + * restore. The orchestrator never parses it. `undefined` means nothing to + * persist (e.g. the agent keeps no resumable backing). + */ + readonly providerData?: string; + /** + * The SDK-level session URI that backs this peer chat, when the agent mints + * one in the same session store its own {@link IAgent.listSessions} enumerates + * (e.g. Claude). First-class and non-opaque — unlike {@link providerData} the + * orchestrator reads it to correlate and suppress the backing session so it + * never surfaces as a top-level session. `undefined` when the agent keeps no + * separately-enumerable backing session. + */ + readonly backingSession?: URI; +} + +/** Payload of {@link IAgent.onDidChangeChatData}. */ +export interface IAgentChatDataChange { + /** The peer chat whose backing chat's blob changed. */ + readonly chat: URI; + /** The new opaque blob to persist (replaces any previously stored value). */ + readonly providerData: string; +} + +/** A legacy peer chat enumerated by {@link IAgent.listLegacyChats} for one-time migration. */ +export interface IAgentLegacyChat { + /** The peer chat's channel URI (see {@link buildChatUri}). */ + readonly uri: URI; + /** The opaque, agent-owned backing blob, encoded as {@link materializeChat} expects. */ + readonly providerData?: string; +} + +/** + * Identifies the parent that spawned a chat. The orchestrator records + * it as the spawned chat's {@link ChatOriginKind.Tool} origin so clients can + * render the parent/child relationship (e.g. a sub-agent "team" member spawned + * by a tool call in the parent chat). + */ +export interface IAgentSpawnedChatParent { + /** The parent chat (chat) URI whose tool call performed the spawn. */ + readonly chat: URI; + /** The id of the tool call in the parent that spawned this chat. */ + readonly toolCallId: string; +} + +/** + * Payload of {@link IAgent.onDidSpawnChat}: a new chat the + * agent spawned itself (e.g. a sub-agent delegated by a tool call), as opposed + * to a user-driven chat created via + * {@link IAgentChats.createChat}. + */ +export interface IAgentSpawnChatEvent { + /** The session URI the spawned chat belongs to. */ + readonly session: URI; + /** The spawned chat's channel URI (the new chat). */ + readonly chat: URI; + /** + * The parent that spawned it, when the spawn was delegated by a tool call. + * Recorded as the chat's tool origin in the catalog. Absent for a + * top-level, agent-initiated chat with no spawning tool call. + */ + readonly parent?: IAgentSpawnedChatParent; + /** Optional display title for the spawned chat. */ + readonly title?: string; +} + +/** Max characters for a subagent tab title before it is ellipsized. */ +const SUBAGENT_CHAT_TITLE_MAX_LENGTH = 60; + +/** + * Builds the tab title for a subagent peer chat. Prefers the concise + * per-task description (so two subagents of the same type still get + * distinct, meaningful names), truncating it so an over-long value never + * blows out the tab strip or the Subagents dropdown; falls back to the + * agent type's display name, then a generic label. Shared by the live + * spawn path and the restore path so both name subagent tabs identically. + */ +export function subagentChatTitle(taskDescription: string | undefined, agentDisplayName: string | undefined): string { + const task = taskDescription?.trim(); + if (task) { + return truncate(task, SUBAGENT_CHAT_TITLE_MAX_LENGTH); + } + return agentDisplayName?.trim() || 'Subagent'; +} + +/** + * Maps agent `subagent_*` signals to the unified chat catalog's + * spawn/end events. Shared by the agents' spawn bridges and the orchestrator so + * subagent membership has one derivation. + */ +export namespace SubagentChatSignal { + + /** + * Derives the {@link IAgentSpawnChatEvent} for a `subagent_started` signal, + * addressing the subagent by the stable {@link buildSubagentChatUri} and + * recording the spawning tool call as its parent edge. Returns `undefined` + * for any other signal (or an unmappable chat URI). + */ + export function toSpawnEvent(signal: AgentSignal): IAgentSpawnChatEvent | undefined { + if (signal.kind !== 'subagent_started') { + return undefined; + } + let session: string; + try { + session = parseRequiredSessionUriFromChatUri(signal.chat); + } catch { + return undefined; + } + return { + session: URI.parse(session), + chat: URI.parse(buildSubagentChatUri(session, signal.toolCallId)), + parent: { chat: signal.chat, toolCallId: signal.toolCallId }, + // Prefer the concise per-task description so two subagents of the same + // type still get distinct, meaningful tab names; fall back to the agent + // type's display name. Truncate so an over-long description never blows + // out the tab strip or the Subagents dropdown. + title: subagentChatTitle(signal.taskDescription, signal.agentDisplayName), + }; + } +} + +// ---- Chat surface -------------------------------------------------- + +/** + * The chat-addressed operation surface an agent exposes for the chats + * within a session. + * + * Every operation method addresses a chat by a concrete chat channel URI: + * the default chat channel for a session's DEFAULT chat, or an additional + * chat's own channel URI. The orchestrator ({@link IAgentService}) owns the + * feature-level `(session, chat)` to chat-channel mapping and only ever calls + * these operations with a concrete chat URI. This replaces the legacy + * `(session, chat?)` parameter pairs and the per-agent default-chat handling on + * {@link IAgent}. + * + * Optional on {@link IAgent}: agents implement this incrementally (waves + * C2/C3/C4). Until an agent exposes it, {@link IAgentService} falls back to the + * agent's legacy `(session, chat?)` methods via a thin adapter. + */ +export interface IAgentChats { + /** + * Create a fresh additional chat within the session the `chat` URI belongs + * to, sharing the session's working directory, model, agent, and + * customizations. `chat` is the client-chosen channel URI the new chat is + * addressed by; its parent session is derived from it. + * Returns the opaque {@link IAgentCreateChatResult} blob to persist for + * restore (or `void` when the agent keeps no resumable backing). + */ + createChat(chat: URI, options?: IAgentCreateChatOptions): Promise; + + /** + * Fork a new chat from an existing one. The new `chat` + * inherits `source`'s backing up to and including + * {@link IAgentCreateChatForkSource.turnId} and then continues + * independently. The new chat's parent session is derived from its URI. + */ + fork(chat: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise; + + /** + * Dispose an additional chat created via + * {@link createChat}/{@link fork}, freeing its backing. A session's + * default chat cannot be disposed in isolation; it lives and dies + * with the session. + */ + disposeChat(chat: URI): Promise; + + /** Send a user message into `chat`. */ + sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise; + + /** Abort the in-flight turn for `chat`. */ + abort(chat: URI): Promise; + + /** Change the model for `chat`. */ + changeModel(chat: URI, model: ModelSelection): Promise; + + /** + * Change (or clear) the selected custom agent for `chat`. Passing + * `undefined` clears the selection (provider default behavior). + */ + changeAgent(chat: URI, agent: AgentSelection | undefined): Promise; + + /** Reconstruct the turns for `chat` (used on restore). */ + getMessages(chat: URI): Promise; } export interface IAgentResolveSessionConfigParams { @@ -673,6 +1078,8 @@ export interface IAgentModelInfo { readonly id: string; readonly name: string; readonly maxContextWindow?: number; + readonly maxOutputTokens?: number; + readonly maxPromptTokens?: number; readonly supportsVision: boolean; readonly configSchema?: ConfigSchema; readonly policyState?: PolicyState; @@ -703,13 +1110,13 @@ export type AgentSignal = * dispatches the action through the state manager after routing via * {@link IAgentActionSignal.parentToolCallId} (if set). * - * Agents are responsible for populating `session` and any `turnId` / + * Agents are responsible for populating the target channel and any `turnId` / * `partId` fields on the action. */ export interface IAgentActionSignal { readonly kind: 'action'; - /** Top-level session URI. For inner subagent events this is the parent session — see {@link parentToolCallId}. */ - readonly session: URI; + /** Target session or chat channel URI. For inner subagent events this is the parent session — see {@link parentToolCallId}. */ + readonly resource: URI; /** Protocol action to dispatch. */ readonly action: SessionAction | ChatAction; /** If set, route the action to the subagent session belonging to this tool call. */ @@ -732,13 +1139,20 @@ export interface IAgentActionSignal { */ export interface IAgentToolPendingConfirmationSignal { readonly kind: 'pending_confirmation'; - readonly session: URI; + /** Target chat channel URI containing the tool call. */ + readonly chat: URI; /** Protocol-shaped pending-confirmation state, dispatched verbatim into `ChatToolCallReady`. */ readonly state: ToolCallPendingConfirmationState; /** Host-only auto-approval kind (not part of the dispatched action). */ readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool' | 'hook' | 'memory' | 'extension-management' | 'extension-permission-access'; /** Host-only auto-approval path target (not part of the dispatched action). */ readonly permissionPath?: string; + /** + * Host-only flag (not part of the dispatched action): the model requested + * this shell command run OUTSIDE the sandbox (and the host opted in via + * `sandbox.allowBypass`). + */ + readonly requestSandboxBypass?: boolean; /** * If set, the tool call belongs to the subagent rooted at this * parent tool call. Used by the host to route the resulting @@ -758,11 +1172,36 @@ export interface IAgentToolPendingConfirmationSignal { */ export interface IAgentSubagentStartedSignal { readonly kind: 'subagent_started'; - readonly session: URI; + readonly chat: URI; readonly toolCallId: string; readonly agentName: string; readonly agentDisplayName: string; readonly agentDescription?: string; + /** + * The spawning Task tool's short (typically 3-5 word) `description` + * input, e.g. "Review package.json structure". Distinct from + * {@link agentDescription} (the agent *type*'s long role blurb) and + * {@link agentDisplayName} (the agent type's name). Preferred as the + * peer chat's tab title because it is concise and per-task, so two + * subagents of the same type still get distinct, meaningful names. + * Absent when the harness does not surface a task description. + */ + readonly taskDescription?: string; + /** + * If set, the spawning tool call ({@link toolCallId}) itself lives + * inside another subagent's chat — this is the tool call **one level up** + * from the spawning tool (its parent), i.e. the tool that spawned the + * immediate parent chat. The host uses it to route the + * subagent-discovery side effect (the `ChatToolCallContentChanged` + * block that lets clients find the child chat) to that immediate parent + * chat rather than the top-level {@link chat}. Because subagent chats + * are flat (all keyed off the root session + the spawning tool id), + * this single one-hop reference resolves the correct parent chat at + * ANY nesting depth — no per-level chain is needed. Absent for a + * top-level subagent, whose spawning tool call lives directly in + * {@link chat}. + */ + readonly parentToolCallId?: string; } /** @@ -774,14 +1213,14 @@ export interface IAgentSubagentStartedSignal { */ export interface IAgentSubagentCompletedSignal { readonly kind: 'subagent_completed'; - readonly session: URI; + readonly chat: URI; readonly toolCallId: string; } /** A steering message was consumed (sent to the model). */ export interface IAgentSteeringConsumedSignal { readonly kind: 'steering_consumed'; - readonly session: URI; + readonly chat: URI; readonly id: string; } @@ -833,6 +1272,46 @@ export interface IMcpNotification { readonly params?: Record; } +/** + * A subagent child session discovered in a parent session's event log, + * returned by {@link IAgent.getSubagentSessions} so a parent restore can + * register the child's state up-front. + */ +export interface IRestoredSubagentSession { + /** Child subagent session URI (subscribable by clients). */ + readonly resource: URI; + /** Parent tool call id that spawned the subagent. */ + readonly toolCallId: string; + /** Display title for the subagent session. */ + readonly title: string; + /** Reconstructed turns for the subagent's transcript. */ + readonly turns: readonly Turn[]; +} + +/** + * A per-session handle for one active client's contributions (tools and + * plugin customizations) to an agent session, obtained via + * {@link IAgent.getOrCreateActiveClient}. + * + * `tools` and `customizations` are mutable accessor properties: assigning a + * new array replaces this client's contribution wholesale and triggers the + * agent's internal reaction (refreshing the merged tool set exposed to the + * model, or kicking off an asynchronous customization sync). The arrays are + * `readonly` so callers cannot mutate them in place and silently bypass the + * setter. The agent merges the contributions of all active clients on a + * session, deduplicating as needed. + */ +export interface IActiveClient { + /** Client identifier (matches `clientId` from `initialize`). */ + readonly clientId: string; + /** Human-readable client name (e.g. `"VS Code"`), if provided. */ + readonly displayName: string | undefined; + /** This client's tools. Assigning replaces the set (full replacement). */ + tools: readonly ToolDefinition[]; + /** This client's plugin customizations. Assigning replaces the set and starts an internal sync. */ + customizations: readonly ClientPluginCustomization[]; +} + /** * Implemented by each agent backend (e.g. Copilot SDK). * The {@link IAgentService} dispatches to the appropriate agent based on @@ -864,6 +1343,20 @@ export interface IAgent { */ setServerToolHost?(host: IAgentServerToolHost): void; + // ---- Chat surface ------------------------------------------------------ + // + // `chats` is the chat-addressed operation surface. Its chats are addressed + // by concrete chat channel URIs. The orchestrator ({@link IAgentService}) + // owns the feature-level `(session, chat)` to chat-channel mapping. + + /** + * Chat-addressed surface for the chats within a session (send/abort/ + * change model/agent, create/fork/dispose chats, read history). + */ + readonly chats: IAgentChats; + + // ---- Session lifecycle / configuration --------------------------------- + /** Create a new session. Returns server-owned session metadata. */ createSession(config?: IAgentCreateSessionConfig): Promise; @@ -873,44 +1366,69 @@ export interface IAgent { /** Return dynamic completions for a session configuration property. */ sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; - /** Send a user message into an existing session. When `chat` is provided - * (and differs from the default chat), the harness routes the message to - * that specific chat within a multi-chat session. */ - sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise; + /** + * Re-attach an agent's in-memory backing for a peer chat on session + * restore, decoding the opaque `providerData` produced earlier by + * {@link IAgentChats.createChat} (or the latest + * {@link onDidChangeChatData}). After this resolves the agent MUST + * be able to serve {@link getSessionMessages}/ + * {@link IAgentChats.sendMessage} for `chat`. + * Best-effort: implementations SHOULD NOT throw on a corrupt/unknown blob — + * log and no-op so the orchestrator restores the chat with history but no + * live backing. `providerData` is `undefined` only for legacy entries with + * no stored blob, in which case the agent MAY consult its own legacy + * persistence once to recover the backing. + */ + materializeChat?(chat: URI, providerData: string | undefined): Promise; /** - * Create an additional chat within an existing session, backed by a new - * conversation that shares the session's scope (working directory, model, - * agent, customizations). Optional: harnesses that do not support multiple - * concurrent chats simply omit it. The `chat` URI is the client-chosen - * channel the new chat will be addressed by. + * Migration-only enumeration of a session's peer chats persisted in the + * agent's OWN legacy format (predating the orchestrator-owned catalog). The + * orchestrator calls this once, when its own catalog is absent, to drain the + * legacy chats into {@link PEER_CHATS_METADATA_KEY}; subsequent restores read + * the orchestrator catalog and never consult this again. Each entry's + * `providerData` uses the same encoding {@link IAgentChats.createChat} + * produces and {@link materializeChat} decodes. Agents with no legacy + * format (e.g. Codex) omit this method. */ - createChat?(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise; + listLegacyChats?(session: URI): Promise; /** - * Dispose an additional chat created via {@link createChat}, freeing its - * backing conversation. The session's default chat cannot be disposed in - * isolation; it lives and dies with the session. + * Fires when a peer chat's opaque `providerData` changes after creation + * (e.g. per-chat model switch, fork remap). The orchestrator re-persists the + * blob. Agents whose blob is immutable never fire this. */ - disposeChat?(session: URI, chat: URI): Promise; + readonly onDidChangeChatData?: Event; + + // ---- Spawned chat (membership) channel ------------------------- + // + // First-class membership channel for chats the agent spawns itself + // (e.g. sub-agent / "team" member chats delegated by a tool call), + // as opposed to user-driven chats created via + // {@link IAgentChats.createChat}. The orchestrator + // ({@link IAgentService}) routes these straight into the chat catalog + // (addChat/removeChat) so harness-spawned and user-driven chats share ONE + // membership path. Agents that never spawn chats omit both events. /** - * Returns the persisted catalog of additional (non-default) peer chats for a - * session as their channel URIs. Used to re-register peer chats (and seed - * their history) when a session is restored after a process restart. - * Optional: harnesses without multi-chat persistence omit it. + * Fires when the agent spawns a new chat within a session (e.g. a + * sub-agent delegated by a tool call). The orchestrator records it in the + * chat catalog, preserving the {@link IAgentSpawnChatEvent.parent} + * spawn edge as the chat's {@link ChatOriginKind.Tool} origin. */ - getChats?(session: URI): Promise; + readonly onDidSpawnChat?: Event; /** * Called when the session's pending (steering) message changes. * The agent harness decides how to react — e.g. inject steering - * mid-turn via `mode: 'immediate'`. + * mid-turn via `mode: 'immediate'`. When `chat` is provided (an additional + * peer chat's URI), the steering targets that chat's chat rather + * than the session's default chat. * * Queued messages are consumed on the server side and are not * forwarded to the agent; `queuedMessages` will always be empty. */ - setPendingMessages?(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void; + setPendingMessages?(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], chat?: URI): void; /** * Retrieve the reconstructed turns for a session, used when restoring @@ -921,27 +1439,19 @@ export interface IAgent { */ getSessionMessages(session: URI): Promise; - /** Dispose a session, freeing resources. */ - disposeSession(session: URI): Promise; - - /** Abort the current turn, stopping any in-flight processing. When `chat` - * is provided, only that chat's in-flight turn is aborted. */ - abortSession(session: URI, chat?: URI): Promise; - - /** Change the model for an existing session. When `chat` is provided (an - * additional peer chat's URI), the change targets that chat's conversation - * rather than the session's default chat. */ - changeModel(session: URI, model: ModelSelection, chat?: URI): Promise; - /** - * Change (or clear) the selected custom agent for an existing session. - * Passing `undefined` clears the selection and resets the session to no - * selected custom agent (provider default behavior). Optional so non- - * Copilot agents can opt out. When `chat` is provided (an additional peer - * chat's URI), the change targets that chat's conversation rather than the - * session's default chat. + * Returns the subagent child sessions discoverable in a session's event + * log so a parent restore can eagerly register them in a single pass. + * Without this, every child is restored separately by re-fetching and + * re-reconstructing the full parent event log (one pass per subagent). + * Agents that serve this from the same reconstruction they already + * produced for the parent turns avoid that redundant work entirely. + * Optional; agents without subagents omit it. */ - changeAgent?(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise; + getSubagentSessions?(session: URI): Promise; + + /** Dispose a session, freeing resources. */ + disposeSession(session: URI): Promise; /** Respond to a pending permission request from the SDK. */ respondToPermissionRequest(requestId: string, approved: boolean): void; @@ -992,6 +1502,12 @@ export interface IAgent { */ authenticate(resource: string, token: string): Promise; + /** + * Optional hook for provider-owned session resources that are not advertised + * as root agent protected resources, such as MCP server OAuth challenges. + */ + handleAuthenticationToken?(params: AuthenticateParams): Promise; + /** * Truncate a session's history. If `turnId` is provided, keeps turns up to * and including that turn. If omitted, all turns are removed. @@ -1008,37 +1524,40 @@ export interface IAgent { onArchivedChanged?(session: URI, isArchived: boolean): Promise; /** - * Receives client-provided customization refs for a session and syncs them - * (e.g. copies plugin files to local storage). The agent publishes - * customization state actions as the sync progresses. + * Get (or lazily create) the per-session handle for an active client, + * identified by `clientId`. Mutating the returned {@link IActiveClient}'s + * `tools` / `customizations` updates only that client's contribution; the + * agent merges the contributions of all active clients when exposing them + * to the model. A session MAY have several active clients at once. * - * The agent MAY defer a client restart until all active sessions are idle. + * @param session The session URI this client contributes to. + * @param client The client's `clientId` and optional human-readable name. */ - setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise; + getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient; /** - * Receives client-provided tool definitions to make available in a - * specific session. The agent registers these as custom tools so the - * LLM can call them; execution is routed back to the owning client. - * - * Always called on `activeClientChanged`, even with an empty array, - * to clear a previous client's tools. + * Remove an active client from a session, clearing its tool and + * customization contributions. No-op when no active client matches + * `clientId`. * - * @param session The session URI this tool set applies to. - * @param clientId The client that owns these tools. - * @param tools The tool definitions (full replacement). + * @param session The session the client is leaving. + * @param clientId The client to remove. */ - setClientTools(session: URI, clientId: string | undefined, tools: ToolDefinition[]): void; + removeActiveClient(session: URI, clientId: string): void; /** * Called when a client completes a client-provided tool call. * Resolves the tool handler's deferred promise so the SDK can continue. * * @param session The session the tool call belongs to. + * @param chat The chat channel the tool call was issued on, when known. + * Agents that track peer chats separately from the default chat (e.g. + * copilot) use this to route the completion to the right chat; + * agents without peer chats ignore it and resolve by `session`. * @param toolCallId The id of the tool call being completed. * @param result The result of the tool call. */ - onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void; + onClientToolCallComplete(session: URI, chat: URI, toolCallId: string, result: ToolCallResult): void; /** * Notifies the agent that a customization has been toggled on or off. @@ -1117,7 +1636,7 @@ export interface IAgentService { /** * Create an additional chat within an existing session. Spins up the - * backing conversation in the harness (sharing the session's scope) and + * backing chat in the harness (sharing the session's session) and * registers the chat in the session's catalog so subscribers observe a * `session/chatAdded` action. The `chat` URI is the client-chosen channel. */ diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 3de44b3dced36..80e182e8c8984 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -87,7 +87,7 @@ export const compareTurnsChangesetDescription = (): string => localize('compareT * Returns `undefined` only when no branch name is known at all, so * callers can omit the description entirely. */ -export function formatSessionChangesetDescription(gitState: ISessionGitState): string | undefined { +export function formatBranchChangesetDescription(gitState: ISessionGitState): string | undefined { const { baseBranchName, branchName, upstreamBranchName } = gitState; // Use branch name @@ -292,10 +292,28 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig * compare-turns diffs construct the URI themselves from two known * turn ids and subscribe directly. */ -export function buildDefaultChangesetCatalogue(sessionUri: URI): Changeset[] { +export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessionGitState): Changeset[] { + if (!gitState) { + return [{ + label: sessionChangesetLabel(), + description: sessionChangesetDescription(), + uriTemplate: buildSessionChangesetUri(sessionUri), + changeKind: ChangesetKind.Session + }, + { + label: thisTurnChangesetLabel(), + description: thisTurnChangesetDescription(), + uriTemplate: buildTurnChangesetUriTemplate(sessionUri), + changeKind: ChangesetKind.Turn + }] satisfies Changeset[]; + } + return [ { label: branchChangesetLabel(), + description: gitState + ? formatBranchChangesetDescription(gitState) + : undefined, uriTemplate: buildBranchChangesetUri(sessionUri), changeKind: ChangesetKind.Branch }, @@ -323,5 +341,5 @@ export function buildDefaultChangesetCatalogue(sessionUri: URI): Changeset[] { uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), changeKind: ChangesetKind.Compare } - ]; + ] satisfies Changeset[]; } diff --git a/src/vs/platform/agentHost/common/fileEditDiff.ts b/src/vs/platform/agentHost/common/fileEditDiff.ts new file mode 100644 index 0000000000000..a0fa733e73b90 --- /dev/null +++ b/src/vs/platform/agentHost/common/fileEditDiff.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 { URI } from '../../../base/common/uri.js'; +import { isEqual } from '../../../base/common/resources.js'; +import type { FileEdit } from './state/protocol/state.js'; +import { FileEditKind } from './state/sessionState.js'; + +/** + * A {@link FileEdit} decoded into parsed URIs and a resolved {@link FileEditKind}. + * + * The create/delete/rename/edit detection and the "primary resource" rule + * (after-URI for create/edit/rename, before-URI for delete) are subtle and + * were historically duplicated across several adapters. {@link normalizeFileEdit} + * centralizes them so every consumer derives the same shape from a protocol + * {@link FileEdit}. + */ +export interface INormalizedFileEdit { + /** The kind of file operation. */ + readonly kind: FileEditKind; + /** Primary file URI: after-URI for create/edit/rename, before-URI for delete. */ + readonly resource: URI; + /** The before-state file URI, when present (absent for creates). */ + readonly beforeUri?: URI; + /** The after-state file URI, when present (absent for deletes). */ + readonly afterUri?: URI; + /** URI from which the before-content can be read (absent for creates). */ + readonly beforeContentUri?: URI; + /** URI from which the after-content can be read (absent for deletes). */ + readonly afterContentUri?: URI; +} + +/** + * Decodes a protocol {@link FileEdit} into parsed URIs and a resolved + * {@link FileEditKind}. Returns `undefined` when the edit carries no usable + * file URI (neither `before` nor `after`). + */ +export function normalizeFileEdit(edit: FileEdit): INormalizedFileEdit | undefined { + const beforeUri = edit.before ? URI.parse(edit.before.uri) : undefined; + const afterUri = edit.after ? URI.parse(edit.after.uri) : undefined; + + const resource = afterUri ?? beforeUri; + if (!resource) { + return undefined; + } + + let kind: FileEditKind; + if (!beforeUri && afterUri) { + kind = FileEditKind.Create; + } else if (beforeUri && !afterUri) { + kind = FileEditKind.Delete; + } else if (beforeUri && afterUri && !isEqual(beforeUri, afterUri)) { + kind = FileEditKind.Rename; + } else { + kind = FileEditKind.Edit; + } + + return { + kind, + resource, + beforeUri, + afterUri, + beforeContentUri: edit.before?.content.uri ? URI.parse(edit.before.content.uri) : undefined, + afterContentUri: edit.after?.content.uri ? URI.parse(edit.after.content.uri) : undefined, + }; +} diff --git a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts index f7e1c46b12f7a..ad993f75654f5 100644 --- a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts +++ b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts @@ -31,6 +31,15 @@ export const FEEDBACK_ANNOTATION_META_KEY = 'vscode.agentFeedback'; */ export const VIEW_UNREVIEWED_COMMENTS_TOOL_NAME = 'viewUnreviewedComments'; +/** + * Name of the agent host server tool that adds a comment (agent feedback) to a + * file range. Shared here (in the layer-neutral `common` module) so the + * node-side server tool implementation and the browser-side chat adapter that + * renders its tool call agree on the name without drifting. The agent sees this + * name directly (Copilot) or prefixed as `mcp__host__` (Claude). + */ +export const ADD_COMMENT_TOOL_NAME = 'addComment'; + /** * Whether {@link toolName} (a tool name as seen on a tool call) refers to the * {@link VIEW_UNREVIEWED_COMMENTS_TOOL_NAME} server tool. Accepts both the bare @@ -40,6 +49,15 @@ export function isViewUnreviewedCommentsTool(toolName: string): boolean { return toolName === VIEW_UNREVIEWED_COMMENTS_TOOL_NAME || toolName.endsWith(`__${VIEW_UNREVIEWED_COMMENTS_TOOL_NAME}`); } +/** + * Whether {@link toolName} (a tool name as seen on a tool call) refers to the + * {@link ADD_COMMENT_TOOL_NAME} server tool. Accepts both the bare name and the + * Claude `mcp____` prefixed form. + */ +export function isAddCommentTool(toolName: string): boolean { + return toolName === ADD_COMMENT_TOOL_NAME || toolName.endsWith(`__${ADD_COMMENT_TOOL_NAME}`); +} + /** * Origin of a feedback item. String values match the client-side * `AgentFeedbackKind` enum so a value written by either side decodes on the diff --git a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts index 86ab1ca422234..2b834e0684f1f 100644 --- a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts +++ b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts @@ -28,6 +28,7 @@ export const enum AgentHostSandboxConfigKey { export const enum AgentHostSandboxKey { Enabled = 'enabled', WindowsEnabled = 'enabled.windows', + AllowNetwork = 'allowNetwork', AllowUnsandboxedCommands = 'allowUnsandboxedCommands', LinuxFileSystem = 'fileSystem.linux', MacFileSystem = 'fileSystem.mac', @@ -41,6 +42,7 @@ export const enum AgentHostSandboxKey { export type ISandboxConfigValue = Partial<{ [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue; [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue; + [AgentHostSandboxKey.AllowNetwork]: boolean; [AgentHostSandboxKey.AllowUnsandboxedCommands]: boolean; [AgentHostSandboxKey.LinuxFileSystem]: Record; [AgentHostSandboxKey.MacFileSystem]: Record; @@ -59,8 +61,8 @@ export type ISandboxConfigValue = Partial<{ * normalized form of each setting is declared here — the workbench is * expected to: * - * - map the legacy boolean form of `chat.agent.sandbox.enabled` to the - * `'on' | 'off' | 'allowNetwork'` enum, and + * - map legacy boolean sandbox enabled values to the `'on' | 'off' | 'allowNetwork'` + * agent-host enum, and * - migrate values from any deprecated setting IDs to their modern key * * before pushing a `RootConfigChanged` action. That keeps the agent-host @@ -81,6 +83,10 @@ export const sandboxConfigSchema = createSchema({ title: localize('agentHost.config.sandbox.windowsEnabled.title', "Sandbox Enabled (Windows)"), enum: [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On, AgentSandboxEnabledValue.AllowNetwork], }, + [AgentHostSandboxKey.AllowNetwork]: { + type: 'boolean', + title: localize('agentHost.config.sandbox.allowNetwork.title', "Allow Network"), + }, [AgentHostSandboxKey.AllowUnsandboxedCommands]: { type: 'boolean', title: localize('agentHost.config.sandbox.allowUnsandboxedCommands.title', "Allow Unsandboxed Commands"), @@ -126,6 +132,7 @@ export const sandboxConfigSchema = createSchema({ export const sandboxSettingIdToAgentHostKey: Readonly> = { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentHostSandboxKey.Enabled, [AgentSandboxSettingId.AgentSandboxWindowsEnabled]: AgentHostSandboxKey.WindowsEnabled, + [AgentSandboxSettingId.AgentSandboxAllowNetwork]: AgentHostSandboxKey.AllowNetwork, [AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands]: AgentHostSandboxKey.AllowUnsandboxedCommands, [AgentSandboxSettingId.AgentSandboxLinuxFileSystem]: AgentHostSandboxKey.LinuxFileSystem, [AgentSandboxSettingId.AgentSandboxMacFileSystem]: AgentHostSandboxKey.MacFileSystem, diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 5c2148d8ea243..0d343a3b0cb9d 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -7,7 +7,7 @@ import { IDisposable, IReference } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { Event } from '../../../base/common/event.js'; -import type { FileEditKind } from './state/sessionState.js'; +import type { FileEditKind, Message } from './state/sessionState.js'; export const ISessionDataService = createDecorator('sessionDataService'); @@ -60,6 +60,20 @@ export interface IFileEditContent { afterContent?: Uint8Array; } +// ---- Reviewed-file types ------------------------------------------------ + +/** + * A record of a file having been reviewed by the user at a specific content + * nonce. Returned by {@link ISessionDatabase.getReviewedFiles} and + * {@link ISessionDatabase.getReviewedFilesForUri}. + */ +export interface IReviewedFileRecord { + /** The reviewed file. */ + uri: URI; + /** Content version/hash captured at review time. */ + nonce: string; +} + // ---- Session database --------------------------------------------------- /** @@ -204,12 +218,52 @@ export interface ISessionDatabase extends IDisposable { */ setMetadata(key: string, value: string): Promise; + /** + * Store or clear the draft for a chat in this session. + */ + setChatDraft(chat: URI, draft: Message | undefined): Promise; + + /** + * Read the stored draft for a chat in this session. + */ + getChatDraft(chat: URI): Promise; + /** * Bulk-remaps turn IDs using the provided old→new mapping. * Used after copying a database file for a forked session. */ remapTurnIds(mapping: ReadonlyMap): Promise; + // ---- Reviewed files -------------------------------------------------- + + /** + * Mark a file (identified by URI + content nonce) as reviewed by the user. + * Idempotent — re-marking the same `(uri, nonce)` pair is a no-op. + */ + markFileReviewed(uri: URI, nonce: string): Promise; + + /** + * Remove the reviewed-file entry for the given URI + content nonce. + * No-op if no such entry exists. + */ + unmarkFileReviewed(uri: URI, nonce: string): Promise; + + /** + * Return every reviewed-file entry in this session, in insertion order. + */ + getReviewedFiles(): Promise; + + /** + * Return all reviewed-file entries for a specific URI (one per reviewed + * content nonce), in insertion order. + */ + getReviewedFilesForUri(uri: URI): Promise; + + /** + * Return whether the given file has been reviewed at the given content nonce. + */ + isFileReviewed(uri: URI, nonce: string): Promise; + /** * Creates a safe, consistent copy of the database at the given path * using SQLite's `VACUUM INTO` command. diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index e8959fbfcfe85..b49803e39d049 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -46,6 +46,9 @@ export interface IAgentSubscription { /** Fires when {@link value} changes (optimistic or confirmed). */ readonly onDidChange: Event; + /** Fires when the subscription enters an error state. */ + readonly onDidError?: Event; + /** Fires before a server-originated action is applied to this subscription's state. */ readonly onWillApplyAction: Event; @@ -103,6 +106,9 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs protected readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; + protected readonly _onDidError = this._register(new Emitter()); + readonly onDidError: Event = this._onDidError.event; + protected readonly _onWillApplyAction = this._register(new Emitter()); readonly onWillApplyAction: Event = this._onWillApplyAction.event; @@ -144,6 +150,7 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs */ setError(error: Error): void { this._error = error; + this._onDidError.fire(error); } /** @@ -782,11 +789,16 @@ export class AgentSubscriptionManager extends Disposable { */ trackSessionCreate(resource: URI, promise: Promise): void { this._inflightCreates.set(resource, promise); + // This branch only observes settlement to evict the inflight entry; the + // `createSession` caller (and the server, via logService.error) owns the + // result. `finally` re-raises a rejection, so without this trailing + // `catch` an expected create failure (e.g. AHP_AUTH_REQUIRED) would be + // reported a second time as an unhandled rejection. void promise.finally(() => { if (this._inflightCreates.get(resource) === promise) { this._inflightCreates.delete(resource); } - }); + }).catch(() => { }); } /** @@ -1081,6 +1093,24 @@ export class AgentSubscriptionManager extends Disposable { } } +/** Returns whether an action envelope targets one of the subscribed channel URIs. */ +export function isActionEnvelopeRelevantToSubscriptionUris(envelope: ActionEnvelope, subscribedUris: Iterable): boolean { + if (isAhpRootChannel(envelope.channel)) { + for (const uri of subscribedUris) { + if (isAhpRootChannel(uri)) { + return true; + } + } + return false; + } + for (const uri of subscribedUris) { + if (uri === envelope.channel) { + return true; + } + } + return false; +} + // --- Observable Adapter ------------------------------------------------------ /** diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index a486d1ec6eb20..55e430c67f539 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -77c6312 +9728f95 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 ebf7a03c9c266..5ce8962a04c0f 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 SessionModelChangedAction, type SessionAgentChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, 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 ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, 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 } 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 SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, 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 ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, 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 } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -43,11 +43,11 @@ export type SessionAction = | SessionChatUpdatedAction | SessionDefaultChatChangedAction | SessionTitleChangedAction - | SessionModelChangedAction - | SessionAgentChangedAction | SessionServerToolsChangedAction - | SessionActiveClientChangedAction - | SessionActiveClientToolsChangedAction + | SessionActiveClientSetAction + | SessionActiveClientRemovedAction + | SessionInputNeededSetAction + | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction @@ -64,10 +64,8 @@ export type SessionAction = /** Union of session actions that clients may dispatch. */ export type ClientSessionAction = | SessionTitleChangedAction - | SessionModelChangedAction - | SessionAgentChangedAction - | SessionActiveClientChangedAction - | SessionActiveClientToolsChangedAction + | SessionActiveClientSetAction + | SessionActiveClientRemovedAction | SessionCustomizationToggledAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction @@ -83,6 +81,8 @@ export type ServerSessionAction = | SessionChatUpdatedAction | SessionDefaultChatChangedAction | SessionServerToolsChangedAction + | SessionInputNeededSetAction + | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationUpdatedAction | SessionCustomizationRemovedAction @@ -107,11 +107,13 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction + | ChatDraftChangedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction | ChatInputCompletedAction @@ -129,6 +131,7 @@ export type ClientChatAction = | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction + | ChatDraftChangedAction | ChatInputAnswerChangedAction | ChatInputCompletedAction | ChatTruncatedAction @@ -143,6 +146,7 @@ export type ServerChatAction = | ChatToolCallReadyAction | ChatTurnCompleteAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatInputRequestedAction @@ -265,11 +269,11 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionChatUpdated]: false, [ActionType.SessionDefaultChatChanged]: false, [ActionType.SessionTitleChanged]: true, - [ActionType.SessionModelChanged]: true, - [ActionType.SessionAgentChanged]: true, [ActionType.SessionServerToolsChanged]: false, - [ActionType.SessionActiveClientChanged]: true, - [ActionType.SessionActiveClientToolsChanged]: true, + [ActionType.SessionActiveClientSet]: true, + [ActionType.SessionActiveClientRemoved]: true, + [ActionType.SessionInputNeededSet]: false, + [ActionType.SessionInputNeededRemoved]: false, [ActionType.SessionCustomizationsChanged]: false, [ActionType.SessionCustomizationToggled]: true, [ActionType.SessionCustomizationUpdated]: false, @@ -294,11 +298,13 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnComplete]: false, [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, + [ActionType.ChatActivityChanged]: false, [ActionType.ChatUsage]: false, [ActionType.ChatReasoning]: false, [ActionType.ChatPendingMessageSet]: true, [ActionType.ChatPendingMessageRemoved]: true, [ActionType.ChatQueuedMessagesReordered]: true, + [ActionType.ChatDraftChanged]: true, [ActionType.ChatInputRequested]: false, [ActionType.ChatInputAnswerChanged]: true, [ActionType.ChatInputCompleted]: true, 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 ec9820bbf8f39..5ea229bb55479 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 @@ -138,6 +138,8 @@ export interface ChatToolCallStartAction extends ToolCallActionBase { toolName: string; /** Human-readable tool name */ displayName: string; + /** Human-readable description of what the tool invocation intends to do */ + intention?: string; /** * Reference to the contributor of the tool being called. Absent for * server-side tools that are not contributed by a client or MCP server. @@ -258,9 +260,10 @@ export type ChatToolCallConfirmedAction = * Tool execution finished. Transitions to `completed` or `pending-result-confirmation` * if `requiresResultConfirmation` is `true`. * - * For client-provided tools (where `toolClientId` is set on the tool call state), - * the owning client dispatches this action with the execution result. The server - * SHOULD reject this action if the dispatching client does not match `toolClientId`. + * For client-provided tools (whose tool call state carries a client + * `ToolCallContributor` with a `clientId`), the owning client dispatches this + * action with the execution result. The server SHOULD reject this action if the + * dispatching client does not match the contributor's `clientId`. * * Servers waiting on a client tool call MAY time out after a reasonable duration * if the implementing client disconnects or becomes unresponsive, and dispatch @@ -300,10 +303,11 @@ export interface ChatToolCallResultConfirmedAction extends ToolCallActionBase { * use this to display live feedback (e.g. a terminal reference) before the * tool completes. * - * For client-provided tools (where `toolClientId` is set on the tool call state), - * the owning client dispatches this action to stream intermediate content while - * executing. The server SHOULD reject this action if the dispatching client does - * not match `toolClientId`. + * For client-provided tools (whose tool call state carries a client + * `ToolCallContributor` with a `clientId`), the owning client dispatches this + * action to stream intermediate content while executing. The server SHOULD + * reject this action if the dispatching client does not match the contributor's + * `clientId`. * * @category Chat Actions * @version 1 @@ -384,6 +388,23 @@ export interface ChatErrorAction { _meta?: Record; } +/** + * The activity description of this chat changed. + * + * Dispatched by the server to indicate what the chat is currently doing + * (e.g. running a tool, thinking). Clear activity by omitting it or setting it + * to `undefined`. + * Producers SHOULD also update the parent session's chat catalog with + * `session/chatUpdated` so `ChatSummary.activity` stays in sync. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatActivityChangedAction { + type: ActionType.ChatActivityChanged; + /** Human-readable description of current activity; omit or set `undefined` to clear */ + activity?: string; +} /** * Token usage report for a turn. @@ -527,6 +548,31 @@ export interface ChatQueuedMessagesReorderedAction { order: string[]; } +// ─── Draft Actions ─────────────────────────────────────────────────────────── + +/** + * The chat's draft input changed. + * + * Clients MAY periodically sync their local input state — the message the user + * is composing, including its {@link Message.model | model} / + * {@link Message.agent | agent} selection and attachments — into the chat's + * {@link ChatState.draft | `draft`} so it survives reloads and is visible to + * other clients viewing the same chat. Eager syncing is **not** required; + * clients SHOULD debounce and MAY sync only at convenient points. Set `draft` + * to `undefined` to clear it (e.g. once the message is sent). + * + * A client is only allowed to draft {@link MessageKind.User} messages. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatDraftChangedAction { + type: ActionType.ChatDraftChanged; + /** New draft message, or `undefined` to clear it */ + draft?: Message; +} + // ─── Session Input Actions ────────────────────────────────────────────────── /** @@ -599,12 +645,14 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction + | ChatDraftChangedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction | ChatInputCompletedAction 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 2811184c39a19..a727004d1ea2c 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 @@ -8,8 +8,6 @@ import type { URI } from '../common/state.js'; import type { BaseParams } from '../common/commands.js'; -import type { ModelSelection } from '../channels-root/state.js'; -import type { AgentSelection } from '../channels-session/state.js'; import type { Message } from './state.js'; // ─── createChat ────────────────────────────────────────────────────────────── @@ -40,10 +38,6 @@ export interface CreateChatParams extends BaseParams { chat: URI; /** Optional initial message for the new chat. */ initialMessage?: Message; - /** Optional per-chat model override. */ - model?: ModelSelection; - /** Optional per-chat agent override. */ - agent?: AgentSelection; /** Optional source chat and turn to fork from. */ source?: ChatForkSource; } 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 09714b2434959..e7c7f0b223437 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 @@ -20,6 +20,7 @@ function tcBase(tc: ToolCallState) { toolCallId: tc.toolCallId, toolName: tc.toolName, displayName: tc.displayName, + intention: tc.intention, contributor: tc.contributor, _meta: tc._meta, }; @@ -312,6 +313,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatError: return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + case ActionType.ChatActivityChanged: + return { ...state, activity: action.activity }; + // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: @@ -330,6 +334,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st toolCallId: action.toolCallId, toolName: action.toolName, displayName: action.displayName, + intention: action.intention, contributor: action.contributor, _meta: action._meta, status: ToolCallStatus.Streaming, @@ -640,6 +645,11 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return { ...state, queuedMessages: reordered }; } + // ── Draft ───────────────────────────────────────────────────────────── + + case ActionType.ChatDraftChanged: + return { ...state, draft: action.draft }; + default: softAssertNever(action, log); return state; 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 e6754714554b9..41d68991fbc19 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 @@ -39,10 +39,6 @@ export interface ChatState { activity?: string; /** Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) */ modifiedAt: string; - /** Optional per-chat model override (defaults to the session's model) */ - model?: ModelSelection; - /** Optional per-chat agent override (defaults to the session's agent) */ - agent?: AgentSelection; /** How this chat came into existence */ origin?: ChatOrigin; /** @@ -57,7 +53,7 @@ export interface ChatState { * Optional per-chat working directory. * * If absent, the chat inherits - * {@link SessionSummary.workingDirectory | the session's working directory}. + * {@link SessionState.workingDirectory | the session's working directory}. * Hosts MAY override this for individual chats — for example, to give a * subordinate chat its own git worktree so multiple chats in a session can * make independent edits that the orchestrator later merges back. @@ -75,6 +71,20 @@ export interface ChatState { queuedMessages?: PendingMessage[]; /** Requests for user input that are currently blocking or informing chat progress */ inputRequests?: ChatInputRequest[]; + /** + * The user's in-progress draft input for this chat — the message they are + * composing but have not sent yet, including its + * {@link Message.model | model} / {@link Message.agent | agent} selection + * and attachments. + * + * Clients MAY periodically sync their local input state into this field so + * a draft survives reloads and is visible to other clients viewing the same + * chat. Eager syncing is **not** required — clients SHOULD debounce and MAY + * sync only at convenient points. When presenting input UI for an existing + * chat, clients SHOULD use any `draft` to initialize their input state. + * Cleared (set to `undefined`) once the message is sent. + */ + draft?: Message; /** * Additional provider-specific metadata for this chat. */ @@ -99,10 +109,6 @@ export interface ChatSummary { activity?: string; /** Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) */ modifiedAt: string; - /** Optional per-chat model override (defaults to the session's model) */ - model?: ModelSelection; - /** Optional per-chat agent override (defaults to the session's agent) */ - agent?: AgentSelection; /** How this chat came into existence */ origin?: ChatOrigin; /** @@ -570,6 +576,24 @@ export interface Message { origin: MessageOrigin; /** File/selection attachments */ attachments?: MessageAttachment[]; + /** + * The model this message was, or will be, sent with. + * + * For historic user/agent messages this records the model actually used, so + * a client editing or resending the message can retain that selection. For a + * {@link ChatState.draft | draft} it carries the model the user picked for + * the message they are composing. Absent means the agent host's default + * model applies. + */ + model?: ModelSelection; + /** + * The custom agent this message was, or will be, sent with. + * + * For historic messages this records the agent actually used; for a + * {@link ChatState.draft | draft} it carries the agent the user picked. + * Absent means no custom agent — the provider's default behavior applies. + */ + agent?: AgentSelection; /** * Additional provider-specific metadata for this message. * @@ -935,6 +959,8 @@ interface ToolCallBase { toolName: string; /** Human-readable tool name */ displayName: string; + /** Human-readable description of what the tool invocation intends to do */ + intention?: string; /** * Reference to the contributor of the tool being called. */ @@ -1102,6 +1128,20 @@ export type ToolCallState = | ToolCallCompletedState | ToolCallCancelledState; +/** + * The two tool-call states that block on a client confirmation: parameter + * confirmation before execution ({@link ToolCallPendingConfirmationState}) and + * result confirmation after execution + * ({@link ToolCallPendingResultConfirmationState}). + * + * Surfaced at the session level by {@link SessionToolConfirmationRequest}. + * + * @category Tool Call Types + */ +export type ToolCallConfirmationState = + | ToolCallPendingConfirmationState + | ToolCallPendingResultConfirmationState; + // ─── Tool Result Content ───────────────────────────────────────────────────── @@ -1116,6 +1156,7 @@ export const enum ToolResultContentType { Resource = 'resource', FileEdit = 'fileEdit', Terminal = 'terminal', + ShellExit = 'shell_exit', Subagent = 'subagent', } @@ -1183,6 +1224,25 @@ export interface ToolResultTerminalContent { title: string; } +/** + * Shell command exit metadata emitted by the Copilot SDK shell tool. + * + * @category Tool Result Content + */ +export interface ToolResultShellExitContent { + type: ToolResultContentType.ShellExit; + /** Shell id, as assigned by Copilot runtime */ + shellId: string; + /** Exit code from the completed shell command */ + exitCode: number; + /** Working directory where the shell command was executed */ + cwd?: string; + /** Output preview associated with the shell command, if available */ + outputPreview?: string; + /** Whether outputPreview is known to be incomplete or truncated */ + outputTruncated?: boolean; +} + /** * A reference, embedded in a tool result, to a worker chat spawned by the tool * call (a sub-agent delegation), referenced by a chat URI (`ahp-chat:/...`). @@ -1211,7 +1271,8 @@ export interface ToolResultSubagentContent { * Mirrors the content blocks in MCP `CallToolResult.content`, plus * `ToolResultResourceContent` for lazy-loading large results, * `ToolResultFileEditContent` for file edit diffs, - * `ToolResultTerminalContent` for live terminal output, and + * `ToolResultTerminalContent` for live terminal output, + * `ToolResultShellExitContent` for shell command exit metadata, and * `ToolResultSubagentContent` for tool-spawned worker chats (AHP extensions). * * @category Tool Result Content @@ -1222,5 +1283,5 @@ export type ToolResultContent = | ToolResultResourceContent | ToolResultFileEditContent | ToolResultTerminalContent + | ToolResultShellExitContent | ToolResultSubagentContent; - diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index 5b5271dd8924c..4032839fbc069 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -32,8 +32,8 @@ import type { SessionSummary } from '../channels-session/state.js'; * "provider": "copilot", * "title": "New Session", * "status": 1, - * "createdAt": 1710000000000, - * "modifiedAt": 1710000000000 + * "createdAt": "2024-03-09T16:00:00.000Z", + * "modifiedAt": "2024-03-09T16:00:00.000Z" * } * } * } @@ -124,7 +124,7 @@ export interface SessionRemovedParams { * "changes": { * "title": "Refactor auth middleware", * "status": 8, - * "modifiedAt": 1710000123456 + * "modifiedAt": "2024-03-09T16:02:03.456Z" * } * } * } @@ -143,3 +143,82 @@ export interface SessionSummaryChangedParams { */ changes: Partial; } + +// ─── progress ──────────────────────────────────────────────────────────────── + +/** + * Generic progress notification for a long-running operation. + * + * A client opts in to progress for a request by including a `progressToken` in + * that request (today: the `progressToken` field on `createSession`). If the + * server does long-running work to service the request — e.g. lazily + * downloading an agent's native SDK the first time a session of that provider + * is materialized — it emits `progress` notifications carrying the same token. + * + * The notification is operation-agnostic: it says nothing about *what* is + * progressing. The client correlates `progressToken` back to the request it + * originated from (and thus the UI surface awaiting it) and renders its own + * localized indicator. The same channel serves any future long-running + * operation without a new method. + * + * Semantics: + * + * - `progress` is monotonically non-decreasing for a given `progressToken`. + * - `total` is present only when the server knows the magnitude up front + * (e.g. a `Content-Length`); when absent the client SHOULD show an + * indeterminate indicator. + * - The operation is complete when `progress === total`. The server MUST emit a + * final frame satisfying `progress === total`; when the total was never + * known, it sets `total` to the final `progress` on that frame. No further + * frames reference the token afterwards. + * - The server MAY emit no progress at all (e.g. the work was already done); + * the client then never shows an indicator. + * - Like all notifications this is ephemeral and is **not** replayed on + * reconnect. A client that never receives the terminal frame SHOULD expire + * the indicator after an idle timeout. + * + * @category Protocol Notifications + * @method root/progress + * @direction Server → Client + * @messageType Notification + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "root/progress", + * "params": { + * "channel": "ahp-root://", + * "progressToken": "9b2c1f7e-4a0d-4e2b-8b1a-2f7e4a0d4e2b", + * "progress": 18874368, + * "total": 41957498 + * } + * } + * ``` + */ +export interface ProgressParams { + /** Channel URI this notification belongs to (the root channel). */ + channel: URI; + /** + * Echoes the `progressToken` the client supplied on the originating request + * (e.g. the `progressToken` field of `createSession`), correlating this frame + * to that call. Unique across the client's active requests. + */ + progressToken: string; + /** + * Progress so far, in operation-defined units (e.g. bytes received). + * Monotonically non-decreasing for a given `progressToken`. + */ + progress: number; + /** + * Total when known up front (e.g. from a `Content-Length`); omitted ⇒ + * indeterminate. The operation is complete once `progress === total`. + */ + total?: number; + /** + * Optional human-readable progress message. The client owns its own + * (localized) presentation derived from the originating request; generic + * clients that don't track the token MAY display this instead. + */ + message?: string; +} 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 e9d897bde9ba3..f0d28f53e009f 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 @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { ConfigSchema, ProtectedResourceMetadata } from '../common/state.js'; +import type { ConfigSchema, JsonPrimitive, ProtectedResourceMetadata } from '../common/state.js'; import type { TerminalInfo } from '../channels-terminal/state.js'; import type { Customization } from '../channels-session/state.js'; @@ -83,6 +83,45 @@ export interface AgentInfo { * into the session's `customizations` list. */ customizations?: Customization[]; + /** + * Static capabilities the agent advertises about itself. Clients use these + * to gate features (multi-chat, fork) instead of switching on the provider + * id. + */ + capabilities?: AgentCapabilities; +} + +/** + * Static capabilities an {@link AgentInfo} advertises. Modelled after MCP + * capabilities: each field is opt-in and its presence (an empty object `{}`) + * signals support, while absence means the feature is unsupported and the + * corresponding client commands MUST NOT be used. Sub-fields carry + * per-capability options. + * + * @category Root State + */ +export interface AgentCapabilities { + /** + * The agent can host more than one concurrent chat per session. When absent, + * clients MUST NOT call `createChat` to open chats beyond the default one the + * session starts with. An empty object `{}` advertises multi-chat without + * forking; set {@link MultipleChatsCapability.fork} to also allow forking. + */ + multipleChats?: MultipleChatsCapability; +} + +/** + * Options for the {@link AgentCapabilities.multipleChats} capability. + * + * @category Root State + */ +export interface MultipleChatsCapability { + /** + * The agent can fork a chat from a specific turn. When absent or `false`, + * clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + * Forking always implies multi-chat support. + */ + fork?: boolean; } /** @@ -97,6 +136,10 @@ export interface SessionModelInfo { name: string; /** Maximum context window size */ maxContextWindow?: number; + /** Maximum number of output tokens the model can generate */ + maxOutputTokens?: number; + /** Maximum number of prompt (input) tokens the model accepts */ + maxPromptTokens?: number; /** Whether the model supports vision */ supportsVision?: boolean; /** Policy configuration state */ @@ -126,8 +169,12 @@ export interface SessionModelInfo { export interface ModelSelection { /** Model identifier */ id: string; - /** Model-specific configuration values */ - config?: Record; + /** + * Model-specific configuration values. Values are JSON primitives: most + * pickers produce strings, but some (e.g. a numeric context-size picker) + * produce numbers or booleans, which are carried through as-is. + */ + config?: Record; } // ─── Root Config Types ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 5fa60999e2962..8aa2943659697 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -8,8 +8,7 @@ import { ActionType } from '../common/actions.js'; import type { ErrorInfo, URI } from '../common/state.js'; -import type { ToolDefinition, SessionActiveClient, Customization, McpServerState, AgentSelection } from './state.js'; -import type { ModelSelection } from '../channels-root/state.js'; +import type { ToolDefinition, SessionActiveClient, SessionInputRequest, Customization, McpServerState } from './state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { ChatSummary } from '../channels-chat/state.js'; @@ -119,42 +118,6 @@ export interface SessionTitleChangedAction { title: string; } -/** - * Model changed for this session. - * - * @category Session Actions - * @version 1 - * @clientDispatchable - */ -export interface SessionModelChangedAction { - type: ActionType.SessionModelChanged; - /** New model selection */ - model: ModelSelection; -} - -/** - * Custom agent selection changed for this session. - * - * Omitting `agent` (or setting it to `undefined`) clears the selection and - * resets the session to no selected custom agent (provider default behavior). - * - * When a turn is currently active, the server MUST defer the change until - * the active turn completes, then apply it for the next turn (same rule as - * {@link SessionModelChangedAction | `session/modelChanged`}). - * - * @category Session Actions - * @version 1 - * @clientDispatchable - */ -export interface SessionAgentChangedAction { - type: ActionType.SessionAgentChanged; - /** - * New agent selection, or `undefined` to clear the selection and reset the - * session to no selected custom agent. - */ - agent?: AgentSelection; -} - /** * The read state of the session changed. * @@ -238,38 +201,97 @@ export interface SessionServerToolsChangedAction { } /** - * The active client for this session has changed. + * An active client for this session was added or updated. + * + * Upsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}: + * a client dispatches this action with its own `SessionActiveClient` to join + * the session's active clients or refresh its entry, replacing any existing + * entry that has the same `clientId`. Multiple clients may be active at once. + * This is also how a client updates its published tools or customizations — + * re-dispatch with the full, updated entry. Use + * {@link SessionActiveClientRemovedAction | `session/activeClientRemoved`} to + * leave. The server SHOULD automatically dispatch that removal when an active + * client disconnects. * - * A client dispatches this action with its own `SessionActiveClient` to claim - * the active role, or with `null` to release it. The server SHOULD reject if - * another client is already active. The server SHOULD automatically dispatch - * this action with `activeClient: null` when the active client disconnects. + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionActiveClientSetAction { + type: ActionType.SessionActiveClientSet; + /** The active client to add or update, matched by `clientId`. */ + activeClient: SessionActiveClient; +} + +/** + * An active client was removed from this session. + * + * Removes the entry for the client identified by `clientId` from + * {@link SessionState.activeClients}; a no-op when no entry matches. + * + * The host SHOULD dispatch this automatically when a client stops participating + * in the session — for example when it unsubscribes from the session channel, + * when it disconnects and does not reconnect within a host-defined grace + * period, or when a `reconnect` command's `subscriptions` omit a session the + * client was still active in. When removing a client, the host SHOULD also + * cancel that client's in-flight tool calls — those whose tool call state + * carries a client `ToolCallContributor` with the matching `clientId` — by + * dispatching `chat/toolCallComplete` with `result.success = false`. (There is + * no per-tool-call server cancel; a failed completion is the cancellation + * mechanism, and the call ends in `completed` status with a failed result.) * * @category Session Actions * @version 1 * @clientDispatchable */ -export interface SessionActiveClientChangedAction { - type: ActionType.SessionActiveClientChanged; - /** The new active client, or `null` to unset */ - activeClient: SessionActiveClient | null; +export interface SessionActiveClientRemovedAction { + type: ActionType.SessionActiveClientRemoved; + /** The `clientId` of the active client to remove. */ + clientId: string; } +// ─── Input Needed Actions ──────────────────────────────────────────────────── + /** - * The active client's tool list has changed. + * A session-level input request was added or updated. + * + * Upsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the + * host dispatches this with the full {@link SessionInputRequest} to append a new + * entry to {@link SessionState.inputNeeded} or replace the existing entry with + * the same `id`. * - * Full-replacement semantics: the `tools` array replaces the active client's - * previous tools entirely. The server SHOULD reject if the dispatching client - * is not the current active client. + * Server-originated: the host mirrors chat-level requests (elicitations, tool + * confirmations, client-tool executions) into the session aggregate so clients + * subscribed only to the session channel can discover them. Clients respond by + * dispatching the ordinary `chat/*` action to the entry's `chat` channel — see + * {@link SessionInputRequest}. * * @category Session Actions * @version 1 - * @clientDispatchable */ -export interface SessionActiveClientToolsChangedAction { - type: ActionType.SessionActiveClientToolsChanged; - /** Updated client tools list (full replacement) */ - tools: ToolDefinition[]; +export interface SessionInputNeededSetAction { + type: ActionType.SessionInputNeededSet; + /** The input request to add or update, matched by `id`. */ + request: SessionInputRequest; +} + +/** + * A session-level input request was removed. + * + * Removes the entry identified by `id` from + * {@link SessionState.inputNeeded}; a no-op when no entry matches. + * + * Server-originated: the host dispatches this once the underlying request + * resolves (the user answers, the tool call is confirmed, or the client + * reports its result). + * + * @category Session Actions + * @version 1 + */ +export interface SessionInputNeededRemovedAction { + type: ActionType.SessionInputNeededRemoved; + /** The `id` of the input request to remove. */ + id: string; } // ─── Customization Actions ─────────────────────────────────────────────────── 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 291c23314c929..010c689452e5f 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 @@ -8,8 +8,7 @@ import type { URI } from '../common/state.js'; import type { BaseParams } from '../common/commands.js'; -import type { ModelSelection } from '../channels-root/state.js'; -import type { SessionActiveClient, AgentSelection } from './state.js'; +import type { SessionActiveClient } from './state.js'; import type { Turn, MessageAttachment } from '../channels-chat/state.js'; // ─── createSession ─────────────────────────────────────────────────────────── @@ -33,7 +32,7 @@ import type { Turn, MessageAttachment } from '../channels-chat/state.js'; * ```jsonc * // Client → Server * { "jsonrpc": "2.0", "id": 2, "method": "createSession", - * "params": { "channel": "ahp-session:/", "provider": "copilot", "model": "gpt-4o" } } + * "params": { "channel": "ahp-session:/", "provider": "copilot" } } * * // Server → Client (success) * { "jsonrpc": "2.0", "id": 2, "result": null } @@ -64,14 +63,6 @@ export interface CreateSessionParams extends BaseParams { channel: URI; /** Agent provider ID */ provider?: string; - /** Model selection (ID and optional model-specific configuration) */ - model?: ModelSelection; - /** - * Initial custom agent selection for the new session. - * - * Omit to start the session with no custom agent selected (provider default). - */ - agent?: AgentSelection; /** Working directory for the session */ workingDirectory?: URI; /** @@ -85,14 +76,27 @@ export interface CreateSessionParams extends BaseParams { */ config?: Record; /** - * Eagerly claim the active client role for the new session. + * Eagerly claim an active client role for the new session. * - * When provided, the server initializes the session with this client as the - * active client, equivalent to dispatching a `session/activeClientChanged` + * When provided, the server initializes the session with this client as an + * active client, equivalent to dispatching a `session/activeClientSet` * action immediately after creation. The `clientId` MUST match the * `clientId` the creating client supplied in `initialize`. */ activeClient?: SessionActiveClient; + /** + * Opt-in progress token. When set, the client is offering to receive + * `progress` notifications (see `ProgressParams`) for any long-running work + * the server does to bring this session up — most notably the lazy, + * first-use download of the provider's native SDK. The server echoes this + * exact token on every `progress` frame so the client can correlate it to + * this `createSession` call (and the UI awaiting it). + * + * The token MUST be unique across the client's active requests. The server + * MAY ignore it (e.g. when nothing long-running is needed), in which case no + * `progress` notifications are emitted. + */ + progressToken?: string; } // ─── disposeSession ────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index 8b75efaeff3ea..288166b9cc451 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -7,17 +7,36 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { SessionLifecycle, SessionStatus, CustomizationType, type SessionState, type McpServerCustomization } from './state.js'; +import { SessionLifecycle, SessionStatus, CustomizationType, type SessionState, type SessionInputRequest, type McpServerCustomization } from './state.js'; import type { SessionAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── +/** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ +const STATUS_ACTIVITY_MASK = (1 << 5) - 1; + /** Sets or clears a metadata flag on a status value. */ function withStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus { return set ? status | flag : status & ~flag; } +/** + * Reflects the session-level {@link SessionState.inputNeeded | input queue} + * into the activity bits of `status`. A non-empty queue promotes the activity + * to {@link SessionStatus.InputNeeded}; emptying it clears the + * input-needed-specific bit. Since `InputNeeded` implies + * {@link SessionStatus.InProgress}, an unblocked turn falls back to + * `InProgress` while an already-idle session stays idle. Orthogonal flags + * (`IsRead` / `IsArchived`) are preserved. + */ +function withInputNeededStatus(status: SessionStatus, inputNeeded: readonly SessionInputRequest[]): SessionStatus { + if (inputNeeded.length > 0) { + return (status & ~STATUS_ACTIVITY_MASK) | SessionStatus.InputNeeded; + } + return status & ~(SessionStatus.InputNeeded & ~SessionStatus.InProgress); +} + // ─── Session Reducer ───────────────────────────────────────────────────────── /** @@ -29,13 +48,12 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionReady: // `SessionReady` is purely a lifecycle transition (Creating -> - // Ready). It must not touch `summary.status`: for provisional - // sessions the first turn can start before materialization - // completes, so an `activeTurn` may already be set when this - // action is dispatched (e.g. from a materialize-session handler). - // Other reducers keep `summary.status` in sync with the activity - // state via `summaryStatus`/`refreshSummaryStatus`, so leaving it - // alone here is correct. + // Ready). It must not touch `status`: for provisional sessions the + // first turn can start before materialization completes, so an + // `activeTurn` may already be set when this action is dispatched + // (e.g. from a materialize-session handler). Other reducers keep + // `status` in sync with the activity state, so leaving it alone here + // is correct. return { ...state, lifecycle: SessionLifecycle.Ready }; case ActionType.SessionCreationFailed: @@ -89,40 +107,22 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: // ── Metadata ────────────────────────────────────────────────────────── case ActionType.SessionTitleChanged: - return { - ...state, - summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, - }; - - case ActionType.SessionModelChanged: - return { - ...state, - summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, - }; - - case ActionType.SessionAgentChanged: - return { - ...state, - summary: { ...state.summary, agent: action.agent, modifiedAt: Date.now() }, - }; + return { ...state, title: action.title }; case ActionType.SessionIsReadChanged: return { ...state, - summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsRead, action.isRead) }, + status: withStatusFlag(state.status, SessionStatus.IsRead, action.isRead), }; case ActionType.SessionIsArchivedChanged: return { ...state, - summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsArchived, action.isArchived) }, + status: withStatusFlag(state.status, SessionStatus.IsArchived, action.isArchived), }; case ActionType.SessionActivityChanged: - return { - ...state, - summary: { ...state.summary, activity: action.activity }, - }; + return { ...state, activity: action.activity }; case ActionType.SessionChangesetsChanged: { const { changesets: _omit, ...stateWithoutChangesets } = state; @@ -141,10 +141,6 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: ...state.config, values: action.replace ? { ...action.config } : { ...state.config.values, ...action.config }, }, - summary: { - ...state.summary, - modifiedAt: Date.now(), - }, }; case ActionType.SessionMetaChanged: @@ -153,20 +149,59 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionServerToolsChanged: return { ...state, serverTools: action.tools }; - case ActionType.SessionActiveClientChanged: - return { - ...state, - activeClient: action.activeClient ?? undefined, - }; + case ActionType.SessionActiveClientSet: { + const list = state.activeClients; + const idx = list.findIndex(c => c.clientId === action.activeClient.clientId); + if (idx < 0) { + return { ...state, activeClients: [...list, action.activeClient] }; + } + const updated = list.slice(); + updated[idx] = action.activeClient; + return { ...state, activeClients: updated }; + } - case ActionType.SessionActiveClientToolsChanged: - if (!state.activeClient) { + case ActionType.SessionActiveClientRemoved: { + const list = state.activeClients; + const idx = list.findIndex(c => c.clientId === action.clientId); + if (idx < 0) { return state; } - return { - ...state, - activeClient: { ...state.activeClient, tools: action.tools }, - }; + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, activeClients: updated }; + } + + // ── Input Needed ──────────────────────────────────────────────────── + + case ActionType.SessionInputNeededSet: { + const list = state.inputNeeded ?? []; + const idx = list.findIndex(r => r.id === action.request.id); + const inputNeeded = idx < 0 ? [...list, action.request] : list.slice(); + if (idx >= 0) { + inputNeeded[idx] = action.request; + } + return { ...state, inputNeeded, status: withInputNeededStatus(state.status, inputNeeded) }; + } + + case ActionType.SessionInputNeededRemoved: { + const list = state.inputNeeded; + if (!list) { + return state; + } + const idx = list.findIndex(r => r.id === action.id); + if (idx < 0) { + return state; + } + const remaining = list.slice(); + remaining.splice(idx, 1); + const next: SessionState = { ...state, status: withInputNeededStatus(state.status, remaining) }; + if (remaining.length > 0) { + next.inputNeeded = remaining; + } else { + delete next.inputNeeded; + } + return next; + } // ── Customizations ────────────────────────────────────────────────── 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 25d0f865d63df..ebf263d6e3c7b 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,8 +8,7 @@ import type { Changeset } from '../channels-changeset/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; -import type { ChatSummary } from '../channels-chat/state.js'; -import type { ModelSelection } from '../channels-root/state.js'; +import type { ChatSummary, ChatInputRequest, ToolCallConfirmationState, ToolCallState } from '../channels-chat/state.js'; import type { ConfigPropertySchema, ErrorInfo, Icon, ProtectedResourceMetadata, TextRange, URI } from '../common/state.js'; // ─── Session State ─────────────────────────────────────────────────────────── @@ -49,22 +48,76 @@ export const enum SessionStatus { IsArchived = 1 << 6, } +/** + * Metadata shared between the full {@link SessionState} (delivered when a + * client subscribes to a session's URI) and the lightweight + * {@link SessionSummary} (carried in the root-channel session catalog). + * + * These fields describe the session at a glance and appear in both places. + * `SessionState` owns the authoritative values for a subscribed session; + * `SessionSummary` mirrors them into the catalog so clients that only render a + * session list don't have to subscribe to every session URI. The host keeps + * the catalog in sync via `root/sessionSummaryChanged`. + * + * @category Session State + */ +export interface SessionMetadata { + /** Agent provider ID */ + provider: string; + /** Session title */ + title: string; + /** Current session status */ + status: SessionStatus; + /** Human-readable description of what the session is currently doing */ + activity?: string; + /** Server-owned project for this session */ + project?: ProjectInfo; + /** + * The default working directory URI for this session. Individual chats + * MAY override via {@link ChatSummary.workingDirectory | their own + * `workingDirectory`}; this field acts as the fallback for any chat that + * does not. + */ + workingDirectory?: URI; + /** + * Lightweight summary of this session's inline annotations channel + * (`ahp-session://annotations`). Surfaced so badge UI can render + * annotation / entry counts without subscribing. Absent when the session + * does not expose an annotations channel. + */ + annotations?: AnnotationsSummary; +} + /** * Full state for a single session, loaded when a client subscribes to the session's URI. * + * Inlines (denormalizes) every {@link SessionMetadata} field directly onto + * itself so subscribers receive one flat object instead of a nested summary. + * The lightweight catalog representation is {@link SessionSummary}, surfaced on + * the root channel; the host keeps the two in sync via + * `root/sessionSummaryChanged`. + * * @category Session State */ -export interface SessionState { - /** Lightweight session metadata */ - summary: SessionSummary; +export interface SessionState extends SessionMetadata { /** Session initialization state */ lifecycle: SessionLifecycle; /** Error details if creation failed */ creationError?: ErrorInfo; /** Tools provided by the server (agent host) for this session */ serverTools?: ToolDefinition[]; - /** The client currently providing tools and interactive capabilities to this session */ - activeClient?: SessionActiveClient; + /** + * The clients currently providing tools and interactive capabilities to this + * session. If multiple tools or customizations are provided by the same + * active client, an agent host MAY deduplicate them when exposed to a model, + * with a preference given to the client that started the turn. + * + * Membership is host-managed: clients add (or refresh) themselves with + * `session/activeClientSet`, and the host removes them with + * `session/activeClientRemoved` when they unsubscribe, disconnect without + * reconnecting in time, or reconnect without resubscribing to the session. + */ + activeClients: SessionActiveClient[]; /** Catalog of chats in this session. */ chats: ChatSummary[]; /** @@ -91,7 +144,7 @@ export interface SessionState { * also appear as children of a container. * * Client-published plugins arrive via - * {@link SessionActiveClient.customizations | `activeClient.customizations`} + * {@link SessionActiveClient.customizations | `activeClients[].customizations`} * and the host propagates them into this list (typically with the * container's `clientId` set and `children` populated). Clients * publish in container shape only; bare MCP servers at the top level @@ -106,6 +159,23 @@ export interface SessionState { * {@link /guide/changesets | Changesets} for an overview of the model. */ changesets?: Changeset[]; + /** + * Outstanding input the session is blocked on, aggregated across every chat + * so a client can discover and answer it from the session channel alone, + * without subscribing to individual chats. + * + * Each entry is self-sufficient: it carries the owning chat's URI plus every + * identifier the client needs to respond. A client answers by dispatching the + * ordinary `chat/*` action to that chat's channel — see + * {@link SessionInputRequest} for the per-variant response path. A present, + * non-empty list implies {@link SessionStatus.InputNeeded} on + * {@link SessionSummary.status}. + * + * Host-managed: the host upserts entries with `session/inputNeededSet` as + * chats raise requests and removes them with `session/inputNeededRemoved` + * once the underlying request resolves. + */ + inputNeeded?: SessionInputRequest[]; /** * Additional provider-specific metadata for this session. * @@ -117,10 +187,11 @@ export interface SessionState { } /** - * The client currently providing tools and interactive capabilities to a session. + * A client currently providing tools and interactive capabilities to a session. * - * Only one client may be active per session at a time. The server SHOULD - * automatically unset the active client if that client disconnects. + * A session MAY have several active clients at once; entries in + * {@link SessionState.activeClients} are keyed by `clientId`. The server SHOULD + * automatically remove an active client when that client disconnects. * * @category Session State */ @@ -142,6 +213,136 @@ export interface SessionActiveClient { customizations?: ClientPluginCustomization[]; } +// ─── Session Input Requests ────────────────────────────────────────────────── + +/** + * Discriminant for the kinds of outstanding input a session can surface in + * {@link SessionState.inputNeeded}. + * + * This is a general/typological union (not a lifecycle), so the discriminant is + * a `*Kind`. + * + * @category Session Input Types + */ +export const enum SessionInputRequestKind { + /** A user-facing elicitation mirrored from a chat's `inputRequests`. */ + ChatInput = 'chatInput', + /** A tool call awaiting parameter- or result-confirmation. */ + ToolConfirmation = 'toolConfirmation', + /** A running tool the session wants an active client to execute. */ + ToolClientExecution = 'toolClientExecution', +} + +/** + * Fields common to every {@link SessionInputRequest} variant. + * + * @category Session Input Types + */ +interface SessionInputRequestBase { + /** + * Stable key for this entry, unique within the session's + * {@link SessionState.inputNeeded} list. The host derives it however it likes + * (for example from the chat URI plus the underlying request or tool-call + * id); consumers MUST treat it as opaque. It is the key for the + * `session/inputNeededSet` / `session/inputNeededRemoved` upsert convention. + */ + id: string; + /** + * The chat the underlying request lives in. This is the channel a client + * dispatches its response to — it does not need to have subscribed to that + * chat first. + */ + chat: URI; +} + +/** + * A user-input elicitation surfaced at the session level, mirroring one entry + * of the owning chat's {@link ChatState.inputRequests}. + * + * Respond by dispatching `chat/inputCompleted` (or syncing drafts with + * `chat/inputAnswerChanged`) to {@link SessionInputRequestBase.chat | `chat`}, + * keyed by {@link ChatInputRequest.id | `request.id`}. + * + * @category Session Input Types + */ +export interface SessionChatInputRequest extends SessionInputRequestBase { + kind: SessionInputRequestKind.ChatInput; + /** The mirrored chat input request. */ + request: ChatInputRequest; +} + +/** + * A tool call blocked on confirmation — either parameter confirmation before + * execution or result confirmation after — surfaced at the session level. + * + * Respond by dispatching `chat/toolCallConfirmed` (for + * {@link ToolCallPendingConfirmationState}) or `chat/toolCallResultConfirmed` + * (for {@link ToolCallPendingResultConfirmationState}) to + * {@link SessionInputRequestBase.chat | `chat`}, keyed by `turnId` and + * `toolCall.toolCallId`. + * + * @category Session Input Types + */ +export interface SessionToolConfirmationRequest extends SessionInputRequestBase { + kind: SessionInputRequestKind.ToolConfirmation; + /** The turn the tool call belongs to. */ + turnId: string; + /** The tool call awaiting confirmation. */ + toolCall: ToolCallConfirmationState; +} + +/** + * A running tool whose execution is delegated to an active client. Surfaced so + * a client that provides the tool can pick up the work without subscribing to + * the owning chat. + * + * The {@link toolCall} is always a {@link ToolCallRunningState} (a + * {@link ToolCallState} in `running` status) whose + * {@link ToolCallRunningState.contributor | `contributor`} is a client + * {@link ToolCallClientContributor} whose `clientId` matches the denormalized + * {@link clientId} here. Execute and report the result by dispatching + * `chat/toolCallComplete` (and optionally streaming with + * `chat/toolCallContentChanged`) to {@link SessionInputRequestBase.chat | + * `chat`}, keyed by `turnId` and `toolCall.toolCallId`. + * + * @category Session Input Types + */ +export interface SessionToolClientExecutionRequest extends SessionInputRequestBase { + kind: SessionInputRequestKind.ToolClientExecution; + /** The turn the tool call belongs to. */ + turnId: string; + /** + * The `clientId` expected to execute the tool. Matches the `clientId` of the + * tool call's client {@link ToolCallContributor}. + */ + 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). + */ + toolCall: ToolCallState; +} + +/** + * One outstanding piece of input a session is blocked on, aggregated across all + * chats in {@link SessionState.inputNeeded}. + * + * Each entry is self-sufficient: it carries the owning + * {@link SessionInputRequestBase.chat | `chat`} URI plus every identifier needed + * to construct the response, so a client can answer by dispatching the ordinary + * `chat/*` action (`chat/inputCompleted`, `chat/toolCallConfirmed`, + * `chat/toolCallComplete`, …) to that chat's channel **without having subscribed + * to the chat**. The host removes the entry with `session/inputNeededRemoved` + * once the underlying request resolves. + * + * @category Session Input Types + */ +export type SessionInputRequest = + | SessionChatInputRequest + | SessionToolConfirmationRequest + | SessionToolClientExecutionRequest; + /** * Server-owned project metadata for a session. * @@ -176,8 +377,6 @@ export interface ProjectInfo { * chat currently driving the promoted status bits when a non-default chat * wins (e.g. the chat that raised `InputNeeded`). * - `modifiedAt`: the max of all chats' `modifiedAt`. - * - `model` / `agent`: the session-level selection. Per-chat overrides are - * surfaced on individual {@link ChatSummary} entries, not aggregated up. * - `workingDirectory`: the session-level **default**. Individual chats MAY * override via {@link ChatSummary.workingDirectory}; aggregating these up * is meaningless and SHOULD NOT be attempted. @@ -191,39 +390,13 @@ export interface ProjectInfo { * * @category Session State */ -export interface SessionSummary { +export interface SessionSummary extends SessionMetadata { /** Session URI */ resource: URI; - /** Agent provider ID */ - provider: string; - /** Session title */ - title: string; - /** Current session status */ - status: SessionStatus; - /** Human-readable description of what the session is currently doing */ - activity?: string; - /** Creation timestamp */ - createdAt: number; - /** Last modification timestamp */ - modifiedAt: number; - /** Server-owned project for this session */ - project?: ProjectInfo; - /** Currently selected model */ - model?: ModelSelection; - /** - * Currently selected custom agent. - * - * Absent (`undefined`) means no custom agent is selected for this session - * — the session uses the provider's default behavior. - */ - agent?: AgentSelection; - /** - * The default working directory URI for this session. Individual chats - * MAY override via {@link ChatSummary.workingDirectory | their own - * `workingDirectory`}; this field acts as the fallback for any chat that - * does not. - */ - workingDirectory?: URI; + /** Creation timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) */ + createdAt: string; + /** Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) */ + modifiedAt: string; /** * Aggregate summary of file changes associated with this session. Servers * may populate this to give clients a quick at-a-glance view of the @@ -231,14 +404,6 @@ export interface SessionSummary { * client to subscribe to a changeset. */ changes?: ChangesSummary; - /** - * Lightweight summary of this session's inline annotations channel - * (`ahp-session://annotations`). Surfaced so badge UI can render - * annotation / entry counts without subscribing. Absent when the session - * does not expose an annotations channel. - */ - annotations?: AnnotationsSummary; - /** * Lightweight server-defined metadata clients may use for the session * presentation. The protocol does not interpret these values; producers @@ -265,10 +430,6 @@ export interface ChangesSummary { files?: number; } -// ─── Model Selection ───────────────────────────────────────────────────────── -// `ModelSelection` is declared in channels-root/state.ts (the model lives on -// `AgentInfo`); we import it above for use in `SessionSummary.model`. - // ─── Agent Selection ───────────────────────────────────────────────────────── /** @@ -279,7 +440,7 @@ export interface ChangesSummary { * the session's effective customizations). Consumers resolve the agent's * display name by looking up `uri` in the session's customization tree. * - * A session with no `agent` selected uses the provider's default behavior. + * A message with no `agent` selected uses the provider's default behavior. * * @category Session State */ @@ -636,6 +797,22 @@ export interface AgentCustomization extends CustomizationBase { * invoke it. Sourced from the agent file's frontmatter `description`. */ description?: string; + /** + * Model the agent is pinned to, sourced from the agent file's + * frontmatter `model`. Absent means the agent inherits the session's + * default model. + */ + model?: string; + /** + * Allowlist of tool names the agent is scoped to, sourced from the + * agent file's frontmatter `tools`. A non-empty list restricts the + * agent to exactly those tools. Absent — or an empty list — imposes no + * restriction beyond the session default: the agent may use any + * available tool. Producers express "no restriction" by omitting the + * field rather than sending an empty array, so an empty list carries no + * meaning distinct from absence. + */ + tools?: string[]; /** * Additional provider-specific metadata for this custom agent. * 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 36932e3c5bc4f..3540853796dbc 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,9 +10,9 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, SessionActiveClientChangedAction, SessionActiveClientToolsChangedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction } from '../channels-chat/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -51,17 +51,19 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', + ChatActivityChanged = 'chat/activityChanged', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', - SessionModelChanged = 'session/modelChanged', - SessionAgentChanged = 'session/agentChanged', SessionServerToolsChanged = 'session/serverToolsChanged', - SessionActiveClientChanged = 'session/activeClientChanged', - SessionActiveClientToolsChanged = 'session/activeClientToolsChanged', + SessionActiveClientSet = 'session/activeClientSet', + SessionActiveClientRemoved = 'session/activeClientRemoved', + SessionInputNeededSet = 'session/inputNeededSet', + SessionInputNeededRemoved = 'session/inputNeededRemoved', ChatPendingMessageSet = 'chat/pendingMessageSet', ChatPendingMessageRemoved = 'chat/pendingMessageRemoved', ChatQueuedMessagesReordered = 'chat/queuedMessagesReordered', + ChatDraftChanged = 'chat/draftChanged', ChatInputRequested = 'chat/inputRequested', ChatInputAnswerChanged = 'chat/inputAnswerChanged', ChatInputCompleted = 'chat/inputCompleted', @@ -150,11 +152,11 @@ export type StateAction = | SessionChatUpdatedAction | SessionDefaultChatChangedAction | SessionTitleChangedAction - | SessionModelChangedAction - | SessionAgentChangedAction | SessionServerToolsChangedAction - | SessionActiveClientChangedAction - | SessionActiveClientToolsChangedAction + | SessionActiveClientSetAction + | SessionActiveClientRemovedAction + | SessionInputNeededSetAction + | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction @@ -179,11 +181,13 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction + | ChatDraftChangedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction | ChatInputCompletedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index bf010f1d70aa1..3eb000893c75d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -15,7 +15,7 @@ import type { CreateResourceWatchParams, CreateResourceWatchResult } from '../ch import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../channels-changeset/commands.js'; import type { ActionEnvelope } from './actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams } from '../channels-root/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams } from '../channels-root/notifications.js'; import type { AuthRequiredParams } from './notifications.js'; import type { OtlpExportLogsParams, OtlpExportTracesParams, OtlpExportMetricsParams } from '../channels-otlp/notifications.js'; import type { AhpError } from './errors.js'; @@ -166,6 +166,7 @@ export interface ServerNotificationMap { 'root/sessionAdded': { params: SessionAddedParams }; 'root/sessionRemoved': { params: SessionRemovedParams }; 'root/sessionSummaryChanged': { params: SessionSummaryChangedParams }; + 'root/progress': { params: ProgressParams }; 'auth/required': { params: AuthRequiredParams }; 'otlp/exportLogs': { params: OtlpExportLogsParams }; 'otlp/exportTraces': { params: OtlpExportTracesParams }; diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index d71968904d5ee..32500adb19665 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -27,6 +27,9 @@ export type URI = string; */ export type StringOrMarkdown = string | { markdown: string }; +/** A primitive JSON value: a string, number, boolean, or `null`. */ +export type JsonPrimitive = string | number | boolean | null; + // ─── Icon ──────────────────────────────────────────────────────────────────── /** @@ -161,8 +164,8 @@ export interface ConfigPropertySchema { description?: string; /** JSON Schema: default value */ default?: unknown; - /** JSON Schema: allowed values (typically used with `string` type) */ - enum?: string[]; + /** JSON Schema: allowed values. May be primitives of any JSON type. */ + enum?: JsonPrimitive[]; /** Display extension: human-readable label per enum value (parallel array) */ enumLabels?: string[]; /** Display extension: description per enum value (parallel array) */ 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 3cb11e65c52de..773ca2c52605b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.5.0'; +export const PROTOCOL_VERSION = '0.5.1'; /** * Every protocol version a client built from this source tree is willing @@ -35,9 +35,8 @@ export const PROTOCOL_VERSION = '0.5.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.5.1', '0.5.0', - '0.4.0', - '0.3.0', ]); // ─── SemVer Comparison ─────────────────────────────────────────────────────── @@ -87,11 +86,11 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionChatUpdated]: '0.4.0', [ActionType.SessionDefaultChatChanged]: '0.4.0', [ActionType.SessionTitleChanged]: '0.1.0', - [ActionType.SessionModelChanged]: '0.1.0', - [ActionType.SessionAgentChanged]: '0.2.0', [ActionType.SessionServerToolsChanged]: '0.1.0', - [ActionType.SessionActiveClientChanged]: '0.1.0', - [ActionType.SessionActiveClientToolsChanged]: '0.1.0', + [ActionType.SessionActiveClientSet]: '0.5.0', + [ActionType.SessionActiveClientRemoved]: '0.5.0', + [ActionType.SessionInputNeededSet]: '0.5.1', + [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', [ActionType.SessionCustomizationToggled]: '0.1.0', [ActionType.SessionCustomizationUpdated]: '0.1.0', @@ -116,11 +115,13 @@ 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.ChatActivityChanged]: '0.5.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', [ActionType.ChatPendingMessageRemoved]: '0.4.0', [ActionType.ChatQueuedMessagesReordered]: '0.4.0', + [ActionType.ChatDraftChanged]: '0.5.0', [ActionType.ChatInputRequested]: '0.4.0', [ActionType.ChatInputAnswerChanged]: '0.4.0', [ActionType.ChatInputCompleted]: '0.4.0', @@ -132,11 +133,11 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChangesetOperationsChanged]: '0.2.0', [ActionType.ChangesetOperationStatusChanged]: '0.3.0', [ActionType.ChangesetCleared]: '0.2.0', - [ActionType.AnnotationsSet]: '0.3.0', + [ActionType.AnnotationsSet]: '0.4.0', [ActionType.AnnotationsUpdated]: '0.4.0', - [ActionType.AnnotationsRemoved]: '0.3.0', - [ActionType.AnnotationsEntrySet]: '0.3.0', - [ActionType.AnnotationsEntryRemoved]: '0.3.0', + [ActionType.AnnotationsRemoved]: '0.4.0', + [ActionType.AnnotationsEntrySet]: '0.4.0', + [ActionType.AnnotationsEntryRemoved]: '0.4.0', [ActionType.RootTerminalsChanged]: '0.1.0', [ActionType.RootConfigChanged]: '0.1.0', [ActionType.TerminalData]: '0.1.0', @@ -181,6 +182,7 @@ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in ProtocolNotificationMe 'root/sessionAdded': '0.1.0', 'root/sessionRemoved': '0.1.0', 'root/sessionSummaryChanged': '0.1.0', + 'root/progress': '0.5.0', 'auth/required': '0.1.0', 'otlp/exportLogs': '0.2.0', 'otlp/exportTraces': '0.2.0', diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index b82768f8aeb75..0702e0c0b0548 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -26,7 +26,6 @@ export { type SessionDefaultChatChangedAction, type ChatDeltaAction, type ChatErrorAction, - type SessionModelChangedAction, type SessionReadyAction, type ChatReasoningAction, type ChatResponsePartAction, @@ -43,10 +42,9 @@ export { type ChatTurnCompleteAction, type ChatTurnStartedAction, type ChatUsageAction, - type SessionAgentChangedAction, type SessionServerToolsChangedAction, - type SessionActiveClientChangedAction, - type SessionActiveClientToolsChangedAction, + type SessionActiveClientSetAction, + type SessionActiveClientRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type ChatPendingMessageSetAction, @@ -62,6 +60,7 @@ export { type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, + type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, @@ -78,6 +77,7 @@ export { type SessionAddedParams, type SessionRemovedParams, type SessionSummaryChangedParams, + type ProgressParams, type AuthRequiredParams, } from './protocol/notifications.js'; @@ -91,6 +91,7 @@ export const NotificationType = { SessionAdded: 'root/sessionAdded', SessionRemoved: 'root/sessionRemoved', SessionSummaryChanged: 'root/sessionSummaryChanged', + Progress: 'root/progress', AuthRequired: 'auth/required', } as const; export type NotificationType = typeof NotificationType[keyof typeof NotificationType]; @@ -102,8 +103,6 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, ChatDeltaAction, - SessionModelChangedAction, - SessionAgentChangedAction, ChatReasoningAction, ChatResponsePartAction, ChatToolCallApprovedAction, @@ -130,7 +129,7 @@ import type { RootConfigChangedAction, } from './protocol/actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, AuthRequiredParams } from './protocol/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_ } from './protocol/action-origin.generated.js'; /** @@ -143,6 +142,7 @@ export type ProtocolNotification = | ({ type: 'root/sessionAdded' } & SessionAddedParams) | ({ type: 'root/sessionRemoved' } & SessionRemovedParams) | ({ type: 'root/sessionSummaryChanged' } & SessionSummaryChangedParams) + | ({ type: 'root/progress' } & ProgressParams) | ({ type: 'auth/required' } & AuthRequiredParams); export type RootAction = IRootAction_; @@ -182,8 +182,6 @@ export type IUsageAction = ChatUsageAction; export type IReasoningAction = ChatReasoningAction; export type IErrorAction = ChatErrorAction; export type IToolCallContentChangedAction = ChatToolCallContentChangedAction; -export type IModelChangedAction = SessionModelChangedAction; -export type IAgentChangedAction = SessionAgentChangedAction; export type ICustomizationsChangedAction = import('./protocol/actions.js').SessionCustomizationsChangedAction; export type ICustomizationToggledAction = import('./protocol/actions.js').SessionCustomizationToggledAction; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index c69bb0b56608f..c036045dd4ef0 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -59,12 +59,12 @@ export { SessionLifecycle, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, - TurnState, type ActiveTurn, type AgentCustomization, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile, + TurnState, type ActiveTurn, type AgentCustomization, type AgentCapabilities, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile, type ChangesetOperation, type ChangesetState, type ChatState, type ChatSummary, type ChatInteractivity, type ChatOrigin, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema, 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 MessageAnnotationsAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, + type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, type ResponsePart, type RootState, type RuleCustomization, type SessionActiveClient, type SessionConfigState, type ChatInputAnswer as SessionInputAnswer, @@ -84,6 +84,7 @@ export { type ToolCallContributor, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, + type ToolResultShellExitContent, type ToolResultSubagentContent, type ToolResultTextContent, type Turn, type URI, type UsageInfo, @@ -471,13 +472,28 @@ export function createRootState(): RootState { }; } +/** + * Creates the initial flat {@link SessionState} for a session from its + * root-channel {@link SessionSummary} catalog entry. Session metadata + * ({@link SessionMetadata}) — and the shared `_meta` bag — are inlined directly + * onto the state. + */ export function createSessionState(summary: SessionSummary): SessionState { - return { - summary, + const state: SessionState = { + provider: summary.provider, + title: summary.title, + status: summary.status, lifecycle: SessionLifecycle.Creating, + activeClients: [], chats: [], defaultChat: undefined, }; + if (summary.activity !== undefined) { state.activity = summary.activity; } + if (summary.project !== undefined) { state.project = summary.project; } + if (summary.workingDirectory !== undefined) { state.workingDirectory = summary.workingDirectory; } + if (summary.annotations !== undefined) { state.annotations = summary.annotations; } + if (summary._meta !== undefined) { state._meta = summary._meta; } + return state; } /** @@ -492,8 +508,6 @@ export function createChatState(summary: ChatSummary): ChatState { status: summary.status, activity: summary.activity, modifiedAt: summary.modifiedAt, - model: summary.model, - agent: summary.agent, origin: summary.origin, interactivity: summary.interactivity, workingDirectory: summary.workingDirectory, @@ -505,23 +519,25 @@ export function createChatState(summary: ChatSummary): ChatState { /** * Derives the default-chat {@link ChatSummary} for a session from its * {@link SessionSummary}. The default chat inherits the session's title, - * status, activity, model, agent and working directory, and is marked as a - * {@link ChatOriginKind.User | user-originated} chat. `modifiedAt` is - * converted from the session's epoch-millis timestamp to the ISO-8601 string - * the chat protocol uses. + * status, activity and working directory, and is marked as a + * {@link ChatOriginKind.User | user-originated} chat. Both the session and + * chat `modifiedAt` are ISO-8601 strings, so it is carried over directly. */ export function createDefaultChatSummary(session: SessionSummary, chatUri: ProtocolURI): ChatSummary { const summary: ChatSummary = { resource: chatUri, title: session.title, status: session.status, - modifiedAt: new Date(session.modifiedAt).toISOString(), + modifiedAt: session.modifiedAt, origin: { kind: ChatOriginKind.User }, }; if (session.activity !== undefined) { summary.activity = session.activity; } - if (session.model !== undefined) { summary.model = session.model; } - if (session.agent !== undefined) { summary.agent = session.agent; } - if (session.workingDirectory !== undefined) { summary.workingDirectory = session.workingDirectory; } + // `workingDirectory` is deliberately NOT copied: per the protocol it is a + // per-chat OVERRIDE and, when absent, the chat inherits the session's + // working directory (see `mergeSessionWithDefaultChat`). Seeding it here + // would denormalize the session default onto every chat as a fake override, + // which then goes stale when the session's working directory is resolved + // later (e.g. a worktree resolved at materialization). return summary; } @@ -538,8 +554,6 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { modifiedAt: state.modifiedAt, }; if (state.activity !== undefined) { summary.activity = state.activity; } - if (state.model !== undefined) { summary.model = state.model; } - if (state.agent !== undefined) { summary.agent = state.agent; } if (state.origin !== undefined) { summary.origin = state.origin; } if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; } if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; } @@ -609,6 +623,19 @@ export function buildDefaultChatUri(sessionUri: ProtocolURI | ResourceURI): stri return buildChatUri(sessionUri, DEFAULT_CHAT_ID); } +const SUBAGENT_CHAT_ID = 'subagent'; + +export function isSubagentChatUri(uri: ProtocolURI | ResourceURI): boolean { + const parsed = typeof uri === 'string' ? ResourceURI.parse(uri) : uri; + return parsed.scheme === AHP_CHAT_SCHEME && parsed.authority === SUBAGENT_CHAT_ID; +} + +export function buildSubagentChatUri(sessionUri: ProtocolURI | ResourceURI, toolCallId: string): string { + const session = typeof sessionUri === 'string' ? sessionUri : sessionUri.toString(); + const encoded = encodeBase64(VSBuffer.fromString(session), false, true); + return `${AHP_CHAT_SCHEME}://${SUBAGENT_CHAT_ID}/${encoded}/${encodeURIComponent(toolCallId)}`; +} + /** * Inverse of {@link buildChatUri}: recovers the owning session URI and chat id * from any chat channel URI. Returns `undefined` when `uri` is not a well-formed @@ -629,6 +656,14 @@ export function parseChatUri(uri: ProtocolURI | ResourceURI): { session: string; return undefined; } try { + if (parsed.authority === SUBAGENT_CHAT_ID) { + const [sessionPart, ...toolCallIdParts] = encoded.split('/'); + const toolCallId = toolCallIdParts.join('/'); + if (!sessionPart || !toolCallId) { + return undefined; + } + return { session: decodeBase64(sessionPart).toString(), chatId: `${SUBAGENT_CHAT_ID}/${decodeURIComponent(toolCallId)}` }; + } return { session: decodeBase64(encoded).toString(), chatId: parsed.authority }; } catch { return undefined; @@ -645,11 +680,30 @@ export function parseDefaultChatUri(uri: ProtocolURI | ResourceURI): string | un return parseChatUri(uri)?.session; } +export function parseRequiredSessionUriFromChatUri(uri: ProtocolURI | ResourceURI): string { + const session = parseDefaultChatUri(uri); + if (session === undefined) { + throw new Error(`Malformed AHP chat URI: ${typeof uri === 'string' ? uri : uri.toString()}`); + } + return session; +} + /** Returns `true` when `uri` is the default chat of its session. */ export function isDefaultChatUri(uri: ProtocolURI | ResourceURI): boolean { return parseChatUri(uri)?.chatId === DEFAULT_CHAT_ID; } +/** + * Resolves a feature-level `(session, chat)` pair to the single chat URI used by + * the agent session/chat surface. A session always owns a DEFAULT chat addressed + * by the session URI itself; additional (peer) chats are addressed by their own + * chat channel URIs. This is the one place default-chat resolution lives so + * agents never re-derive "is this the default chat?". + */ +export function resolveChatUri(session: ResourceURI, chat: ResourceURI): ResourceURI { + return isDefaultChatUri(chat) ? session : chat; +} + /** Returns `true` when `uri` identifies a chat channel. */ export function isAhpChatChannel(uri: string): boolean { try { @@ -662,39 +716,52 @@ export function isAhpChatChannel(uri: string): boolean { // ---- Session + default-chat composite -------------------------------------- /** - * A {@link SessionState} merged with the conversation contents of its default - * {@link ChatState}. The protocol moved turns and pending/input state off the - * session and onto a per-chat channel; VS Code recombines the session summary - * with its single default chat into this composite so consumers can read - * `turns`/`activeTurn`/pending state through one object as they did before - * multi-chat. + * A single chat's effective session context: the shared {@link SessionState} + * (working directory, active clients, config, customizations/MCP scope, …) + * resolved for one chat and merged with that chat's conversation contents. + * + * The protocol moved turns and pending/input state off the session and onto a + * per-chat channel, and lets a chat override session defaults (e.g. + * {@link ChatState.workingDirectory}). This composite recombines the session + * with one of its chats — default or peer — so consumers read the chat's + * effective context and conversation through one object without walking back to + * the session to re-derive shared state. The inherited + * {@link SessionState.workingDirectory} carries the chat's *effective* working + * directory (its own override when present, else the session default). */ export interface ISessionWithDefaultChat extends SessionState { - /** Completed turns of the default chat. */ + /** Completed turns of this chat. */ turns: Turn[]; - /** Currently in-progress turn of the default chat. */ + /** Currently in-progress turn of this chat. */ activeTurn?: ActiveTurn; - /** Steering message pending on the default chat. */ + /** Steering message pending on this chat. */ steeringMessage?: PendingMessage; - /** Queued messages pending on the default chat. */ + /** Queued messages pending on this chat. */ queuedMessages?: PendingMessage[]; - /** Input requests outstanding on the default chat. */ + /** Input requests outstanding on this chat. */ inputRequests?: ChatInputRequest[]; + /** Draft input of this chat. */ + draft?: Message; } /** - * Merges a {@link SessionState} with its default {@link ChatState} into an - * {@link ISessionWithDefaultChat}. When the chat state is absent (e.g. not yet - * hydrated) the conversation fields default to empty. + * Projects a {@link SessionState} and one of its {@link ChatState | chats} + * (default or peer) into that chat's {@link ISessionWithDefaultChat | effective + * session context}. Per-chat overrides (currently the working directory) are + * layered over the session defaults, and the conversation fields are taken from + * the chat. When the chat state is absent (e.g. not yet hydrated) the + * conversation fields default to empty and the session defaults apply. */ export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat { return { ...session, + workingDirectory: chat?.workingDirectory ?? session.workingDirectory, turns: chat?.turns ?? [], activeTurn: chat?.activeTurn, steeringMessage: chat?.steeringMessage, queuedMessages: chat?.queuedMessages, inputRequests: chat?.inputRequests, + draft: chat?.draft, }; } @@ -727,6 +794,13 @@ export function getDefaultChat(session: SessionState): ChatSummary | undefined { */ export type SessionMeta = Record; +/** + * VS Code-side alias for the protocol's open `_meta` property bag on + * {@link SessionSummary}. Keys SHOULD be namespaced (e.g. `git`, `vscode.foo`) + * to avoid collisions; values MUST be JSON-serializable. + */ +export type SessionSummaryMeta = Record; + /** * Reserved key under {@link SessionMeta} for the well-known git-state * payload. Value at this key, when present, MUST be shaped like @@ -736,6 +810,15 @@ export type SessionMeta = Record; */ export const SESSION_META_GIT_KEY = 'git'; +/** + * Reserved key under {@link SessionMeta} for the well-known GitHub-state + * payload. Value at this key, when present, MUST be shaped like + * {@link ISessionGitHubState}. This is a VS Code-specific convention layered + * on top of the protocol's generic `_meta` bag — the protocol itself does + * not know about GitHub state. + */ +export const SESSION_META_GITHUB_KEY = 'github'; + /** * Git state of a session's working directory, carried under * {@link SessionMeta} at {@link SESSION_META_GIT_KEY}. Used by clients to @@ -767,6 +850,24 @@ export interface ISessionGitState { readonly githubRepo?: string; } +/** + * GitHub state of a session, carried under {@link SessionMeta} at + * {@link SESSION_META_GITHUB_KEY}. Used by clients to drive GitHub-specific + * affordances (e.g. PR/merge buttons in the Agents app). + * + * All fields are optional — agents that do not track a particular field + * should omit it rather than send a placeholder, so clients can distinguish + * "unknown" from "known to be zero". + */ +export interface ISessionGitHubState { + /** The owner of the GitHub repository. */ + readonly owner?: string; + /** The name of the GitHub repository. */ + readonly repo?: string; + /** The URL of the GitHub pull request. */ + readonly pullRequestUrl?: string; +} + /** * Reads the well-known git-state payload from {@link SessionMeta}, if * present. Returns `undefined` when the meta bag is absent or the value at @@ -822,6 +923,92 @@ export function withSessionGitState(meta: SessionMeta | undefined, gitState: ISe return Object.keys(next).length > 0 ? next : undefined; } +/** + * Reads the well-known GitHub state payload from {@link SessionSummaryMeta}, if + * present. Returns `undefined` when the meta bag is absent or the value at the + * GitHub key is not a plain object (e.g. an array or a primitive). + * Individual fields with wrong types are silently dropped so partial state + * still propagates. + * + * Unlike the other typed readers, this takes the raw {@link SessionSummaryMeta} + * value rather than its parent {@link SessionState}: the sessions provider stores and + * reads a detached meta snapshot without retaining the owning state. + */ +export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): ISessionGitHubState | undefined { + const value = meta?.[SESSION_META_GITHUB_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + const result: { + owner?: string; + repo?: string; + pullRequestUrl?: string; + } = {}; + + if (typeof raw['owner'] === 'string') { result.owner = raw['owner']; } + if (typeof raw['repo'] === 'string') { result.repo = raw['repo']; } + if (typeof raw['pullRequestUrl'] === 'string') { result.pullRequestUrl = raw['pullRequestUrl']; } + return result; +} + +/** + * Returns a new {@link SessionSummaryMeta} with the GitHub-state payload set to + * `gitHubState`, or with the GitHub slot removed if `gitHubState` is `undefined`. + * Returns `undefined` if the result would be empty. + */ +export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, gitHubState: ISessionGitHubState | undefined): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (gitHubState !== undefined) { + next[SESSION_META_GITHUB_KEY] = gitHubState; + } else { + delete next[SESSION_META_GITHUB_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +/** + * Reserved key under {@link SessionSummaryMeta} marking a session as + * workspace-less: a session with no workspace/folder binding (surfaced in the + * UI as a "Quick Chat"). Carried on the summary bag (not the full state) so + * clients can group/style such sessions in session lists without subscribing to + * full session state. VS Code-specific convention layered on the protocol's + * generic `_meta` bag. + */ +export const SESSION_META_WORKSPACELESS_KEY = 'workspaceless'; + +/** + * Session-database metadata key recording whether a session is workspace-less (a + * workspace-less chat). Owned by the AH service: `AgentService` writes it centrally at + * create/materialize and overlays it onto every agent's summary `_meta` in + * `listSessions`; agents only read it (e.g. to pick the workspace-less system prompt + * on resume) and never persist it themselves. + */ +export const AH_META_WORKSPACELESS_DB_KEY = 'agentHost.workspaceless'; + +/** + * Reads the workspace-less marker from {@link SessionSummaryMeta}. Returns + * `true` only when the well-known key is present and set to boolean `true`. + */ +export function readSessionWorkspaceless(meta: SessionSummaryMeta | undefined): boolean { + return meta?.[SESSION_META_WORKSPACELESS_KEY] === true; +} + +/** + * Returns a new {@link SessionSummaryMeta} with the workspace-less marker set, + * or with the slot removed when `workspaceless` is `false`. Returns `undefined` + * if the result would be empty. + */ +export function withSessionWorkspaceless(meta: SessionSummaryMeta | undefined, workspaceless: boolean): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (workspaceless) { + next[SESSION_META_WORKSPACELESS_KEY] = true; + } else { + delete next[SESSION_META_WORKSPACELESS_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + // ---- RootState _meta accessors --------------------------------------------- /** diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 45e744031786f..04dbd2e4eb49b 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -8,17 +8,18 @@ import { Emitter, Relay } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IReference } from '../../../base/common/lifecycle.js'; import { IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { getDelayedChannel, IChannelServer, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; +import { ipcRenderer } from '../../../base/parts/sandbox/electron-browser/globals.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostByokModelsEnabledSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification, AgentHostOTelPolicyIpcChannel, readAgentHostOTelPolicySettings } from '../common/agentService.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js'; -import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; +import { AgentSubscriptionManager, isActionEnvelopeRelevantToSubscriptionUris, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js'; @@ -28,9 +29,10 @@ import { StateComponents, ROOT_STATE_URI, parseChatUri, type RootState } from '. import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } from '../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostSessionSyncEnabledConfigKey, SESSION_SYNC_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; /** * Renderer-side implementation of {@link IAgentHostService} that connects @@ -49,6 +51,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private readonly _ahpLogger: AhpJsonlLogger | undefined; private readonly _connectionTracker: IConnectionTrackerService; private readonly _subscriptionManager: AgentSubscriptionManager; + private readonly _subscribedResources = new Map(); private readonly _onAgentHostExit = this._register(new Emitter()); readonly onAgentHostExit = this._onAgentHostExit.event; @@ -126,6 +129,21 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos if (e.affectsConfiguration(SESSION_SYNC_ENABLED_SETTING_ID)) { this._updateSessionSyncEnabled(); } + if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID)) { + this._updateTerminalAutoApproveEnabled(); + } + if (e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID)) { + this._updateGlobalAutoApproveEnabled(); + } + if (e.affectsConfiguration(AUTO_REPLY_SETTING_ID)) { + this._updateAutoReplyEnabled(); + } + if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_SETTING_ID) || e.affectsConfiguration(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID)) { + this._updateTerminalAutoApproveRules(); + } + if (e.affectsConfiguration(AgentHostCodexAgentEnabledSettingId)) { + this._updateCodexEnabled(); + } })); if (isAgentHostEnabled(this._configurationService)) { @@ -135,6 +153,13 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private async _connect(): Promise { this._logService.info('[AgentHost:renderer] Acquiring MessagePort to agent host...'); + // Forward the enterprise-resolved OTel policy to the main-process starter BEFORE + // requesting the connection. The main config service does not include the renderer-only + // managed-settings policy (`AccountPolicyService`), so without this the agent host would + // be spawned missing managed OTel settings (endpoint/protocol/enabled). Sent first so it + // is processed (FIFO per sender) before the starter spawns the host on the connection + // request. See `AgentHostOTelPolicyIpcChannel`. + ipcRenderer.send(AgentHostOTelPolicyIpcChannel, readAgentHostOTelPolicySettings(this._configurationService)); const port = await acquirePort('vscode:createAgentHostMessageChannel', 'vscode:createAgentHostMessageChannelResult'); this._logService.info('[AgentHost:renderer] MessagePort acquired, creating client...'); @@ -143,16 +168,21 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos // calls (vscode-agent-client filesystem reads) back to this renderer // via `IPCServer.getChannel(name, c => c.ctx === clientId)`. const client = store.add(new MessagePortClient(port, this.clientId)); - // Serve filesystem reverse-RPCs from the local file service. The - // agent host registers an authority on its - // AgentHostClientFileSystemProvider that calls back through this channel. - client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, this._instantiationService.createInstance(AgentHostClientResourceChannel, this._ahpLogger)); + registerAgentHostClientChannels(client, this._instantiationService, this._logService, this._ahpLogger, this._configurationService.getValue(AgentHostByokModelsEnabledSettingId) === true); this._clientEventually.complete(client); this._updateTelemetryLevel(); this._updateSessionSyncEnabled(); + this._updateTerminalAutoApproveEnabled(); + this._updateGlobalAutoApproveEnabled(); + this._updateAutoReplyEnabled(); + this._updateTerminalAutoApproveRules(); + this._updateCodexEnabled(); store.add(this._proxy.onDidAction(e => { const revived = revive(e) as ActionEnvelope; + if (!isActionEnvelopeRelevantToSubscriptionUris(revived, this._subscribedResources.keys())) { + return; + } if (this._ahpLogger) { const frame = { jsonrpc: '2.0' as const, method: 'action', params: e }; this._ahpLogger.log(frame, 's2c'); @@ -194,6 +224,46 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos }, this.clientId, 0); } + private _updateTerminalAutoApproveEnabled(): void { + const enabled = this._configurationService.getValue(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID) !== false; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostTerminalAutoApproveEnabledConfigKey]: enabled }, + }, this.clientId, 0); + } + + private _updateGlobalAutoApproveEnabled(): void { + const enabled = this._configurationService.getValue(GLOBAL_AUTO_APPROVE_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: enabled }, + }, this.clientId, 0); + } + + private _updateAutoReplyEnabled(): void { + const enabled = this._configurationService.getValue(AUTO_REPLY_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostAutoReplyEnabledConfigKey]: enabled }, + }, this.clientId, 0); + } + + private _updateCodexEnabled(): void { + // Disabling only takes effect on the next agent host restart. + const enabled = this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostCodexEnabledConfigKey]: enabled }, + }, this.clientId, 0); + } + + private _updateTerminalAutoApproveRules(): void { + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostTerminalAutoApproveRulesConfigKey]: getAgentHostTerminalAutoApproveRulesConfig(this._configurationService) }, + }, this.clientId, 0); + } + // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- authenticate(params: AuthenticateParams): Promise { @@ -257,11 +327,34 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._proxy.shutdown(); } private subscribe(resource: URI): Promise { - return this._proxy.subscribe(resource, this.clientId); + this._addSubscribedResource(resource); + return this._proxy.subscribe(resource, this.clientId).catch(err => { + this._removeSubscribedResource(resource); + throw err; + }); } private unsubscribe(resource: URI): void { + this._removeSubscribedResource(resource); this._proxy.unsubscribe(resource, this.clientId); } + + private _addSubscribedResource(resource: URI): void { + const key = resource.toString(); + this._subscribedResources.set(key, (this._subscribedResources.get(key) ?? 0) + 1); + } + + private _removeSubscribedResource(resource: URI): void { + const key = resource.toString(); + const count = this._subscribedResources.get(key); + if (count === undefined) { + return; + } + if (count === 1) { + this._subscribedResources.delete(key); + } else { + this._subscribedResources.set(key, count - 1); + } + } dispatchAction(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { this._proxy.dispatchAction(channel, action, clientId, clientSeq); } @@ -342,3 +435,34 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._connectionTracker.getInspectInfo(tryEnable); } } + +/** + * Register the reverse-RPC server channels every in-process renderer exposes to + * the agent host's {@link UtilityProcessServer}: the filesystem resource bridge + * ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}) and the BYOK language-model + * bridge ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The agent host reaches + * these via `server.getChannel(name, c => c.ctx === clientId)`. + */ +export function registerAgentHostClientChannels( + client: IChannelServer, + instantiationService: IInstantiationService, + logService: ILogService, + ahpLogger: AhpJsonlLogger | undefined, + byokEnabled: boolean, +): void { + // Serve filesystem reverse-RPCs from the local file service. The agent host + // registers an authority on its AgentHostClientFileSystemProvider that calls + // back through this channel. + client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, instantiationService.createInstance(AgentHostClientResourceChannel, ahpLogger)); + // Serve BYOK language-model reverse-RPCs from the renderer LM API, gated + // behind `chat.agentHost.byokModels.enabled`. When disabled, the node-side + // proxy + registry are also skipped, so the channel would never be called. + + if (byokEnabled) { + try { + client.registerChannel(AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, instantiationService.createInstance(AgentHostClientByokLmChannel)); + } catch (err) { + logService.warn(`[AgentHost:renderer] BYOK language-model bridge not registered for this window. ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index 054f96ca48969..bb711f6d895a3 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,7 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHost.config.contribution.js'; import '../common/agentHostStarter.config.contribution.js'; @@ -34,6 +34,15 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt private readonly _onWillShutdown = this._register(new Emitter()); readonly onWillShutdown = this._onWillShutdown.event; + /** + * Enterprise OTel policy forwarded by the renderer (see `AgentHostOTelPolicyIpcChannel`). + * The main-process config service lacks the managed-settings (`AccountPolicyService`) policy + * layer, so the renderer — which has it — sends the resolved values here before requesting + * the connection that lazily spawns the host. Used as the `policySettings` of + * `buildAgentHostOTelEnv` in `start()`, falling back to main-process policy when absent. + */ + private _otelPolicyFromRenderer: IAgentHostOTelSettings | undefined = undefined; + constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, @@ -44,6 +53,16 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt this._register(this._lifecycleMainService.onWillShutdown(() => this._onWillShutdown.fire())); + // Capture the enterprise OTel policy the renderer forwards before it requests a + // connection (FIFO per sender ensures this lands before the spawn in `start()`). + const onOTelPolicy = (_e: IpcMainEvent, policy: unknown) => { + this._otelPolicyFromRenderer = sanitizeAgentHostOTelPolicySettings(policy); + }; + validatedIpcMain.on(AgentHostOTelPolicyIpcChannel, onOTelPolicy); + this._register(toDisposable(() => { + validatedIpcMain.removeListener(AgentHostOTelPolicyIpcChannel, onOTelPolicy); + })); + // Listen for new windows to establish a direct MessagePort connection to the agent host const onWindowConnection = (e: IpcMainEvent, nonce: string) => this._onWindowConnection(e, nonce); validatedIpcMain.on('vscode:createAgentHostMessageChannel', onWindowConnection); @@ -75,11 +94,29 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt codexBinaryArgs: this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId), codexAgentEnabled: this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId), + byokModelsEnabled: this._configurationService.getValue(AgentHostByokModelsEnabledSettingId), }, process.env); // Translate `chat.agentHost.otel.*` settings into the env vars consumed by // the agent host process. Any value already present on `process.env` wins - // (developer override) — see `buildAgentHostOTelEnv` for the precedence. + // for user settings, while enterprise policy values win over inherited env — + // see `buildAgentHostOTelEnv` for the precedence. + // + // Policy source: prefer the renderer-forwarded policy (its config service + // includes the managed-settings `AccountPolicyService` layer that the main + // process cannot see); fall back to the main-process policy for the keys it + // can resolve (e.g. native MDM via the policy channel). + const policyValue = (key: string): T | undefined => this._configurationService.inspect(key).policyValue; + const policySettings: IAgentHostOTelSettings = this._otelPolicyFromRenderer ?? { + enabled: policyValue(AgentHostOTelEnabledSettingId), + exporterType: policyValue(AgentHostOTelExporterTypeSettingId), + otlpProtocol: policyValue(AgentHostOTelOtlpProtocolSettingId), + otlpEndpoint: policyValue(AgentHostOTelOtlpEndpointSettingId), + captureContent: policyValue(AgentHostOTelCaptureContentSettingId), + outfile: policyValue(AgentHostOTelOutfileSettingId), + serviceName: policyValue(AgentHostOTelServiceNameSettingId), + resourceAttributes: policyValue>(AgentHostOTelResourceAttributesSettingId), + }; const otelEnv = buildAgentHostOTelEnv({ enabled: this._configurationService.getValue(AgentHostOTelEnabledSettingId), exporterType: this._configurationService.getValue(AgentHostOTelExporterTypeSettingId), @@ -87,7 +124,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt captureContent: this._configurationService.getValue(AgentHostOTelCaptureContentSettingId), outfile: this._configurationService.getValue(AgentHostOTelOutfileSettingId), dbSpanExporterEnabled: this._configurationService.getValue(AgentHostOTelDbSpanExporterEnabledSettingId), - }, process.env); + }, process.env, policySettings); const args = [ '--logsPath', this._environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath, diff --git a/src/vs/platform/agentHost/node/activeClientState.ts b/src/vs/platform/agentHost/node/activeClientState.ts index 444f018b08cd7..d758a0e218094 100644 --- a/src/vs/platform/agentHost/node/activeClientState.ts +++ b/src/vs/platform/agentHost/node/activeClientState.ts @@ -52,6 +52,103 @@ export function structuralToolsEqual( return true; } +/** + * A per-session registry of the tools contributed by each active client, + * keyed by `clientId` and kept in insertion order. Backs the multi-active-client + * tool model shared by the agent-host providers (Copilot, Claude, Codex): + * each provider stores one of these per session and exposes the + * {@link merged} view to its SDK while routing tool calls back to the + * {@link ownerOf | owning client}. + * + * Deduplication of {@link merged} is by tool `name`, first-inserted-client + * wins, so the merged order and the owner of any given tool name are + * deterministic regardless of how many clients contribute it. + */ +export class ActiveClientToolSet { + private readonly _byClient = new Map(); + + /** Number of clients currently contributing tools. */ + get size(): number { + return this._byClient.size; + } + + /** Whether `clientId` currently contributes tools. */ + has(clientId: string): boolean { + return this._byClient.has(clientId); + } + + /** The client ids currently contributing tools, in insertion order. */ + clientIds(): IterableIterator { + return this._byClient.keys(); + } + + /** This client's contributed tools, or an empty array when absent. */ + get(clientId: string): readonly ToolDefinition[] { + return this._byClient.get(clientId) ?? []; + } + + /** + * Replace `clientId`'s contributed tools (full replacement). A new + * `clientId` is appended after existing ones; re-setting an existing + * `clientId` keeps its insertion position so merged ordering and tool + * ownership stay stable across updates. + */ + set(clientId: string, tools: readonly ToolDefinition[]): void { + this._byClient.set(clientId, tools); + } + + /** Remove `clientId`'s contribution. Returns whether anything was removed. */ + delete(clientId: string): boolean { + return this._byClient.delete(clientId); + } + + /** + * The union of every client's tools, deduplicated by `name` with the + * first-inserted contributor winning. Order follows client insertion + * order, then per-client tool order. + */ + merged(): readonly ToolDefinition[] { + const seen = new Set(); + const result: ToolDefinition[] = []; + for (const tools of this._byClient.values()) { + for (const tool of tools) { + if (seen.has(tool.name)) { + continue; + } + seen.add(tool.name); + result.push(tool); + } + } + return result; + } + + /** + * The `clientId` that owns the tool named `toolName`, or `undefined` when + * no active client provides it. When `preferredClientId` currently provides + * the tool it wins; otherwise the first-inserted contributor wins. + */ + ownerOf(toolName: string, preferredClientId?: string): string | undefined { + if (preferredClientId && this.get(preferredClientId).some(tool => tool.name === toolName)) { + return preferredClientId; + } + for (const [clientId, tools] of this._byClient) { + if (tools.some(tool => tool.name === toolName)) { + return clientId; + } + } + return undefined; + } + + /** + * Structural comparison of the current {@link merged} tools against a + * previously-applied snapshot (`name + description + inputSchema`, + * order-insensitive). Returns `true` when no SDK restart is required. + */ + structuralEquals(applied: readonly ToolDefinition[] | undefined): boolean { + return structuralToolsEqual(this.merged(), applied); + } +} + /** * Live, mutable holder for the active client's identity (`clientId`) and the * structural tool snapshot it contributes. Shared between the Copilot and diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 7095b1f4d9974..152ee60d5f141 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -169,13 +169,13 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi } getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined { - const own = this._stateManager.getSessionState(session)?.summary.workingDirectory; + const own = this._stateManager.getSessionState(session)?.workingDirectory; if (own !== undefined) { return own; } const parentInfo = parseSubagentSessionUri(session); if (parentInfo) { - return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.summary.workingDirectory; + return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectory; } return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts index 904dcf1882eca..18e9901a1224d 100644 --- a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts @@ -22,6 +22,7 @@ export class AgentHostAuthenticationService { async authenticate(params: AuthenticateParams, providers: Iterable): Promise { this._logService.trace(`[AgentHostAuthenticationService] authenticate called: resource=${params.resource}`); + const providerList = [...providers]; // Multiple providers may share the same protected resource (e.g. // both Copilot CLI and Claude consume the GitHub Copilot token). // Fan out to every matching provider in parallel; the request is @@ -29,7 +30,7 @@ export class AgentHostAuthenticationService { // failures are isolated -- one provider rejecting (e.g. proxy // server bind failure) MUST NOT prevent another provider from // accepting the same token. - const matching = [...providers].filter( + const matching = providerList.filter( p => p.getProtectedResources().some(r => r.resource === params.resource), ); const settled = await Promise.allSettled( @@ -47,6 +48,21 @@ export class AgentHostAuthenticationService { ); } } + const sessionResourceHandlers = providerList.filter(p => p.handleAuthenticationToken); + const sessionResourceSettled = await Promise.allSettled( + sessionResourceHandlers.map(p => p.handleAuthenticationToken ? p.handleAuthenticationToken(params) : Promise.resolve(false)), + ); + for (let i = 0; i < sessionResourceSettled.length; i++) { + const result = sessionResourceSettled[i]; + if (result.status === 'fulfilled') { + authenticated ||= result.value; + } else { + this._logService.error( + result.reason, + `[AgentHostAuthenticationService] Provider '${sessionResourceHandlers[i].id}' handleAuthenticationToken threw for resource=${params.resource}`, + ); + } + } if (authenticated) { const scopes = this._normalizeScopes(params.scopes); this._tokens.set(this._key(params.resource, scopes), { resource: params.resource, scopes, token: params.token }); diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 3f7ecec5acf7f..eef150a67e5cb 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -6,20 +6,15 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { IAgentSessionMetadata } from '../common/agentService.js'; -import { - ChangesetKind, - parseChangesetUri, -} from '../common/changesetUri.js'; -import { ISessionGitState } from '../common/state/sessionState.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { buildBranchChangesetUri, ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; import { ChangesetFileMonitorCoordinator } from './agentHostChangesetFileMonitorCoordinator.js'; -import { IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; -import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; -import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetService, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { isAhpChatChannel } from '../common/state/sessionState.js'; /** * Raw metadata blob values for the session DB, batch-read by the caller. @@ -53,13 +48,13 @@ export class AgentHostChangesetCoordinator extends Disposable { @IAgentHostChangesetOperationService private readonly _changesetOperationService: IAgentHostChangesetOperationService, @IAgentHostChangesetService private readonly _changesets: IAgentHostChangesetService, @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - @IAgentHostFileMonitorService fileMonitorService: IAgentHostFileMonitorService, - @IAgentHostGitService gitService: IAgentHostGitService, - @ILogService private readonly _logService: ILogService, + @IAgentHostGitStateService gitStateService: IAgentHostGitStateService, + @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this._changesetFileMonitor = this._register(new ChangesetFileMonitorCoordinator(this._stateManager, this._changesets, this._configurationService, fileMonitorService, gitService, this._logService)); + + this._changesetFileMonitor = this._register(instantiationService.createInstance(ChangesetFileMonitorCoordinator, this._stateManager)); + this._register(gitStateService.onDidRefreshSessionGitState(sessionStr => this.onDidRunSessionGitStateRefresh(sessionStr))); } // ---- Lifecycle hooks ---------------------------------------------------- @@ -76,6 +71,7 @@ export class AgentHostChangesetCoordinator extends Disposable { * `SessionReady` is dispatched. */ onSessionCreated(sessionStr: string): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.registerStaticChangesets(sessionStr); } @@ -87,6 +83,7 @@ export class AgentHostChangesetCoordinator extends Disposable { * keys. */ onSessionRestored(sessionStr: string, metadata: IChangesetSessionMetadata): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.registerStaticChangesets(sessionStr); this._changesets.restorePersistedStaticChangesets(sessionStr, { branchRaw: metadata[META_CHANGESET_BRANCH], @@ -106,24 +103,10 @@ export class AgentHostChangesetCoordinator extends Disposable { * because the working directory was not yet known. */ onSessionMaterialized(sessionStr: string): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.onWorkingDirectoryAvailable(sessionStr); - this._changesetFileMonitor.onSessionMaterialized(sessionStr); - } - - /** - * Called after `_meta.git` is attached or updated. Git state can provide - * the base branch used by Branch Changes and fresh uncommitted counts, so - * refresh both static changesets once the session has a working directory. - */ - onSessionGitStateChanged(sessionStr: string, gitState: ISessionGitState): void { - // Git state can provide the base branch used by Branch Changes and - // fresh uncommitted counts; recompute every changeset currently - // subscribed for the session (the service reads the exposed - // subscription list). - this._changesets.recomputeSubscribedChangesets(sessionStr); - // Update the operations for all subscribed changesets - this._changesetOperationService.updateOperations(sessionStr, undefined, gitState); + this._changesetFileMonitor.onSessionMaterialized(sessionStr); } /** @@ -139,6 +122,11 @@ export class AgentHostChangesetCoordinator extends Disposable { onSessionTurnActiveChanged(sessionStr: string, active: boolean): void { this._changesetFileMonitor.onSessionTurnActiveChanged(sessionStr, active); + + // Advertised operations are disabled while a turn is active so the + // working tree / branch state can't be mutated mid-request; recompute + // them whenever the active-turn state flips. + this._changesetOperationService.updateOperations(sessionStr); } // ---- Subscription hooks ------------------------------------------------- @@ -157,27 +145,39 @@ export class AgentHostChangesetCoordinator extends Disposable { const resourceStr = resource.toString(); const parsed = parseChangesetUri(resourceStr); + if (!parsed && !isAhpChatChannel(resourceStr) && this._stateManager.getSessionState(resourceStr)) { + // For the session URI, we add a subscription for the branch + // changeset since this is the changeset that is being used to + // track the changes that are being used to calculate the diff + // statistics for the session changes. + this._addSubscription(resourceStr, buildBranchChangesetUri(resourceStr)); + this._changesets.refreshBranchChangeset(resourceStr); + this._changesetFileMonitor.trackSessionChanges(resourceStr, resourceStr); + + return; + } + if (parsed?.kind === ChangesetKind.Branch) { this._addSubscription(parsed.sessionUri, resourceStr); this._changesets.refreshBranchChangeset(parsed.sessionUri); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } + if (parsed?.kind === ChangesetKind.Uncommitted) { this._addSubscription(parsed.sessionUri, resourceStr); void this._changesets.computeUncommittedChangeset(parsed.sessionUri); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } + if (parsed?.kind === ChangesetKind.Session) { this._addSubscription(parsed.sessionUri, resourceStr); this._changesets.refreshSessionChangeset(parsed.sessionUri); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } + if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { // Track the new subscriber so the service's per-turn recompute // gating starts including this turn. The initial snapshot is @@ -185,22 +185,8 @@ export class AgentHostChangesetCoordinator extends Disposable { // subsequent deltas flow from `onToolCallEditsApplied` / // `onTurnComplete` once we've added this turn id here. this._addSubscription(parsed.sessionUri, resourceStr); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); return; } - if (!parsed && this._stateManager.getSessionState(resourceStr)) { - // Plain session-URI subscription (Agents Window list / detail - // observing the session). Track the session URI itself as a - // subscription marker so a later git-state change / - // materialization recompute (driven from the exposed - // subscription list) re-refreshes the static changesets, then - // refresh both now so the catalogue chip doesn't show a stale - // value just because no turn has run since process start. - this._addSubscription(resourceStr, resourceStr); - this._changesets.refreshBranchChangeset(resourceStr); - this._changesets.refreshSessionChangeset(resourceStr); - this._changesetFileMonitor.trackSessionChanges(resourceStr, resourceStr); - } } /** @@ -336,4 +322,19 @@ export class AgentHostChangesetCoordinator extends Disposable { const changes = this._changesets.computeListEntryChanges(entry.session.toString(), metadata); return changes ? { ...entry, changes } : entry; } + + // ---- Git state events ------------------------------------------------- + + /** + * Called when a session's Git state is refreshed. + */ + private onDidRunSessionGitStateRefresh(sessionStr: string): void { + // Refresh the list of changesets for the session. + this._changesets.refreshChangesetCatalog(sessionStr); + + // Git state has been refreshed so we need to recompute every + // changeset currently subscribed for the session (the service + // reads the exposed subscription list). + this._changesets.recomputeSubscribedChangesets(sessionStr); + } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts index 2ae3ef9820961..6ecf296fabab5 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts @@ -9,11 +9,11 @@ import { URI } from '../../../base/common/uri.js'; import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../common/changesetUri.js'; import { parseSubagentSessionUri } from '../common/state/sessionState.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { DEFAULT_AGENT_HOST_WATCH_EXCLUDES, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { ILogService } from '../../log/common/log.js'; +import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; class WatchInterestReferenceCollection extends ReferenceCollection { constructor( @@ -80,11 +80,11 @@ export class ChangesetFileMonitorCoordinator extends Disposable { constructor( private readonly _stateManager: AgentHostStateManager, - private readonly _changesets: IAgentHostChangesetService, - private readonly _configurationService: IAgentConfigurationService, - private readonly _fileMonitorService: IAgentHostFileMonitorService, - private readonly _gitService: IAgentHostGitService, - private readonly _logService: ILogService, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostFileMonitorService private readonly _fileMonitorService: IAgentHostFileMonitorService, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, + @ILogService private readonly _logService: ILogService, ) { super(); } @@ -239,8 +239,14 @@ export class ChangesetFileMonitorCoordinator extends Disposable { if (activeSessions.length === 0) { return; } + + const workingDirectory = URI.parse(rootStr); + for (const session of activeSessions) { - this._changesets.recomputeSubscribedChangesets(session); + // Refresh the git state for each active session. If there are multiple + // sessions on the same root, trigger the git state refresh for each + // individual session as the git state refresh will be throttled downstream. + void this._gitStateService.refreshSessionGitState(session, workingDirectory); } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 1fee52be73f98..88ceac5c677a6 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -10,16 +10,11 @@ import { parseChangesetUri } from '../common/changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { ActionType } from '../common/state/sessionActions.js'; -import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, ISessionGitHubState, readSessionGitHubState, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js'; import type { IChangesetOperationContribution, IAgentHostChangesetOperationService, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; -import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; -import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; -import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; export class AgentHostChangesetOperationService extends Disposable implements IAgentHostChangesetOperationService { declare readonly _serviceBrand: undefined; @@ -33,19 +28,14 @@ export class AgentHostChangesetOperationService extends Disposable implements IA private readonly _stateManager: AgentHostStateManager, @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, - @IInstantiationService instantiationService: IInstantiationService ) { super(); + this._registry = { registerChangesetOperationHandler: (operationId, handler) => this._registerChangesetOperationHandler(operationId, handler), + refreshSessionGitState: sessionKey => this._gitStateService.refreshSessionGitState(sessionKey), onDidChangeOperations: sessionKey => this.updateOperations(sessionKey), - refreshSessionGitState: sessionKey => this._refreshSessionGitStateAndOperations(sessionKey), }; - - this._register(this.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager))); - this._register(this.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution, this._stateManager))); - this._register(this.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager))); - this._register(this.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager))); } registerContribution(contribution: IChangesetOperationContribution): IDisposable { @@ -59,40 +49,34 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } - updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState): void { + getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] { if (!gitState) { const sessionState = this._stateManager.getSessionState(sessionKey); gitState = readSessionGitState(sessionState?._meta); if (!gitState) { - return; + return []; } } - const changesets = changeset - ? [changeset] - : this._changesetSubscriptions.getSessionSubscriptions(sessionKey); - - for (const changeset of changesets) { - const parsed = parseChangesetUri(changeset); - if (!parsed) { - continue; - } - - const operations = this._getOperations({ - sessionKey, - changesetUri: changeset, - changesetKind: parsed.kind, - gitState - }); + if (!gitHubState) { + gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + } - this._stateManager.dispatchServerAction(changeset, { - type: ActionType.ChangesetOperationsChanged, - operations: operations ? [...operations] : undefined, - }); + const parsed = parseChangesetUri(changeset); + if (!parsed) { + return []; } + + return this._getOperations({ + sessionKey, + changesetUri: changeset, + changesetKind: parsed.kind, + gitState, + gitHubState + }); } - private _getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined { + private _getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] { const operations: ChangesetOperation[] = []; for (const contribution of this._handlerRegistrations.keys()) { const contributed = contribution.getOperations(context); @@ -100,16 +84,45 @@ export class AgentHostChangesetOperationService extends Disposable implements IA operations.push(...contributed); } } - return operations.length > 0 ? operations : undefined; + + // Operations are disabled while a turn is active so the working tree / + // branch state can't be mutated mid-request. + if (this._stateManager.hasActiveTurn(context.sessionKey)) { + return operations.map(operation => ({ + ...operation, + status: ChangesetOperationStatus.Disabled + })); + } + + return operations; } - private async _refreshSessionGitStateAndOperations(sessionKey: string): Promise { - const gitState = await this._gitStateService.refreshSessionGitState(sessionKey); + updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void { if (!gitState) { - return; + const sessionState = this._stateManager.getSessionState(sessionKey); + gitState = readSessionGitState(sessionState?._meta); + if (!gitState) { + return; + } } - this.updateOperations(sessionKey, undefined, gitState); + if (!gitHubState) { + const sessionState = this._stateManager.getSessionState(sessionKey); + gitHubState = readSessionGitHubState(sessionState?._meta); + } + + const changesets = changeset + ? [changeset] + : this._changesetSubscriptions.getSessionSubscriptions(sessionKey); + + for (const changeset of changesets) { + const operations = this.getOperations(sessionKey, changeset, gitState, gitHubState); + + this._stateManager.dispatchServerAction(changeset, { + type: ActionType.ChangesetOperationsChanged, + operations: [...operations], + }); + } } async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { @@ -125,6 +138,17 @@ export class AgentHostChangesetOperationService extends Disposable implements IA throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Operation '${params.operationId}' is disabled on changeset ${params.channel}`); } + // Enforce the active-turn gate at invocation time too, independent of + // the advertised operation status. A ChangesetOperationStatusChanged + // action (e.g. a previously running operation finishing) can reset the + // status back to Idle while a chat turn is still streaming, which would + // otherwise re-enable invocation mid-turn and let the working tree / + // branch state be mutated. + const parsed = parseChangesetUri(params.channel); + if (parsed && this._stateManager.hasActiveTurn(parsed.sessionUri)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Operation '${params.operationId}' is disabled while a turn is active on changeset ${params.channel}`); + } + const targetKind: ChangesetOperationScope = params.target?.kind === ChangesetOperationTargetKind.Resource ? ChangesetOperationScope.Resource : params.target?.kind === ChangesetOperationTargetKind.Range diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 8f3ed306951dd..ca5e2ef33b7d3 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -16,6 +16,7 @@ import { buildUncommittedChangesetUri, parseChangesetUri, ChangesetKind, + buildDefaultChangesetCatalog, } from '../common/changesetUri.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; @@ -27,16 +28,19 @@ import { type ISessionFileDiff, type URI as ProtocolURI, readSessionGitState, + isDefaultChatUri, + SessionLifecycle, } from '../common/state/sessionState.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../common/agentHostGitService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { computeSessionDiffs, computeTurnDiffs, type IIncrementalDiffOptions } from './sessionDiffAggregator.js'; +import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js'; import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js'; import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; +import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): ProtocolURI { return kind === 'branch' @@ -83,7 +87,7 @@ function summariseDiffs(diffs: readonly ISessionFileDiff[] | undefined): Changes * Only the `changeKind: 'session'` entry feeds the summary; other kinds * (`'uncommitted'`, `'turn'`, `'compare-turns'`) describe slices, not * the session-level footprint. The static catalogue itself (built by - * {@link buildDefaultChangesetCatalogue}) is independent of counts and + * {@link buildDefaultChangesetCatalog}) is independent of counts and * is seeded once at session creation. */ function computeChangesSummaryFromLiveState( @@ -157,6 +161,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostChangesetOperationService private readonly _changesetOperationService: IAgentHostChangesetOperationService, @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, ) { super(); @@ -222,7 +227,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC // `changeKind: 'session'` changeset state (registered but not-yet- // restored session) is authoritative, so the caller can skip loading // the potentially-large persisted diff blobs. - const liveSummaryChanges = this._stateManager.getSessionState(sessionUri)?.summary.changes; + const liveSummaryChanges = this._stateManager.getSessionSummary(sessionUri)?.changes; if (liveSummaryChanges) { return undefined; } @@ -254,9 +259,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } // Read live state for an unopened session: synthesise the aggregate - // from the live `changeKind: 'session'` changeset state. Counts stay + // from the live `changeKind: 'branch'` changeset state. Counts stay // in lockstep with the actual changeset state for the session-list chip. - const liveSession = this._stateManager.getChangesetState(buildSessionChangesetUri(sessionUri)); + const liveSession = this._stateManager.getChangesetState(buildBranchChangesetUri(sessionUri)); const liveChanges = computeChangesSummaryFromLiveState(liveSession); if (liveChanges) { // Migrate the changes summary to the new storage mechanism. @@ -265,18 +270,18 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } // No live source — try persisted blobs (if the caller batched them). - const sessionRaw = metadata[META_CHANGESET_SESSION]; + const branchRaw = metadata[META_CHANGESET_BRANCH]; const legacyRaw = metadata[META_LEGACY_DIFFS]; - if (sessionRaw === undefined && legacyRaw === undefined) { + if (branchRaw === undefined && legacyRaw === undefined) { return undefined; } - const restored = this.parsePersistedStaticChangesets(sessionUri, { sessionRaw, legacyRaw }); + const restored = this.parsePersistedStaticChangesets(sessionUri, { branchRaw, legacyRaw }); // `listSessions` must not seed full changeset state for every row; it // only parses persisted blobs enough to render the chip aggregate. // Once the session is opened via `restoreSession`, the live overlay in // `AgentService.listSessions` replaces this parse-only aggregate. - const persistedChanges = computeChangesSummaryFromPersistedDiffs(restored.session); + const persistedChanges = computeChangesSummaryFromPersistedDiffs(restored.branch); if (persistedChanges) { // Migrate the changes summary to the new storage mechanism. this.persistChangesSummary(sessionUri, persistedChanges); @@ -301,6 +306,17 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this.restoreStaticChangeset(session, kind, diffs); } + refreshChangesetCatalog(session: ProtocolURI): void { + const state = this._stateManager.getSessionState(session); + if (state?.lifecycle !== SessionLifecycle.Ready) { + return; + } + + const gitState = readSessionGitState(state?._meta); + const changesets = buildDefaultChangesetCatalog(session, gitState); + this._stateManager.setSessionChangesets(session, changesets); + } + refreshBranchChangeset(session: ProtocolURI): void { if (!this._hasWorkingDirectory(session)) { this._pendingMaterialization.add(session); @@ -550,7 +566,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } private async _computeUncommittedDiffs(session: ProtocolURI): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.summary.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; if (!workingDirectory) { return undefined; } @@ -756,28 +772,62 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC // working dir or not a git work tree). Fall back to the // edit-tracker aggregator — for the session changeset the // SDK-tracked edits are the best available approximation. - let incremental: IIncrementalDiffOptions | undefined; - if (changedTurnId) { - const previousDiffs = this._readPreviousChangesetDiffs(changesetUri); - if (previousDiffs) { - incremental = { changedTurnId, previousDiffs: [...previousDiffs] }; + // + // In multi-chat sessions each peer chat records its file + // edits into its OWN database (the chat URI is used as the + // session URI for that chat's edit tracker). Union the + // session DB with every peer chat DB so peer-chat edits roll + // up into the session-level changes. + const peerSources = this._openPeerChatSources(session); + try { + if (peerSources.length > 0) { + const sources: ISessionDiffSource[] = [ + { sessionUri: session, db: ref.object }, + ...peerSources.map(p => ({ sessionUri: p.sessionUri, db: p.ref.object })), + ]; + // TODO (debt): multi-chat always does a full recompute + // (the incremental `changedTurnId`/`previousDiffs` path is + // only used for single-chat below). A follow-up can make + // `computeUnionedDiffs` incremental — see its doc comment + // and the tracking issue. + diffs = await computeUnionedDiffs(sources, this._diffComputeService); + } else { + let incremental: IIncrementalDiffOptions | undefined; + if (changedTurnId) { + const previousDiffs = this._readPreviousChangesetDiffs(changesetUri); + if (previousDiffs) { + incremental = { changedTurnId, previousDiffs: [...previousDiffs] }; + } + } + diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); + } + } finally { + for (const peer of peerSources) { + peer.ref.dispose(); } } - diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); } this._publishChangesetDiffs(session, changesetUri, diffs); + // Persist the file list so a subsequent `listSessions` / // `restoreSession` can reseed the changeset before the first // post-restart compute completes. this._persistSessionFlag(session, persistKeyFor(kind), JSON.stringify(diffs)); - // Migration: also overwrite the legacy `'diffs'` key with the - // session-changeset payload so older readers stay correct - // during the rollout window. - if (kind === 'session') { + + if (kind === ChangesetKind.Branch) { + // Migration: also overwrite the legacy `'diffs'` key with the + // session-changeset payload so older readers stay correct + // during the rollout window. this._persistSessionFlag(session, META_LEGACY_DIFFS, JSON.stringify(diffs)); - // Persist the changes summary and update the in-memory session summary. + // Persist the changes summary and update the in-memory session + // summary from the BRANCH changeset. The session-list chip and the + // inactive-session aggregate (`computeListEntryChanges`) read the + // branch changeset, as does the active session view, so sourcing + // the persisted summary from the same place keeps the count stable + // across the active <-> inactive transition instead of flipping to + // the (different) session changeset's count. const changesSummary = summariseDiffs(diffs) ?? { additions: 0, deletions: 0, files: 0 }; this.persistChangesSummary(session, changesSummary); this._stateManager.setSessionSummaryChanges(session, changesSummary); @@ -832,36 +882,27 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * fresh file list has been applied. */ private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[]): void { - const previous = this._stateManager.getChangesetState(changesetUri); - const previousIds = new Set(previous?.files.map(f => f.id) ?? []); - - // Emit file upserts. Use `after.uri` as the stable id when available - // (covers creates and edits) and fall back to `before.uri` for - // deletions; this matches the spec's recommendation and avoids id - // collisions for renames (which carry distinct before/after URIs). - const nextFilesById = new Map(); + // Get the available operations for this changeset. This call assumes that at this point + // the git state of the session is up-to-date as it is being used to determine the available + // operations. Long term this should be replaced with a more robust mechanism. + const operations = this._changesetOperationService.getOperations(session, changesetUri); + + const files: ChangesetFile[] = []; for (const edit of diffs) { const id = edit.after?.uri ?? edit.before?.uri; if (!id) { continue; } - nextFilesById.set(id, edit); - const file: ChangesetFile = { id, edit }; - this._stateManager.dispatchServerAction(changesetUri, { - type: ActionType.ChangesetFileSet, - file, - }); + files.push({ id, edit }); } - // Emit removals for any file that disappeared in this pass. - for (const id of previousIds) { - if (!nextFilesById.has(id)) { - this._stateManager.dispatchServerAction(changesetUri, { - type: ActionType.ChangesetFileRemoved, - fileId: id, - }); - } - } + this._stateManager.dispatchServerAction(changesetUri, { + type: ActionType.ChangesetContentChanged, + files, + operations: operations + ? [...operations] + : undefined, + }); // Move the changeset out of `computing` (or out of an earlier error) // now that we have a fresh, complete file list. @@ -874,6 +915,64 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } + /** + * Opens the databases for every non-default (peer) chat in a multi-chat + * session. Each peer chat records its file edits into its own database + * keyed by the chat URI, so the session changeset must union those + * databases with the session DB. Returns an empty array for single-chat + * sessions. Callers MUST dispose every returned `ref`. + */ + private _openPeerChatSources(session: ProtocolURI): { sessionUri: ProtocolURI; ref: ReturnType }[] { + const chats = this._stateManager.getSessionState(session)?.chats ?? []; + const sources: { sessionUri: ProtocolURI; ref: ReturnType }[] = []; + for (const chat of chats) { + if (isDefaultChatUri(chat.resource)) { + continue; + } + try { + const ref = this._sessionDataService.openDatabase(URI.parse(chat.resource)); + sources.push({ sessionUri: chat.resource, ref }); + } catch (err) { + this._logService.warn(`[AgentHostChangesetService] Failed to open peer chat database for session changes: ${chat.resource}`, err); + } + } + return sources; + } + + /** + * Returns the turn id whose checkpoint best represents the latest state of + * the session's shared working tree. For single-chat sessions this is the + * default chat's last turn. For multi-chat sessions it is the last turn of + * the most-recently-modified chat (peer-chat turn checkpoints are stored + * under the session URI keyed by their turn id). Returns `undefined` when + * no chat has any turns. + */ + private _latestTurnIdAcrossChats(session: ProtocolURI): string | undefined { + const sessionState = this._stateManager.getSessionState(session); + if (!sessionState) { + return undefined; + } + + const chats = sessionState.chats ?? []; + if (chats.length <= 1) { + return sessionState.turns.at(-1)?.id; + } + + let bestTurnId: string | undefined; + let bestModifiedAt = ''; + for (const chat of chats) { + const turns = isDefaultChatUri(chat.resource) + ? sessionState.turns + : this._stateManager.getChatState(chat.resource)?.turns; + const lastTurnId = turns?.at(-1)?.id; + if (lastTurnId && chat.modifiedAt >= bestModifiedAt) { + bestModifiedAt = chat.modifiedAt; + bestTurnId = lastTurnId; + } + } + return bestTurnId; + } + /** * Computes diffs for a static changeset by shelling out to git. * Returns the diff list when the session has a working directory and @@ -888,7 +987,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * branch git falls back to `HEAD`. */ private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase, kind: StaticChangesetKind): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.summary.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; if (!workingDirectory) { return undefined; } @@ -902,8 +1001,11 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC // Session if (kind === 'session') { - // Get session checkpoints - const latestTurnId = this._stateManager.getSessionState(session)?.turns.at(-1)?.id; + // Get session checkpoints. In multi-chat sessions the working tree + // is shared and each chat's turn checkpoints are stored under the + // session URI keyed by their turn id, so the most-recently-modified + // chat's last turn captures the full working-tree delta. + const latestTurnId = this._latestTurnIdAcrossChats(session); if (!latestTurnId) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index e60d681334ed6..9426a1051e56f 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -8,14 +8,13 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; -import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { readSessionGitState, type ISessionFileDiff, type SessionState } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; -import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { CopilotApiError, ICopilotApiService } from './shared/copilotApiService.js'; const MAX_CHANGE_SUMMARY_PROMPT_CHARS = 20_000; @@ -30,7 +29,6 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl @IAgentService private readonly _agentService: IAgentService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, - @IAgentHostChangesetService private readonly _changesets: IAgentHostChangesetService, @ILogService private readonly _logService: ILogService, ) { } @@ -49,7 +47,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl private async _invoke(params: InvokeChangesetOperationParams, token: CancellationToken, signal: AbortSignal): Promise { const parsed = parseChangesetUri(params.channel); - if (!parsed || parsed.kind !== ChangesetKind.Uncommitted) { + if (!parsed) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not an uncommitted changeset URI: ${params.channel}`); } this._throwIfCancelled(token); @@ -60,7 +58,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.summary.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectory; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } @@ -130,8 +128,6 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl this._logService.warn(`[AgentHostCommitOperationHandler] Post-commit refresh failed for session ${sessionUri}: ${err instanceof Error ? err.message : String(err)}`); } - this._changesets.recomputeSubscribedChangesets(sessionUri); - return { message: { markdown: localize('agentHost.changeset.commit.committed', "Committed changes with message: `{0}`", message.split('\n')[0]) } }; } diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index 5cff419b69f7d..d7eea1afbfa55 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -6,7 +6,6 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { ChangesetKind } from '../common/changesetUri.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js'; import { AgentHostCommitOperationHandler } from './agentHostCommitOperationHandler.js'; @@ -33,9 +32,13 @@ export class AgentHostCommitOperationContribution extends Disposable implements return store; } - getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (changesetKind !== ChangesetKind.Uncommitted || (gitState.uncommittedChanges ?? 0) <= 0) { - return undefined; + getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { + if ((gitState?.uncommittedChanges ?? 0) <= 0) { + return []; + } + + if (!gitHubState?.pullRequestUrl && changesetKind !== 'uncommitted') { + return []; } return [{ diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts index 67017fb843a84..e3ff474843759 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts @@ -7,7 +7,6 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { basename } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams, type InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -22,7 +21,6 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat constructor( private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, - @IAgentHostChangesetService private readonly _changesets: IAgentHostChangesetService, @IAgentHostGitService private readonly _agentHostGitService: IAgentHostGitService, @ILogService private readonly _logService: ILogService, ) { } @@ -59,7 +57,7 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat `Operation '${AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES}' requires a resource target.`); } - const workingDirectoryStr = sessionState.summary.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectory; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } @@ -78,9 +76,6 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat `Failed to discard changes: ${err instanceof Error ? err.message : String(err)}`); } - // Re-compute the uncommitted changeset for the session - void this._changesets.computeUncommittedChangeset(sessionUri); - return { message: { markdown: localize('agentHost.changeset.discardChanges.discarded', "Discarded changes to `{0}`.", basename(resource)) } }; } diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts index 0175351ef630b..cb6935a324aaf 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts @@ -30,9 +30,9 @@ export class AgentHostDiscardChangesOperationContribution extends Disposable imp return store; } - getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (changesetKind !== ChangesetKind.Uncommitted || (gitState.uncommittedChanges ?? 0) <= 0) { - return undefined; + getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] { + if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) { + return []; } return [{ diff --git a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts index 607fd97da03ea..1b4ca1aaef932 100644 --- a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -111,7 +111,7 @@ export class AgentHostFileCompletionProvider implements IAgentHostCompletionItem ) { } async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise { - const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.summary.workingDirectory; + const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectory; if (!workingDirectoryStr) { return []; } diff --git a/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts index 12daa08739c66..4440ed0572a64 100644 --- a/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts +++ b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts @@ -20,6 +20,7 @@ export const DEFAULT_AGENT_HOST_WATCH_EXCLUDES: readonly string[] = Object.freez '**/.git/objects/**', '**/.git/subtree-cache/**', '**/.git/**/*.lock', + '**/.git/**/FETCH_HEAD', '**/.git/**/fsmonitor--daemon/**', '**/*.watchman-cookie-*', ]); diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 97c1dbfc54d98..db63ec242fea8 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -13,20 +13,24 @@ import { ILogService } from '../../log/common/log.js'; import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; import { buildGitBlobUri } from './gitDiffContent.js'; import { EMPTY_TREE_OBJECT, getBranchCompletions, IAgentHostGitService, IComputeSessionFileDiffsOptions, IPullOptions, IPushOptions } from '../common/agentHostGitService.js'; +import { LRUCache } from '../../../base/common/map.js'; +import { SequencerByKey } from '../../../base/common/async.js'; export class AgentHostGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; + /** + * A cache of repository roots that have already been discovered. + */ + private readonly _repositoryRoots = new LRUCache(100); + private readonly _repositoryRootSequencer = new SequencerByKey(); + constructor( @IFileService private readonly _fileService: IFileService, @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, @ILogService private readonly _logService: ILogService, ) { } - async isInsideWorkTree(workingDirectory: URI): Promise { - return (await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']))?.trim() === 'true'; - } - async getCurrentBranch(workingDirectory: URI): Promise { return (await this._runGit(workingDirectory, ['branch', '--show-current']))?.trim() || (await this._runGit(workingDirectory, ['rev-parse', '--short', 'HEAD']))?.trim() @@ -72,8 +76,26 @@ export class AgentHostGitService implements IAgentHostGitService { } async getRepositoryRoot(workingDirectory: URI): Promise { - const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim(); - return repositoryRootPath ? URI.file(repositoryRootPath) : undefined; + const workingDirectoryKey = workingDirectory.toString(); + + return this._repositoryRootSequencer.queue(workingDirectoryKey, async () => { + let repositoryRoot = this._repositoryRoots.get(workingDirectoryKey); + if (repositoryRoot) { + return repositoryRoot; + } + + try { + const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim(); + if (repositoryRootPath) { + repositoryRoot = URI.file(repositoryRootPath); + this._repositoryRoots.set(workingDirectoryKey, repositoryRoot); + } + + return repositoryRoot; + } catch (error) { } + + return undefined; + }); } async getWorktreeRoots(workingDirectory: URI): Promise { @@ -186,21 +208,13 @@ export class AgentHostGitService implements IAgentHostGitService { } async computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise { - // Bail fast if not inside a git work tree so callers can fall back - // to other diff sources. - const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']); - if (inside?.trim() !== 'true') { - return undefined; - } - // All git invocations run from the working tree's repository root so // `--raw` paths are repo-relative — that's what `git show :` // expects when we resolve `git-blob:` URIs later. - const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim(); - if (!repositoryRootPath) { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { return undefined; } - const repositoryRoot = URI.file(repositoryRootPath); // Resolve the merge-base commit. With a base branch, prefer the // corresponding origin/ remote-tracking ref when it exists so @@ -300,23 +314,17 @@ export class AgentHostGitService implements IAgentHostGitService { return output !== undefined ? remoteBranch : undefined; } - async showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise { - // Validate sha before passing it to git. `git show :` parses - // its argument as a revision, so an attacker-controlled sha that starts - // with `-` could inject options, and a non-hex value could resolve to - // commit could resolve to surprising refs. Object names are 4-64 lowercase hex chars. - if (!/^[0-9a-f]{4,64}$/.test(sha)) { - return undefined; - } - const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']); - if (inside?.trim() !== 'true') { + async showBlob(workingDirectory: URI, ref: string, repoRelativePath: string): Promise { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { return undefined; } + // `git show` exits non-zero when the path didn't exist at that - // commit; `_runGit` swallows that into `undefined` which is exactly + // ref; `_runGit` swallows that into `undefined` which is exactly // the contract callers want. return new Promise((resolve) => { - cp.execFile('git', ['show', `${sha}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { + cp.execFile('git', ['show', `${ref}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { if (error) { resolve(undefined); return; @@ -331,15 +339,11 @@ export class AgentHostGitService implements IAgentHostGitService { } async captureWorkingTreeAsTree(workingDirectory: URI): Promise { - const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']); - if (inside?.trim() !== 'true') { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { return undefined; } - const repoRoot = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim(); - if (!repoRoot) { - return undefined; - } - const repositoryRoot = URI.file(repoRoot); + const statusOut = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']); if (statusOut === undefined) { return undefined; @@ -402,33 +406,27 @@ export class AgentHostGitService implements IAgentHostGitService { } async computeFileDiffsBetweenRefs(workingDirectory: URI, options: { readonly sessionUri: string; readonly fromRef: string; readonly toRef: string }): Promise { - const repoRoot = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim(); - if (!repoRoot) { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { return undefined; } - const repositoryRoot = URI.file(repoRoot); - - // Validate both refs resolve before invoking `git diff` so a missing - // ref returns undefined rather than producing a confusing error. - const fromOid = (await this._runGit(repositoryRoot, ['rev-parse', '--verify', '--quiet', options.fromRef]))?.trim(); - const toOid = (await this._runGit(repositoryRoot, ['rev-parse', '--verify', '--quiet', options.toRef]))?.trim(); - if (!fromOid || !toOid) { - return undefined; - } + try { + const raw = await this._runGit(repositoryRoot, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', options.fromRef, options.toRef, '--']); + if (raw === undefined) { + return undefined; + } - const raw = await this._runGit(repositoryRoot, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', fromOid, toOid, '--']); - if (raw === undefined) { + return parseGitDiffRawNumstat(raw, repositoryRoot, options.sessionUri, options.fromRef, options.toRef); + } catch (err) { + this._logService.warn(`[AgentHostGitService][computeFileDiffsBetweenRefs] Failed to compute file diffs ${repositoryRoot.toString()}, ${options.fromRef}, ${options.toRef}: ${err}`); return undefined; } - - return parseGitDiffRawNumstat(raw, repositoryRoot, options.sessionUri, fromOid, toOid); } private async _computeSessionGitState(workingDirectory: URI): Promise { - // Bail fast if not inside a git work tree. - const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']); - if (inside?.trim() !== 'true') { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { return undefined; } @@ -439,9 +437,9 @@ export class AgentHostGitService implements IAgentHostGitService { remotesOutput, defaultBranchRef, ] = await Promise.all([ - this._runGit(workingDirectory, ['status', '-b', '--porcelain=v2']), - this._runGit(workingDirectory, ['remote', '-v']), - this._runGit(workingDirectory, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']), + this._runGit(repositoryRoot, ['status', '-b', '--porcelain=v2']), + this._runGit(repositoryRoot, ['remote', '-v']), + this._runGit(repositoryRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']), ]); const status = parseGitStatusV2(statusOutput); @@ -458,7 +456,7 @@ export class AgentHostGitService implements IAgentHostGitService { // actually cares about for "is there work to PR?". let outgoingChanges = status.outgoingChanges; if (outgoingChanges === undefined && baseBranchName && status.branchName && status.branchName !== baseBranchName) { - const ahead = await this._runGit(workingDirectory, ['rev-list', '--count', `${baseBranchName}..HEAD`]); + const ahead = await this._runGit(repositoryRoot, ['rev-list', '--count', `${baseBranchName}..HEAD`]); const parsed = ahead === undefined ? NaN : Number(ahead.trim()); if (Number.isFinite(parsed)) { outgoingChanges = parsed; diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index fc6ace3b20a5a..7c71c1d7712b3 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -5,101 +5,190 @@ import { equals as objectEquals } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; +import { Emitter } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; -import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri, formatSessionChangesetDescription } from '../common/changesetUri.js'; -import { readSessionGitState, withSessionGitState, type Changeset, type ISessionGitState } from '../common/state/sessionState.js'; +import { IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE } from '../common/agentHostGitStateService.js'; +import { ISessionGitHubState, readSessionGitHubState, readSessionGitState, SessionLifecycle, withSessionGitHubState, withSessionGitState, type ISessionGitState } from '../common/state/sessionState.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; +import { GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { ThrottlerByKey, timeout } from '../../../base/common/async.js'; +import { isCancellationError } from '../../../base/common/errors.js'; -export class AgentHostGitStateService implements IAgentHostGitStateService { +export class AgentHostGitStateService extends Disposable implements IAgentHostGitStateService { declare readonly _serviceBrand: undefined; + private readonly _onDidRefreshSessionGitState = this._register(new Emitter()); + readonly onDidRefreshSessionGitState = this._onDidRefreshSessionGitState.event; + + private readonly _gitStateRefreshThrottler = this._register(new ThrottlerByKey()); + private readonly _gitStateRefreshCancellationTokenSource = new CancellationTokenSource(); + constructor( private readonly _stateManager: AgentHostStateManager, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, + @IAgentService private readonly _agentService: IAgentService, @ILogService private readonly _logService: ILogService, - ) { } + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + ) { + super(); - async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { - if (!workingDirectory) { - const workingDirectoryStr = this._stateManager.getSessionState(sessionKey)?.summary.workingDirectory; - if (workingDirectoryStr) { - workingDirectory = URI.parse(workingDirectoryStr); - } + this._register(toDisposable(() => this._gitStateRefreshCancellationTokenSource.dispose(true))); + } + + async attachSessionGitHubPullRequest(sessionKey: string): Promise { + const state = this._stateManager.getSessionState(sessionKey); + if (!state) { + return; } - if (!workingDirectory) { - return null; + // New session + if (state.lifecycle !== SessionLifecycle.Ready) { + return; + } + + // GitHub state + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + if (!gitHubState?.owner || !gitHubState?.repo || gitHubState?.pullRequestUrl) { + return; + } + + // Git state + const gitState = readSessionGitState(state._meta); + if (!gitState?.branchName || (gitState.branchName === gitState.baseBranchName)) { + return; } try { - const gitState = await this._gitService.getSessionGitState(workingDirectory); - if (!gitState) { - this._stripGitOnlyChangesetEntries(sessionKey); - return null; + const authToken = this._agentService.getAuthToken({ + resource: GITHUB_REPO_PROTECTED_RESOURCE.resource, + scopes: GITHUB_REPO_PROTECTED_RESOURCE.scopes_supported, + }); + if (!authToken) { + return; } - const current = this._stateManager.getSessionState(sessionKey)?._meta; - if (objectEquals(readSessionGitState(current), gitState)) { - return undefined; + const signal = new AbortController().signal; + const pr = await this._octoKitService.findPullRequestByHeadBranch( + gitHubState.owner, gitHubState.repo, gitState.branchName, authToken, signal); + if (!pr?.url) { + return; } - this._setSessionGitState(sessionKey, gitState); - return gitState; - } catch (e) { - this._logService.warn(`[AgentHostGitStateService][refreshSessionGitState] Failed to compute git state for ${sessionKey}`, e); - return null; + this.setSessionGitHubState(sessionKey, { + owner: gitHubState.owner, + repo: gitHubState.repo, + pullRequestUrl: pr.url + } satisfies ISessionGitHubState); + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][attachSessionGitHubPullRequest] Failed to find pull request for ${sessionKey}`, error); } } - private _setSessionGitState(sessionKey: string, gitState: ISessionGitState): void { - const current = this._stateManager.getSessionState(sessionKey)?._meta; - this._stateManager.setSessionMeta(sessionKey, withSessionGitState(current, gitState)); - this._updateBranchChangesetDescription(sessionKey, gitState); - } + async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { + if (!workingDirectory) { + const workingDirectoryStr = this._stateManager.getSessionState(sessionKey)?.workingDirectory; + if (workingDirectoryStr) { + workingDirectory = URI.parse(workingDirectoryStr); + } + } - private _stripGitOnlyChangesetEntries(sessionKey: string): void { - const state = this._stateManager.getSessionState(sessionKey); - const current = state?.changesets; - if (!current || current.length === 0) { + if (!workingDirectory) { return; } - const branchUri = buildSessionChangesetUri(sessionKey); - const uncommittedUri = buildUncommittedChangesetUri(sessionKey); - const filtered = current.filter((c: Changeset) => c.uriTemplate !== branchUri && c.uriTemplate !== uncommittedUri); - if (filtered.length === current.length) { + + await this._gitStateRefreshThrottler.queue(sessionKey, async () => { + try { + this._logService.trace(`[AgentHostGitStateService][refreshSessionGitState] Refreshing git state for ${sessionKey}, ${workingDirectory?.fsPath}`); + + const gitState = await this._gitService.getSessionGitState(workingDirectory); + if (gitState) { + const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; + if (!objectEquals(readSessionGitState(currentMeta), gitState)) { + // Update the session's git state + await this._setSessionGitState(sessionKey, gitState); + + // Update the session's GitHub state + if (gitState.githubOwner && gitState.githubRepo) { + await this.setSessionGitHubState(sessionKey, { + owner: gitState.githubOwner, + repo: gitState.githubRepo + } satisfies ISessionGitHubState); + } + } + } + + this._onDidRefreshSessionGitState.fire(sessionKey); + + // We want to ensure that we refresh the git state at + // most every 5 seconds in order to avoid excessive git + // operations and excessive traffic between the server + // and the client(s). + await timeout(5_000, this._gitStateRefreshCancellationTokenSource.token); + } catch (error) { + if (isCancellationError(error)) { + return; + } + + this._logService.warn(`[AgentHostGitStateService][refreshSessionGitState] Failed to compute git state for ${sessionKey}:`, error); + } + }); + } + + async setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise { + const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; + + const currentState = readSessionGitHubState(currentMeta); + const nextState = { ...(currentState ?? {}), ...state } satisfies ISessionGitHubState; + + if (objectEquals(currentState, nextState)) { return; } - this._stateManager.setSessionChangesets(sessionKey, filtered); + + // Update session state manager + const nextMeta = withSessionGitHubState(currentMeta, nextState); + this._stateManager.setSessionMeta(sessionKey, nextMeta); + + // Update session database + await this._saveSessionState(sessionKey, META_GITHUB_STATE, JSON.stringify(nextState)); + } + + private async _setSessionGitState(sessionKey: string, gitState: ISessionGitState): Promise { + // Update session state manager + const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; + const nextMeta = withSessionGitState(currentMeta, gitState); + this._stateManager.setSessionMeta(sessionKey, nextMeta); + + // Update session database + await this._saveSessionState(sessionKey, META_GIT_STATE, JSON.stringify(gitState)); } - private _updateBranchChangesetDescription(sessionKey: string, gitState: ISessionGitState): void { - const description = formatSessionChangesetDescription(gitState); + private async _saveSessionState(sessionKey: string, key: string, value: string): Promise { + // Skip saving session state if the session is not materialized const state = this._stateManager.getSessionState(sessionKey); - const current = state?.changesets; - if (!current || current.length === 0) { + if (state?.lifecycle === SessionLifecycle.Creating) { return; } - const branchUri = buildBranchChangesetUri(sessionKey); - let changed = false; - const next = current.map((c: Changeset) => { - if (c.uriTemplate !== branchUri) { - return c; - } - if (c.description === description) { - return c; - } - changed = true; - if (description === undefined) { - const { description: _omit, ...rest } = c; - return rest; - } - return { ...c, description }; - }); - if (!changed) { + + let databaseRef; + try { + databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionKey)); + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to open session database for ${sessionKey}`, error); return; } - this._stateManager.setSessionChangesets(sessionKey, next); + + try { + await databaseRef.object.setMetadata(key, value); + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to persist ${key}`, error); + } finally { + databaseRef.dispose(); + } } } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 20796dd4debdb..ab3f0461cd889 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -7,7 +7,7 @@ import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, type Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { joinPath } from '../../../base/common/resources.js'; import { isWindows } from '../../../base/common/platform.js'; @@ -15,7 +15,8 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledEnvVar, AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; +import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentService } from './agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; @@ -28,7 +29,9 @@ import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { ByokLmProxyService, IByokLmProxyService } from './copilot/byokLmProxyService.js'; +import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; @@ -64,6 +67,7 @@ import { IEditSurvivalReporterFactory, EditSurvivalReporterFactory } from './sha import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, createAgentHostClientResourceConnection } from '../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, createAgentHostClientByokLmConnection } from '../common/agentHostClientByokLmChannel.js'; import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { AgentHostGitService } from './agentHostGitService.js'; @@ -131,6 +135,17 @@ async function startAgentHost(): Promise { // Create the real service implementation that lives in this process let agentService: AgentService; let instantiationService: IInstantiationService; + // Hoisted out of the `try` below so the protocol handlers (constructed + // after the block) can forward agent-SDK download progress to clients. + let sdkDownloadProgress: Event | undefined; + let byokLmBridgeRegistry: ByokLmBridgeRegistry; + // Gate BYOK *use* behind the opt-in `chat.agentHost.byokModels.enabled` + // setting, forwarded from the renderer as an env var. The proxy and bridge + // registry are always constructed below (so the session launcher can inject + // them), but when off they stay inert: the per-connection bridge and the + // renderer's BYOK server channel are not wired, so the registry stays empty + // and the proxy never binds. + const byokLmEnabled = isAgentEnabled(process.env[AgentHostByokModelsEnabledEnvVar], false); try { // Build the DI container early so the git service can be created via // `createInstance` (it needs IFileService + INativeEnvironmentService). @@ -161,8 +176,9 @@ async function startAgentHost(): Promise { // Register the agent SDK downloader BEFORE any service that injects it // (ClaudeAgentSdkService and CodexAgent below). The downloader resolves // dev-override env var → on-disk cache → product.agentSdks download. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); diServices.set(ICopilotApiService, copilotApiService); diServices.set(ICopilotBranchNameGenerator, instantiationService.createInstance(CopilotBranchNameGenerator)); @@ -172,6 +188,15 @@ async function startAgentHost(): Promise { diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); diServices.set(ICodexProxyService, codexProxyService); + // BYOK language-model proxy + bridge registry. Always registered so the + // session launcher can inject them, but BYOK *use* is gated: the + // per-connection bridge below (and the renderer's server channel) are only + // wired when `chat.agentHost.byokModels.enabled` is on, so the registry + // stays empty and the proxy never binds when the feature is off. + byokLmBridgeRegistry = new ByokLmBridgeRegistry(); + diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); + const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); + diServices.set(IByokLmProxyService, byokLmProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); @@ -194,20 +219,55 @@ async function startAgentHost(): Promise { // so the bare-import path in `ClaudeAgentSdkService._loadSdk` // always succeeds in dev; in built products the SDK ships via // `product.agentSdks.claude` and the downloader handles it. Codex - // has no equivalent dev path yet, so it still requires either the + // is likewise a devDependency, so `CodexAgent._resolveSdkRoot` + // resolves it from `node_modules` in dev; built products use the // env-var override or a `product.agentSdks.codex` entry. // If either gate fails, the provider is not registered and never appears // in the agent picker (matches the pre-CDN UX exactly). if (isAgentEnabled(process.env[AgentHostClaudeAgentEnabledEnvVar], true) && (!environmentService.isBuilt || agentSdkDownloader.isAvailable(ClaudeSdkPackage))) { agentService.registerProvider(instantiationService.createInstance(ClaudeAgent)); } - if (isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false) && agentSdkDownloader.isAvailable(CodexSdkPackage)) { - agentService.registerProvider(instantiationService.createInstance(CodexAgent)); + // Codex registration is one-way (register-on-enable): the env-var toggle + // or the renderer-forwarded `codexAgentEnabled` root config enables it. + // Disabling requires an agent host restart. + if (!environmentService.isBuilt || agentSdkDownloader.isAvailable(CodexSdkPackage)) { + const agentConfigurationService = agentService.configurationService; + let codexRegistered = false; + const registerCodexIfEnabled = () => { + if (codexRegistered) { + return; + } + const enabledByEnv = isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false); + const enabledByRootConfig = agentConfigurationService.getRootValue(platformRootSchema, AgentHostCodexEnabledConfigKey) === true; + if (enabledByEnv || enabledByRootConfig) { + codexRegistered = true; + agentService.registerProvider(instantiationService.createInstance(CodexAgent)); + } + }; + registerCodexIfEnabled(); + disposables.add(agentConfigurationService.onDidRootConfigChange(() => registerCodexIfEnabled())); } } catch (err) { logService.error('Failed to create AgentService', err); throw err; } + + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both the local (IPC) and any external (WebSocket) renderer + // receive them via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); @@ -219,8 +279,9 @@ async function startAgentHost(): Promise { disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); // Wire reverse-RPC for in-process renderer connections. The renderer's - // `MessagePortClient` ctx is its `clientId`, and it exposes - // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` for filesystem reads. + // `MessagePortClient` ctx is its `clientId`, and it exposes the + // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` (filesystem reads) and + // `AGENT_HOST_CLIENT_BYOK_LM_CHANNEL` (BYOK language-model calls). if (server instanceof UtilityProcessServer) { const authorityRegistrations = new Map(); const registerConnection = (connection: (typeof server.connections)[number]) => { @@ -231,9 +292,18 @@ async function startAgentHost(): Promise { if (typeof clientId !== 'string' || !clientId) { return; } - const channel = server.getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, c => c.ctx === clientId); - const fsConnection = createAgentHostClientResourceConnection(channel); - authorityRegistrations.set(connection, clientFileSystemProvider.registerAuthority(clientId, fsConnection)); + const connectionStore = new DisposableStore(); + const getChannel = (channelName: string) => server.getChannel(channelName, c => c.ctx === clientId); + const fsConnection = createAgentHostClientResourceConnection(getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL)); + connectionStore.add(clientFileSystemProvider.registerAuthority(clientId, fsConnection)); + // BYOK bridge is gated: only wire it when the feature is enabled, so + // the registry stays empty (and the launcher synthesizes no BYOK + // providers/models) when `chat.agentHost.byokModels.enabled` is off. + if (byokLmEnabled && byokLmBridgeRegistry) { + const byokLmConnection = createAgentHostClientByokLmConnection(getChannel(AGENT_HOST_CLIENT_BYOK_LM_CHANNEL)); + connectionStore.add(byokLmBridgeRegistry.register(clientId, byokLmConnection)); + } + authorityRegistrations.set(connection, connectionStore); }; disposables.add(server.onDidAddConnection(registerConnection)); disposables.add(server.onDidRemoveConnection(connection => { diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index dd4429a3452fe..2d991dfb3717d 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -6,19 +6,35 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; -import { readSessionGitState, type ChangesetOperationFollowUp, type SessionState } from '../common/state/sessionState.js'; +import { readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type ISessionFileDiff, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; -import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; +import { type AutoMergeMethod, type CreatedPullRequest, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; +import { buildConversationContext } from '../common/agentHostConversationContext.js'; + +/** + * Soft upper bound, in characters, for the conversation context fed to the + * utility model when generating a PR title and description. Sized to stay + * within the small model's context window while leaving room for the changed + * file summary and prompt scaffolding. + */ +const MAX_PR_CONVERSATION_CONTEXT_CHARS = 12_000; + +/** + * Soft upper bound, in characters, for the changed-file summary fed to the + * utility model when generating a PR title and description. + */ +const MAX_PR_CHANGE_SUMMARY_CHARS = 4_000; export interface PullRequestCreatedEvent { readonly sessionKey: string; - readonly branchName: string; + readonly pullRequestUrl: string; } /** @@ -44,14 +60,19 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation public static readonly OPERATION_CREATE_PR = 'create-pr'; public static readonly OPERATION_CREATE_DRAFT_PR = 'create-draft-pr'; + public static readonly OPERATION_CREATE_PR_AUTO_MERGE = 'create-pr-auto-merge'; + public static readonly OPERATION_CREATE_PR_AUTO_SQUASH = 'create-pr-auto-squash'; + public static readonly OPERATION_CREATE_PR_AUTO_REBASE = 'create-pr-auto-rebase'; constructor( private readonly _draft: boolean, - private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, + private readonly _autoMergeMethod: AutoMergeMethod | undefined, + private readonly _getSessionState: (sessionKey: string) => ISessionWithDefaultChat | undefined, private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void, @IAgentService private readonly _agentService: IAgentService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @ILogService private readonly _logService: ILogService, ) { } @@ -81,26 +102,27 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.summary.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectory; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } - const workingDirectory = URI.parse(workingDirectoryStr); - const gitState = readSessionGitState(sessionState._meta); - if (!gitState?.hasGitHubRemote || !gitState.githubOwner || !gitState.githubRepo) { + const gitHubState = readSessionGitHubState(sessionState._meta); + if (!gitHubState?.owner || !gitHubState?.repo) { throw new ProtocolError( JsonRpcErrorCodes.InternalError, `Session's working directory is not a GitHub-backed git repo: ${sessionUri}`, ); } - const branchName = gitState.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); + const workingDirectory = URI.parse(workingDirectoryStr); + const gitState = readSessionGitState(sessionState._meta); + const branchName = gitState?.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); if (!branchName) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`); } - const baseBranchName = gitState.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory); + const baseBranchName = gitState?.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory); if (!baseBranchName) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`); } @@ -152,23 +174,24 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation } this._throwIfCancelled(token); - const title = this._formatTitle(branchName); - const body = this._formatBody(branchName, base); - - const existing = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + const existing = await this._octoKitService.findPullRequestByHeadBranch(gitHubState.owner, gitHubState.repo, branchName, authToken, signal); if (existing) { this._throwIfCancelled(token); - this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); - return this._createResult(existing, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", existing.number, existing.url)); + return await this._finalize(existing, true, sessionUri, gitHubState.owner, gitHubState.repo, authToken, signal, token); } this._throwIfCancelled(token); - this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitState.githubOwner}/${gitState.githubRepo} ${branchName} -> ${base}`); - let created: { readonly url: string; readonly number: number }; + const generated = await this._generateTitleAndDescription(sessionState, branchName, base, branchChanges, signal, token); + this._throwIfCancelled(token); + const title = generated?.title ?? this._formatTitle(branchName); + const body = generated?.description ?? this._formatBody(branchName, base); + + this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${branchName} -> ${base}`); + let created: CreatedPullRequest; try { created = await this._octoKitService.createPullRequest( - gitState.githubOwner, - gitState.githubRepo, + gitHubState.owner, + gitHubState.repo, title, body, branchName, @@ -179,27 +202,103 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation ); } catch (err) { this._throwIfCancelled(token); - let foundAfterFailure: { readonly url: string; readonly number: number } | undefined; + let foundAfterFailure: CreatedPullRequest | undefined; try { - foundAfterFailure = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + foundAfterFailure = await this._octoKitService.findPullRequestByHeadBranch(gitHubState.owner, gitHubState.repo, branchName, authToken, signal); } catch { this._throwIfCancelled(token); throw err; } if (foundAfterFailure) { this._throwIfCancelled(token); - this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); - return this._createResult(foundAfterFailure, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", foundAfterFailure.number, foundAfterFailure.url)); + return await this._finalize(foundAfterFailure, true, sessionUri, gitHubState.owner, gitHubState.repo, authToken, signal, token); } throw err; } this._throwIfCancelled(token); - const message = this._draft - ? localize('agentHost.changeset.pr.createdDraft', "Created draft pull request [#{0}]({1}).", created.number, created.url) - : localize('agentHost.changeset.pr.created', "Created pull request [#{0}]({1}).", created.number, created.url); + return await this._finalize(created, false, sessionUri, gitHubState.owner, gitHubState.repo, authToken, signal, token); + } + + /** + * Notifies listeners that the pull request now exists, optionally enables + * auto-merge with the configured {@link AutoMergeMethod} (best-effort: a + * failure to enable auto-merge does not fail the operation), and builds the + * result message describing what happened. + */ + private async _finalize( + pr: CreatedPullRequest, + isExisting: boolean, + sessionUri: string, + owner: string, + repo: string, + authToken: string, + signal: AbortSignal, + token: CancellationToken, + ): Promise { + if (!this._autoMergeMethod) { + // No auto-merge configured + this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url }); + return this._createResult(pr, this._buildMessage(pr, isExisting, 'none', undefined)); + } + + let autoMergeError: string | undefined; + let autoMergeOutcome: 'none' | 'enabled' | 'failed' = 'none'; - this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); - return this._createResult(created, message); + if (pr.nodeId) { + try { + await this._octoKitService.enablePullRequestAutoMerge(pr.nodeId, this._autoMergeMethod, authToken, signal); + autoMergeOutcome = 'enabled'; + } catch (err) { + this._throwIfCancelled(token); + autoMergeError = err instanceof Error ? err.message : String(err); + autoMergeOutcome = 'failed'; + this._logService.warn(`[AgentHostPullRequestOperationHandler] Failed to enable auto-merge for ${owner}/${repo}#${pr.number}: ${autoMergeError}`); + } + } else { + autoMergeError = localize('agentHost.changeset.pr.autoMerge.noNodeId', "the pull request identifier was not returned by GitHub."); + autoMergeOutcome = 'failed'; + this._logService.warn(`[AgentHostPullRequestOperationHandler] Cannot enable auto-merge for ${owner}/${repo}#${pr.number}: missing pull request node id`); + } + + this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url }); + return this._createResult(pr, this._buildMessage(pr, isExisting, autoMergeOutcome, autoMergeError)); + } + + private _buildMessage(pr: CreatedPullRequest, isExisting: boolean, autoMergeOutcome: 'none' | 'enabled' | 'failed', autoMergeError: string | undefined): string { + let mergeMethodLabel: string | undefined; + switch (this._autoMergeMethod) { + case 'SQUASH': + mergeMethodLabel = localize('agentHost.changeset.pr.autoMerge.squash', "squash"); + break; + case 'REBASE': + mergeMethodLabel = localize('agentHost.changeset.pr.autoMerge.rebase', "rebase"); + break; + default: + mergeMethodLabel = localize('agentHost.changeset.pr.autoMerge.merge', "merge"); + break; + } + + if (isExisting) { + switch (autoMergeOutcome) { + case 'enabled': + return localize('agentHost.changeset.pr.existing.autoMerge', "Pull request [#{0}]({1}) already exists; enabled auto-merge ({2}).", pr.number, pr.url, mergeMethodLabel); + case 'failed': + return localize('agentHost.changeset.pr.existing.autoMergeFailed', "Pull request [#{0}]({1}) already exists, but auto-merge could not be enabled: {2}", pr.number, pr.url, autoMergeError ?? ''); + default: + return localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", pr.number, pr.url); + } + } + + switch (autoMergeOutcome) { + case 'enabled': + return localize('agentHost.changeset.pr.created.autoMerge', "Created pull request [#{0}]({1}) with auto-merge ({2}) enabled.", pr.number, pr.url, mergeMethodLabel); + case 'failed': + return localize('agentHost.changeset.pr.created.autoMergeFailed', "Created pull request [#{0}]({1}), but auto-merge could not be enabled: {2}", pr.number, pr.url, autoMergeError ?? ''); + default: + return this._draft + ? localize('agentHost.changeset.pr.createdDraft', "Created draft pull request [#{0}]({1}).", pr.number, pr.url) + : localize('agentHost.changeset.pr.created', "Created pull request [#{0}]({1}).", pr.number, pr.url); + } } private _throwIfCancelled(token: CancellationToken): void { @@ -227,6 +326,151 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation return localize('agentHost.changeset.pr.body', "Created from `{0}` targeting `{1}`.", branchName, baseBranchName); } + /** + * Best-effort generation of a PR title and description using the utility + * model. The model is given the main session conversation (only the + * markdown text of user requests and agent responses — tool calls, + * subagents, and reasoning are excluded and the text is character-bounded) + * along with a summary of the changed files. Returns `undefined` when no + * Copilot token is available or generation fails, so the caller can fall + * back to the branch-name based title/description. PR creation must never + * fail just because the model is unavailable. + */ + private async _generateTitleAndDescription( + sessionState: ISessionWithDefaultChat, + branchName: string, + base: string, + branchChanges: readonly ISessionFileDiff[], + signal: AbortSignal, + token: CancellationToken, + ): Promise<{ title: string; description: string } | undefined> { + const copilotToken = this._agentService.getAuthToken({ + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, + scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported, + }); + if (!copilotToken) { + return undefined; + } + + const conversation = buildConversationContext(sessionState.turns, { maxChars: MAX_PR_CONVERSATION_CONTEXT_CHARS }); + const changeSummary = this._summarizeDiffsForPrompt(branchChanges); + if (!conversation && !changeSummary) { + return undefined; + } + + try { + const raw = await this._copilotApiService.utilityChatCompletion(copilotToken, { + messages: this._buildTitleAndDescriptionPrompt(branchName, base, conversation, changeSummary), + }, { signal }); + this._throwIfCancelled(token); + return this._parseTitleAndDescription(raw); + } catch (err) { + if (token.isCancellationRequested) { + return undefined; + } + this._logService.warn(`[AgentHostPullRequestOperationHandler] Failed to generate PR title and description: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + } + + private _buildTitleAndDescriptionPrompt(branchName: string, base: string, conversation: string | undefined, changeSummary: string): ICopilotUtilityChatMessage[] { + const userSections: string[] = [ + `Branch: ${branchName}`, + `Base branch: ${base}`, + ]; + if (changeSummary) { + userSections.push(`Changed files:\n${changeSummary}`); + } + if (conversation) { + userSections.push(`Conversation (the request that produced these changes):\n${conversation}`); + } + return [ + { + role: 'system', + content: [ + 'You write clear, concise GitHub pull request titles and descriptions.', + 'The first line of your reply is the PR title: a short imperative summary under 72 characters, with no "Title:" prefix, no surrounding quotes, and no markdown heading.', + 'After the title, add one blank line, then write the PR description in GitHub-flavored markdown.', + 'Summarize what changed and why, grounded in the conversation and changed files. Use a short paragraph and/or bullet points.', + 'Do not invent changes that are not supported by the provided context, and do not wrap the whole reply in code fences.', + ].join(' '), + }, + { + role: 'user', + content: userSections.join('\n\n'), + }, + ]; + } + + private _summarizeDiffsForPrompt(diffs: readonly ISessionFileDiff[]): string { + const lines: string[] = []; + let length = 0; + for (const diff of diffs) { + const before = diff.before?.uri; + const after = diff.after?.uri; + const path = after ?? before ?? '(unknown)'; + let kind = 'Edit'; + if (!before && after) { + kind = 'Create'; + } else if (before && !after) { + kind = 'Delete'; + } else if (before && after && before !== after) { + kind = 'Rename'; + } + const line = `- ${kind}: ${this._displayUri(path)} (+${diff.diff?.added ?? 0} -${diff.diff?.removed ?? 0})`; + lines.push(line); + // `+ 1` accounts for the newline that joins this line to the previous one. + length += line.length + (lines.length > 1 ? 1 : 0); + if (length > MAX_PR_CHANGE_SUMMARY_CHARS) { + lines.push('[file list truncated]'); + break; + } + } + return lines.join('\n'); + } + + private _displayUri(uri: string): string { + try { + const parsed = URI.parse(uri); + return parsed.scheme === 'file' ? parsed.fsPath : parsed.path || uri; + } catch { + return uri; + } + } + + private _parseTitleAndDescription(raw: string): { title: string; description: string } | undefined { + let text = raw.trim().replace(/\r\n/g, '\n'); + const fenced = /^```(?:markdown|md|text)?\s*([\s\S]*?)\s*```$/i.exec(text); + if (fenced) { + text = fenced[1].trim(); + } + if (!text) { + return undefined; + } + + const lines = text.split('\n'); + let i = 0; + while (i < lines.length && lines[i].trim().length === 0) { + i++; + } + if (i >= lines.length) { + return undefined; + } + + const title = lines[i].trim() + .replace(/^#+\s*/, '') + .replace(/^title:\s*/i, '') + .trim() + .replace(/^"(?.+)"$/, (_match, inner) => inner) + .trim(); + if (!title) { + return undefined; + } + + const description = lines.slice(i + 1).join('\n').trim().replace(/^description:\s*/i, '').trim(); + return { title, description }; + } + private _createResult(created: { readonly url: string; readonly number: number }, message: string): InvokeChangesetOperationResult { const followUp: ChangesetOperationFollowUp = { content: { uri: created.url, contentType: 'text/html' }, diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index d03583512e729..4d321d0fcf6d0 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -3,48 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { disposableTimeout } from '../../../base/common/async.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js'; import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } from './agentHostPullRequestOperationHandler.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; -const OPTIMISTIC_PR_CREATED_CACHE_TTL = 30_000; - -/** - * Owns PR-specific changeset operation availability. - * - * The optimistic cache is intentionally in-memory only. It hides Create PR - * immediately after a successful create/reuse while the normal git/session - * refresh catches up; persisted PR metadata remains out of scope. - */ export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { - private readonly _optimisticCreatedPullRequests = this._register(new DisposableMap()); private _registry: IChangesetOperationRegistry | undefined; - readonly onPullRequestCreated = (event: PullRequestCreatedEvent): void => { - const key = this._key(event.sessionKey, event.branchName); - this._optimisticCreatedPullRequests.set(key, disposableTimeout(() => { - this._optimisticCreatedPullRequests.deleteAndDispose(key); - this._registry?.onDidChangeOperations(event.sessionKey); - }, OPTIMISTIC_PR_CREATED_CACHE_TTL)); - - this._registry?.onDidChangeOperations(event.sessionKey); - this._registry?.refreshSessionGitState(event.sessionKey).finally(() => { - if (this._optimisticCreatedPullRequests.has(key)) { - this._optimisticCreatedPullRequests.deleteAndDispose(key); - this._registry?.onDidChangeOperations(event.sessionKey); - } - }); - }; - constructor( private readonly _stateManager: AgentHostStateManager, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService ) { super(); } @@ -53,45 +28,83 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem this._registry = registry; const store = new DisposableStore(); const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); - const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, getSessionState, this.onPullRequestCreated.bind(this)); - const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, getSessionState, this.onPullRequestCreated.bind(this)); + const onCreated = (event: PullRequestCreatedEvent) => this._onPullRequestCreated(event); + const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, getSessionState, onCreated); + const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, undefined, getSessionState, onCreated); + const createAutoMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'MERGE', getSessionState, onCreated); + const createAutoSquashPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'SQUASH', getSessionState, onCreated); + const createAutoRebasePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'REBASE', getSessionState, onCreated); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_MERGE, createAutoMergePrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_SQUASH, createAutoSquashPrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_REBASE, createAutoRebasePrHandler)); store.add({ dispose: () => { this._registry = undefined; } }); return store; } - getOperations({ sessionKey, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (gitState.branchName && this._optimisticCreatedPullRequests.has(this._key(sessionKey, gitState.branchName))) { + getOperations({ gitState, gitHubState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + if (gitHubState?.pullRequestUrl) { return undefined; } - const hasChanges = (gitState.outgoingChanges ?? 0) > 0 || (gitState.uncommittedChanges ?? 0) > 0; - if (!gitState.hasGitHubRemote || !hasChanges) { + const outgoingChanges = gitState?.outgoingChanges ?? 0; + const uncommittedChanges = gitState?.uncommittedChanges ?? 0; + const hasChanges = outgoingChanges > 0 || uncommittedChanges > 0; + if (!gitState?.hasGitHubRemote || !hasChanges) { return undefined; } - return [ - { - id: 'create-pr', - label: localize('agentHost.changeset.createPR', "Create Pull Request"), - icon: 'git-pull-request-create', - group: 'pull-request', - scopes: [ChangesetOperationScope.Changeset], - status: ChangesetOperationStatus.Idle, - }, - { - id: 'create-draft-pr', - label: localize('agentHost.changeset.createDraftPR', "Create Draft Pull Request"), - icon: 'git-pull-request-draft', - group: 'pull-request', - scopes: [ChangesetOperationScope.Changeset], - status: ChangesetOperationStatus.Idle, - }, - ] satisfies ChangesetOperation[]; + return [{ + id: 'create-pr', + label: localize('agentHost.changeset.createPR', "Create Pull Request"), + icon: 'git-pull-request-create', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }, + { + id: 'create-pr-auto-merge', + label: localize('agentHost.changeset.createPRAutoMerge', "Create Pull Request (Auto-Merge)"), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }, + { + id: 'create-pr-auto-squash', + label: localize('agentHost.changeset.createPRAutoSquash', "Create Pull Request (Auto-Squash)"), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }, + { + id: 'create-pr-auto-rebase', + label: localize('agentHost.changeset.createPRAutoRebase', "Create Pull Request (Auto-Rebase)"), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }, + { + id: 'create-draft-pr', + label: localize('agentHost.changeset.createDraftPR', "Create Draft Pull Request"), + icon: 'git-pull-request-draft', + group: 'pull-request_draft', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }] satisfies ChangesetOperation[]; } - private _key(sessionKey: string, branchName: string): string { - return `${sessionKey}\n${branchName}`; + private _onPullRequestCreated(event: PullRequestCreatedEvent): void { + const sessionKey = event.sessionKey; + + this._registry?.onDidChangeOperations(sessionKey); + this._registry?.refreshSessionGitState(sessionKey); + + this._gitStateService.setSessionGitHubState(sessionKey, { + pullRequestUrl: event.pullRequestUrl + }); } } diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts new file mode 100644 index 0000000000000..71a41f529d59f --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { ICommonProperties } from '../../telemetry/common/telemetry.js'; + +/** + * Public GitHub Copilot telemetry ingestion keys. These are instrumentation keys, not + * secrets; the iKey selects the destination hydro table: + * - standard -> `copilot_v0_copilot_event` + * - enhanced -> `copilot_v0_restricted_copilot_event` + */ +const GH_STANDARD_IKEY = '7d7048df-6dd0-4048-bb23-b716c1461f8f'; +const GH_ENHANCED_IKEY = '3fdd7f28-937a-48c8-9a21-ba337db23bd1'; + +/** + * Fallback Copilot telemetry endpoint (the dotcom value of the CAPI token's + * `endpoints.telemetry`, with the `/telemetry` path the Copilot CLI/runtime appends). + * Used until {@link IAgentHostRestrictedTelemetry.setRestrictedTelemetryEndpoint} supplies + * the user's discovered endpoint (dotcom, GHE, or proxy). Accepts unauthenticated POSTs. + */ +const GH_TELEMETRY_URL = 'https://copilot-telemetry.githubusercontent.com/telemetry'; + +/** Event names are namespaced by client category; the CTS name filter requires this. */ +const NAMESPACE = 'copilot-chat'; + +export type TelemetryProps = Record; +export type TelemetryMeasurements = Record; + +/** The subset of the global `fetch` used to POST envelopes; injectable so tests avoid live network calls. */ +type FetchFn = typeof globalThis.fetch; + +/** + * App Insights caps a single property value at ~8192 chars. Long values are split across + * numbered keys (`key`, `key_02`, `key_03`, …) so the Copilot Telemetry Service reassembles + * them, mirroring the Copilot extension's `multiplexProperties` so events look identical on the + * wire and downstream. + */ +const MAX_PROPERTY_LENGTH = 8192; +const MAX_CONCATENATED_PROPERTIES = 50; + +export function multiplexProperties(properties: TelemetryProps): TelemetryProps { + const newProperties: TelemetryProps = { ...properties }; + for (const key in properties) { + const value = properties[key]; + let remaining = value?.length ?? 0; + if (remaining > MAX_PROPERTY_LENGTH) { + let lastStartIndex = 0; + let count = 0; + while (remaining > 0 && count < MAX_CONCATENATED_PROPERTIES) { + count += 1; + let propertyName = key; + if (count > 1) { + propertyName = key + '_' + (count < 10 ? '0' : '') + count; + } + let offsetIndex = lastStartIndex + MAX_PROPERTY_LENGTH; + if (remaining < MAX_PROPERTY_LENGTH) { + offsetIndex = lastStartIndex + remaining; + } + newProperties[propertyName] = value!.slice(lastStartIndex, offsetIndex); + remaining -= MAX_PROPERTY_LENGTH; + lastStartIndex += MAX_PROPERTY_LENGTH; + } + } + } + return newProperties; +} + +/** + * The restricted telemetry surface the agent host exposes, mirroring the Copilot extension's + * `ITelemetryService` restricted methods so agent-host code can emit the same GH/MSFT events. + */ +export interface IAgentHostRestrictedTelemetry { + /** GH standard (non-restricted) telemetry -> `copilot_v0_copilot_event`. */ + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** GH enhanced/restricted telemetry (prompts, tools, etc.) -> `copilot_v0_restricted_copilot_event`. */ + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** MSFT-internal telemetry -> Aria/Collector++ (internal-only table). No-op without an internal key. */ + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** Sets the Copilot user tracking id (`copilot_trackingId`) carried on every subsequent event. */ + setCopilotTrackingId(trackingId: string | undefined): void; + /** Overrides the POST endpoint with the user's CAPI `endpoints.telemetry`; falsy restores the default. */ + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void; + /** Enables enhanced GH telemetry once the token opts in (`rt=1`); off by default and on flip/logout. */ + setRestrictedTelemetryEnabled(enabled: boolean): void; +} + +/** + * Emits GitHub Copilot restricted/enhanced telemetry from the agent-host process by POSTing + * Application-Insights envelopes to the Copilot telemetry endpoint (the same wire format the + * Copilot extension uses). Fire-and-forget; failures are logged, never thrown. + */ +export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedTelemetry { + + private readonly _commonProps: TelemetryProps; + + /** + * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Off by + * default so the sole writer to the restricted table never emits for public users — a hard + * safety boundary that holds even if the enclosing service's gate is bypassed. Mirrors the + * Copilot extension, which only creates the restricted reporter for opted-in users. + */ + private _restrictedTelemetryEnabled = false; + + constructor( + commonProperties: ICommonProperties, + private readonly _logService: ILogService, + private _endpointUrl: string = GH_TELEMETRY_URL, + private readonly _internalSink?: (eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements) => void, + private readonly _fetchFn: FetchFn = globalThis.fetch, + ) { + // Map the resolved common properties onto the GH property names the hydro schema reads. + this._commonProps = { + client_machineid: asString(commonProperties['common.machineId']), + client_deviceid: asString(commonProperties['common.devDeviceId']), + client_sessionid: asString(commonProperties['sessionID']), + common_os: asString(commonProperties['common.nodePlatform']) ?? process.platform, + editor_version: asString(commonProperties['version']), + }; + } + + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + this._post(GH_STANDARD_IKEY, eventName, properties, measurements); + } + + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + // Hard safety boundary: enhanced/restricted telemetry is the pipeline that may carry prompt + // and tool content, so the only writer to the restricted table refuses to emit unless the + // user's token opted in (`rt=1`). This holds even if a caller reaches the sender without the + // service-level `rt`/telemetry-level gate. + if (!this._restrictedTelemetryEnabled) { + return; + } + this._post(GH_ENHANCED_IKEY, eventName, properties, measurements); + } + + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + // Internal MSFT telemetry lands in the Aria/Collector++ pipeline via a dedicated key, + // which is not present in the agent-host product config. Route to the optional sink when + // wired; otherwise trace so the event is at least visible in the agent-host log. + if (this._internalSink) { + this._internalSink(eventName, properties, measurements); + return; + } + this._logService.trace(`[ahp-restricted] internal MSFT event (not sent, no internal key): ${eventName}`); + } + + setCopilotTrackingId(trackingId: string | undefined): void { + // `copilot_trackingId` is the Copilot token's `tid` claim: a stable per-user id (one user + // per agent-host process). The Copilot Telemetry Service reads it into the + // `copilot_tracking_id` column, matching the Copilot extension. + this._commonProps.copilot_trackingId = trackingId || undefined; + } + + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + // The user's telemetry host comes from the CAPI `endpoints.telemetry` discovery; fall back + // to the dotcom default when it is unknown so events are never sent to an empty URL. + this._endpointUrl = endpointUrl || GH_TELEMETRY_URL; + } + + setRestrictedTelemetryEnabled(enabled: boolean): void { + this._restrictedTelemetryEnabled = enabled; + } + + private _post(iKey: string, eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + const name = eventName.includes('/') ? eventName : `${NAMESPACE}/${eventName}`; + const envelope = { + ver: 1, + name: `Microsoft.ApplicationInsights.${iKey.replace(/-/g, '')}.Event`, + time: new Date().toISOString(), + sampleRate: 100, + seq: '', + iKey, + tags: { 'ai.operation.id': generateUuid() }, + data: { + baseType: 'EventData', + baseData: { + name, + // `unique_id` is a fresh per-event id (its hydro column is read by the Copilot + // Telemetry Service from the snake_case `unique_id` property, NOT `uniqueId`), + // mirroring the Copilot extension so each emitted event stays individually + // addressable. Placed first so explicit properties still win on collision. + properties: { unique_id: generateUuid(), ...this._commonProps, ...properties }, + measurements: measurements ?? {}, + }, + }, + }; + + this._logService.trace(`[ahp-restricted] emit ${name} (iKey ${iKey.slice(0, 8)})`); + + if (typeof this._fetchFn !== 'function') { + this._logService.warn('[ahp-restricted] global fetch unavailable; telemetry not sent'); + return; + } + + // Fire-and-forget: post the event and move on. Delivery/robustness is intentionally kept + // simple here — failures are logged, not retried (a retry loop would only mask local + // telemetry-blocking resolvers, which do not exist in production). + this._fetchFn(this._endpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-json-stream' }, + body: JSON.stringify(envelope), + }).then(res => { + if (!res.ok) { + this._logService.warn(`[ahp-restricted] ${name} rejected: HTTP ${res.status}`); + } + }).catch(err => { + this._logService.warn(`[ahp-restricted] ${name} POST failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } +} + +function asString(value: string | boolean | undefined): string | undefined { + return typeof value === 'string' ? value : value === undefined ? undefined : String(value); +} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index e91f9817bc346..14961ed37cda7 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -15,6 +15,7 @@ globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta. import * as fs from 'fs'; import * as os from 'os'; +import type { Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { raceTimeout } from '../../../base/common/async.js'; import { joinPath } from '../../../base/common/resources.js'; @@ -34,6 +35,8 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; +import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; @@ -41,7 +44,7 @@ import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentService } from './agentService.js'; @@ -249,6 +252,7 @@ async function main(): Promise { diServices.set(IAgentService, agentService); // Register agents + let sdkDownloadProgress: Event | undefined; if (!options.quiet) { // Production agents (require DI) const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); @@ -273,8 +277,9 @@ async function main(): Promise { process.env[AgentHostCodexAgentSdkRootEnvVar] = options.codexSdkRoot; } // Register the agent SDK downloader BEFORE any service that injects it. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); diServices.set(IClaudeProxyService, claudeProxyService); const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); @@ -283,6 +288,11 @@ async function main(): Promise { diServices.set(ICodexProxyService, codexProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); + // BYOK is unsupported in the remote agent host (no extension host runs + // next to it to serve the renderer LM API). Inject null implementations + // to satisfy CopilotAgent / CopilotSessionLauncher DI. + diServices.set(IByokLmBridgeRegistry, new NullByokLmBridgeRegistry()); + diServices.set(IByokLmProxyService, new NullByokLmProxyService()); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); log('CopilotAgent registered'); @@ -295,20 +305,38 @@ async function main(): Promise { // so the bare-import path in `ClaudeAgentSdkService._loadSdk` // always succeeds in dev; in built/shipped server installs the // SDK comes from the CLI flag / env var dev override or a - // `product.agentSdks.claude` entry. Codex still requires the - // env-var override or product config. + // `product.agentSdks.claude` entry. Codex is likewise a + // devDependency, so `CodexAgent._resolveSdkRoot` resolves it from + // `node_modules` in dev; built/shipped installs use the env-var + // override or `product.agentSdks.codex`. if (isAgentEnabled(process.env[AgentHostClaudeAgentEnabledEnvVar], true) && (!environmentService.isBuilt || agentSdkDownloader.isAvailable(ClaudeSdkPackage))) { const claudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); agentService.registerProvider(claudeAgent); log('ClaudeAgent registered'); } - if (isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false) && agentSdkDownloader.isAvailable(CodexSdkPackage)) { + if (isAgentEnabled(process.env[AgentHostCodexAgentEnabledEnvVar], false) && (!environmentService.isBuilt || agentSdkDownloader.isAvailable(CodexSdkPackage))) { const codexAgent = disposables.add(instantiationService.createInstance(CodexAgent)); agentService.registerProvider(codexAgent); log('CodexAgent registered'); } } + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both local (IPC) and remote (WebSocket) renderers receive them + // via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + if (options.enableMockAgent) { // Dynamic import to avoid bundling test code in production import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 5db0170020524..fecd89057627c 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -9,7 +9,8 @@ import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType } from '../common/state/sessionActions.js'; -import { isAhpChatChannel, isDefaultChatUri, ResponsePartKind, type ResponsePart, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { isAhpChatChannel, isDefaultChatUri, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { buildConversationContext, renderResponseMarkdown, truncateMiddle } from '../common/agentHostConversationContext.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; @@ -77,7 +78,7 @@ export class AgentHostSessionTitleController extends Disposable { } const state = this._stateManager.getSessionState(channel); - if (!state || state.turns.length !== 0 || state.summary.title) { + if (!state || state.turns.length !== 0 || state.title) { return; } @@ -92,7 +93,7 @@ export class AgentHostSessionTitleController extends Disposable { false, fallbackTitle, apply, - () => this._stateManager.getSessionState(channel)?.summary.title === this._lastAppliedTitle.get(channel), + () => this._stateManager.getSessionState(channel)?.title === this._lastAppliedTitle.get(channel), title => this._persistSessionFlag(channel, 'customTitle', title), ); } @@ -143,7 +144,7 @@ export class AgentHostSessionTitleController extends Disposable { return; } const lastApplied = this._lastAppliedTitle.get(channel); - if (lastApplied === undefined || state.summary.title !== lastApplied) { + if (lastApplied === undefined || state.title !== lastApplied) { return; } const context = this._buildFirstTurnContext(state.turns[0]); @@ -160,7 +161,62 @@ export class AgentHostSessionTitleController extends Disposable { true, lastApplied, apply, - () => this._stateManager.getSessionState(channel)?.summary.title === this._lastAppliedTitle.get(channel), + () => this._stateManager.getSessionState(channel)?.title === this._lastAppliedTitle.get(channel), + title => this._persistSessionFlag(channel, 'customTitle', title), + ); + } + + /** + * Generates a title for a freshly forked session or chat from its + * inherited conversation context. Forks copy the source history up to the + * fork point, so neither {@link seedTitleFromFirstMessage} nor + * {@link refineTitleFromFirstTurn} (which require an empty / single-turn + * state) ever fire for them. This is the fork equivalent, run once at fork + * time over the kept turns, so the new chat gets a content-derived title + * instead of permanently inheriting the source's `Forked: …` title. + * + * `fallbackTitle` is the title the caller already applied to the new + * session/chat (e.g. `Forked: `); it is recorded as the + * last-applied title so a concurrent manual rename suppresses the + * generated title, and stays visible until generation completes. The + * context is bounded to {@link MAX_TITLE_CONTEXT_CHARS} (middle-truncated), + * so generation costs at most a single small-model call. + */ + generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void { + const context = this._buildConversationContext(turns, sourceTitle); + if (!context) { + return; + } + + const isAdditionalChat = !!chatChannel && isAhpChatChannel(chatChannel) && !isDefaultChatUri(chatChannel); + if (isAdditionalChat) { + const key = chatChannel; + this._lastAppliedTitle.set(key, fallbackTitle); + const apply = (title: string) => this._applyTitle(key, title, t => this._stateManager.updateChatTitle(channel, key, t)); + this._generateTitleSoon( + key, + context, + true, + fallbackTitle, + apply, + () => this._stateManager.getChatState(key)?.title === this._lastAppliedTitle.get(key), + title => this._persistSessionFlag(channel, `customChatTitle:${key}`, title), + ); + return; + } + + this._lastAppliedTitle.set(channel, fallbackTitle); + const apply = (title: string) => this._applyTitle(channel, title, t => this._stateManager.dispatchServerAction(channel, { + type: ActionType.SessionTitleChanged, + title: t, + })); + this._generateTitleSoon( + channel, + context, + true, + fallbackTitle, + apply, + () => this._stateManager.getSessionState(channel)?.title === this._lastAppliedTitle.get(channel), title => this._persistSessionFlag(channel, 'customTitle', title), ); } @@ -269,6 +325,7 @@ export class AgentHostSessionTitleController extends Disposable { 'Aim for 3-6 words. Prefer the shortest accurate title.', 'Drop articles like "a", "an", and "the" unless needed for clarity.', 'Drop filler and generic framing like "help with", "question about", "request for", or "issue with".', + 'Never describe the chat itself as forked, branched, or continued — title only the underlying topic.', 'Prefer short, concrete synonyms and omit unnecessary words.', 'Do not wrap the title in quotes or add trailing punctuation.', ].join(' '), @@ -307,7 +364,7 @@ export class AgentHostSessionTitleController extends Disposable { * title in that case). */ private _buildFirstTurnContext(turn: Turn): string | undefined { - const response = this._renderResponseText(turn.responseParts); + const response = renderResponseMarkdown(turn.responseParts); if (!response) { return undefined; } @@ -315,46 +372,37 @@ export class AgentHostSessionTitleController extends Disposable { const userBudget = Math.floor(MAX_TITLE_CONTEXT_CHARS / 2); let userRequest = turn.message.text.trim(); if (userRequest.length > userBudget) { - userRequest = this._truncateMiddle(userRequest, userBudget); + userRequest = truncateMiddle(userRequest, userBudget); } const userBlock = `User request:\n${userRequest}`; const responseLabel = '\n\nAgent response:\n'; const responseBudget = Math.max(0, MAX_TITLE_CONTEXT_CHARS - userBlock.length - responseLabel.length); - const trimmedResponse = response.length > responseBudget ? this._truncateMiddle(response, responseBudget) : response; + const trimmedResponse = response.length > responseBudget ? truncateMiddle(response, responseBudget) : response; return trimmedResponse ? `${userBlock}${responseLabel}${trimmedResponse}` : userBlock; } - private _renderResponseText(parts: readonly ResponsePart[]): string { - const segments: string[] = []; - for (const part of parts) { - if (part.kind === ResponsePartKind.Markdown) { - const text = part.content.trim(); - if (text) { - segments.push(text); - } - } - } - return segments.join('\n\n'); - } - /** - * Truncates `text` to at most `maxChars` characters by removing the middle - * and inserting a `...` marker, preserving the start and end. + * Builds a conversation context string for forked-title generation by + * concatenating each kept turn's user request and textual response. Only + * normal text (markdown) response parts are considered — tool calls, + * reasoning, and other parts are ignored, mirroring + * {@link _buildFirstTurnContext}. When the fork's `sourceTitle` is known, a + * short framing note is prepended so the model understands the conversation + * is a branch continued from an earlier chat. The conversation is + * middle-truncated to {@link MAX_TITLE_CONTEXT_CHARS} to bound model cost; + * the framing note is always preserved in full. + * + * @returns the context string, or `undefined` when no turn carries any + * text worth titling from. */ - private _truncateMiddle(text: string, maxChars: number): string { - if (text.length <= maxChars) { - return text; - } - const marker = '\n...\n'; - if (maxChars <= marker.length) { - return text.slice(0, maxChars); - } - const keep = maxChars - marker.length; - const head = Math.ceil(keep / 2); - const tail = keep - head; - return `${text.slice(0, head)}${marker}${text.slice(text.length - tail)}`; + private _buildConversationContext(turns: readonly Turn[], sourceTitle?: string): string | undefined { + const framedTitle = sourceTitle?.trim(); + const framing = framedTitle + ? `This conversation was branched from an earlier chat titled "${framedTitle}". The turns below, oldest first, are the inherited history up to the branch point.\n\n` + : undefined; + return buildConversationContext(turns, { maxChars: MAX_TITLE_CONTEXT_CHARS, framing }); } private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 105e6b96df247..bb70e2e40e997 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -9,16 +9,16 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type ProgressParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { buildAnnotationsUri, isAnnotationsUri } from '../common/annotationsUri.js'; import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js'; -import { ChangesSummary } from '../common/state/protocol/state.js'; +import { ChangesSummary, ChatInteractivity, type ChatOrigin } from '../common/state/protocol/state.js'; import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; export interface IAgentHostStateManagerOptions { @@ -31,6 +31,122 @@ export interface IAgentHostStateManagerOptions { readonly hostBuildInfo?: IHostBuildInfo; } +/** + * Authoritative per-session record held by the state manager. Bundles the flat + * {@link SessionState} with the {@link SessionSummary} catalog-only fields that + * do not live on the state. The session URI (catalog `resource`) is the map + * key, and the catalog `_meta` is the same object as {@link SessionState._meta}, + * so the only extra fields the record carries are the timestamps and the + * aggregate change counts. + */ +interface ISessionEntry { + state: SessionState; + /** Creation timestamp (ISO 8601). Catalog-only; immutable after creation. */ + readonly createdAt: string; + /** Last modification timestamp (ISO 8601). Catalog-only; derived from chat aggregation. */ + modifiedAt: string; + /** Aggregate file-change counts for the session-wide changeset. Catalog-only. */ + changes?: ChangesSummary; +} + +/** + * Encapsulates the root-channel summary-notification bookkeeping for the + * {@link AgentHostStateManager}: the last {@link SessionSummary} announced to + * clients per session (the diff baseline) and the set of sessions whose summary + * changed since the last debounced flush. The snapshot map and the dirty set + * are always mutated in lockstep, so keeping them together — rather than as two + * loose fields on the manager — keeps the diffing state cohesive. + * + * The current summary for a session is sourced via the injected `getSummary` + * callback; diff-based `root/sessionSummaryChanged` notifications are emitted + * through `emit`. + */ +class SessionSummaryNotifier extends Disposable { + + /** Last summary announced to clients (via sessionAdded or sessionSummaryChanged). */ + private readonly _lastNotified = new Map(); + + /** Sessions whose summary changed since the last flush. */ + private readonly _dirty = new Set(); + + private readonly _scheduler = this._register(new RunOnceScheduler(() => this._flushAll(), 100)); + + constructor( + private readonly _getSummary: (session: string) => SessionSummary | undefined, + private readonly _emit: (session: string, changes: Partial) => void, + ) { + super(); + } + + /** Records `summary` as the last value announced to clients for `session`. */ + announce(session: string, summary: SessionSummary): void { + this._lastNotified.set(session, summary); + } + + /** Whether `session` has already been announced to clients. */ + isAnnounced(session: string): boolean { + return this._lastNotified.has(session); + } + + /** Marks `session` dirty and schedules a debounced flush. */ + markDirty(session: string): void { + this._dirty.add(session); + this._scheduler.schedule(); + } + + /** Whether `session` has a pending (unflushed) summary change. */ + isDirty(session: string): boolean { + return this._dirty.has(session); + } + + /** Drops the pending dirty flag for `session` without flushing it. */ + clearDirty(session: string): void { + this._dirty.delete(session); + } + + /** Drops all notification bookkeeping for `session`. */ + remove(session: string): void { + this._lastNotified.delete(session); + this._dirty.delete(session); + } + + private _flushAll(): void { + for (const session of this._dirty) { + this.flush(session); + } + this._dirty.clear(); + } + + /** + * Emits a `root/sessionSummaryChanged` notification for `session` if its + * current summary differs from the last announced one, then advances the + * snapshot. Does NOT clear the dirty flag — callers own that bookkeeping. + */ + flush(session: string): void { + const current = this._getSummary(session); + const lastNotified = this._lastNotified.get(session); + if (!current || !lastNotified) { + return; + } + + const changes: Partial = {}; + if (current.title !== lastNotified.title) { changes.title = current.title; } + if (current.status !== lastNotified.status) { changes.status = current.status; } + if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } + if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } + if (current.project !== lastNotified.project) { changes.project = current.project; } + if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } + if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } + if (current._meta !== lastNotified._meta) { changes._meta = current._meta; } + + this._lastNotified.set(session, current); + + if (Object.keys(changes).length > 0) { + this._emit(session, changes); + } + } +} + /** * Server-side state manager for the sessions process protocol. * @@ -43,7 +159,17 @@ export class AgentHostStateManager extends Disposable { private _serverSeq = 0; private _rootState: RootState; - private readonly _sessionStates = new Map(); + + /** + * Authoritative per-session state, keyed by session URI string. Each entry + * bundles the flat {@link SessionState} with the catalog-only fields that + * are not part of the state (`createdAt`, `modifiedAt`, `changes`). The + * root-channel {@link SessionSummary} catalog view is derived on demand from + * an entry via {@link getSessionSummary} (its `_meta` is the same object as + * {@link SessionState._meta}); the host streams catalog deltas via + * `root/sessionSummaryChanged`. + */ + private readonly _sessionStates = new Map(); /** * Authoritative per-chat conversation state, keyed by chat channel URI. @@ -54,6 +180,21 @@ export class AgentHostStateManager extends Disposable { */ private readonly _chatStates = new Map(); + /** + * Opaque, agent-owned `providerData` blobs keyed by peer-chat channel URI. + * + * Each entry is the verbatim token the owning agent produced for a peer + * chat (see {@link IAgentCreateChatResult.providerData}). The orchestrator + * persists it with the session and hands it back to the agent on restore so + * the agent can re-materialize its SDK conversation; the StateManager itself + * **never parses, validates, or mutates it** — it stores and returns the + * string as-is. The map is kept separate from the protocol-visible + * {@link ChatState}/{@link ChatSummary} catalog so the private blob is not + * streamed to clients. The default chat carries no `providerData`, so it + * never appears here. + */ + private readonly _chatProviderData = new Map(); + /** Expanded changeset states, separated from protocol sequencing so cache policy stays local. */ private readonly _changesets: AgentHostChangesetStateCache; @@ -65,21 +206,27 @@ export class AgentHostStateManager extends Disposable { private readonly _annotations = new Map(); /** - * Sessions whose authoritative state has an active turn. Derived from - * `state.activeTurn` (the source of truth maintained by the session - * reducer) — never from raw action turn-ids — so that mismatched or - * out-of-order turn lifecycle actions can't desync the count from - * reality. Drives `RootActiveSessionsChanged` and `hasActiveSessions`, - * which together gate `--enable-remote-auto-shutdown`. + * Active turns per session, keyed by session URI string with the value + * being the set of that session's chat channel URIs that currently have an + * active turn. A session is "active" while at least one of its chats is + * streaming — this stays correct for multi-chat sessions whose chats can run + * concurrent turns (e.g. agent-team / sub-agent workers), where the previous + * single-flag-per-session model would clear too early. Active state is + * derived from `state.activeTurn` (the source of truth maintained by the + * session reducer) — never from raw action turn-ids — so that mismatched or + * out-of-order turn lifecycle actions can't desync it from reality. The + * session count (`size`) drives `RootActiveSessionsChanged` and + * `hasActiveSessions`, which together gate `--enable-remote-auto-shutdown`. */ - private readonly _sessionsWithActiveTurn = new Set(); - - /** Last summary sent to clients (via sessionAdded or sessionSummaryChanged). */ - private readonly _lastNotifiedSummaries = new Map(); + private readonly _sessionsWithActiveTurn = new Map>(); - /** Sessions whose summary changed since the last flush. */ - private readonly _dirtySummaries = new Set(); - private readonly _summaryNotifyScheduler = this._register(new RunOnceScheduler(() => this._flushSummaryNotifications(), 100)); + /** + * Root-channel summary notification bookkeeping: the diff baseline (last + * announced summary per session) and the dirty set, debounced into + * `root/sessionSummaryChanged` notifications. Assigned in the constructor + * since it closes over {@link _toSummary} and {@link _onDidEmitNotification}. + */ + private readonly _summaryNotifier: SessionSummaryNotifier; private readonly _onDidEmitEnvelope = this._register(new Emitter()); readonly onDidEmitEnvelope: Event = this._onDidEmitEnvelope.event; @@ -111,6 +258,18 @@ export class AgentHostStateManager extends Disposable { }, _meta: withHostBuildInfo(this._rootState._meta, options.hostBuildInfo), }; + this._summaryNotifier = this._register(new SessionSummaryNotifier( + session => { + const entry = this._sessionStates.get(session); + return entry ? this._toSummary(session, entry) : undefined; + }, + (session, changes) => this._onDidEmitNotification.fire({ + type: 'root/sessionSummaryChanged', + channel: ROOT_STATE_URI, + session, + changes, + }), + )); } private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); @@ -118,6 +277,16 @@ export class AgentHostStateManager extends Disposable { return this._sessionsWithActiveTurn.size > 0; } + /** + * Whether the given session currently has an active turn — i.e. a request is + * in progress on any of its chats. Stays `true` while at least one chat is + * streaming, so it remains correct for multi-chat sessions running + * concurrent turns. + */ + hasActiveTurn(sessionKey: string): boolean { + return this._sessionsWithActiveTurn.has(sessionKey); + } + // ---- State accessors ---------------------------------------------------- get rootState(): RootState { @@ -133,12 +302,64 @@ export class AgentHostStateManager extends Disposable { if (session === undefined) { return undefined; } - const state = this._sessionStates.get(session); - if (!state) { + const entry = this._sessionStates.get(session); + if (!entry) { return undefined; } const chatUri = isChat ? sessionOrChat : buildDefaultChatUri(session); - return mergeSessionWithDefaultChat(state, this._chatStates.get(chatUri)); + return mergeSessionWithDefaultChat(entry.state, this._chatStates.get(chatUri)); + } + + /** + * Returns the root-channel {@link SessionSummary} catalog entry for a + * session, or `undefined` when the session is unknown. The summary is + * derived on demand from the session's {@link ISessionEntry}: its metadata + * fields and `_meta` come straight off the live {@link SessionState}, while + * the catalog-only `resource` / `createdAt` / `modifiedAt` / `changes` come + * from the entry. + */ + getSessionSummary(session: URI): SessionSummary | undefined { + const entry = this._sessionStates.get(session); + return entry ? this._toSummary(session, entry) : undefined; + } + + /** + * Projects an {@link ISessionEntry} into its root-channel + * {@link SessionSummary}. The summary's `_meta` is the same object as + * {@link SessionState._meta} — the host treats the two as identical. + */ + private _toSummary(session: string, entry: ISessionEntry): SessionSummary { + const { state } = entry; + const summary: SessionSummary = { + resource: session, + provider: state.provider, + title: state.title, + status: state.status, + createdAt: entry.createdAt, + modifiedAt: entry.modifiedAt, + }; + if (state.activity !== undefined) { summary.activity = state.activity; } + if (state.project !== undefined) { summary.project = state.project; } + if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; } + if (state.annotations !== undefined) { summary.annotations = state.annotations; } + if (entry.changes !== undefined) { summary.changes = entry.changes; } + if (state._meta !== undefined) { summary._meta = state._meta; } + return summary; + } + + /** + * Whether the {@link SessionSummary}-relevant fields of two session states + * are field-equal. Used to decide whether a session action mutated anything + * the root-channel catalog cares about. + */ + private _summaryFieldsEqual(a: SessionState, b: SessionState): boolean { + return a.title === b.title + && a.status === b.status + && a.activity === b.activity + && a.project === b.project + && a.workingDirectory === b.workingDirectory + && a.annotations === b.annotations + && a._meta === b._meta; } /** @@ -156,6 +377,18 @@ export class AgentHostStateManager extends Disposable { return this._chatStates.get(chat); } + /** + * Returns the opaque, agent-owned `providerData` blob previously recorded + * for a peer chat via {@link addChat} or {@link restoreChat}, or `undefined` + * when none was stored (e.g. the default chat, or a peer chat the agent had + * nothing resumable to persist for). The value is returned verbatim — the + * StateManager never interprets it; callers persist it with the session and + * hand it back to the owning agent on restore. + */ + getChatProviderData(chat: URI): string | undefined { + return this._chatProviderData.get(chat); + } + /** * Seeds the conversation contents (turns) of a session's default chat. * Used by the fork flow, which materializes a new session pre-populated @@ -191,14 +424,14 @@ export class AgentHostStateManager extends Disposable { */ getOverlaySessionSummaries(): SessionSummary[] { const summaries: SessionSummary[] = []; - for (const state of this._sessionStates.values()) { + for (const [key, entry] of this._sessionStates) { // Turn activity lives on the session's default chat after the // multi-chat protocol move, so consult that chat's turns/activeTurn. - const chat = this._chatStates.get(buildDefaultChatUri(state.summary.resource)); - if (state.lifecycle === SessionLifecycle.Creating && !chat?.activeTurn && (chat?.turns.length ?? 0) === 0) { + const chat = this._chatStates.get(buildDefaultChatUri(key)); + if (entry.state.lifecycle === SessionLifecycle.Creating && !chat?.activeTurn && (chat?.turns.length ?? 0) === 0) { continue; } - summaries.push(state.summary); + summaries.push(this._toSummary(key, entry)); } return summaries; } @@ -269,14 +502,14 @@ export class AgentHostStateManager extends Disposable { }; } - const sessionState = this._sessionStates.get(resource); - if (!sessionState) { + const entry = this._sessionStates.get(resource); + if (!entry) { return undefined; } return { resource, - state: sessionState, + state: entry.state, fromSeq: this._serverSeq, }; } @@ -308,23 +541,24 @@ export class AgentHostStateManager extends Disposable { */ createSession(summary: SessionSummary, options?: { readonly emitNotification?: boolean }): SessionState { const key = summary.resource; - if (this._sessionStates.has(key)) { + const existing = this._sessionStates.get(key); + if (existing) { this._logService.warn(`[AgentHostStateManager] Session already exists: ${key}`); - return this._sessionStates.get(key)!; + return existing.state; } const state = createSessionState(summary); - this._sessionStates.set(key, state); + this._sessionStates.set(key, this._newEntry(state, summary)); this._ensureDefaultChat(key, summary); this._logService.trace(`[AgentHostStateManager] Created session: ${key}`); if (options?.emitNotification !== false) { - // Recording the summary in `_lastNotifiedSummaries` is what makes - // `_flushSummaryNotifications` later emit incremental updates and - // what makes `markSessionPersisted` a no-op. Provisional sessions + // Announcing the summary to the notifier is what makes + // its later flush emit incremental updates and what makes + // `markSessionPersisted` a no-op. Provisional sessions // intentionally skip both until they are persisted. - this._lastNotifiedSummaries.set(key, summary); + this._summaryNotifier.announce(key, summary); this._onDidEmitNotification.fire({ type: 'root/sessionAdded', channel: ROOT_STATE_URI, @@ -335,40 +569,50 @@ export class AgentHostStateManager extends Disposable { return state; } + /** Builds the authoritative {@link ISessionEntry} for a freshly seeded state. */ + private _newEntry(state: SessionState, summary: SessionSummary): ISessionEntry { + return { state, createdAt: summary.createdAt, modifiedAt: summary.modifiedAt, changes: summary.changes }; + } + /** * Fire a {@link NotificationType.SessionAdded} notification for a session * whose creation was deferred via `createSession({ emitNotification: false })`. * - * Atomically writes the supplied summary into `state.summary` so - * subscribers reading state directly stay consistent with what was - * announced. No-ops for sessions that were already announced - * (idempotent). + * Propagates the materialization-resolved catalog fields (`project`, + * `workingDirectory`, `modifiedAt`, `changes`) from the supplied summary + * onto the session entry so subscribers see them. The reducer-owned metadata + * (`title`, `status`, `activity`) is intentionally NOT copied back — the live + * state is authoritative for those. No-ops for sessions that were already + * announced (idempotent). */ markSessionPersisted(session: URI, summary: SessionSummary): void { const key = session.toString(); - const state = this._sessionStates.get(key); - if (!state) { + const entry = this._sessionStates.get(key); + if (!entry) { this._logService.warn(`[AgentHostStateManager] markSessionPersisted: unknown session ${key}`); return; } - // `_lastNotifiedSummaries` is set whenever a session has been announced - // to clients (either through `createSession` or here); using it as the - // idempotency check keeps us from firing `SessionAdded` twice for a - // session whose creation was not deferred. - if (this._lastNotifiedSummaries.has(key)) { + // The notifier records a session's announced summary whenever it has + // been surfaced to clients (either through `createSession` or here); + // using it as the idempotency check keeps us from firing `SessionAdded` + // twice for a session whose creation was not deferred. + if (this._summaryNotifier.isAnnounced(key)) { return; } - // Update the in-memory summary so subscribers calling - // `getSessionState` see the same fields the notification carries. - // We don't need to schedule a `SessionSummaryChanged` flush because - // the upcoming `SessionAdded` notification carries the complete - // summary already. - state.summary = summary; - this._lastNotifiedSummaries.set(key, summary); + // Propagate the materialization-resolved fields so subscribers calling + // `getSessionState` / `getSessionSummary` see the resolved working + // directory / project. We don't need to schedule a + // `SessionSummaryChanged` flush because the upcoming `SessionAdded` + // notification carries the complete summary already. + entry.state = { ...entry.state, project: summary.project, workingDirectory: summary.workingDirectory }; + entry.modifiedAt = summary.modifiedAt; + entry.changes = summary.changes; + const full = this._toSummary(key, entry); + this._summaryNotifier.announce(key, full); this._onDidEmitNotification.fire({ type: 'root/sessionAdded', channel: ROOT_STATE_URI, - summary, + summary: full, }); } @@ -381,20 +625,21 @@ export class AgentHostStateManager extends Disposable { * notification because the session is already known to clients via * `listSessions`. */ - restoreSession(summary: SessionSummary, turns: Turn[]): SessionState { + restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string }): SessionState { const key = summary.resource; - if (this._sessionStates.has(key)) { + const existing = this._sessionStates.get(key); + if (existing) { this._logService.warn(`[AgentHostStateManager] Session already exists (restore): ${key}`); - return this._sessionStates.get(key)!; + return existing.state; } const state: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready, }; - this._sessionStates.set(key, state); - this._ensureDefaultChat(key, summary, turns); - this._lastNotifiedSummaries.set(key, summary); + this._sessionStates.set(key, this._newEntry(state, summary)); + this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle); + this._summaryNotifier.announce(key, summary); this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`); @@ -413,24 +658,22 @@ export class AgentHostStateManager extends Disposable { * at creation/restore time, so the snapshot a client later receives on * subscribe already reflects the default chat. */ - private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[]): void { + private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string): void { const chatUri = buildDefaultChatUri(sessionKey); - // The default chat starts with an empty title so it inherits the session - // title for display. It only gets its own title when renamed independently - // (via a per-chat `SessionChatUpdated`). This keeps the session title and - // the default chat tab title independent. - const chatSummary: ChatSummary = { ...createDefaultChatSummary(summary, chatUri), title: '' }; - this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: turns ?? [] }); - const sessionState = this._sessionStates.get(sessionKey); - if (sessionState) { + // Empty title means "inherit the session title"; a persisted independent + // rename (`defaultChatTitle`) is seeded back here so it survives restore. + const chatSummary: ChatSummary = { ...createDefaultChatSummary(summary, chatUri), title: defaultChatTitle ?? '' }; + this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: turns ?? [], draft }); + const entry = this._sessionStates.get(sessionKey); + if (entry) { // Update the session's chat catalog in place so the object // identity returned by `createSession`/`restoreSession` stays // live in the map. Callers (e.g. `AgentService.createSession`) // mutate the returned state directly (`state.config = …`), so // replacing the map entry with a fresh clone here would strand // those mutations on a detached object. - sessionState.chats = [chatSummary]; - sessionState.defaultChat = chatUri; + entry.state.chats = [chatSummary]; + entry.state.defaultChat = chatUri; } } @@ -443,13 +686,19 @@ export class AgentHostStateManager extends Disposable { * The chat inherits the session's model/agent/working-directory scope. It * is a no-op (returning the existing summary) when a chat with the same URI * already exists. + * + * When `options.providerData` is supplied it is recorded verbatim as the + * peer chat's opaque, agent-owned restore blob (see + * {@link getChatProviderData}); the StateManager never parses it. The + * default chat never carries `providerData`. */ - addChat(session: URI, chatUri: URI, options?: { readonly title?: string }): ChatSummary | undefined { - const sessionState = this._sessionStates.get(session); - if (!sessionState) { + addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin; readonly providerData?: string; readonly interactivity?: ChatInteractivity }): ChatSummary | undefined { + const entry = this._sessionStates.get(session); + if (!entry) { this._logService.warn(`[AgentHostStateManager] addChat for unknown session: ${session}`); return undefined; } + const sessionState = entry.state; const existing = sessionState.chats.find(c => c.resource === chatUri); if (existing) { return existing; @@ -462,16 +711,21 @@ export class AgentHostStateManager extends Disposable { // would also move the default chat tab and vice-versa. const defaultChatUri = sessionState.defaultChat ?? buildDefaultChatUri(session); const defaultEntry = sessionState.chats.find(c => c.resource === defaultChatUri); - if (defaultEntry && !defaultEntry.title && sessionState.summary.title) { - this.updateChatTitle(session, defaultChatUri, sessionState.summary.title); + if (defaultEntry && !defaultEntry.title && sessionState.title) { + this.updateChatTitle(session, defaultChatUri, sessionState.title); } const chatSummary: ChatSummary = { - ...createDefaultChatSummary(sessionState.summary, chatUri), + ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options?.title ?? '', status: SessionStatus.Idle, + origin: options?.origin, + interactivity: options?.interactivity, }; - this._chatStates.set(chatUri, createChatState(chatSummary)); + this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options?.turns ?? [] }); + if (options?.providerData !== undefined) { + this._chatProviderData.set(chatUri, options.providerData); + } this.dispatchServerAction(session, { type: ActionType.SessionChatAdded, summary: chatSummary }); return chatSummary; } @@ -485,22 +739,30 @@ export class AgentHostStateManager extends Disposable { * in place so the object identity returned by {@link restoreSession} stays * live; no {@link ActionType.SessionChatAdded} is dispatched because restore * runs before clients subscribe. + * + * When `options.providerData` is supplied it is recorded verbatim as the + * peer chat's opaque, agent-owned restore blob (see + * {@link getChatProviderData}); the StateManager never parses it. */ - restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[] }): void { - const sessionState = this._sessionStates.get(session); - if (!sessionState) { + restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[]; readonly draft?: Message; readonly providerData?: string }): void { + const entry = this._sessionStates.get(session); + if (!entry) { this._logService.warn(`[AgentHostStateManager] restoreChat for unknown session: ${session}`); return; } + const sessionState = entry.state; if (sessionState.chats.some(c => c.resource === chatUri)) { return; } const chatSummary: ChatSummary = { - ...createDefaultChatSummary(sessionState.summary, chatUri), + ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options.title ?? '', status: SessionStatus.Idle, }; - this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options.turns }); + this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options.turns, draft: options.draft }); + if (options.providerData !== undefined) { + this._chatProviderData.set(chatUri, options.providerData); + } sessionState.chats = [...sessionState.chats, chatSummary]; } @@ -512,15 +774,24 @@ export class AgentHostStateManager extends Disposable { * isolation; it lives and dies with its session. */ removeChat(session: URI, chatUri: URI): void { - const sessionState = this._sessionStates.get(session); - if (!sessionState || !sessionState.chats.some(c => c.resource === chatUri)) { + const entry = this._sessionStates.get(session); + if (!entry || !entry.state.chats.some(c => c.resource === chatUri)) { return; } + const sessionState = entry.state; if (chatUri === sessionState.defaultChat || isDefaultChatUri(chatUri)) { this._logService.warn(`[AgentHostStateManager] refusing to remove default chat: ${chatUri}`); return; } + // Drop the chat from its session's active-turn set before deleting its + // state. A peer chat can be removed while it still has an active turn; + // because active-turn tracking is driven by chat state transitions, + // deleting the ChatState here without this would strand the chat URI in + // the active set forever, keeping the session permanently "active" + // (activeSessions > 0) and leaving changeset operations disabled. + this._removeChatActiveTurn(session, chatUri); this._chatStates.delete(chatUri); + this._chatProviderData.delete(chatUri); this.dispatchServerAction(session, { type: ActionType.SessionChatRemoved, chat: chatUri }); } @@ -562,16 +833,16 @@ export class AgentHostStateManager extends Disposable { * before invoking `removeSession`. */ removeSession(session: URI): void { - const state = this._sessionStates.get(session); - if (!state) { + const entry = this._sessionStates.get(session); + if (!entry) { return; } // Flush any pending summary notification before tearing down state so // that the final status (e.g. Idle) reaches clients even if the session // is evicted within the scheduler's debounce window. - if (this._dirtySummaries.has(session)) { - this._flushSummaryNotificationFor(session); + if (this._summaryNotifier.isDirty(session)) { + this._summaryNotifier.flush(session); } // Clean up active turn tracking. We must dispatch @@ -587,13 +858,13 @@ export class AgentHostStateManager extends Disposable { // Tear down every chat owned by the session, not just the default // chat: additional peer chats each hold their own ChatState. - for (const chat of state.chats) { + for (const chat of entry.state.chats) { this._chatStates.delete(chat.resource); + this._chatProviderData.delete(chat.resource); } this._chatStates.delete(buildDefaultChatUri(session)); this._sessionStates.delete(session); - this._lastNotifiedSummaries.delete(session); - this._dirtySummaries.delete(session); + this._summaryNotifier.remove(session); this._logService.trace(`[AgentHostStateManager] Removed session: ${session}`); } @@ -609,11 +880,11 @@ export class AgentHostStateManager extends Disposable { * cause clients to drop a session URI they had eagerly subscribed to). */ deleteSession(session: URI): void { - const wasAnnounced = this._lastNotifiedSummaries.has(session); + const wasAnnounced = this._summaryNotifier.isAnnounced(session); // Drop any pending summary diff: the forthcoming SessionRemoved notification // supersedes it and we don't want to emit spurious SessionSummaryChanged // events just before the session disappears from the client's view. - this._dirtySummaries.delete(session); + this._summaryNotifier.clearDirty(session); // Tear down per-session changesets first so subscribers see the // final `changeset/cleared` envelope before the session itself goes // away. The envelopes flow through the same emitter as everything @@ -658,12 +929,12 @@ export class AgentHostStateManager extends Disposable { * detached composite copy and stranding the mutation there. */ setSessionConfig(session: URI, config: SessionConfigState | undefined): void { - const state = this._sessionStates.get(session); - if (!state) { + const entry = this._sessionStates.get(session); + if (!entry) { this._logService.warn(`[AgentHostStateManager] setSessionConfig: unknown session ${session}`); return; } - state.config = config; + entry.state.config = config; } /** @@ -672,12 +943,12 @@ export class AgentHostStateManager extends Disposable { * first snapshot already contains customizations. */ setSessionCustomizations(session: URI, customizations: readonly Customization[] | undefined): void { - const state = this._sessionStates.get(session); - if (!state) { + const entry = this._sessionStates.get(session); + if (!entry) { this._logService.warn(`[AgentHostStateManager] setSessionCustomizations: unknown session ${session}`); return; } - state.customizations = customizations ? [...customizations] : undefined; + entry.state.customizations = customizations ? [...customizations] : undefined; } // ---- Changeset registry ------------------------------------------------- @@ -705,34 +976,28 @@ export class AgentHostStateManager extends Disposable { } /** - * Updates the aggregate `summary.changes` for a session. + * Updates the aggregate `changes` for a session. * * There is no dedicated action for this field: the value is purely * informational (chip rendering on the session list), so the write * piggybacks on the existing `sessionSummaryChanged` notification - * path. We mutate `state.summary` in place, mark the session dirty, - * and let {@link _flushSummaryNotificationFor} pick the new value up - * via its `current.changes !== lastNotified.changes` diff. + * path. We update the session entry, mark the session dirty, and let + * the summary notifier's flush pick the new value up via its + * `current.changes !== lastNotified.changes` diff. */ setSessionSummaryChanges(session: URI, changes: ChangesSummary | undefined): void { - const state = this._sessionStates.get(session); - if (!state) { + const entry = this._sessionStates.get(session); + if (!entry) { this._logService.warn(`[AgentHostStateManager] setSessionSummaryChanges: unknown session ${session}`); return; } - if (structuralEquals(state.summary.changes, changes)) { + if (structuralEquals(entry.changes, changes)) { return; } - const newState = { - ...state, - summary: { ...state.summary, changes }, - }; - - this._sessionStates.set(session, newState); + entry.changes = changes; - this._dirtySummaries.add(session); - this._summaryNotifyScheduler.schedule(); + this._summaryNotifier.markDirty(session); } /** @@ -740,7 +1005,7 @@ export class AgentHostStateManager extends Disposable { * dispatching a {@link ActionType.SessionChangesetsChanged} action. * Subscribers see the mutation in the standard session action stream — * the catalogue lives on session state and is not its own subscribable - * resource. Aggregate `summary.changes` counts (additions / deletions / + * resource. Aggregate `changes` counts (additions / deletions / * files) are propagated separately via {@link setSessionSummaryChanges}. * * Producers call this after each compute pass to keep the list of @@ -748,11 +1013,12 @@ export class AgentHostStateManager extends Disposable { * can render the correct entries without subscribing to each one. */ setSessionChangesets(session: URI, changesets: readonly Changeset[] | undefined): void { - const state = this._sessionStates.get(session); - if (!state) { + const entry = this._sessionStates.get(session); + if (!entry) { this._logService.warn(`[AgentHostStateManager] setSessionChangesets: unknown session ${session}`); return; } + const state = entry.state; // Skip dispatch when the catalogue is field-equal to the existing one. // Producers call this after every compute pass, so duplicate calls @@ -865,11 +1131,6 @@ export class AgentHostStateManager extends Disposable { private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined): unknown { let resultingState: unknown = undefined; - // Channel the resulting envelope is emitted on. Chat actions are - // dispatched by producers against the owning session URI for - // backward compatibility, but must be emitted on the chat channel - // URI so per-chat subscribers receive them. - let emitChannel = channel; // Apply to state if (isRootAction(action)) { // `RootConfigChanged` can be a true no-op: the reducer merges/replaces @@ -895,15 +1156,16 @@ export class AgentHostStateManager extends Disposable { if (isSessionAction(action)) { const sessionAction = action as SessionAction; const key = channel; - const state = this._sessionStates.get(key); - if (state) { - const newState = sessionReducer(state, sessionAction, this._log); - this._sessionStates.set(key, newState); - - // Detect summary changes for notification - if (state.summary !== newState.summary) { - this._dirtySummaries.add(key); - this._summaryNotifyScheduler.schedule(); + const entry = this._sessionStates.get(key); + if (entry) { + const newState = sessionReducer(entry.state, sessionAction, this._log); + const summaryChanged = !this._summaryFieldsEqual(entry.state, newState); + entry.state = newState; + + // When the reducer touched a summary-relevant field, notify + // root-channel clients of the derived-summary delta. + if (summaryChanged) { + this._summaryNotifier.markDirty(key); } resultingState = newState; @@ -913,22 +1175,20 @@ export class AgentHostStateManager extends Disposable { } if (isChatAction(action)) { + if (!isAhpChatChannel(channel)) { + throw new Error(`[AgentHostStateManager] Chat action dispatched to non-chat channel: ${channel}, type=${action.type}`); + } + const chatAction = action as ChatAction; - // Producers dispatch chat actions against either the session URI - // (compat) or the chat channel URI. Resolve both so we can update - // the chat state, bridge status to the session summary, and emit - // on the chat channel. - const sessionKey = isAhpChatChannel(channel) ? parseDefaultChatUri(channel) : channel; - const chatUri = isAhpChatChannel(channel) ? channel : buildDefaultChatUri(channel); - emitChannel = chatUri; - const chat = this._chatStates.get(chatUri); + const sessionKey = parseRequiredSessionUriFromChatUri(channel); + const chat = this._chatStates.get(channel); if (chat && sessionKey !== undefined) { const newChat = chatReducer(chat, chatAction, this._log); - this._chatStates.set(chatUri, newChat); - this._onChatStateChanged(sessionKey, chatUri, chat, newChat); + this._chatStates.set(channel, newChat); + this._onChatStateChanged(sessionKey, channel, chat, newChat); resultingState = newChat; } else { - this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${chatUri}, type=${action.type}`); + this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${channel}, type=${action.type}`); } } @@ -966,18 +1226,40 @@ export class AgentHostStateManager extends Disposable { // Emit envelope const envelope: ActionEnvelope = { - channel: emitChannel, + channel, action, serverSeq: ++this._serverSeq, origin, }; - this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); + this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); this._onDidEmitEnvelope.fire(envelope); return resultingState; } + /** + * Removes a single chat from its session's active-turn set, firing the + * session-level active flip ({@link onDidChangeSessionActiveTurn} + + * {@link ActionType.RootActiveSessionsChanged}) when this clears the + * session's last active chat. Safe to call for chats that aren't currently + * tracked as active — it is a no-op in that case. Used both when a turn + * ends and when a chat is removed mid-turn, so the session can't be + * stranded as permanently "active". + */ + private _removeChatActiveTurn(sessionKey: string, chatUri: string): void { + const activeChats = this._sessionsWithActiveTurn.get(sessionKey); + if (!activeChats || !activeChats.delete(chatUri)) { + return; + } + + if (activeChats.size === 0) { + this._sessionsWithActiveTurn.delete(sessionKey); + this._onDidChangeSessionActiveTurn.fire({ session: sessionKey, active: false }); + this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size }); + } + } + /** * Bridges a default-chat state transition back onto its owning session. * @@ -988,60 +1270,94 @@ export class AgentHostStateManager extends Disposable { * and `hasActiveSessions`, which gate `--enable-remote-auto-shutdown`), * keyed by the owning session URI; * - mirror the chat's denormalized `status`/`activity`/`modifiedAt` - * onto the session summary so the session list reflects progress; and + * onto the session summary so the session list reflects progress; + * - forward the chat's own `status` to the session `chats` catalog (via a + * {@link ActionType.SessionChatUpdated}) so per-chat tabs reflect that + * chat's progress, not just the aggregated session summary; and * - keep the session's `chats` catalog entry in sync. */ private _onChatStateChanged(sessionKey: string, chatUri: string, prev: ChatState, next: ChatState): void { // Active turn tracking — derive from the reducer's view of state, // never from raw action turn-ids, so out-of-order lifecycle actions - // can't desync the count from reality. + // can't desync the count from reality. Track active turns per chat so a + // session stays active until ALL of its concurrent chat turns finish; + // only notify when the session's overall active state actually flips. const hadActive = !!prev.activeTurn; const hasActive = !!next.activeTurn; if (hadActive !== hasActive) { if (hasActive) { - this._sessionsWithActiveTurn.add(sessionKey); + let activeChats = this._sessionsWithActiveTurn.get(sessionKey); + const wasSessionActive = !!activeChats?.size; + if (!activeChats) { + activeChats = new Set(); + this._sessionsWithActiveTurn.set(sessionKey, activeChats); + } + activeChats.add(chatUri); + if (!wasSessionActive) { + this._onDidChangeSessionActiveTurn.fire({ session: sessionKey, active: true }); + this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size }); + } } else { - this._sessionsWithActiveTurn.delete(sessionKey); + this._removeChatActiveTurn(sessionKey, chatUri); } - this._onDidChangeSessionActiveTurn.fire({ session: sessionKey, active: hasActive }); - this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size }); } - const sessionState = this._sessionStates.get(sessionKey); - if (!sessionState) { + const entry = this._sessionStates.get(sessionKey); + if (!entry) { return; } + const sessionState = entry.state; // Mirror denormalized chat summary fields onto the session, aggregating // across the whole chat catalog per the SessionSummary rules. - const chats = sessionState.chats.map(c => c.resource === chatUri ? chatSummaryFromState(next) : c); + const nextEntry = chatSummaryFromState(next); + const prevEntry = sessionState.chats.find(c => c.resource === chatUri); + const chats = sessionState.chats.map(c => c.resource === chatUri ? nextEntry : c); + + // Forward the chat's own status to the session catalog so full + // SessionState subscribers (the per-chat tabs) reflect this chat's + // progress — not just the aggregated session summary. Status changes + // at most a couple of times per turn, so this won't flood the channel. + if (prevEntry?.status !== nextEntry.status) { + this.dispatchServerAction(sessionKey, { + type: ActionType.SessionChatUpdated, + chat: chatUri, + changes: { status: nextEntry.status, activity: nextEntry.activity }, + }); + } + const aggregate = this._aggregateChatSummaries(chats, sessionState.defaultChat); - const prevSummary = sessionState.summary; - const statusChanged = aggregate.status !== undefined && this._mergeSessionStatus(prevSummary.status, aggregate.status) !== prevSummary.status; - const activityChanged = aggregate.activity !== prevSummary.activity; - const modifiedAtChanged = aggregate.modifiedAt !== undefined && aggregate.modifiedAt !== prevSummary.modifiedAt; - const summaryChanged = statusChanged || activityChanged || modifiedAtChanged; - const newSummary = summaryChanged - ? { - ...prevSummary, - ...(statusChanged ? { status: this._mergeSessionStatus(prevSummary.status, aggregate.status!) } : undefined), - ...(activityChanged ? { activity: aggregate.activity } : undefined), - ...(modifiedAtChanged ? { modifiedAt: aggregate.modifiedAt } : undefined), - } - : prevSummary; - this._sessionStates.set(sessionKey, { ...sessionState, chats, summary: newSummary }); - if (summaryChanged) { - this._dirtySummaries.add(sessionKey); - this._summaryNotifyScheduler.schedule(); + const newStatus = aggregate.status !== undefined ? this._mergeSessionStatus(sessionState.status, aggregate.status) : sessionState.status; + const statusChanged = newStatus !== sessionState.status; + const activityChanged = aggregate.activity !== sessionState.activity; + entry.state = { + ...sessionState, + chats, + ...(statusChanged ? { status: newStatus } : undefined), + ...(activityChanged ? { activity: aggregate.activity } : undefined), + }; + + // Roll the aggregated `modifiedAt` into the catalog-only timestamp. + const newModifiedAt = aggregate.modifiedAt !== undefined ? new Date(aggregate.modifiedAt).toISOString() : undefined; + const modifiedAtChanged = newModifiedAt !== undefined && newModifiedAt !== entry.modifiedAt; + if (modifiedAtChanged) { + entry.modifiedAt = newModifiedAt; + } + + if (statusChanged || activityChanged || modifiedAtChanged) { + this._summaryNotifier.markDirty(sessionKey); } } /** * Aggregates a session's chat catalog into the derived session-summary * fields per the protocol rules: activity bits come from the default chat - * (else the most recently modified chat) with `InputNeeded`/`Error` - * promoted whenever any chat raises them; the `activity` string follows the - * chat driving the resulting status; `modifiedAt` is the max across chats. + * (else the most recently modified chat) with `InputNeeded`/`Error`/ + * `InProgress` promoted whenever any chat raises them; the `activity` string + * follows the chat driving the resulting status; `modifiedAt` is the max + * across chats. Promotion precedence is `InputNeeded` > `Error` > + * `InProgress`, so a running peer (sub) chat surfaces as `InProgress` on the + * session even when the default chat is idle. */ private _aggregateChatSummaries(chats: readonly ChatSummary[], defaultChat: URI | undefined): { status?: SessionStatus; activity?: string; modifiedAt?: number } { if (chats.length === 0) { @@ -1054,12 +1370,18 @@ export class AgentHostStateManager extends Disposable { let driver = base; const errorChat = chats.find(c => (c.status & SessionStatus.Error) === SessionStatus.Error); const inputChat = chats.find(c => (c.status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded); + // `InputNeeded` is a superset of the `InProgress` bit, so exclude + // input-needed chats here to find one that is purely streaming. + const inProgressChat = chats.find(c => (c.status & SessionStatus.InputNeeded) === SessionStatus.InProgress); if (inputChat) { status = SessionStatus.InputNeeded; driver = inputChat; } else if (errorChat) { status = SessionStatus.Error; driver = errorChat; + } else if (inProgressChat) { + status = SessionStatus.InProgress; + driver = inProgressChat; } const modifiedAt = chats.reduce((max, c) => Math.max(max, Date.parse(c.modifiedAt)), 0); return { status, activity: driver.activity, modifiedAt }; @@ -1076,48 +1398,20 @@ export class AgentHostStateManager extends Disposable { return activityBits | metaFlags; } - private _flushSummaryNotifications(): void { - for (const session of this._dirtySummaries) { - this._flushSummaryNotificationFor(session); - } - this._dirtySummaries.clear(); - } - /** - * Emits a {@link NotificationType.SessionSummaryChanged} notification for - * `session` if its current summary differs from the last one sent to - * clients, then advances `_lastNotifiedSummaries` to the current summary. - * - * Does NOT remove `session` from `_dirtySummaries` — callers are - * responsible for that bookkeeping. + * Emit a generic progress notification on the root channel, correlated to + * the originating request by {@link ProgressParams.progressToken}. Routed to + * clients through the same {@link onDidEmitNotification} path as session + * notifications, so both the local (IPC proxy) and remote (WebSocket + * {@link ProtocolServerHandler}) renderers receive it without any + * transport-specific special casing. Progress for host-level work (e.g. a + * shared SDK download) rides the root channel rather than a per-session one. */ - private _flushSummaryNotificationFor(session: string): void { - const state = this._sessionStates.get(session); - const lastNotified = this._lastNotifiedSummaries.get(session); - if (!state || !lastNotified || state.summary === lastNotified) { - return; - } - - const current = state.summary; - const changes: Partial = {}; - if (current.title !== lastNotified.title) { changes.title = current.title; } - if (current.status !== lastNotified.status) { changes.status = current.status; } - if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } - if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } - if (current.project !== lastNotified.project) { changes.project = current.project; } - if (current.model !== lastNotified.model) { changes.model = current.model; } - if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } - if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } - - this._lastNotifiedSummaries.set(session, current); - - if (Object.keys(changes).length > 0) { - this._onDidEmitNotification.fire({ - type: 'root/sessionSummaryChanged', - channel: ROOT_STATE_URI, - session, - changes, - }); - } + emitProgress(progress: Omit): void { + this._onDidEmitNotification.fire({ + type: 'root/progress', + channel: ROOT_STATE_URI, + ...progress, + }); } } diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts index ea93ac1c87aed..9ea663d006f23 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts @@ -38,7 +38,7 @@ export class AgentHostSyncOperationHandler implements IChangesetOperationHandler throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.summary.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectory; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts index 67e4fb273e257..3aeb4fcda157e 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts @@ -33,7 +33,7 @@ export class AgentHostSyncOperationContribution extends Disposable implements IC } getOperations({ gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (!gitState.upstreamBranchName || (gitState.outgoingChanges ?? 0) === 0) { + if (!gitState?.upstreamBranchName || (gitState?.outgoingChanges ?? 0) === 0) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 6a1cadce7ecb9..cb96d4dd48348 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -3,10 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../telemetry/common/languageModelToolTelemetry.js'; import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentSession } from '../common/agentService.js'; -import type { MessageAttachment } from '../common/state/protocol/state.js'; -import { isSubagentSession, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; +import type { MessageAttachment, ToolDefinition } from '../common/state/protocol/state.js'; +import { isAhpChatChannel, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; +import type { ToolInvokedResult } from './agentHostToolCallTracker.js'; +import { multiplexProperties, type IAgentHostRestrictedTelemetry } from './agentHostRestrictedTelemetry.js'; export type AgentHostUserMessageSentSource = 'direct' | 'queued'; @@ -28,9 +31,9 @@ export type IAgentHostUserMessageSentClassification = { source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user message was sent directly or from the queued-message flow.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the message was sent to a subagent session.' }; turnCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of completed turns in the session when the message was sent.' }; - activeClientId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The active client identifier for the session, if any.' }; - activeClientToolCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of tools provided by the active client, if any.' }; - activeClientCustomizationCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of customizations provided by the active client, if any.' }; + activeClientId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the first active client for the session, if any.' }; + activeClientToolCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The total number of tools provided by the active clients, if any.' }; + activeClientCustomizationCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The total number of customizations provided by the active clients, if any.' }; attachmentCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of attachments included with the user message.' }; owner: 'roblourens'; comment: 'Tracks user messages sent from the agent host process to an agent provider.'; @@ -70,32 +73,75 @@ export interface IAgentHostTurnCompletedReport { permissionLevel: string | undefined; } +export interface IAgentHostToolInvokedReport { + provider: string; + session: string; + toolId: string; + toolSourceKind: string; + result: ToolInvokedResult; + invocationTimeMs: number; +} + export class AgentHostTelemetryReporter { constructor(private readonly _telemetryService: ITelemetryService) { } + /** The restricted GH/MSFT telemetry surface, present when the agent-host telemetry service is wired. */ + private get _restricted(): IAgentHostRestrictedTelemetry | undefined { + const ts = this._telemetryService as Partial; + return typeof ts.sendEnhancedGHTelemetryEvent === 'function' ? ts as IAgentHostRestrictedTelemetry : undefined; + } + userMessageSent(provider: string, session: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, attachments: readonly MessageAttachment[] | undefined): void { const attachmentCount = attachments?.length ?? 0; - const activeClient = sessionState?.activeClient; + const activeClients = sessionState?.activeClients ?? []; + const sessionUri = isAhpChatChannel(session) ? parseRequiredSessionUriFromChatUri(session) : session; this._telemetryService.publicLog2('agentHost.userMessageSent', { provider, - agentSessionId: AgentSession.id(session), + agentSessionId: AgentSession.id(sessionUri), source, - isSubagentSession: isSubagentSession(session), + isSubagentSession: isSubagentSession(sessionUri), turnCount: sessionState?.turns.length ?? 0, - ...(activeClient ? { - activeClientId: activeClient.clientId, - activeClientToolCount: activeClient.tools.length, - activeClientCustomizationCount: activeClient.customizations?.length ?? 0, + ...(activeClients.length > 0 ? { + activeClientId: activeClients[0].clientId, + activeClientToolCount: activeClients.reduce((sum, client) => sum + client.tools.length, 0), + activeClientCustomizationCount: activeClients.reduce((sum, client) => sum + (client.customizations?.length ?? 0), 0), } : {}), attachmentCount, }); } + /** + * Mirrors the Copilot extension's enhanced GH `request.options.tools` event for the agent-host + * flow. The extension emits it per LLM request from its model fetcher; the agent host observes + * the equivalent boundary when an `assistant.message` arrives (one per model call). The + * extension populates `headerRequestId` with the client-minted `x-request-id`, which the SDK + * does not surface on success; we keep the same field name (so science queries are undisturbed) + * but fill it with the model call's `x-copilot-service-request-id`, the per-call id the SDK does + * expose. `messagesJson` is the raw tool definitions offered for the call, multiplexed across + * ~8192-char chunks like the extension, so it lands identically downstream. + * + * @param session Session URI string; its id becomes `conversationId`. + * @param serviceRequestId The model call's `x-copilot-service-request-id`, mapped to the extension's `headerRequestId`. No-ops when absent (e.g. providers that don't surface it). + * @param tools The tool definitions offered to the model for this call. + */ + assistantMessageReceived(session: string, serviceRequestId: string | undefined, tools: readonly ToolDefinition[]): void { + const restricted = this._restricted; + if (!restricted || !serviceRequestId || tools.length === 0) { + return; + } + restricted.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ + headerRequestId: serviceRequestId, + conversationId: AgentSession.id(session), + messagesJson: JSON.stringify(tools), + })); + } + turnCompleted(report: IAgentHostTurnCompletedReport): void { + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.turnCompleted', { provider: report.provider, - agentSessionId: AgentSession.id(report.session), + agentSessionId: AgentSession.id(session), timeToFirstProgress: report.timeToFirstProgress, totalTime: report.totalTime, result: report.result, @@ -103,5 +149,20 @@ export class AgentHostTelemetryReporter { permissionLevel: report.permissionLevel, }); } -} + toolInvoked(report: IAgentHostToolInvokedReport): void { + // `chatSessionId` is the full session URI string (matching the value + // previously emitted by `CopilotAgentSession`). Action signals are keyed + // by their chat-channel URI, so normalize it back to the session URI. + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + this._telemetryService.publicLog2('languageModelToolInvoked', { + result: report.result, + chatSessionId: session, + toolId: report.toolId, + toolExtensionId: undefined, + toolSourceKind: report.toolSourceKind, + invocationTimeMs: report.invocationTimeMs, + provider: report.provider, + }); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 6cc0a3f9bf6e5..3e56ef303bfef 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -21,6 +21,7 @@ import { TelemetryLogAppender } from '../../telemetry/common/telemetryLogAppende import { TelemetryService } from '../../telemetry/common/telemetryService.js'; import { getPiiPathsFromEnvironment, isInternalTelemetry, isLoggingOnly, NullTelemetryService, supportsTelemetry, type ITelemetryAppender } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel } from '../common/agentHostSchema.js'; +import { AgentHostRestrictedTelemetrySender, IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from './agentHostRestrictedTelemetry.js'; export interface IAgentHostTelemetryServiceOptions { readonly environmentService: INativeEnvironmentService; @@ -32,7 +33,7 @@ export interface IAgentHostTelemetryServiceOptions { readonly disableTelemetry?: boolean; } -export interface IAgentHostTelemetryService extends ITelemetryService { +export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHostRestrictedTelemetry { updateTelemetryLevel(telemetryLevel: TelemetryLevel): void; } @@ -41,7 +42,17 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT private _telemetryLevel = TelemetryLevel.USAGE; - constructor(private readonly _delegate: ITelemetryService) { + /** + * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Defaults + * to `false` so nothing restricted is sent until an authenticated token confirms the opt-in, + * keeping public users off the enhanced pipeline the way the Copilot extension does. + */ + private _restrictedTelemetryEnabled = false; + + constructor( + private readonly _delegate: ITelemetryService, + private readonly _restricted?: IAgentHostRestrictedTelemetry, + ) { super(); if (isDisposable(_delegate)) { this._register(_delegate); @@ -108,6 +119,42 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT this._delegate.publicLogError2(eventName, data); } + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE) { + return; + } + this._restricted?.sendGHTelemetryEvent(eventName, properties, measurements); + } + + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE || !this._restrictedTelemetryEnabled) { + return; + } + this._restricted?.sendEnhancedGHTelemetryEvent(eventName, properties, measurements); + } + + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE) { + return; + } + this._restricted?.sendInternalMSFTTelemetryEvent(eventName, properties, measurements); + } + + setCopilotTrackingId(trackingId: string | undefined): void { + this._restricted?.setCopilotTrackingId(trackingId); + } + + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + this._restricted?.setRestrictedTelemetryEndpoint(endpointUrl); + } + + setRestrictedTelemetryEnabled(enabled: boolean): void { + this._restrictedTelemetryEnabled = enabled; + // Mirror onto the sender so the restricted-table writer enforces the same `rt` gate + // independently (defense in depth), matching the extension's opted-in-only reporter. + this._restricted?.setRestrictedTelemetryEnabled(enabled); + } + setExperimentProperty(name: string, value: string): void { this._delegate.setExperimentProperty(name, value); } @@ -130,7 +177,7 @@ export function updateAgentHostTelemetryLevelFromConfig(telemetryService: ITelem telemetryService.updateTelemetryLevel(telemetryLevelValue); } -function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService { +export function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService { return typeof (telemetryService as IAgentHostTelemetryService).updateTelemetryLevel === 'function'; } @@ -159,12 +206,16 @@ export async function createAgentHostTelemetryService(options: IAgentHostTelemet getDevDeviceId(error => logService.error(error)), ]); + const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date); + const telemetryService = new TelemetryService({ appenders, sendErrorTelemetry: true, - commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date), + commonProperties, piiPaths: getPiiPathsFromEnvironment(environmentService), }, configurationService, productService); - return disposables.add(new AgentHostTelemetryService(telemetryService)); + const restricted = new AgentHostRestrictedTelemetrySender(commonProperties, logService); + + return disposables.add(new AgentHostTelemetryService(telemetryService, restricted)); } diff --git a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts new file mode 100644 index 0000000000000..49af2f6ddb8f0 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { StopWatch } from '../../../base/common/stopwatch.js'; +import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js'; +import type { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; + +export type ToolInvokedResult = 'success' | 'error' | 'userCancelled'; + +/** + * Maps a completed tool call's result to the telemetry result bucket. Mirrors + * the derivation previously done inline in `CopilotAgentSession`: a denied, + * rejected, or cancelled tool call counts as `userCancelled`; any other + * failure counts as `error`. + */ +export function deriveToolInvokedResult(result: ToolCallResult): ToolInvokedResult { + if (result.success) { + return 'success'; + } + const code = result.error?.code; + if (code === 'rejected' || code === 'denied' || code === 'cancelled') { + return 'userCancelled'; + } + return 'error'; +} + +/** + * Maps a tool call's contributor to the telemetry `toolSourceKind`. A tool with + * no contributor is provided by the agent host itself; an MCP contributor maps + * to `mcp` and a client contributor to `client`. + */ +export function toolSourceKindFromContributor(contributor: ToolCallContributor | undefined): string { + if (!contributor) { + return 'agentHost'; + } + // Widen to `string` so an unrecognized kind from a newer protocol version + // falls through to a valid telemetry value rather than `undefined`. + const kind: string = contributor.kind; + switch (kind) { + case ToolCallContributorKind.MCP: + return 'mcp'; + case ToolCallContributorKind.Client: + return 'client'; + default: + return kind; + } +} + +/** Per-tool-call timing state, keyed by `session:toolCallId`. */ +interface IToolCallTiming { + readonly stopWatch: StopWatch; + readonly provider: string; + readonly session: string; + readonly toolId: string; + readonly toolSourceKind: string; +} + +/** + * Tracks per-tool-call timing for agent host sessions and reports a + * `languageModelToolInvoked` event via the provided + * {@link AgentHostTelemetryReporter} when a tool call completes. + * + * Lifecycle per tool call: + * 1. {@link toolCallStarted} — begins a stopwatch and records the tool's + * name and source kind (only the start action carries these) + * 2. {@link toolCallCompleted} — emits the telemetry event and clears state + * + * In-flight tool calls that never complete (e.g. the turn is cancelled mid + * tool call) are dropped via {@link clearSession} / {@link clear} so the + * tracking map cannot leak. + */ +export class AgentHostToolCallTracker { + + private readonly _toolCalls = new Map(); + + constructor(private readonly _reporter: AgentHostTelemetryReporter) { } + + toolCallStarted(provider: string, session: string, toolCallId: string, toolName: string, contributor: ToolCallContributor | undefined): void { + this._toolCalls.set(this._key(session, toolCallId), { + stopWatch: StopWatch.create(true), + provider, + session, + toolId: toolName, + toolSourceKind: toolSourceKindFromContributor(contributor), + }); + } + + toolCallCompleted(session: string, toolCallId: string, result: ToolCallResult): void { + const key = this._key(session, toolCallId); + const timing = this._toolCalls.get(key); + if (!timing) { + // No matching start: either the start was never observed, or this is + // a duplicate completion (the entry was already consumed). Either + // way, do not emit so volume stays accurate. + return; + } + this._toolCalls.delete(key); + + this._reporter.toolInvoked({ + provider: timing.provider, + session: timing.session, + toolId: timing.toolId, + toolSourceKind: timing.toolSourceKind, + result: deriveToolInvokedResult(result), + invocationTimeMs: timing.stopWatch.elapsed(), + }); + } + + /** + * Drops any in-flight (never-completed) tool calls for a session. Called + * when a turn ends or a session is torn down so the tracking map cannot + * leak. A no-op in the normal case where every tool call completes. + */ + clearSession(session: string): void { + const prefix = `${session}\0`; + for (const key of this._toolCalls.keys()) { + if (key.startsWith(prefix)) { + this._toolCalls.delete(key); + } + } + } + + clear(): void { + this._toolCalls.clear(); + } + + private _key(session: string, toolCallId: string): string { + return `${session}\0${toolCallId}`; + } +} diff --git a/src/vs/platform/agentHost/node/agentPeerChats.ts b/src/vs/platform/agentHost/node/agentPeerChats.ts new file mode 100644 index 0000000000000..8b1e88d5cd631 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentPeerChats.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 { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; +import { type ModelSelection } from '../common/state/protocol/state.js'; + +/** + * In-memory backing for an additional (non-default) peer chat. Records the SDK + * chat id that backs the chat so it can be re-resumed after a process restart, + * along with any model override chosen at creation time. This is also the shape + * serialized into the opaque, agent-owned `providerData` blob the orchestrator + * persists in its chat catalog and hands back on restore. + */ +export interface IPersistedChat { + readonly sdkSessionId: string; + readonly model?: ModelSelection; +} + +export interface IResolvedAgentChat { + readonly chatSession: TSession; + readonly isDefault: boolean; +} + +/** + * Serializes a peer-chat backing into the opaque `providerData` token the + * orchestrator persists verbatim. The encoding is the agent's private business + * — today it is the JSON of {@link IPersistedChat}. + */ +export function encodeProviderData(backing: IPersistedChat): string { + return JSON.stringify(backing); +} + +/** + * Decodes an opaque `providerData` token produced by {@link encodeProviderData} + * back into a peer-chat backing, tolerating corrupt/foreign blobs by returning + * `undefined` (the same drop-on-corrupt policy as the legacy chat catalog read). + */ +export function decodeProviderData(providerData: string): IPersistedChat | undefined { + try { + const value = JSON.parse(providerData) as { sdkSessionId?: unknown; model?: unknown }; + if (!value || typeof value !== 'object') { + return undefined; + } + const { sdkSessionId, model } = value; + if (typeof sdkSessionId !== 'string' || !sdkSessionId) { + return undefined; + } + // The blob is client-influenced and may be corrupted or shape-shifted by + // a future serialization change: only accept a `model` that actually + // looks like a `ModelSelection`. + const validModel = model && typeof model === 'object' && typeof (model as { id?: unknown }).id === 'string' + ? model as ModelSelection + : undefined; + return { sdkSessionId, ...(validModel ? { model: validModel } : {}) }; + } catch { + return undefined; + } +} + +/** + * Per-session container shared by the multi-chat agents. Keeps ALL chats of a + * session — the default (main) chat and any additional peer chats — together in + * ONE per-agent map keyed by each chat's channel URI string (no parallel maps, + * no default-vs-peer storage split). The default chat is just the entry marked + * as default, so send/abort/model/agent/history operations resolve any chat by a + * single uniform {@link getChat} lookup with no default-chat resolution branch. + * + * Each entry can act as a leaf (wrapping one {@link ownSession} plus its + * event-forwarding disposables) or as the container (holding the chat map). + * Disposing the container disposes every chat leaf it holds. + */ +export class AgentSessionEntry extends Disposable { + /** All chats of the session (default + peers) as leaf entries, keyed by chat-URI string. */ + private readonly _chats = this._register(new DisposableMap>()); + /** The key of the session's default (main) chat within {@link _chats}. */ + private _defaultChatKey: string | undefined; + /** This leaf's own chat session (set when the entry wraps a single chat). */ + private _ownSession: TSession | undefined; + + constructor(session?: TSession) { + super(); + if (session) { + this._ownSession = session; + this._register(session); + } + } + + /** This leaf's own chat session, or `undefined` for a bare container. */ + get ownSession(): TSession | undefined { + return this._ownSession; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } + + // ---- Uniform chat map (default + peers) -------------------------------- + + /** Register the session's default (main) chat leaf under its chat-URI key. */ + setDefaultChat(chatKey: string, entry: AgentSessionEntry): void { + this._chats.set(chatKey, entry); + this._defaultChatKey = chatKey; + } + + /** Dispose the default chat leaf (e.g. a config-driven restart) while keeping peer chats. */ + clearDefaultChat(): void { + if (this._defaultChatKey !== undefined) { + this._chats.deleteAndDispose(this._defaultChatKey); + this._defaultChatKey = undefined; + } + } + + /** The session's materialized default (main) chat, or `undefined` while provisional. */ + get defaultChat(): TSession | undefined { + return this._defaultChatKey !== undefined ? this._chats.get(this._defaultChatKey)?.ownSession : undefined; + } + + /** Uniform lookup: the chat's session (default OR peer) by its chat-URI key. */ + getChat(chatKey: string): TSession | undefined { + return this._chats.get(chatKey)?.ownSession; + } + + /** Uniform lookup with default-vs-peer identity from the entry that resolved the chat. */ + resolveChat(chatKey: string): IResolvedAgentChat | undefined { + const chatSession = this._chats.get(chatKey)?.ownSession; + if (!chatSession) { + return undefined; + } + return { chatSession, isDefault: chatKey === this._defaultChatKey }; + } + + /** Every live chat session — the default chat plus all peers. */ + allChatSessions(): TSession[] { + const sessions: TSession[] = []; + for (const entry of this._chats.values()) { + if (entry.ownSession) { + sessions.push(entry.ownSession); + } + } + return sessions; + } + + // ---- Peer chats (every chat except the default) ------------------------ + + getPeerChat(chatKey: string): TSession | undefined { + return chatKey === this._defaultChatKey ? undefined : this._chats.get(chatKey)?.ownSession; + } + + hasPeerChat(chatKey: string): boolean { + return chatKey !== this._defaultChatKey && this._chats.has(chatKey); + } + + registerPeerChat(chatKey: string, entry: AgentSessionEntry): void { + this._chats.set(chatKey, entry); + } + + disposePeerChat(chatKey: string): void { + if (chatKey !== this._defaultChatKey) { + this._chats.deleteAndDispose(chatKey); + } + } + + peerChatKeys(): string[] { + return [...this._chats.keys()].filter(key => key !== this._defaultChatKey); + } + + peerChatSessions(): TSession[] { + const sessions: TSession[] = []; + for (const key of this._chats.keys()) { + if (key === this._defaultChatKey) { + continue; + } + const session = this._chats.get(key)?.ownSession; + if (session) { + sessions.push(session); + } + } + return sessions; + } +} diff --git a/src/vs/platform/agentHost/node/agentSdkDownloader.ts b/src/vs/platform/agentHost/node/agentSdkDownloader.ts index 32c86eacaa17e..55ce50b79f651 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloader.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloader.ts @@ -8,9 +8,12 @@ import * as tar from 'tar'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import * as path from '../../../base/common/path.js'; import { format2 } from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; import { detectLibcSync, type LibcFamily } from '../../../base/node/libc.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { FileOperationError, FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; @@ -47,6 +50,12 @@ import { IRequestContext } from '../../../base/parts/request/common/request.js'; export interface IAgentSdkPackage { /** Key under `product.agentSdks` — e.g. `'claude'`, `'codex'`. */ readonly id: string; + /** + * Brand display name for user-facing progress, e.g. `'Claude'`, `'Codex'`. + * The downloader puts this on {@link IAgentSdkDownloadProgress.displayName} + * so clients can build a localized "Downloading {displayName} agent…" label. + */ + readonly displayName: string; /** Env var that, when set, becomes the SDK root and short-circuits the download. */ readonly devOverrideEnvVar: string; /** @@ -111,9 +120,46 @@ export function resolveSdkTarget( export const IAgentSdkDownloader = createDecorator('agentSdkDownloader'); +/** Lifecycle phase of a single SDK download (downloader-internal). */ +export type AgentSdkDownloadPhase = 'started' | 'progress' | 'completed' | 'failed'; + +/** + * A process-global download-progress sample fired on + * {@link IAgentSdkDownloader.onDidDownloadProgress}. The downloader owns the + * lifecycle: one `started`, throttled `progress` frames, then exactly one + * terminal `completed` / `failed` — all sharing a `downloadId`. Concurrent + * `loadSdkRoot` callers for the same tarball are deduped, so they observe one + * shared download (one `downloadId`). + */ +export interface IAgentSdkDownloadProgress { + /** Stable id for one download; coalesces frames and distinguishes concurrent fetches. */ + readonly downloadId: string; + /** Package id, e.g. `'claude'` / `'codex'`. */ + readonly packageId: string; + /** Brand display name, e.g. `'Claude'`. */ + readonly displayName: string; + /** Lifecycle phase of this frame. */ + readonly phase: AgentSdkDownloadPhase; + /** Bytes written so far. Monotonically non-decreasing within a `downloadId`. */ + readonly receivedBytes: number; + /** Total bytes from `Content-Length`, or `undefined` when unknown (indeterminate). */ + readonly totalBytes: number | undefined; + /** Short, non-localized failure reason; present only when `phase: 'failed'`. */ + readonly error?: string; +} + export interface IAgentSdkDownloader { readonly _serviceBrand: undefined; + /** + * Fires while a tarball is being fetched (cold cache only): one `started`, + * throttled `progress` samples, then one terminal `completed` / `failed`. + * Never fires for dev-override or cache-hit resolutions (no bytes move). + * Process-global so a single subscriber (the protocol server) can forward + * progress to clients regardless of which session triggered the fetch. + */ + readonly onDidDownloadProgress: Event; + /** * Returns the absolute path of the SDK root directory — the directory that * contains the package's `node_modules/` subtree. Callers resolve the @@ -138,6 +184,20 @@ export interface IAgentSdkDownloader { * download. */ isAvailable(pkg: IAgentSdkPackage): boolean; + + /** + * True iff {@link loadSdkRoot} would resolve WITHOUT a network download — + * the dev override is set, or a completed cache for the configured version + * already exists on disk. False when product config is present but the + * cache is cold (a fetch would be required), and false when neither an + * override nor product config is configured. + * + * Performs at most a single sentinel `exists` check and never downloads. + * Eager / background callers (e.g. a provider listing its sessions at + * startup) use this to avoid kicking off a multi-second cold download + * before the user has asked for anything. + */ + isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise; } // #endregion @@ -147,9 +207,31 @@ export interface IAgentSdkDownloader { /** How long a `loadSdkRoot` failure latches before we try again. */ const LOAD_FAILURE_NEGATIVE_CACHE_MS = 30_000; -export class AgentSdkDownloader implements IAgentSdkDownloader { +/** + * Minimum gap between download-progress samples. A 70-95MB tarball over a fast + * link produces thousands of chunks; without throttling we'd flood the progress + * channel. ~250ms keeps the percentage visibly moving without spamming. + */ +const PROGRESS_EMIT_THROTTLE_MS = 250; + +/** + * Parses a `Content-Length` header into a positive integer byte count, or + * `undefined` when the header is absent, an array, or not a clean integer. + */ +function parseContentLength(header: string | string[] | undefined): number | undefined { + if (typeof header !== 'string' || !/^\d+$/.test(header)) { + return undefined; + } + const parsed = parseInt(header, 10); + return parsed > 0 ? parsed : undefined; +} + +export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloader { declare readonly _serviceBrand: undefined; + private readonly _onDidDownloadProgress = this._register(new Emitter()); + readonly onDidDownloadProgress: Event = this._onDidDownloadProgress.event; + /** * In-flight downloads keyed by the destination `cacheDir` (which * already encodes `//`). Concurrent @@ -180,7 +262,9 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { @IRequestService private readonly _requestService: IRequestService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, - ) { } + ) { + super(); + } isAvailable(pkg: IAgentSdkPackage): boolean { if (process.env[pkg.devOverrideEnvVar]) { @@ -189,6 +273,22 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return !!this._productService.agentSdks?.[pkg.id] && resolveSdkTarget(pkg) !== undefined; } + async isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise { + if (process.env[pkg.devOverrideEnvVar]) { + return true; + } + const config = this._productService.agentSdks?.[pkg.id]; + if (!config) { + return false; + } + const sdkTarget = resolveSdkTarget(pkg); + if (!sdkTarget) { + return false; + } + const sentinel = URI.joinPath(URI.file(this._cacheDir(pkg.id, config.version, sdkTarget)), '.complete'); + return this._fileService.exists(sentinel); + } + async loadSdkRoot(pkg: IAgentSdkPackage, token: CancellationToken): Promise { // 1. Dev override. const override = process.env[pkg.devOverrideEnvVar]; @@ -310,9 +410,21 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { await this._delIgnoringMissing(tmpDirUri); await this._fileService.createFolder(tmpDirUri); + // Fire the download lifecycle on the process-global event so a single + // subscriber (the protocol server) can forward it to clients. One + // `started`, throttled `progress` from `_fetch`, then a terminal frame. + const downloadId = generateUuid(); + let lastReceived = 0; + let lastTotal: number | undefined; + this._fireProgress(pkg, downloadId, 'started', 0, undefined); + try { const tarballPath = path.join(tmpDir, 'sdk.tgz'); - await this._fetch(url, tarballPath, token); + await this._fetch(url, tarballPath, token, (receivedBytes, totalBytes) => { + lastReceived = receivedBytes; + lastTotal = totalBytes; + this._fireProgress(pkg, downloadId, 'progress', receivedBytes, totalBytes); + }); await this._extractTarGz(tarballPath, tmpDir); await this._fileService.del(URI.file(tarballPath)); @@ -334,6 +446,7 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { } catch (err) { if (await this._handleRenameLoser(err, sentinel, tmpDirUri)) { this._logService.info(`[AgentSdkDownloader] ${pkg.id}: lost rename race, using existing cache`); + this._fireProgress(pkg, downloadId, 'completed', lastReceived, lastTotal); return cacheDir; } throw err; @@ -341,21 +454,44 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { const elapsed = Math.round((Date.now() - start) / 1000); this._logService.info(`[AgentSdkDownloader] ${pkg.id}: downloaded in ${elapsed}s`); + this._fireProgress(pkg, downloadId, 'completed', lastTotal ?? lastReceived, lastTotal); return cacheDir; } catch (err) { await this._delIgnoringMissing(tmpDirUri); if (token.isCancellationRequested) { + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, 'cancelled'); throw new CancellationError(); } + const message = err instanceof Error ? err.message : String(err); + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, message); throw new Error( `Failed to download ${pkg.id} SDK from ${url} ` + `(cache target: ${cacheDir}). ` + `Set ${pkg.devOverrideEnvVar} to a local SDK root to bypass. ` + - `Cause: ${err instanceof Error ? err.message : String(err)}`, + `Cause: ${message}`, ); } } + private _fireProgress( + pkg: IAgentSdkPackage, + downloadId: string, + phase: AgentSdkDownloadPhase, + receivedBytes: number, + totalBytes: number | undefined, + error?: string, + ): void { + this._onDidDownloadProgress.fire({ + downloadId, + packageId: pkg.id, + displayName: pkg.displayName, + phase, + receivedBytes, + totalBytes, + ...(error !== undefined ? { error } : {}), + }); + } + private async _handleRenameLoser( err: unknown, sentinel: URI, @@ -375,7 +511,12 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return true; } - private async _fetch(url: string, dest: string, token: CancellationToken): Promise { + private async _fetch( + url: string, + dest: string, + token: CancellationToken, + onBytes?: (receivedBytes: number, totalBytes: number | undefined) => void, + ): Promise { // Delegate to IRequestService (corporate proxy, strictSSL, kerberos, // retries, redirect follow). `fs.createWriteStream` (not // `IFileService.writeFile`) so that cancelling a multi-MB download @@ -401,9 +542,31 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { throw new Error(`HTTP ${statusCode} fetching ${url}`); } + // The CDN sends `Content-Length` for these static tarballs, which lets + // us report determinate percentage progress. A missing/garbled header + // degrades gracefully to an indeterminate (byte-count only) report. + const totalBytes = parseContentLength(context.res.headers['content-length']); + await new Promise((resolve, reject) => { const out = fs.createWriteStream(dest); let settled = false; + // Throttle progress so a fast link doesn't fire thousands of + // samples. The first chunk always passes (lastEmit starts at 0) + // and 'end' forces a final sample, so consumers see a start and a + // 100% finish regardless of chunk timing. + let receivedBytes = 0; + let lastEmitTime = 0; + const emitBytes = (force: boolean) => { + if (!onBytes) { + return; + } + const now = Date.now(); + if (!force && now - lastEmitTime < PROGRESS_EMIT_THROTTLE_MS) { + return; + } + lastEmitTime = now; + onBytes(receivedBytes, totalBytes); + }; const settleResolve = () => { if (settled) { return; } settled = true; @@ -428,11 +591,16 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { // resume on 'drain'. out.on('drain', () => context.stream.resume()); context.stream.on('data', chunk => { + receivedBytes += chunk.byteLength; + emitBytes(false); if (!out.write(chunk.buffer)) { context.stream.pause(); } }); - context.stream.on('end', () => out.end()); + context.stream.on('end', () => { + emitBytes(true); + out.end(); + }); context.stream.on('error', settleReject); }); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index fed9ef2e90f29..117a90240fb97 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -20,16 +20,16 @@ import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProv import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js'; +import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; -import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; import { ActionType, ActionEnvelope, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; 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, MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; +import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -39,7 +39,7 @@ import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; -import { feedbackServerToolGroup } from './shared/agentFeedbackServerTools.js'; +import { serverToolGroups } from './shared/serverToolGroups.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js'; @@ -62,8 +62,12 @@ import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agen import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js'; -import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; +import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; +import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; +import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -72,7 +76,6 @@ import { IAgentHostChangesetOperationService } from '../common/agentHostChangese * provider-side session, worktree, and on-disk state. */ const SESSION_GC_GRACE_MS = 30_000; - /** * Grace period before an idle resource watch is torn down after its last * subscriber unsubscribes (mirrors {@link SESSION_GC_GRACE_MS}). Within @@ -83,6 +86,40 @@ const SESSION_GC_GRACE_MS = 30_000; */ const RESOURCE_WATCH_GRACE_MS = 30_000; +/** + * Session-database metadata key under which the orchestrator persists its own + * catalog of additional (non-default) peer chats for a session. The value is a + * JSON array of {@link IPersistedPeerChat}. This is the orchestrator's single + * source of truth for peer-chat enumeration on restore. When the key is absent + * the session predates orchestrator-owned persistence and a one-time migration + * drains the agent's legacy `*.chats` (see + * {@link AgentService._migrateLegacyPeerChats}). + */ +const PEER_CHATS_METADATA_KEY = 'peerChats'; + +/** + * Session-database metadata key written on a peer chat's *backing* SDK session + * (see {@link IAgentCreateChatResult.backingSession}). Its presence marks that + * session as an internal peer-chat backing that must never surface as a + * top-level session; the value is the owning peer chat's channel URI string. + * Persisted, so it survives a host restart without re-stamping. + */ +const PEER_CHAT_BACKING_METADATA_KEY = 'peerChatBacking'; + +/** + * A single entry in the orchestrator's persisted peer-chat catalog. `uri` is + * the peer chat's channel URI; `providerData` is the opaque, agent-owned blob + * (see {@link IAgentCreateChatResult.providerData}) handed back to the agent on + * restore — the orchestrator never parses it. `providerData` may be omitted, + * in which case the agent recovers its backing from its own persistence on + * {@link IAgent.materializeChat}. + */ +interface IPersistedPeerChat { + readonly uri: string; + readonly providerData?: string; +} + + /** * The agent service implementation that runs inside the agent-host utility * process. Dispatches to registered {@link IAgent} instances based @@ -116,8 +153,28 @@ export class AgentService extends Disposable implements IAgentService { private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ private readonly _sessionToProvider = new Map(); + /** + * Sessions that have opted in to bring-up progress, keyed by provider id. + * A session is added here when its `createSession` carries a + * {@link IAgentCreateSessionConfig.progressToken} and removed once it + * materializes (the SDK is now resolved) or is disposed. The SDK download is + * host-level and shared across every session of a provider, so this only + * records *interest*: as long as one or more sessions of a provider is + * registered, {@link emitDownloadProgress} surfaces that provider's download as a single + * progress stream keyed by the download's own identity (the package id), + * rather than one stream per session. + */ + private readonly _downloadProgressInterest = new Map>(); /** Subscriptions to provider progress events; cleared when providers change. */ private readonly _providerSubscriptions = this._register(new DisposableStore()); + /** + * Per-session tail of in-flight persisted peer-chat catalog writes, keyed by + * session URI string. Read-modify-write updates to the {@link + * PEER_CHATS_METADATA_KEY} blob are chained per session so a `createChat`, + * `disposeChat`, and `onDidChangeChatData` racing for the same + * session can't clobber each other's edits. + */ + private readonly _peerChatCatalogWrites = new Map>(); private readonly _authService: AgentHostAuthenticationService; /** Default provider used when no explicit provider is specified. */ private _defaultProvider: AgentProvider | undefined; @@ -255,7 +312,7 @@ export class AgentService extends Disposable implements IAgentService { const effectiveCopilotApiService = copilotApiService ?? instantiationService.createInstance(CopilotApiService, undefined); services.set(ICopilotApiService, effectiveCopilotApiService); - this._gitStateService = instantiationService.createInstance(AgentHostGitStateService, this._stateManager); + this._gitStateService = this._register(instantiationService.createInstance(AgentHostGitStateService, this._stateManager)); services.set(IAgentHostGitStateService, this._gitStateService); // The checkpoint service is constructed in the outer agent-host @@ -269,19 +326,25 @@ export class AgentService extends Disposable implements IAgentService { this._changesetSubscriptions = instantiationService.createInstance(AgentHostChangesetSubscriptionService); services.set(IAgentHostChangesetSubscriptionService, this._changesetSubscriptions); - // The changeset service is responsible for computing, publishing, and persisting changesets. - this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager)); - services.set(IAgentHostChangesetService, this._changesets); - // The operation contribution service manages the lifecycle of changeset operations. this._changesetOperationService = this._register(instantiationService.createInstance(AgentHostChangesetOperationService, this._stateManager)); services.set(IAgentHostChangesetOperationService, this._changesetOperationService); + // The changeset service is responsible for computing, publishing, and persisting changesets. + this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager)); + services.set(IAgentHostChangesetService, this._changesets); + // The coordinator owns all AgentService-side orchestration of the changeset feature: lifecycle // hooks, listSessions overlay, subscription URI routing, and the deferred-refresh state machine. this._changesetCoordinator = this._register(instantiationService.createInstance(AgentHostChangesetCoordinator, this._stateManager)); this._register(this._stateManager.onDidChangeSessionActiveTurn(e => this._changesetCoordinator.onSessionTurnActiveChanged(e.session, e.active))); + // Register the changeset operation contributions. + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager))); + this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); // Built-in generic provider: completes files in the session's workspace folder. const workspaceFiles = this._register(instantiationService.createInstance(AgentHostWorkspaceFiles)); @@ -308,9 +371,13 @@ export class AgentService extends Disposable implements IAgentService { scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported, }); }, - onTurnComplete: session => { - const workingDirStr = this._stateManager.getSessionState(session)?.summary.workingDirectory; - this._attachGitState(URI.parse(session), workingDirStr ? URI.parse(workingDirStr) : undefined); + onTurnComplete: async session => { + // Refresh the git state for the session. + const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectory; + void this._gitStateService.refreshSessionGitState(session, workingDirStr ? URI.parse(workingDirStr) : undefined); + + // Check for a GitHub pull request associated with the session's branch. + void this._gitStateService.attachSessionGitHubPullRequest(session.toString()); }, })); @@ -322,7 +389,7 @@ export class AgentService extends Disposable implements IAgentService { // state. Tool groups are contributed here at startup (feedback today) and // handed to providers that support them during registration (see // registerProvider). - this._serverToolHost = new AgentServerToolHost(this._stateManager, [feedbackServerToolGroup]); + this._serverToolHost = new AgentServerToolHost(this._stateManager, serverToolGroups); } // ---- provider registration ---------------------------------------------- @@ -334,6 +401,14 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`Registering agent provider: ${provider.id}`); this._providers.set(provider.id, provider); provider.setServerToolHost?.(this._serverToolHost); + // Deterministic subagent membership ordering: apply a spawned subagent's + // catalog membership (via the spawn-channel handlers) BEFORE + // AgentSideEffects — registered next — handles the same signal and starts + // a turn on the subagent chat, which requires that chat to already exist. + // Registering this listener ahead of the side-effects listener makes the + // ordering independent of when the agent registers its own subagent->spawn + // bridge; addChat/removeChat are idempotent, so the overlap is safe. + this._providerSubscriptions.add(provider.onDidSessionProgress(signal => this._sequenceSpawnedChat(signal))); this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider)); if (provider.onDidMaterializeSession) { this._providerSubscriptions.add(provider.onDidMaterializeSession(e => this._onDidMaterializeSession(e))); @@ -341,6 +416,12 @@ export class AgentService extends Disposable implements IAgentService { if (provider.onMcpNotification) { this._providerSubscriptions.add(provider.onMcpNotification(e => this._onMcpNotification.fire(e))); } + if (provider.onDidChangeChatData) { + this._providerSubscriptions.add(provider.onDidChangeChatData(e => this._onChatDataChanged(e))); + } + if (provider.onDidSpawnChat) { + this._providerSubscriptions.add(provider.onDidSpawnChat(e => this._onChatSpawned(e))); + } this._registerSkillCompletionProvider(); if (!this._defaultProvider) { this._defaultProvider = provider.id; @@ -402,7 +483,7 @@ export class AgentService extends Disposable implements IAgentService { const flat = results.flat(); // Overlay persisted custom titles from per-session databases. - const result = await Promise.all(flat.map(async s => { + const overlaid = await Promise.all(flat.map(async (s): Promise => { try { const ref = await this._sessionDataService.tryOpenDatabase(s.session); if (!ref) { @@ -418,9 +499,17 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, isRead: true, isArchived: true, isDone: true, ...changesetKeys } - : { customTitle: true, isRead: true, isArchived: true, isDone: true }; + ? { customTitle: true, isRead: true, isArchived: true, isDone: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, isRead: true, isArchived: true, isDone: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: 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 + // own `listSessions`). Drop it so it never leaks as a + // standalone top-level session — mirrors the subagent filter + // on the state-manager overlay path below. + if (m[PEER_CHAT_BACKING_METADATA_KEY]) { + return undefined; + } let updated = s; if (m.customTitle) { updated = { ...updated, summary: m.customTitle }; @@ -433,6 +522,27 @@ export class AgentService extends Disposable implements IAgentService { } else if (m.isDone !== undefined) { updated = { ...updated, isArchived: m.isDone === 'true' }; } + if (m[META_GIT_STATE]) { + try { + const gitState = JSON.parse(m[META_GIT_STATE]) as ISessionGitState; + updated = { ...updated, _meta: withSessionGitState(updated._meta, gitState) }; + } catch (e) { + this._logService.warn(`[AgentService][listSessions] Failed to parse Git state for ${s.session}`, e); + } + } + if (m[META_GITHUB_STATE]) { + try { + const gitHubState = JSON.parse(m[META_GITHUB_STATE]) as ISessionGitHubState; + updated = { ...updated, _meta: withSessionGitHubState(updated._meta, gitHubState) }; + } catch (e) { + this._logService.warn(`[AgentService][listSessions] Failed to parse GitHub state for ${s.session}`, e); + } + } + + if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; + } + return this._changesetCoordinator.decorateListEntry(updated, m as Record); } finally { ref.dispose(); @@ -442,6 +552,7 @@ export class AgentService extends Disposable implements IAgentService { } return s; })); + const result = overlaid.filter((s): s is IAgentSessionMetadata => s !== undefined); // Overlay live session state from the state manager. // For the title, prefer the state manager's value when it is @@ -449,22 +560,30 @@ export class AgentService extends Disposable implements IAgentService { // initial empty placeholder. The default changeset catalogue lives // on `state.changesets` (seeded after `createSession` / // `restoreSession` and refreshed after each compute pass) and the - // chip aggregate on `state.summary.changes`; both must be surfaced - // here so a fresh `listSessions` call returns the same values + // chip aggregate on the catalog summary's `changes`; both must be + // surfaced here so a fresh `listSessions` call returns the same values // subscribers see via the per-session action stream and // `notify/sessionSummaryChanged`. const withStatus = result.map(s => { - const liveState = this._stateManager.getSessionState(s.session.toString()); - if (liveState) { + const liveSummary = this._stateManager.getSessionSummary(s.session.toString()); + if (liveSummary) { + // Overlay the live `_meta` over the DB-derived value. The live + // `_meta` is the freshest source (e.g. the GitHub state is + // published here as soon as a PR is created), so a freshly-created + // session that has not yet persisted its state to its session + // database still reports it here. Keep the DB value as the base so + // any keys absent from the live `_meta` are preserved. + const _meta = liveSummary._meta !== undefined || s._meta !== undefined + ? { ...s._meta, ...liveSummary._meta } + : undefined; return { ...s, - summary: liveState.summary.title || s.summary, - status: liveState.summary.status, - activity: liveState.summary.activity, - model: liveState.summary.model ?? s.model, - agent: liveState.summary.agent ?? s.agent, - changes: liveState.summary.changes ?? s.changes, - changesets: liveState.changesets ?? s.changesets, + summary: liveSummary.title || s.summary, + status: liveSummary.status, + activity: liveSummary.activity, + changes: liveSummary.changes ?? s.changes, + changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets, + ...(_meta !== undefined ? { _meta } : {}), }; } return s; @@ -492,18 +611,24 @@ export class AgentService extends Disposable implements IAgentService { if (isSubagentSession(summary.resource)) { continue; } + additions.push({ session: URI.parse(summary.resource), - startTime: summary.createdAt, - modifiedTime: summary.modifiedAt, + startTime: Date.parse(summary.createdAt), + modifiedTime: Date.parse(summary.modifiedAt), summary: summary.title, status: summary.status, activity: summary.activity, - model: summary.model, - agent: summary.agent, workingDirectory: typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : undefined, ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}), changes: summary.changes, + // This overlay path never opens the session database (unlike the + // provider-returned sessions handled above), so carry the + // in-memory `summary._meta` directly. It holds the live state + // (e.g. the GitHub state published when a PR is created), so a + // freshly-created session that the provider transiently omits + // still reports it here. + ...(summary._meta !== undefined ? { _meta: summary._meta } : {}), }); } const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; @@ -548,7 +673,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`); const [, created] = await Promise.all([ this._sideEffects.initialize(), - provider.createSession(config), + this._createSession(provider, config), ]); const session = created.session; this._logService.trace(`[AgentService] createSession: initialization complete`); @@ -562,6 +687,19 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); this._sessionToProvider.set(session.toString(), provider.id); + // Record this session's opt-in so a cold SDK download triggered at + // materialization (first message) is surfaced as progress. The download + // is provider-global, so we only track interest here; emission is keyed + // by the download's own identity, not this token. Cleared on + // materialize/dispose. + if (config?.progressToken) { + let sessions = this._downloadProgressInterest.get(provider.id); + if (!sessions) { + sessions = new Set(); + this._downloadProgressInterest.set(provider.id, sessions); + } + sessions.add(session.toString()); + } this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); // Resolve config and seed the initial customization set in parallel so @@ -600,7 +738,7 @@ export class AgentService extends Disposable implements IAgentService { // reinventing the convention. Avoid double-prefixing when a user // forks an already-forked session. const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: "); - const sourceTitle = sourceState?.summary.title; + const sourceTitle = sourceState?.title; const forkedTitle = sourceTitle ? (sourceTitle.startsWith(forkedTitlePrefix) ? sourceTitle : `${forkedTitlePrefix}${sourceTitle}`) : localize('agentHost.forkedSessionFallback', "Forked Session"); @@ -608,10 +746,18 @@ export class AgentService extends Disposable implements IAgentService { const state = this._stateManager.createSession(summary); state.config = sessionConfig; this._stateManager.seedDefaultChatTurns(summary.resource, sourceTurns); - state.activeClient = config.activeClient; + state.activeClients = config.activeClient ? [config.activeClient] : []; if (initialCustomizations && initialCustomizations.length > 0) { state.customizations = [...initialCustomizations]; } + + // Refine the forked session's placeholder `Forked: …` title into one + // derived from the inherited chat. Forks seed pre-existing + // turns, so the normal first-message/first-turn title generation + // never fires for them — this is the fork-time equivalent. + if (sourceTurns.length > 0) { + this._sideEffects.generateForkedTitle(summary.resource, undefined, sourceTurns, forkedTitle, sourceTitle); + } } else { // Provisional sessions defer the `sessionAdded` notification and // the `SessionReady` lifecycle transition until the agent fires @@ -622,7 +768,7 @@ export class AgentService extends Disposable implements IAgentService { const summary = this._buildInitialSummary(provider, session, config, created, ''); const state = this._stateManager.createSession(summary, { emitNotification: !created.provisional }); state.config = sessionConfig; - state.activeClient = config?.activeClient; + state.activeClients = config?.activeClient ? [config.activeClient] : []; if (initialCustomizations && initialCustomizations.length > 0) { state.customizations = [...initialCustomizations]; } @@ -636,22 +782,14 @@ export class AgentService extends Disposable implements IAgentService { this._persistConfigValues(session, sessionConfig.values); } - // Initial changeset state is established as part of session creation, - // never deferred to materialization. Two halves: (1) the catalogue - // is seeded on `state.changesets` via `setSessionChangesets` right - // after `createSession`; (2) the backing per-changeset states are - // registered by `_changesetCoordinator.onSessionCreated` here. Both - // run before `SessionReady` is dispatched. Any future change must - // keep both halves at create time so client subscriptions resolve - // `_attachGitState` strips them once the git probe confirms the - // resolved working directory is not a git repo. Pinned by item-2 - // regression tests in `agentService.test.ts`. - const changesets = buildDefaultChangesetCatalogue(session.toString()); - this._stateManager.setSessionChangesets(session.toString(), changesets); - this._changesetCoordinator.onSessionCreated(session.toString()); if (!created.provisional) { + // Persist the AH-owned workspace-less marker now that the session DB + // exists, from the value `_buildInitialSummary` inferred. Provisional + // sessions defer this to `_onDidMaterializeSession`. + this._persistWorkspaceless(session, readSessionWorkspaceless(this._stateManager.getSessionSummary(session.toString())?._meta)); + // `SessionReady` transitions the session lifecycle from // `Creating` to `Ready`. For provisional sessions we defer // this to {@link _onDidMaterializeSession} so subscribers @@ -659,9 +797,9 @@ export class AgentService extends Disposable implements IAgentService { // session, working directory, etc. this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionReady }); - // Lazily compute git state for sessions with a working directory; - // attaches under `state._meta.git` once ready. - this._attachGitState(session, created.workingDirectory ?? config?.workingDirectory); + // Refresh the git state for the session. + const workingDirectory = created.workingDirectory ?? config?.workingDirectory; + void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); } return session; @@ -673,26 +811,141 @@ export class AgentService extends Disposable implements IAgentService { if (!provider) { throw new Error(`[AgentService] createChat: no provider for session ${sessionKey}`); } - if (!provider.createChat) { + if (!this._supportsChats(provider)) { throw new Error(`[AgentService] createChat: provider ${provider.id} does not support multiple chats`); } - // Spin up the backing conversation in the harness first, then register + + // When forking, resolve the source chat's turns up to the fork point and + // mint fresh turn IDs for the new chat. The agent uses the mapping to + // remap per-turn data in the forked chat; the seeded turns make + // the new chat surface the forked history immediately. + let forkedTurns: Turn[] | undefined; + let forkedTitle: string | undefined; + let forkedSourceTitle: string | undefined; + let createOptions = options; + if (options?.fork) { + const sourceKey = options.fork.source.toString(); + const sourceState = this._stateManager.getChatState(sourceKey) + ?? this._stateManager.getDefaultChatState(sourceKey); + const sourceTurns = sourceState?.turns ?? []; + const forkIndex = sourceTurns.findIndex(t => t.id === options.fork!.turnId); + if (forkIndex < 0) { + // The fork point is unknown, so a fork is indistinguishable from a + // fresh chat. Drop the fork to avoid the provider inheriting the + // whole backend chat while the UI is seeded with no turns. + createOptions = { ...options, fork: undefined }; + } else { + const slice = sourceTurns.slice(0, forkIndex + 1); + const turnIdMapping = new Map(); + for (const t of slice) { + turnIdMapping.set(t.id, generateUuid()); + } + forkedTurns = slice.map(t => ({ ...t, id: turnIdMapping.get(t.id) ?? generateUuid() })); + + const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: "); + forkedSourceTitle = sourceState?.title || this._stateManager.getSessionState(sessionKey)?.title; + forkedTitle = forkedSourceTitle + ? (forkedSourceTitle.startsWith(forkedTitlePrefix) ? forkedSourceTitle : `${forkedTitlePrefix}${forkedSourceTitle}`) + : localize('agentHost.forkedChatFallback', "Forked Chat"); + createOptions = { ...options, fork: { ...options.fork, turnIdMapping } }; + } + } + + // Spin up the backing chat in the harness first, then register // the chat in the catalog so a `session/chatAdded` only reaches - // subscribers once the chat can actually receive messages. - await provider.createChat(session, chat, options); - this._stateManager.addChat(sessionKey, chat.toString(), options?.title !== undefined ? { title: options.title } : undefined); + // subscribers once the chat can actually receive messages. The agent + // returns the opaque `providerData` blob the orchestrator persists for + // restore (it never parses it); single-chat-only agents return `void`. + const createResult = await this._createChat(provider, chat, createOptions); + const providerData = createResult?.providerData; + this._stateManager.addChat(sessionKey, chat.toString(), { + ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}), + ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), + ...(providerData !== undefined ? { providerData } : {}), + }); + + // Persist the new peer chat into the orchestrator-owned catalog so it is + // re-enumerated and re-materialized on the next restore without asking + // the agent. + void this._persistPeerChat(session, chat, providerData); + + // When the agent backs this peer chat with its own separately-enumerable + // SDK session (e.g. Claude), mark that session so it is filtered out of + // the top-level session list instead of leaking as a standalone session. + if (createResult?.backingSession) { + this._markPeerChatBacking(createResult.backingSession, chat); + } + + // Refine the forked chat's placeholder `Forked: …` title into one + // derived from the inherited chat. Forks seed pre-existing + // turns, so the normal first-message/first-turn title generation never + // fires for them — this is the fork-time equivalent. + if (forkedTurns && forkedTurns.length > 0 && forkedTitle !== undefined) { + this._sideEffects.generateForkedTitle(sessionKey, chat.toString(), forkedTurns, forkedTitle, forkedSourceTitle); + } } async disposeChat(session: URI, chat: URI): Promise { const sessionKey = session.toString(); const provider = this._findProviderForSession(session); this._stateManager.removeChat(sessionKey, chat.toString()); - await provider?.disposeChat?.(session, chat); + // Drop the chat from the orchestrator-owned catalog so it isn't + // re-materialized on the next restore. + void this._removePersistedPeerChat(session, chat); + if (provider) { + await this._disposeChat(provider, chat); + } + } + + // ---- Chat dispatch adapter --------------------------------------------- + // + // The orchestrator owns the feature-level `(session, chat)` → + // `(agent, session, chat)` mapping. It dispatches against an agent's + // chat-addressed surface ({@link IAgent.chats}) and session lifecycle + // ({@link IAgent.createSession}/{@link IAgent.disposeSession}). + + /** Whether `provider` can host additional (peer) chats. */ + private _supportsChats(provider: IAgent): boolean { + return !!provider.chats; + } + + private _createSession(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise { + return provider.createSession(config); + } + + private async _disposeSession(provider: IAgent, session: URI): Promise { + await provider.disposeSession(session); + } + + /** + * Reconstruct the turns for a chat. `chat` is the concrete chat channel URI, + * except for legacy restore paths that still address subagent sessions. + */ + private _getChatMessages(provider: IAgent, chat: URI): Promise { + return provider.chats.getMessages(chat); + } + + /** + * Create (or fork) the peer chat `chat` within `session`. `chat` is + * always a peer URI here (the default chat is created implicitly with + * the session), so no default-chat resolution is needed. + */ + private _createChat(provider: IAgent, chat: URI, options: IAgentCreateChatOptions | undefined): Promise { + const convOptions: IAgentCreateChatOptions | undefined = options && (options.title !== undefined || options.model !== undefined) + ? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: options.model } : {}) } + : undefined; + return options?.fork + ? provider.chats.fork(chat, options.fork, convOptions) + : provider.chats.createChat(chat, convOptions); + } + + private async _disposeChat(provider: IAgent, chat: URI): Promise { + await provider.chats.disposeChat(chat); } private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary { - const now = Date.now(); + const now = new Date().toISOString(); return { resource: session.toString(), provider: provider.id, @@ -701,9 +954,11 @@ export class AgentService extends Disposable implements IAgentService { createdAt: now, modifiedAt: now, ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), - model: config?.model, - agent: config?.agent, workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(), + // Workspace-less is inferred at create from an absent input + // `workingDirectory` (the host assigns a scratch cwd, so it can't be + // re-inferred later) and tagged on the generic `_meta` bag. + ...(config && !config.fork && !config.workingDirectory ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), }; } @@ -721,21 +976,32 @@ export class AgentService extends Disposable implements IAgentService { */ private _onDidMaterializeSession(e: IAgentMaterializeSessionEvent): void { const sessionKey = e.session.toString(); + // The session is now materialized — its SDK is resolved (any cold + // download already finished), so no further progress is expected for it. + this._clearDownloadProgressInterest(sessionKey); const state = this._stateManager.getSessionState(sessionKey); if (!state) { this._logService.warn(`[AgentService] onDidMaterializeSession for unknown session: ${sessionKey}`); return; } + const currentSummary = this._stateManager.getSessionSummary(sessionKey); + if (!currentSummary) { + this._logService.warn(`[AgentService] onDidMaterializeSession missing summary for session: ${sessionKey}`); + return; + } const summary: SessionSummary = { - ...state.summary, + ...currentSummary, ...(e.project ? { project: { uri: e.project.uri.toString(), displayName: e.project.displayName } } : {}), - workingDirectory: e.workingDirectory?.toString() ?? state.summary.workingDirectory, - modifiedAt: Date.now(), + workingDirectory: e.workingDirectory?.toString() ?? currentSummary.workingDirectory, + modifiedAt: new Date().toISOString(), }; const configValues = state.config?.values; if (configValues && Object.keys(configValues).length > 0) { this._persistConfigValues(e.session, configValues); } + // Persist the AH-owned workspace-less marker now that the session has a + // real on-disk database (deferred from create for provisional sessions). + this._persistWorkspaceless(e.session, readSessionWorkspaceless(summary._meta)); // `markSessionPersisted` writes the summary into state and fires // the deferred `SessionAdded` notification atomically so subscribers // see consistent state through both paths. @@ -743,11 +1009,7 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); // Attach git state for the working directory (if present) - this._attachGitState(e.session, e.workingDirectory); - - // Initialize the session's changesets from the catalogue - const changesets = buildDefaultChangesetCatalogue(sessionKey); - this._stateManager.setSessionChangesets(sessionKey, changesets); + void this._gitStateService.refreshSessionGitState(e.session.toString(), e.workingDirectory); // If a client subscribed to this session's uncommitted changeset // before the working directory was known, the coordinator drains @@ -755,37 +1017,65 @@ export class AgentService extends Disposable implements IAgentService { this._changesetCoordinator.onSessionMaterialized(sessionKey); } + /** Drop a session's download-progress opt-in, if any. */ + private _clearDownloadProgressInterest(sessionKey: string): void { + for (const [provider, sessions] of this._downloadProgressInterest) { + if (sessions.delete(sessionKey) && sessions.size === 0) { + this._downloadProgressInterest.delete(provider); + } + } + } + /** - * Fire-and-forget probe that resolves the session's git state for its - * working directory (if any) and merges it into `state._meta.git` via - * the state manager. Failures are logged; sessions simply remain without - * git state. + * Surface a host-level SDK download as client progress. The downloader fires + * process-global frames keyed by package id (which equals the provider id); + * because the download is shared across every session of that provider, we + * emit a SINGLE `progress` stream keyed by that package id — not one per + * session — so the client shows exactly one indicator no matter how many + * sessions of the provider are awaiting it. Frames are only emitted while at + * least one session has opted in (supplied a + * {@link IAgentCreateSessionConfig.progressToken} on `createSession`). A + * terminal frame reports `total === progress` (using `receivedBytes` when the + * size was never known) so the client dismisses the indicator deterministically. * - * Also gates the two git-only default catalogue entries - * (`Branch Changes`, `Uncommitted Changes`): when the working - * directory is resolved AND the git probe confirms it is not a git - * repo, those entries are stripped from `summary.changesets`, leaving - * only `This Turn`. An absent working directory is treated as - * transient (provisional / pre-materialize / pre-restore) — we do NOT - * strip in that case because there is no path that re-adds the - * entries when a subsequent `onSessionMaterialized` / restore call - * resolves the working directory and the probe succeeds. The - * entries' counts remain unset until a real compute lands, so chip - * rendering naturally skips them in the meantime. + * `displayName` is the provider's brand noun (e.g. `Claude`). It is woven + * into the notification's localized, human-readable `message` (e.g. + * "Downloading Claude agent…") so a generic client can render the indicator + * verbatim without knowing the resource is an agent SDK. */ - private _attachGitState(session: URI, workingDirectory: URI | undefined): void { - const sessionKey = session.toString(); - this._gitStateService.refreshSessionGitState(sessionKey, workingDirectory).then( - gitState => { - if (!gitState) { - return; - } - this._changesetCoordinator.onSessionGitStateChanged(sessionKey, gitState); - }, - e => { - this._logService.warn(`[AgentService] Failed to compute git state for ${session}`, e); - }, - ); + emitDownloadProgress(packageId: string, displayName: string, receivedBytes: number, totalBytes: number | undefined, terminal: boolean): void { + const sessions = this._downloadProgressInterest.get(packageId); + if (!sessions || sessions.size === 0) { + return; + } + // On a terminal frame force `progress === total` so clients treat the + // operation as complete (covers both the determinate case and the + // indeterminate one where `totalBytes` was never known, plus failures — + // the real error surfaces via the session-failure path). + const total = terminal ? receivedBytes : totalBytes; + const message = localize('agentHost.download.agentSdkTitle', "Downloading {0} agent…", displayName); + // `progressToken` is the download's own stable identity (the package id), + // shared by every session of the provider, so the client coalesces all + // frames into one indicator and dismisses it on the terminal frame. + this._stateManager.emitProgress({ progressToken: packageId, progress: receivedBytes, total, message }); + if (terminal) { + this._downloadProgressInterest.delete(packageId); + } + } + + private _persistWorkspaceless(session: URI, workspaceless: boolean): void { + let ref; + try { + ref = this._sessionDataService.openDatabase(session); + } catch (err) { + this._logService.warn(`[AgentService] Failed to open session database to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`); + return; + } + ref.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, workspaceless ? 'true' : 'false').catch(err => { + this._logService.warn(`[AgentService] Failed to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`); + }).finally(() => { + ref.dispose(); + }); } private _persistConfigValues(session: URI, values: Record): void { @@ -850,8 +1140,9 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); const provider = this._findProviderForSession(session); if (provider) { - await provider.disposeSession(session); + await this._disposeSession(provider, session); this._sessionToProvider.delete(session.toString()); + this._clearDownloadProgressInterest(session.toString()); } this._changesetCoordinator.onSessionDisposed(session.toString()); this._sideEffects.cancelSessionTitleGeneration(session.toString()); @@ -949,9 +1240,11 @@ export class AgentService extends Disposable implements IAgentService { // is async and updates `_meta.git` once ready, which clients see via // the normal state-update stream. const sessionState = this._stateManager.getSessionState(resourceStr); - if (sessionState && readSessionGitState(sessionState._meta) === undefined) { - const wd = sessionState.summary?.workingDirectory; - this._attachGitState(resource, wd ? URI.parse(wd) : undefined); + if (!isAhpChatChannel(resourceStr) && sessionState && readSessionGitState(sessionState._meta) === undefined) { + const workingDirectory = sessionState.workingDirectory + ? URI.parse(sessionState.workingDirectory) + : undefined; + void this._gitStateService.refreshSessionGitState(resourceStr, workingDirectory); } return snapshot; @@ -1071,14 +1364,23 @@ export class AgentService extends Disposable implements IAgentService { } /** - * If `resource` names an idle session and no client is still subscribed to - * it (or, for a subagent URI, no sibling subagent under the same parent is - * still subscribed), drop its cached state from the state manager. Subagent - * URIs evict the parent session entry; the parent owns the materialized - * turn tree that backs every subagent view. The next subscribe will - * rehydrate the session via {@link restoreSession}. + * Eviction is currently disabled: this is a no-op that keeps cached session + * state in memory. When re-enabled, it should drop cached state for an idle + * session that has no remaining subscribers (walking up subagent ancestry + * and evicting the root session entry), allowing the session to be + * rehydrated later via {@link restoreSession}. */ private _maybeEvictIdleSession(resource: URI): void { + // Idle-session eviction is disabled while we investigate issues where + // cached session state is dropped while clients still expect it to be + // observable. Keeping the cached state in memory prevents spurious + // re-restores and state loss. + // TODO: re-enable eviction or add an LRU cap to avoid unbounded memory + // growth in long-lived agent-host processes. + void resource; + return; + + /* const key = resource.toString(); if (this._resourceSubscribers.has(resource)) { return; @@ -1115,6 +1417,7 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.removeSession(cachedKey); } this._stateManager.removeSession(evictionTargetKey); + */ } // Returns true when a changeset is safe to drop from the in-memory cache. @@ -1175,13 +1478,13 @@ export class AgentService extends Disposable implements IAgentService { dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); - // Clients dispatch conversation (chat) actions against a chat channel + // Clients dispatch chat (chat) actions against a chat channel // URI. Keep that chat channel for the optimistic state apply and for // per-chat routing in side effects, while deriving the owning session // URI for all session-scoped work (attachment snapshotting, agent // lookup, telemetry, permissions — all keyed by session). const chatChannel = isAhpChatChannel(channel) ? channel : undefined; - const sessionChannel = chatChannel ? (parseDefaultChatUri(chatChannel) ?? channel) : channel; + const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel; const pending = this._clientDispatchQueues.get(clientId); if (!pending && !this._needsAsyncRewrite(sessionChannel, action)) { @@ -1210,9 +1513,7 @@ export class AgentService extends Disposable implements IAgentService { if (action.type === ActionType.RootConfigChanged) { this._configurationService.persistRootConfig(); } - // Side effects key session-scoped work by the session URI, but route - // per-chat operations (message send, turn cancel) to the chat channel. - this._sideEffects.handleAction(sessionChannel, action, channel !== sessionChannel ? channel : undefined); + this._sideEffects.handleAction(channel, action, clientId); } private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction { @@ -1428,9 +1729,10 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); } + const defaultChatUri = URI.parse(buildDefaultChatUri(sessionStr)); let turns: readonly Turn[]; try { - turns = await agent.getSessionMessages(session); + turns = await this._getChatMessages(agent, defaultChatUri); } catch (err) { if (err instanceof ProtocolError) { throw err; @@ -1445,7 +1747,9 @@ export class AgentService extends Disposable implements IAgentService { let isArchived: boolean | undefined; let persistedConfigValues: Record | undefined; let changes: ChangesSummary | undefined; + let gitMetadata: Record | undefined; let changesetMetadata: Record | undefined; + let sessionMetadata: Record | undefined; const ref = this._sessionDataService.tryOpenDatabase?.(session); if (ref) { try { @@ -1458,6 +1762,8 @@ export class AgentService extends Disposable implements IAgentService { isArchived: true, isDone: true, configValues: true, + [AH_META_WORKSPACELESS_DB_KEY]: true, + ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, }); if (m.customTitle) { @@ -1481,6 +1787,33 @@ export class AgentService extends Disposable implements IAgentService { } } + gitMetadata = m as Record; + + if (gitMetadata[META_GIT_STATE]) { + try { + const gitState = JSON.parse(gitMetadata[META_GIT_STATE]); + sessionMetadata = { [SESSION_META_GIT_KEY]: gitState }; + } catch (err) { + this._logService.warn(`[AgentService] Failed to parse Git state for ${sessionStr}: ${toErrorMessage(err)}`); + } + } + + if (gitMetadata[META_GITHUB_STATE]) { + try { + const githubState = JSON.parse(gitMetadata[META_GITHUB_STATE]); + sessionMetadata = { + ...(sessionMetadata ? sessionMetadata : {}), + [SESSION_META_GITHUB_KEY]: githubState + }; + } catch (err) { + this._logService.warn(`[AgentService] Failed to parse GitHub state for ${sessionStr}: ${toErrorMessage(err)}`); + } + } + + if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true'); + } + if (m.configValues) { try { persistedConfigValues = JSON.parse(m.configValues); @@ -1511,24 +1844,44 @@ export class AgentService extends Disposable implements IAgentService { provider: agent.id, title, status, - createdAt: meta.startTime, - modifiedAt: meta.modifiedTime, + createdAt: new Date(meta.startTime).toISOString(), + modifiedAt: new Date(meta.modifiedTime).toISOString(), ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), - model: meta.model, - agent: meta.agent, changes: meta.changes ?? changes, workingDirectory: meta.workingDirectory?.toString(), + _meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined, }; - this._stateManager.restoreSession(summary, [...turns]); + const [defaultDraft, defaultChatTitle] = await Promise.all([ + this._getChatDraft(session, defaultChatUri), + this._readPersistedChatTitle(session, defaultChatUri), + ]); + this._stateManager.restoreSession(summary, [...turns], { draft: defaultDraft, defaultChatTitle }); + + const promises: Promise[] = []; + // Eagerly register subagent child sessions discovered in the event log + // so the client's per-subagent subscriptions resolve from in-memory + // state (hitting `restoreSubagent skipped existing`) instead of each + // re-fetching and re-reconstructing the full parent event log. The + // agent serves these from the same reconstruction it already produced + // for the parent turns above, so this adds no extra event-log reads. + promises.push((async () => { + if (agent.getSubagentSessions) { + try { + const children = await agent.getSubagentSessions(session); + for (const child of children) { + this._registerRestoredSubagent(child, summary, sessionStr); + } + } catch (err) { + this._logService.warn(`[AgentService] restoreSession failed to eagerly register subagents session=${sessionStr}`, err); + } + } + })()); // Restore any additional (non-default) peer chats the provider has // persisted for this session, seeding each with its own history and // persisted title so they reappear after a process restart. - await this._restorePeerChats(agent, session); - - const changesets = buildDefaultChangesetCatalogue(sessionStr); - this._stateManager.setSessionChangesets(sessionStr, changesets); + promises.push(this._restorePeerChats(agent, session)); // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue @@ -1564,6 +1917,7 @@ export class AgentService extends Disposable implements IAgentService { return undefined; }) : Promise.resolve(undefined), + ...promises ]); if (restoredConfig) { this._stateManager.setSessionConfig(sessionStr, restoredConfig); @@ -1577,45 +1931,305 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`); - // Lazily compute git state for sessions with a working directory; - // attaches under `state._meta.git` once ready. - this._attachGitState(session, meta.workingDirectory); + // Refresh the git state for the session. + void this._gitStateService.refreshSessionGitState(sessionStr, meta.workingDirectory); + + // Check for a GitHub pull request associated with the session's branch. + void this._gitStateService.attachSessionGitHubPullRequest(sessionStr); } /** - * Restores the additional (non-default) peer chats persisted for a session. - * For each chat returned by the provider, loads its history and persisted - * title and re-registers it in the state manager so it reappears in the - * session's chat catalog after a process restart. Best-effort: a chat whose - * history fails to load is restored with no turns rather than dropped. + * Restores the additional (non-default) peer chats for a session. + * + * Enumeration is driven by the orchestrator's OWN persisted catalog (the + * {@link PEER_CHATS_METADATA_KEY} blob). For each catalog entry the agent's + * in-memory backing is re-attached via + * {@link IAgent.materializeChat} (handing back the opaque + * `providerData` blob) BEFORE its history is read, then the chat is + * re-registered in the state manager with its persisted title and draft so + * it reappears after a process restart. Best-effort: a chat whose history + * fails to load is restored with no turns rather than dropped. + * + * When the orchestrator catalog is absent ({@link _readPersistedPeerChatCatalog} + * returns `undefined`) the session predates orchestrator-owned persistence: + * a one-time migration ({@link _migrateLegacyPeerChats}) drains the agent's + * legacy `*.chats` enumeration into the catalog so it is never consulted + * again. */ private async _restorePeerChats(agent: IAgent, session: URI): Promise { - if (!agent.getChats) { + const persisted = await this._readPersistedPeerChatCatalog(session); + if (persisted !== undefined) { + // The orchestrator owns the catalog: enumerate from it. + await this._restorePeerChatsFromCatalog(agent, session, persisted); + return; + } + // No orchestrator catalog yet: one-time migration from legacy `*.chats`. + await this._migrateLegacyPeerChats(agent, session); + } + + /** + * One-time migration for sessions persisted before the orchestrator owned + * the peer-chat catalog: enumerate the agent's legacy `*.chats` + * ({@link IAgent.listLegacyChats}), restore them via the same path as the + * new catalog, then write the orchestrator {@link PEER_CHATS_METADATA_KEY} + * blob so subsequent restores read the new catalog and never consult the + * legacy read again. No-op when the agent has no legacy enumeration or none + * is persisted. + */ + private async _migrateLegacyPeerChats(agent: IAgent, session: URI): Promise { + const legacy = await agent.listLegacyChats?.(session); + if (!legacy || legacy.length === 0) { + // Write an empty catalog sentinel so `_readPersistedPeerChatCatalog` + // returns `[]` on subsequent restores and this migration never re-runs. + await this._enqueuePeerChatCatalogWrite(session, () => []); return; } - let chats: readonly URI[]; + const entries: IPersistedPeerChat[] = legacy.map(chat => ({ + uri: chat.uri.toString(), + ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), + })); + await this._restorePeerChatsFromCatalog(agent, session, entries); + // Single atomic write: the key is absent before and complete after, so no + // partial catalog can survive a crash mid-migration (which would make + // `_readPersistedPeerChatCatalog` return a proper subset and permanently + // skip re-migration). The callback takes no parameter so `entries` here is + // the full migrated set, not the (absent) current catalog. + await this._enqueuePeerChatCatalogWrite(session, () => [...entries]); + } + + /** + * Restores a set of peer chats from an enumerated catalog. Loads each + * chat's history in parallel (after re-attaching its backing) but restores + * them in catalog order, so the catalog never reorders by which chat's + * history/title happened to resolve first. + */ + private async _restorePeerChatsFromCatalog(agent: IAgent, session: URI, entries: readonly IPersistedPeerChat[]): Promise { + const restored = await Promise.all(entries.map(async (entry) => { + let chatUri: URI; + try { + chatUri = URI.parse(entry.uri); + } catch (err) { + this._logService.warn(`[AgentService] Skipping malformed persisted peer chat URI '${entry.uri}': ${toErrorMessage(err)}`); + return undefined; + } + // Re-attach the agent's in-memory backing for the chat BEFORE + // reading its history, so `getSessionMessages` can resolve the + // chat. Best-effort: a corrupt/unknown blob must not abort + // the restore — the chat is then surfaced with history but no live + // backing. + if (agent.materializeChat) { + try { + await agent.materializeChat(chatUri, entry.providerData); + } catch (err) { + this._logService.warn(`[AgentService] Failed to materialize peer chat ${entry.uri}: ${toErrorMessage(err)}`); + } + } + let turns: readonly Turn[] = []; + try { + turns = await this._getChatMessages(agent, chatUri); + } catch (err) { + this._logService.warn(`[AgentService] Failed to load history for peer chat ${chatUri.toString()}: ${toErrorMessage(err)}`); + } + const [title, draft] = await Promise.all([ + this._readPersistedChatTitle(session, chatUri), + this._getChatDraft(session, chatUri), + ]); + return { chatUri, title, turns: [...turns], draft, providerData: entry.providerData }; + })); + for (const item of restored) { + if (!item) { + continue; + } + const { chatUri, title, turns, draft, providerData } = item; + this._stateManager.restoreChat(session.toString(), chatUri.toString(), { + title, + turns, + draft, + ...(providerData !== undefined ? { providerData } : {}), + }); + } + } + + /** + * Re-persists a peer chat's opaque `providerData` blob when the agent + * reports it changed (e.g. per-chat model switch, fork remap). The + * orchestrator never parses the blob; it stores whatever it is handed. + */ + private _onChatDataChanged(e: IAgentChatDataChange): void { + const sessionStr = parseDefaultChatUri(e.chat); + if (sessionStr === undefined) { + this._logService.warn(`[AgentService] onDidChangeChatData for malformed chat URI: ${e.chat.toString()}`); + return; + } + void this._persistPeerChat(URI.parse(sessionStr), e.chat, e.providerData); + } + + /** + * Deterministic membership sequencer for agent-spawned chats, + * driven off {@link IAgent.onDidSessionProgress}: a `subagent_started` adds + * the subagent chat to the catalog via the same spawn-channel handler + * ({@link _onChatSpawned}) used by {@link IAgent.onDidSpawnChat}. + * A completed subagent chat stays live and subscribable, so completion is + * not sequenced here; subagent chats are removed only on session teardown. + * Registered before {@link AgentSideEffects} so the subagent chat exists + * before its turn starts; addChat is idempotent so overlapping with the + * agent's own spawn bridge is safe. + */ + private _sequenceSpawnedChat(signal: AgentSignal): void { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + this._onChatSpawned(spawn); + } + } + + /** + * Routes an agent-spawned chat (e.g. a sub-agent delegated by a tool + * call) straight into the chat catalog via {@link IAgentHostStateManager.addChat}, + * so harness-spawned chats and user-driven chats share ONE membership path. + * The {@link IAgentSpawnChatEvent.parent} spawn edge is recorded as + * the chat's {@link ChatOriginKind.Tool} origin. Spawned chats are + * not written to the orchestrator's persisted peer-chat catalog — they are + * transient children re-derived from the parent's event log on restore. + */ + private _onChatSpawned(e: IAgentSpawnChatEvent): void { + this._stateManager.addChat(e.session.toString(), e.chat.toString(), { + ...(e.title !== undefined ? { title: e.title } : {}), + ...(e.parent ? { + origin: { kind: ChatOriginKind.Tool, chat: e.parent.chat.toString(), toolCallId: e.parent.toolCallId }, + // Subagent worker chats are observable but not directly steerable: + // the user watches them and steers the lead chat. Mark read-only so + // the UI hides the composer and shows a lock (the agent-team pattern). + interactivity: ChatInteractivity.ReadOnly, + } : {}), + }); + } + + /** + * Reads the orchestrator's persisted peer-chat catalog for a session. + * Returns `undefined` when the session has no catalog yet (a legacy session + * predating orchestrator-owned persistence, or a corrupt blob); the caller + * then performs a one-time migration from the agent's legacy `*.chats` + * enumeration (see {@link _restorePeerChats} / {@link _migrateLegacyPeerChats}). + * An empty array means the session is known to have no peer chats, so + * migration is skipped. + */ + private async _readPersistedPeerChatCatalog(session: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase?.(session); + if (!ref) { + return undefined; + } try { - chats = await agent.getChats(session); + const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + if (raw === undefined) { + return undefined; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}`); + return undefined; + } + return parsed + .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string') + .map(entry => ({ uri: entry.uri, ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}) })); } catch (err) { - this._logService.warn(`[AgentService] Failed to enumerate peer chats for ${session.toString()}: ${toErrorMessage(err)}`); + this._logService.warn(`[AgentService] Failed to read peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); + return undefined; + } finally { + ref.dispose(); + } + } + + /** + * Marks a peer chat's backing SDK session (in that session's own DB) so + * {@link listSessions} filters it out of the top-level session list. The + * marker is persisted, so it survives a host restart. Best-effort: a failure + * only means the backing session may transiently reappear in the list. + */ + private _markPeerChatBacking(backingSession: URI, chat: URI): void { + let ref; + try { + ref = this._sessionDataService.openDatabase(backingSession); + } catch (err) { + this._logService.warn(`[AgentService] Failed to open backing session database to mark peer-chat backing for ${backingSession.toString()}: ${toErrorMessage(err)}`); return; } - if (chats.length === 0) { + ref.object.setMetadata(PEER_CHAT_BACKING_METADATA_KEY, chat.toString()).catch(err => { + this._logService.warn(`[AgentService] Failed to mark peer-chat backing for ${backingSession.toString()}: ${toErrorMessage(err)}`); + }).finally(() => { + ref.dispose(); + }); + } + + /** + * Inserts or updates a single peer chat in the orchestrator's persisted + * catalog, recording its opaque `providerData` verbatim (or clearing it when + * `undefined`). Serialized per session via {@link _enqueuePeerChatCatalogWrite}. + */ + private _persistPeerChat(session: URI, chat: URI, providerData: string | undefined): Promise { + const chatUri = chat.toString(); + return this._enqueuePeerChatCatalogWrite(session, entries => { + const next = entries.filter(entry => entry.uri !== chatUri); + next.push({ uri: chatUri, ...(providerData !== undefined ? { providerData } : {}) }); + return next; + }); + } + + /** + * Removes a peer chat from the orchestrator's persisted catalog. Serialized + * per session via {@link _enqueuePeerChatCatalogWrite}. + */ + private _removePersistedPeerChat(session: URI, chat: URI): Promise { + const chatUri = chat.toString(); + return this._enqueuePeerChatCatalogWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); + } + + /** + * Chains a read-modify-write of a session's persisted peer-chat catalog + * behind any in-flight write for the same session, so concurrent + * create/dispose/data-change updates can't clobber each other. + */ + private _enqueuePeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { + const key = session.toString(); + const previous = this._peerChatCatalogWrites.get(key) ?? Promise.resolve(); + const next = previous + .catch(() => { /* a failed prior write must not block later ones */ }) + .then(() => this._applyPeerChatCatalogWrite(session, mutate)); + this._peerChatCatalogWrites.set(key, next.finally(() => { + if (this._peerChatCatalogWrites.get(key) === next) { + this._peerChatCatalogWrites.delete(key); + } + })); + return next; + } + + private async _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { + const ref = await this._sessionDataService.tryOpenDatabase?.(session); + if (!ref) { return; } - for (const chatUri of chats) { - let turns: readonly Turn[] = []; + try { + let current: IPersistedPeerChat[] = []; try { - turns = await agent.getSessionMessages(chatUri); + const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + if (raw !== undefined) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + current = parsed.filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string'); + } + } } catch (err) { - this._logService.warn(`[AgentService] Failed to load history for peer chat ${chatUri.toString()}: ${toErrorMessage(err)}`); + this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); } - const title = await this._readPersistedChatTitle(session, chatUri); - this._stateManager.restoreChat(session.toString(), chatUri.toString(), { title, turns: [...turns] }); + const updated = mutate(current); + await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); + } catch (err) { + this._logService.warn(`[AgentService] Failed to persist peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); + } finally { + ref.dispose(); } } - /** Reads a peer chat's persisted custom title, if any. */ + /** Reads a chat's persisted custom title (default or peer chat), if any. */ private async _readPersistedChatTitle(session: URI, chatUri: URI): Promise { const ref = await this._sessionDataService.tryOpenDatabase?.(session); if (!ref) { @@ -1630,6 +2244,18 @@ export class AgentService extends Disposable implements IAgentService { } } + private async _getChatDraft(session: URI, chatUri: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return undefined; + } + try { + return await ref.object.getChatDraft(chatUri); + } finally { + ref.dispose(); + } + } + private async _getSessionMetadataForRestore(agent: IAgent, session: URI): Promise { const sessionStr = session.toString(); if (agent.getSessionMetadata) { @@ -2057,6 +2683,7 @@ export class AgentService extends Disposable implements IAgentService { } await Promise.all(promises); this._sessionToProvider.clear(); + this._downloadProgressInterest.clear(); } // ---- helpers ------------------------------------------------------------ @@ -2087,7 +2714,7 @@ export class AgentService extends Disposable implements IAgentService { if (!this._gitService) { throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`); } - const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.summary.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectory; if (!workingDirectory) { throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`); } @@ -2183,7 +2810,7 @@ export class AgentService extends Disposable implements IAgentService { const agent = this._findProviderForSession(parentSession); if (agent) { try { - childTurns = await agent.getSessionMessages(URI.parse(subagentUri)); + childTurns = await this._getChatMessages(agent, URI.parse(subagentUri)); } catch (err) { this._logService.warn(`[AgentService] Failed to load subagent turns for ${subagentUri}`, err); } @@ -2192,21 +2819,61 @@ export class AgentService extends Disposable implements IAgentService { // Use metadata from subagent content if available, otherwise synthesize const title = subagentContent?.title ?? 'Subagent'; + const subagentNow = new Date().toISOString(); this._stateManager.restoreSession( { resource: subagentUri, provider: 'subagent', title, status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - ...(parentState?.summary.project ? { project: parentState.summary.project } : {}), + createdAt: subagentNow, + modifiedAt: subagentNow, + ...(parentState?.project ? { project: parentState.project } : {}), }, [...childTurns], ); this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`); } + /** + * Registers a subagent child session's state up-front from data the agent + * already reconstructed for the parent, so a later subscribe-driven + * {@link _restoreSubagentSession} finds it present and returns early + * instead of re-reading the parent event log. No-op if already registered. + */ + private _registerRestoredSubagent(child: IRestoredSubagentSession, parentSummary: SessionSummary, parentSessionStr: string): void { + const resourceStr = child.resource.toString(); + if (this._stateManager.getSessionState(resourceStr)) { + return; + } + const registeredNow = new Date().toISOString(); + this._stateManager.restoreSession( + { + resource: resourceStr, + provider: 'subagent', + title: child.title, + status: SessionStatus.Idle, + createdAt: registeredNow, + modifiedAt: registeredNow, + ...(parentSummary.project ? { project: parentSummary.project } : {}), + }, + [...child.turns], + ); + + // Mirror the live `_handleSubagentStarted` flow on restore: surface the + // subagent as a read-only peer chat in the PARENT session's catalog so it + // reappears as a tab (and the inline "Open Agent" link can reveal it) + // after a restart. Uses the same `ahp-chat://subagent/...` chat URI form + // as the live path so the sessions provider parses and surfaces it. + const subagentChatUri = buildSubagentChatUri(parentSessionStr, child.toolCallId); + this._stateManager.addChat(parentSessionStr, subagentChatUri, { + title: child.title, + turns: [...child.turns], + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(parentSessionStr), toolCallId: child.toolCallId }, + interactivity: ChatInteractivity.ReadOnly, + }); + } + private _findProviderForSession(session: URI | string): IAgent | undefined { const key = typeof session === 'string' ? session : session.toString(); const providerId = this._sessionToProvider.get(key); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 0cb3f7fa50f59..4f65634225780 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -4,52 +4,59 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { NKeyMap } from '../../../base/common/map.js'; import { equals } from '../../../base/common/objects.js'; import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; +import { AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; +import { toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { ToolCallContributorKind, type AgentInfo } from '../common/state/protocol/state.js'; -import { ActionType, StateAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; +import { SessionInputRequestKind, ToolCallContributorKind, type AgentInfo, type SessionInputRequest } from '../common/state/protocol/state.js'; +import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; import { - buildSubagentSessionUri, + buildSubagentChatUri, getToolFileEdits, isAhpChatChannel, isDefaultChatUri, + isSubagentChatUri, MessageKind, - parseDefaultChatUri, + parseChatUri, + parseRequiredSessionUriFromChatUri, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, - SessionStatus, ToolCallStatus, ToolResultContentType, - type URI as ProtocolURI, - type ISessionWithDefaultChat, type ErrorInfo, + type ISessionWithDefaultChat, + type Message, + type URI as ProtocolURI, type SessionState, - type ToolResultContent + type ToolCallState, + type ToolCallResult, + type ToolResultContent, + type Turn } from '../common/state/sessionState.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; import { parseRenameCommand } from './agentHostRenameCommand.js'; -import { SessionPermissionManager } from './sessionPermissions.js'; -import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/forwardedChatError.js'; -import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; +import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; +import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js'; +import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostTurnTracker } from './agentHostTurnTracker.js'; -import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js'; +import { SessionPermissionManager } from './sessionPermissions.js'; import type { ICopilotApiService } from './shared/copilotApiService.js'; +import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/forwardedChatError.js'; /** * Options for constructing an {@link AgentSideEffects} instance. @@ -79,6 +86,13 @@ interface IPendingSubagentSignal { readonly agent: IAgent; } +interface ISubagentSessionRef { + readonly parentChatUri: ProtocolURI; + readonly toolCallId: string; + readonly sessionUri: ProtocolURI; + readonly chatUri: ProtocolURI; +} + /** * Shared implementation of agent side-effect handling. * @@ -97,11 +111,7 @@ export class AgentSideEffects extends Disposable { private readonly _permissionManager: SessionPermissionManager; - /** - * Maps `parentSession:toolCallId` → subagent session URI. - * Used to route signals with `parentToolCallId` to the correct subagent. - */ - private readonly _subagentSessions = new Map(); + private readonly _subagentChats = new NKeyMap(); /** * Buffers signals whose `parentToolCallId` references a subagent @@ -112,11 +122,11 @@ export class AgentSideEffects extends Disposable { * UI would render the inner tool calls flat at the top level rather than * grouping them under the subagent. Drained by `_handleSubagentStarted`. * - * Key: `${parentSession}:${parentToolCallId}`. */ - private readonly _pendingSubagentSignals = new Map(); + private readonly _pendingSubagentSignals = new NKeyMap(); private readonly _telemetryReporter: AgentHostTelemetryReporter; private readonly _turnTracker: AgentHostTurnTracker; + private readonly _toolCallTracker: AgentHostToolCallTracker; private readonly _titleController: AgentHostSessionTitleController; constructor( @@ -131,6 +141,7 @@ export class AgentSideEffects extends Disposable { super(); this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._turnTracker = new AgentHostTurnTracker(this._telemetryReporter); + this._toolCallTracker = new AgentHostToolCallTracker(this._telemetryReporter); this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager)); this._titleController = this._register(instantiationService.createInstance(AgentHostSessionTitleController, this._stateManager, { sessionDataService: this._options.sessionDataService, @@ -149,14 +160,23 @@ export class AgentSideEffects extends Disposable { // handleAction, so the agent's SDK deferred never resolves. // Listen for these envelopes and notify the agent directly. this._register(this._stateManager.onDidEmitEnvelope(envelope => { + if (isAhpChatChannel(envelope.channel) && isChatAction(envelope.action)) { + this._syncSessionInputNeededForChatAction(envelope.channel, envelope.action); + } if (!envelope.origin && envelope.action.type === ActionType.ChatToolCallComplete) { const action = envelope.action; // Chat-action envelopes are emitted on the chat channel URI; // agents are keyed by session URI, so resolve back to the - // owning session before notifying the agent. - const sessionChannel = isAhpChatChannel(envelope.channel) ? (parseDefaultChatUri(envelope.channel) ?? envelope.channel) : envelope.channel; - const agent = this._options.getAgent(sessionChannel); - agent?.onClientToolCallComplete(URI.parse(sessionChannel), action.toolCallId, action.result); + // owning session before notifying the agent. Pass the chat URI + // alongside so agents that track peer chats can route correctly. + if (!isAhpChatChannel(envelope.channel)) { + return; // Not a chat channel; ignore (already logged elsewhere). + } + const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); + this._notifyClientToolCallComplete(sessionChannel, envelope.channel, action.toolCallId, action.result, 'server-envelope'); + } + if (envelope.action.type === ActionType.ChatDraftChanged) { + this._persistChatDraft(envelope.channel, envelope.action.draft); } })); } @@ -176,6 +196,8 @@ export class AgentSideEffects extends Disposable { provider: m.provider, name: m.name, maxContextWindow: m.maxContextWindow, + maxOutputTokens: m.maxOutputTokens, + maxPromptTokens: m.maxPromptTokens, supportsVision: m.supportsVision, policyState: m.policyState, configSchema: m.configSchema, @@ -183,6 +205,7 @@ export class AgentSideEffects extends Disposable { })), customizations: customizations?.length ? [...customizations] : undefined, protectedResources: protectedResources.length > 0 ? protectedResources : undefined, + capabilities: d.capabilities ? { ...d.capabilities } : undefined, }; }); if (equals(this._lastAgentInfos, infos)) { @@ -227,6 +250,121 @@ export class AgentSideEffects extends Disposable { } } + // ---- Session input-needed aggregation ---------------------------------- + // + // Mirrors per-chat blockers (user-input elicitations, tool confirmations, + // and running client-tool executions) into the owning session's + // `inputNeeded` list so clients subscribed only to the session channel can + // discover and answer them without subscribing to each chat. This handler + // only produces the state; it does not consume it. + + private _syncSessionInputNeededForChatAction(chatUri: ProtocolURI, action: ChatAction): void { + switch (action.type) { + case ActionType.ChatInputRequested: + this._setSessionInputNeeded(chatUri, { + id: this._chatInputNeededId(chatUri, action.request.id), + kind: SessionInputRequestKind.ChatInput, + chat: chatUri, + request: action.request, + }); + break; + case ActionType.ChatInputCompleted: + this._removeSessionInputNeeded(chatUri, this._chatInputNeededId(chatUri, action.requestId)); + break; + case ActionType.ChatToolCallStart: + case ActionType.ChatToolCallReady: + case ActionType.ChatToolCallConfirmed: + case ActionType.ChatToolCallComplete: + case ActionType.ChatToolCallResultConfirmed: + this._syncToolInputNeeded(chatUri, action.turnId, action.toolCallId); + break; + case ActionType.ChatTurnComplete: + case ActionType.ChatTurnCancelled: + case ActionType.ChatError: + case ActionType.ChatTruncated: + this._removeSessionInputNeededForChat(chatUri); + break; + } + } + + private _syncToolInputNeeded(chatUri: ProtocolURI, turnId: string, toolCallId: string): void { + const confirmationId = this._toolConfirmationNeededId(chatUri, turnId, toolCallId); + const clientExecutionId = this._toolClientExecutionNeededId(chatUri, turnId, toolCallId); + const toolCall = this._findToolCall(chatUri, turnId, toolCallId); + + const needsConfirmation = toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation; + if (needsConfirmation && toolCall) { + this._setSessionInputNeeded(chatUri, { + id: confirmationId, + kind: SessionInputRequestKind.ToolConfirmation, + chat: chatUri, + turnId, + toolCall, + }); + } else { + this._removeSessionInputNeeded(chatUri, confirmationId); + } + + const contributor = toolCall?.contributor; + if (toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) { + this._setSessionInputNeeded(chatUri, { + id: clientExecutionId, + kind: SessionInputRequestKind.ToolClientExecution, + chat: chatUri, + turnId, + clientId: contributor.clientId, + toolCall, + }); + } else { + this._removeSessionInputNeeded(chatUri, clientExecutionId); + } + } + + private _findToolCall(chatUri: ProtocolURI, turnId: string, toolCallId: string): ToolCallState | undefined { + const state = this._stateManager.getSessionState(chatUri); + const turn = state?.activeTurn?.id === turnId ? state.activeTurn : state?.turns.find(t => t.id === turnId); + const part = turn?.responseParts.find(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.toolCallId === toolCallId); + return part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; + } + + private _setSessionInputNeeded(chatUri: ProtocolURI, request: SessionInputRequest): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + const existing = this._stateManager.getSessionState(sessionUri)?.inputNeeded?.find(r => r.id === request.id); + if (existing && equals(existing, request)) { + return; + } + this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request }); + } + + private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) { + return; + } + this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id }); + } + + private _removeSessionInputNeededForChat(chatUri: ProtocolURI): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) { + if (request.chat === chatUri) { + this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id: request.id }); + } + } + } + + private _chatInputNeededId(chatUri: ProtocolURI, requestId: string): string { + return `chatInput:${chatUri}:${requestId}`; + } + + private _toolConfirmationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { + return `toolConfirmation:${chatUri}:${turnId}:${toolCallId}`; + } + + private _toolClientExecutionNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { + return `toolClientExecution:${chatUri}:${turnId}:${toolCallId}`; + } + // ---- Initialization ---------------------------------------------------- /** @@ -269,40 +407,26 @@ export class AgentSideEffects extends Disposable { * once the `subagent_started` arrives. */ private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void { - const sessionKey = signal.session.toString(); - - // Track tool calls so handleAction can route confirmations. Defer - // registration for inner subagent tool calls until we know which - // subagent session they belong to — otherwise we'd register them - // under the parent session key and a later `pending_confirmation` - // (which lacks - // `parentToolCallId`) could be routed against the wrong session. - if (signal.kind === 'action' - && signal.action.type === ActionType.ChatToolCallStart - && !signal.parentToolCallId - ) { - this._toolCallAgents.set(`${sessionKey}:${signal.action.toolCallId}`, agent.id); - } - if (signal.kind === 'subagent_started') { - this._handleSubagentStarted(sessionKey, signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription); - this._drainPendingSubagentSignals(sessionKey, signal.toolCallId); + this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription, signal.parentToolCallId); + this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId); return; } if (signal.kind === 'subagent_completed') { - this.completeSubagentSession(sessionKey, signal.toolCallId); + this.completeSubagentSession(signal.chat.toString(), signal.toolCallId); return; } if (signal.kind === 'steering_consumed') { - this._stateManager.dispatchServerAction(sessionKey, { + this._stateManager.dispatchServerAction(signal.chat.toString(), { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Steering, id: signal.id, }); return; } + const sessionKey = signal.kind === 'action' ? signal.resource.toString() : signal.chat.toString(); // Route signals with parentToolCallId to the subagent session. // Both action signals and pending_confirmation signals can carry @@ -311,31 +435,24 @@ export class AgentSideEffects extends Disposable { // call, and that signal must be routed to the subagent session // (otherwise the resulting ChatToolCallReady would land on the // parent session, which has no matching ChatToolCallStart). - const parentToolCallId = signal.kind === 'action' || signal.kind === 'pending_confirmation' - ? signal.parentToolCallId - : undefined; + const parentToolCallId = signal.parentToolCallId; if (parentToolCallId) { - const subagentKey = `${sessionKey}:${parentToolCallId}`; - const subagentSession = this._subagentSessions.get(subagentKey); + const subagentSession = this._subagentChats.get(sessionKey, parentToolCallId); if (subagentSession) { - // Track tool calls in subagent context for confirmation routing. - if (signal.kind === 'action' && signal.action.type === ActionType.ChatToolCallStart) { - this._toolCallAgents.set(`${subagentSession}:${signal.action.toolCallId}`, agent.id); - } - const subTurnId = this._stateManager.getActiveTurnId(subagentSession); + const subTurnId = this._stateManager.getActiveTurnId(subagentSession.chatUri); if (subTurnId) { - this._dispatchActionForSession(signal, subagentSession, subTurnId, agent); + this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, agent); } return; } // Subagent session does not exist yet — buffer the signal so we can // replay it after `subagent_started` arrives. - this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${subagentKey}`); - let buffer = this._pendingSubagentSignals.get(subagentKey); + this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${sessionKey}/${parentToolCallId}`); + let buffer = this._pendingSubagentSignals.get(sessionKey, parentToolCallId); if (!buffer) { buffer = []; - this._pendingSubagentSignals.set(subagentKey, buffer); + this._pendingSubagentSignals.set(buffer, sessionKey, parentToolCallId); } buffer.push({ signal, agent }); return; @@ -346,10 +463,10 @@ export class AgentSideEffects extends Disposable { // tool was previously registered under its subagent session key in // _toolCallAgents). if (signal.kind === 'pending_confirmation') { - const subagentSession = this._findSubagentSessionForToolCall(sessionKey, signal.state.toolCallId); - if (subagentSession) { - const subTurnId = this._stateManager.getActiveTurnId(subagentSession) ?? ''; - void this._handleToolReady(signal, subagentSession, subTurnId, agent).catch(err => { + const subagentChatUri = this._findSubagentChatForToolCall(sessionKey, signal.state.toolCallId); + if (subagentChatUri) { + const subTurnId = this._stateManager.getActiveTurnId(subagentChatUri) ?? ''; + void this._handleToolReady(signal, subagentChatUri, subTurnId, agent).catch(err => { this._logService.error('[AgentSideEffects] _handleToolReady failed', err); }); return; @@ -417,15 +534,25 @@ export class AgentSideEffects extends Disposable { action = { ...action, turnId }; } + if (action.type === ActionType.ChatToolCallStart && agent) { + this._toolCallAgents.set(`${sessionKey}:${action.toolCallId}`, agent.id); + // Stamp the tool call start for `languageModelToolInvoked` telemetry. + // Only the start action carries the tool name and contributor, so the + // source kind must be captured here rather than on completion. The + // provider comes from the agent that emitted the signal. + this._toolCallTracker.toolCallStarted(agent.id, sessionKey, action.toolCallId, action.toolName, action.contributor); + } + + const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; + // When a parent tool call has an associated subagent session, // preserve the subagent content metadata in the completion result. // The SDK's tool_complete provides its own content which would // overwrite the ToolResultSubagentContent that was set via // ChatToolCallContentChanged while running. if (action.type === ActionType.ChatToolCallComplete) { - const subagentKey = `${sessionKey}:${action.toolCallId}`; - const subagentUri = this._subagentSessions.get(subagentKey); - if (subagentUri) { + const subagent = this._subagentChats.get(sessionKey, action.toolCallId); + if (subagent) { const parentState = this._stateManager.getSessionState(sessionKey); const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId); const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); @@ -434,6 +561,7 @@ export class AgentSideEffects extends Disposable { const merged: ChatToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } }; action = merged; } + } } @@ -448,32 +576,37 @@ export class AgentSideEffects extends Disposable { } if (action.type === ActionType.ChatToolCallComplete) { + // Emit `languageModelToolInvoked` telemetry for the completed tool + // call. `action.result` carries `success`/`error.code` even after the + // subagent-content merge above (which only touches `result.content`). + this._toolCallTracker.toolCallCompleted(sessionKey, action.toolCallId, action.result); + // Drop any events that were buffered for a subagent whose // `subagent_started` never arrived (e.g. the parent tool failed // before the subagent was created). The actual subagent session // teardown is driven by the `subagent_completed` signal because // background subagents (`mode: background`) continue running // after the parent tool call returns. - this._pendingSubagentSignals.delete(`${sessionKey}:${action.toolCallId}`); + this._pendingSubagentSignals.delete(sessionKey, action.toolCallId); if (getToolFileEdits(action.result).length > 0) { - // Changesets track the session's shared working tree; key by - // the owning session when this is an additional chat channel. - const sessionScope = isAhpChatChannel(sessionKey) ? (parseDefaultChatUri(sessionKey) ?? sessionKey) : sessionKey; - this._changesets.onToolCallEditsApplied(sessionScope, turnId); + this._changesets.onToolCallEditsApplied(sessionUri, turnId); } } if (action.type === ActionType.ChatTurnComplete) { this._turnTracker.turnCompleted(sessionKey, turnId, 'success'); + this._toolCallTracker.clearSession(sessionKey); this._runTurnCompleteSideEffects(sessionKey, turnId); } if (action.type === ActionType.ChatTurnCancelled) { this._turnTracker.turnCompleted(sessionKey, turnId, 'cancelled'); + this._toolCallTracker.clearSession(sessionKey); } if (action.type === ActionType.ChatError) { this._turnTracker.turnCompleted(sessionKey, turnId, 'error'); + this._toolCallTracker.clearSession(sessionKey); } } @@ -490,7 +623,7 @@ export class AgentSideEffects extends Disposable { // message consumption (queues live on the chat state). For the // default chat / single-chat case `sessionKey` is already the // session URI, so this is a no-op. - const sessionScope = isAhpChatChannel(sessionKey) ? (parseDefaultChatUri(sessionKey) ?? sessionKey) : sessionKey; + const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; // Capture the end-of-turn git checkpoint BEFORE notifying the // changeset service so the per-turn changeset recompute can take // the authoritative git-diff fast path (which includes terminal-tool @@ -501,17 +634,17 @@ export class AgentSideEffects extends Disposable { // completion since those have always been fire-and-forget; the // ordering guarantee we care about is checkpoint-then-changeset. if (turnId !== undefined) { - this._checkpointService.captureTurnCheckpoint(URI.parse(sessionScope), turnId).then(() => { - this._changesets.onTurnComplete(sessionScope, turnId); + this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), turnId).then(() => { + this._changesets.onTurnComplete(sessionUri, turnId); }, err => { - this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionScope}/${turnId}: ${err instanceof Error ? err.message : String(err)}`); - this._changesets.onTurnComplete(sessionScope, turnId); + this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionUri}/${turnId}: ${err instanceof Error ? err.message : String(err)}`); + this._changesets.onTurnComplete(sessionUri, turnId); }); } else { - this._changesets.onTurnComplete(sessionScope, turnId); + this._changesets.onTurnComplete(sessionUri, turnId); } this._tryConsumeNextQueuedMessage(sessionKey); - this._options.onTurnComplete(sessionScope); + this._options.onTurnComplete(sessionUri); // After the first turn completes, refine the auto-generated title using // the full first-turn context (request + response). No-op for later @@ -519,7 +652,7 @@ export class AgentSideEffects extends Disposable { // additional chat channel; route it as `chatChannel` so the refinement // targets that chat's title, mirroring `seedTitleFromFirstMessage`. const titleChatChannel = isAhpChatChannel(sessionKey) && !isDefaultChatUri(sessionKey) ? sessionKey : undefined; - this._titleController.refineTitleFromFirstTurn(sessionScope, titleChatChannel); + this._titleController.refineTitleFromFirstTurn(sessionUri, titleChatChannel); } private _describeSignal(signal: AgentSignal): string { @@ -531,14 +664,13 @@ export class AgentSideEffects extends Disposable { * `subagent_started` to create the subagent session. Called immediately * after `_handleSubagentStarted`. */ - private _drainPendingSubagentSignals(parentSession: ProtocolURI, parentToolCallId: string): void { - const subagentKey = `${parentSession}:${parentToolCallId}`; - const buffer = this._pendingSubagentSignals.get(subagentKey); + private _drainPendingSubagentSignals(parentChatURI: ProtocolURI, parentToolCallId: string): void { + const buffer = this._pendingSubagentSignals.get(parentChatURI, parentToolCallId); if (!buffer) { return; } - this._pendingSubagentSignals.delete(subagentKey); - this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${subagentKey}`); + this._pendingSubagentSignals.delete(parentChatURI, parentToolCallId); + this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${parentChatURI}/${parentToolCallId}`); for (const { signal, agent } of buffer) { this._handleAgentSignal(agent, signal); } @@ -547,73 +679,79 @@ export class AgentSideEffects extends Disposable { // ---- Subagent session management ---------------------------------------- /** - * Creates a subagent session in response to a `subagent_started` event. - * The subagent session is created silently (no `sessionAdded` notification) - * and immediately transitioned to ready with an active turn. + * Starts the subagent turn in response to a `subagent_started` event and + * wires the parent tool call to the subagent chat. The subagent chat's + * catalog membership is owned by the spawn channel + * ({@link AgentService._onChatSpawned}), which the orchestrator applies + * before this runs, so this only drives the turn/tracking/parent content + * — it does not add the chat. + * + * `chatURI` is always the agent's top-level chat: the subagent is + * registered (and inner events routed) under it because inner-tool + * signals carry the top-level chat as their resource. `spawningToolParentId`, + * when set, is the tool call one level up from the spawning `toolCallId` + * — the tool call in whose (subagent) chat the spawning tool lives — and + * is used to route the discovery content block to that immediate parent + * chat. Since subagent chats are flat (keyed off the root session), this + * one-hop reference resolves the parent chat at any nesting depth. */ private _handleSubagentStarted( - parentSession: ProtocolURI, + chatURI: ProtocolURI, toolCallId: string, agentName: string, agentDisplayName: string, agentDescription?: string, + spawningToolParentId?: string, ): void { - const subagentSessionUri = buildSubagentSessionUri(parentSession, toolCallId); - const subagentKey = `${parentSession}:${toolCallId}`; + const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI); + const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId); // Already tracking this subagent - if (this._subagentSessions.has(subagentKey)) { + if (this._subagentChats.get(chatURI, toolCallId)) { return; } - this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`); - const parentState = this._stateManager.getSessionState(parentSession); - - // Create the subagent session silently (restoreSession skips notification) - this._stateManager.restoreSession( - { - resource: subagentSessionUri, - provider: 'subagent', - title: agentDisplayName, - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - ...(parentState?.summary.project ? { project: parentState.summary.project } : {}), - }, - [], - ); + this._logService.info(`[AgentSideEffects] Starting subagent turn: ${subagentChatUri} (parent=${chatURI}, toolCallId=${toolCallId})`); // Start a turn on the subagent session const turnId = generateUuid(); - this._stateManager.dispatchServerAction(subagentSessionUri, { + this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, message: { text: '', origin: { kind: MessageKind.User } }, }); - this._subagentSessions.set(subagentKey, subagentSessionUri); - - // Dispatch content on the parent tool call so clients discover the subagent. - // Merge with any existing content to avoid dropping prior content blocks. - const parentTurnId = this._stateManager.getActiveTurnId(parentSession); + this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri }, chatURI, toolCallId); + + // Dispatch content on the spawning tool call so clients discover the + // subagent. The tool call lives in the immediate parent chat, which is + // the top-level chat for a first-level subagent or the immediate + // parent subagent chat when nested (at any depth) — resolve it via + // `spawningToolParentId` so the block lands where the tool call is + // (dispatching on the top-level chat would be a no-op, leaving nested + // subagents undiscoverable). Merge with any existing content to avoid + // dropping prior content blocks. + const contentChatUri = spawningToolParentId + ? this._subagentChats.get(chatURI, spawningToolParentId)?.chatUri ?? chatURI + : chatURI; + const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); if (parentTurnId) { - const parentState = this._stateManager.getSessionState(parentSession); + const parentState = this._stateManager.getSessionState(contentChatUri); const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId); - const mergedContent = [ - ...existingContent, - { - type: ToolResultContentType.Subagent as const, - resource: subagentSessionUri, - title: agentDisplayName, - agentName, - description: agentDescription, - }, - ]; - this._stateManager.dispatchServerAction(parentSession, { + this._stateManager.dispatchServerAction(contentChatUri, { type: ActionType.ChatToolCallContentChanged, turnId: parentTurnId, toolCallId, - content: mergedContent, + content: [ + ...existingContent, + { + type: ToolResultContentType.Subagent, + resource: subagentChatUri, + title: agentDisplayName, + agentName, + description: agentDescription, + }, + ], }); } } @@ -640,26 +778,21 @@ export class AgentSideEffects extends Disposable { /** * Cancels all active subagent sessions for a given parent session. */ - cancelSubagentSessions(parentSession: ProtocolURI): void { - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(`${parentSession}:`)) { - const turnId = this._stateManager.getActiveTurnId(subagentUri); - if (turnId) { - this._stateManager.dispatchServerAction(subagentUri, { - type: ActionType.ChatTurnCancelled, - turnId, - }); - this._turnTracker.turnCompleted(subagentUri, turnId, 'cancelled'); - } - this._subagentSessions.delete(key); + cancelSubagentSessions(parentChatURI: ProtocolURI): void { + for (const subagent of this._subagentChats.getAll(parentChatURI)) { + const turnId = this._stateManager.getActiveTurnId(subagent.chatUri); + if (turnId) { + this._stateManager.dispatchServerAction(subagent.chatUri, { + type: ActionType.ChatTurnCancelled, + turnId, + }); + this._turnTracker.turnCompleted(subagent.chatUri, turnId, 'cancelled'); } + this._toolCallTracker.clearSession(subagent.chatUri); } + this._subagentChats.deleteAll(parentChatURI); // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentSignals.keys()]) { - if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentSignals.delete(key); - } - } + this._pendingSubagentSignals.deleteAll(parentChatURI); } /** @@ -669,60 +802,43 @@ export class AgentSideEffects extends Disposable { * parent tool call completion — background subagents keep running after * their parent tool returns. */ - completeSubagentSession(parentSession: ProtocolURI, toolCallId: string): void { - const key = `${parentSession}:${toolCallId}`; - + completeSubagentSession(parentChatURI: ProtocolURI, toolCallId: string): void { // Drop any events that were buffered waiting for a `subagent_started` // that never arrived (e.g. the parent tool failed before the subagent // was created). Without this, the buffer entry would leak until the // parent session is disposed. - this._pendingSubagentSignals.delete(key); + this._pendingSubagentSignals.delete(parentChatURI, toolCallId); - const subagentUri = this._subagentSessions.get(key); - if (!subagentUri) { + const subagent = this._subagentChats.get(parentChatURI, toolCallId); + if (!subagent) { return; } - const turnId = this._stateManager.getActiveTurnId(subagentUri); + const turnId = this._stateManager.getActiveTurnId(subagent.chatUri); if (turnId) { - this._stateManager.dispatchServerAction(subagentUri, { + this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnComplete, turnId, }); } - this._subagentSessions.delete(key); + this._subagentChats.delete(parentChatURI, toolCallId); } /** - * Removes all subagent sessions for a given parent session from - * the state manager. Called when the parent session is disposed. + * Removes all subagent chats for a given parent session from the state manager. */ removeSubagentSessions(parentSession: ProtocolURI): void { - const toRemove: string[] = []; - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(`${parentSession}:`)) { - this._stateManager.disposeSessionChangesets(subagentUri); - this._stateManager.removeSession(subagentUri); - toRemove.push(key); + const parentChatURIs = new Set(); + for (const subagent of this._subagentChats.values()) { + if (subagent.sessionUri === parentSession) { + this._stateManager.removeChat(subagent.sessionUri, subagent.chatUri); + this._toolCallTracker.clearSession(subagent.chatUri); + parentChatURIs.add(subagent.parentChatUri); } } - for (const key of toRemove) { - this._subagentSessions.delete(key); - } - - // Also clean up any subagent sessions that are in the state manager - // but not tracked (e.g. restored sessions) - const prefix = `${parentSession}/subagent/`; - for (const uri of this._stateManager.getSessionUrisWithPrefix(prefix)) { - this._stateManager.disposeSessionChangesets(uri); - this._stateManager.removeSession(uri); - } - - // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentSignals.keys()]) { - if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentSignals.delete(key); - } + for (const parentChatURI of parentChatURIs) { + this._subagentChats.deleteAll(parentChatURI); + this._pendingSubagentSignals.deleteAll(parentChatURI); } } @@ -732,16 +848,41 @@ export class AgentSideEffects extends Disposable { * session key in `_toolCallAgents`. Scoped to subagent sessions owned * by the given parent to avoid cross-session collisions. */ - private _findSubagentSessionForToolCall(parentSession: ProtocolURI, toolCallId: string): ProtocolURI | undefined { - const prefix = `${parentSession}:`; - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(prefix) && this._toolCallAgents.has(`${subagentUri}:${toolCallId}`)) { - return subagentUri; + private _findSubagentChatForToolCall(parentChatURI: ProtocolURI, toolCallId: string): ProtocolURI | undefined { + for (const subagent of this._subagentChats.getAll(parentChatURI)) { + if (this._toolCallAgents.has(`${subagent.chatUri}:${toolCallId}`)) { + return subagent.chatUri; } } return undefined; } + private _toolCallCompletionChat(chatChannel: ProtocolURI): ProtocolURI { + if (!isSubagentChatUri(chatChannel)) { + return chatChannel; + } + + for (const subagent of this._subagentChats.values()) { + if (subagent.chatUri === chatChannel) { + return this._toolCallCompletionChat(subagent.parentChatUri); + } + } + + this._logService.warn(`[AgentSideEffects] Missing parent chat for subagent tool completion: chat=${chatChannel}`); + return chatChannel; + } + + private _notifyClientToolCallComplete(sessionChannel: ProtocolURI, chatChannel: ProtocolURI, toolCallId: string, result: ToolCallResult, source: 'client-dispatch' | 'server-envelope'): void { + const completionChat = this._toolCallCompletionChat(chatChannel); + const agent = this._options.getAgent(sessionChannel); + if (!agent) { + this._logService.warn(`[AgentSideEffects] No agent for client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}`); + return; + } + this._logService.info(`[AgentSideEffects] Forwarding client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}, success=${result.success}`); + agent.onClientToolCallComplete(URI.parse(sessionChannel), URI.parse(completionChat), toolCallId, result); + } + // ---- Side-effect handlers -------------------------------------------------- /** @@ -753,10 +894,11 @@ export class AgentSideEffects extends Disposable { private async _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): Promise { const approvalEvent = { toolCallId: e.state.toolCallId, - session: e.session, + session: e.chat, permissionKind: e.permissionKind, permissionPath: e.permissionPath, toolInput: e.state.toolInput, + requestSandboxBypass: e.requestSandboxBypass, }; const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); @@ -785,13 +927,14 @@ export class AgentSideEffects extends Disposable { ); } - handleAction(channel: ProtocolURI, action: StateAction, chatChannel?: ProtocolURI): void { - // `channel` is always the session URI (session-scoped work keys by it). - // `chatChannel`, when present, is the originating chat channel URI used - // to route per-chat operations (message send, turn cancel) to the - // correct chat in a multi-chat session. + handleAction(channel: ProtocolURI, action: StateAction, clientId?: string): void { + const chatChannel = isAhpChatChannel(channel) ? channel : undefined; + const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel; switch (action.type) { case ActionType.ChatTurnStarted: { + if (!chatChannel) { + throw new Error(`ChatTurnStarted must be handled on an AHP chat channel: ${channel}`); + } // Per-turn streaming part tracking is owned by the agent // (e.g. CopilotAgentSession) and reset on its `send()` call. @@ -800,7 +943,7 @@ export class AgentSideEffects extends Disposable { // than forwarded to the agent SDK. Mirrors the per-agent text-side // dispatch (`parseLeadingSlashCommand` in CopilotAgentSession), but // applies to every session type. - if (this._tryHandleRenameCommand(channel, action.turnId, action.message.text, chatChannel)) { + if (this._tryHandleRenameCommand(channel, action.turnId, action.message.text)) { break; } @@ -808,9 +951,9 @@ export class AgentSideEffects extends Disposable { if (!state) { this._logService.info(`[AgentSideEffects] Turn started for session not in state manager: ${channel}, turnId=${action.turnId} - status/summary updates may be dropped unless the session is restored`); } - this._titleController.seedTitleFromFirstMessage(channel, action.message.text, chatChannel); + this._titleController.seedTitleFromFirstMessage(sessionChannel, action.message.text, chatChannel); - const agent = this._options.getAgent(channel); + const agent = this._options.getAgent(sessionChannel); if (!agent) { this._stateManager.dispatchServerAction(channel, { type: ActionType.ChatError, @@ -821,21 +964,23 @@ export class AgentSideEffects extends Disposable { } const attachments = action.message.attachments; this._telemetryReporter.userMessageSent(agent.id, channel, state, 'direct', attachments); - const { model, permissionLevel } = this._getTurnTelemetryContext(state); + const { model, permissionLevel } = this._getTurnTelemetryContext(state, action.message.model?.id); this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, permissionLevel); - agent.sendMessage(URI.parse(channel), action.message.text, attachments, action.turnId, chatChannel ? URI.parse(chatChannel) : undefined).catch(err => { - const errCode = (err as { code?: number })?.code; - this._logService.error(`[AgentSideEffects] sendMessage failed for session=${channel}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err); - this._stateManager.dispatchServerAction(channel, { - type: ActionType.ChatError, - turnId: action.turnId, - error: buildSendFailedError(err), - }); - this._turnTracker.turnCompleted(channel, action.turnId, 'error'); + void this._sendTurnMessage({ + agent, + sessionChannel, + turnChannel: channel, + chat: channel, + message: action.message, + turnId: action.turnId, + senderClientId: clientId, }); break; } case ActionType.ChatToolCallConfirmed: { + if (!chatChannel) { + throw new Error(`ChatToolCallConfirmed must be handled on an AHP chat channel: ${channel}`); + } const toolCallKey = `${channel}:${action.toolCallId}`; const agentId = this._toolCallAgents.get(toolCallKey); if (agentId) { @@ -854,18 +999,28 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.ChatInputCompleted: { - const agent = this._options.getAgent(channel); + if (!chatChannel) { + throw new Error(`ChatInputCompleted must be handled on an AHP chat channel: ${channel}`); + } + const agent = this._options.getAgent(sessionChannel); agent?.respondToUserInputRequest(action.requestId, action.response, action.answers); break; } case ActionType.ChatTurnCancelled: { + if (!chatChannel) { + throw new Error(`ChatTurnCancelled must be handled on an AHP chat channel: ${channel}`); + } this._turnTracker.turnCompleted(channel, action.turnId, 'cancelled'); + this._toolCallTracker.clearSession(channel); // Cancel all subagent sessions for this parent this.cancelSubagentSessions(channel); - const agent = this._options.getAgent(channel); - agent?.abortSession(URI.parse(channel), chatChannel ? URI.parse(chatChannel) : undefined).catch(err => { - this._logService.error('[AgentSideEffects] abortSession failed', err); - }); + const agent = this._options.getAgent(sessionChannel); + if (agent) { + const chat = URI.parse(channel); + agent.chats.abort(chat).catch(err => { + this._logService.error('[AgentSideEffects] abort failed', err); + }); + } // Intentionally do NOT drain queued messages here: cancelling means // "stop", so messages queued behind the turn stay queued for the // user to dequeue/run manually. (A message the user sends *after* @@ -873,27 +1028,13 @@ export class AgentSideEffects extends Disposable { // once cancellation has cleared the active turn.) break; } - case ActionType.SessionModelChanged: { - const agent = this._options.getAgent(channel); - agent?.changeModel?.(URI.parse(channel), action.model, chatChannel ? URI.parse(chatChannel) : undefined).catch(err => { - this._logService.error('[AgentSideEffects] changeModel failed', err); - }); - break; - } - case ActionType.SessionAgentChanged: { - const agent = this._options.getAgent(channel); - agent?.changeAgent?.(URI.parse(channel), action.agent, chatChannel ? URI.parse(chatChannel) : undefined).catch(err => { - this._logService.error('[AgentSideEffects] changeAgent failed', err); - }); - break; - } case ActionType.SessionTitleChanged: { if (chatChannel) { // The rename targeted a specific chat (default or additional), // not the whole session. Route it to a per-chat title update so // the session title stays independent. - this._stateManager.updateChatTitle(channel, chatChannel, action.title); - this._persistSessionFlag(channel, `customChatTitle:${chatChannel}`, action.title); + this._stateManager.updateChatTitle(sessionChannel, chatChannel, action.title); + this._persistSessionFlag(sessionChannel, `customChatTitle:${chatChannel}`, action.title); break; } this._persistSessionFlag(channel, 'customTitle', action.title); @@ -902,30 +1043,40 @@ export class AgentSideEffects extends Disposable { case ActionType.ChatPendingMessageSet: case ActionType.ChatPendingMessageRemoved: case ActionType.ChatQueuedMessagesReordered: { + if (!chatChannel) { + throw new Error(`${action.type} must be handled on an AHP chat channel: ${channel}`); + } this._syncPendingMessages(channel); break; } case ActionType.ChatTruncated: { - const agent = this._options.getAgent(channel); - agent?.truncateSession?.(URI.parse(channel), action.turnId).catch(err => { + if (!chatChannel) { + throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`); + } + const agent = this._options.getAgent(sessionChannel); + agent?.truncateSession?.(URI.parse(sessionChannel), action.turnId).catch(err => { this._logService.error('[AgentSideEffects] truncateSession failed', err); }); - this._changesets.onSessionTruncated(channel); + this._changesets.onSessionTruncated(sessionChannel); break; } - case ActionType.SessionActiveClientChanged: { + case ActionType.SessionActiveClientSet: { const agent = this._options.getAgent(channel); if (!agent) { break; } - // Always forward client tools, even if empty, to clear previous client's tools - const clientId = action.activeClient?.clientId; - agent.setClientTools(URI.parse(channel), clientId, action.activeClient?.tools ?? []); - - const refs = action.activeClient?.customizations ?? []; - agent.setClientCustomizations(URI.parse(channel), clientId ?? '', refs).catch(err => { - this._logService.error('[AgentSideEffects] setClientCustomizations failed', err); + const activeClient = action.activeClient; + const handle = agent.getOrCreateActiveClient(URI.parse(channel), { + clientId: activeClient.clientId, + displayName: activeClient.displayName, }); + handle.tools = activeClient.tools; + handle.customizations = activeClient.customizations ?? []; + break; + } + case ActionType.SessionActiveClientRemoved: { + const agent = this._options.getAgent(channel); + agent?.removeActiveClient(URI.parse(channel), action.clientId); break; } case ActionType.RootConfigChanged: { @@ -938,17 +1089,6 @@ export class AgentSideEffects extends Disposable { this._publishAllSessionCustomizations(); break; } - case ActionType.SessionActiveClientToolsChanged: { - const agent = this._options.getAgent(channel); - if (agent) { - const sessionState = this._stateManager.getSessionState(channel); - const toolClientId = sessionState?.activeClient?.clientId; - if (toolClientId) { - agent.setClientTools(URI.parse(channel), toolClientId, action.tools); - } - } - break; - } case ActionType.SessionCustomizationToggled: { const agent = this._options.getAgent(channel); agent?.setCustomizationEnabled?.(action.id, action.enabled); @@ -977,8 +1117,10 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.ChatToolCallComplete: { - const agent = this._options.getAgent(channel); - agent?.onClientToolCallComplete(URI.parse(channel), action.toolCallId, action.result); + if (!chatChannel) { + break; // Not a chat channel; ignore. + } + this._notifyClientToolCallComplete(sessionChannel, chatChannel, action.toolCallId, action.result, 'client-dispatch'); break; } } @@ -988,6 +1130,15 @@ export class AgentSideEffects extends Disposable { this._titleController.cancelTitleGeneration(session); } + /** + * Generates a content-derived title for a freshly forked session + * (`chatChannel` undefined) or peer chat from its inherited chat + * turns, replacing the placeholder `Forked: …` title once ready. + */ + generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void { + this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle); + } + /** * Handles the generic `/rename [title]` slash command. When `text` is a * rename command it is redirected to a {@link ActionType.SessionTitleChanged} @@ -997,18 +1148,15 @@ export class AgentSideEffects extends Disposable { * @returns `true` when the message was a rename command and was handled here * (the caller MUST NOT forward it to the agent), `false` otherwise. */ - private _tryHandleRenameCommand(channel: ProtocolURI, turnId: string, text: string, chatChannel?: ProtocolURI): boolean { + private _tryHandleRenameCommand(channel: ProtocolURI, turnId: string, text: string): boolean { const title = parseRenameCommand(text); if (title === undefined) { return false; } - // The additional chat the turn belongs to (if any): an explicit - // `chatChannel`, or `channel` itself when it is an additional chat - // channel (the queued-message consumer passes the chat channel directly). const isAdditional = (uri: ProtocolURI | undefined): uri is ProtocolURI => !!uri && isAhpChatChannel(uri) && !isDefaultChatUri(uri); - const chatTarget = isAdditional(chatChannel) ? chatChannel : (isAdditional(channel) ? channel : undefined); - const sessionChannel = chatTarget ? (parseDefaultChatUri(chatTarget) ?? channel) : channel; + const chatTarget = isAdditional(channel) ? channel : undefined; + const sessionChannel = chatTarget ? parseRequiredSessionUriFromChatUri(chatTarget) : (isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel); // The just-opened turn lives wherever the message was dispatched. const turnTarget = chatTarget ?? channel; if (title.length > 0) { @@ -1072,21 +1220,42 @@ export class AgentSideEffects extends Disposable { }); } + private _persistChatDraft(channel: ProtocolURI, draft: Message | undefined): void { + if (!isAhpChatChannel(channel)) { + return; + } + + const parsed = parseChatUri(channel); + if (!parsed) { + return; + } + + const session = URI.parse(parsed.session); + const ref = this._options.sessionDataService.openDatabase(session); + ref.object.setChatDraft(URI.parse(channel), draft).catch(err => { + this._logService.warn(`[AgentSideEffects] Failed to persist chat draft for ${channel.toString()}`, err); + }).finally(() => { + ref.dispose(); + }); + } + /** * Pushes the current pending message state from the session to the agent. * The server controls queued message consumption; only steering messages * are forwarded to the agent for mid-turn injection. */ - private _syncPendingMessages(session: ProtocolURI): void { - const state = this._stateManager.getSessionState(session); + private _syncPendingMessages(chatChannel: ProtocolURI): void { + const sessionChannel = parseRequiredSessionUriFromChatUri(chatChannel); + const state = this._stateManager.getSessionState(chatChannel); if (!state) { return; } - const agent = this._options.getAgent(session); + const agent = this._options.getAgent(sessionChannel); agent?.setPendingMessages?.( - URI.parse(session), + URI.parse(sessionChannel), state.steeringMessage, [], + isDefaultChatUri(chatChannel) ? undefined : URI.parse(chatChannel), ); // Steering message removal is now dispatched by the agent @@ -1094,7 +1263,7 @@ export class AgentSideEffects extends Disposable { // has actually been sent to the model. // If the session is idle, try to consume the next queued message - this._tryConsumeNextQueuedMessage(session); + this._tryConsumeNextQueuedMessage(chatChannel); } /** @@ -1105,6 +1274,7 @@ export class AgentSideEffects extends Disposable { * consumed when the next `idle` event fires. */ private _tryConsumeNextQueuedMessage(session: ProtocolURI): void { + const sessionChannel = parseRequiredSessionUriFromChatUri(session); // Bail if there's already an active turn if (this._stateManager.getActiveTurnId(session)) { return; @@ -1135,11 +1305,9 @@ export class AgentSideEffects extends Disposable { } // Send the message to the agent backend. When `session` is an - // additional chat channel, the SDK conversation is owned by the + // additional chat channel, the SDK chat is owned by the // parent session: look up the provider by the parent session URI and // pass the chat channel so the harness routes to the right peer chat. - const chatTarget = isAhpChatChannel(session) && !isDefaultChatUri(session) ? session : undefined; - const sessionChannel = chatTarget ? (parseDefaultChatUri(chatTarget) ?? session) : session; const agent = this._options.getAgent(sessionChannel); if (!agent) { this._stateManager.dispatchServerAction(session, { @@ -1152,30 +1320,79 @@ export class AgentSideEffects extends Disposable { const attachments = msg.message.attachments; const queuedState = this._stateManager.getSessionState(session); this._telemetryReporter.userMessageSent(agent.id, session, queuedState, 'queued', attachments); - const { model, permissionLevel } = this._getTurnTelemetryContext(queuedState); + const { model, permissionLevel } = this._getTurnTelemetryContext(queuedState, msg.message.model?.id); this._turnTracker.turnStarted(agent.id, session, turnId, model, permissionLevel); - agent.sendMessage(URI.parse(sessionChannel), msg.message.text, attachments, turnId, chatTarget ? URI.parse(chatTarget) : undefined).catch(err => { - this._logService.error('[AgentSideEffects] sendMessage failed (queued)', err); - this._stateManager.dispatchServerAction(session, { - type: ActionType.ChatError, - turnId, - error: buildSendFailedError(err), - }); - this._turnTracker.turnCompleted(session, turnId, 'error'); + // Selection travels on the queued message; it is applied before sending. + void this._sendTurnMessage({ + agent, + sessionChannel, + turnChannel: session, + chat: session, + message: msg.message, + turnId, + senderClientId: undefined, }); } - private _getTurnTelemetryContext(state: SessionState | undefined): { model: string | undefined; permissionLevel: string | undefined } { - const model = state?.summary.model?.id; + private _getTurnTelemetryContext(state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; permissionLevel: string | undefined } { const permissionValue = state?.config?.values[SessionConfigKey.AutoApprove]; const permissionLevel = typeof permissionValue === 'string' ? permissionValue : undefined; - return { model, permissionLevel }; + return { model: modelId, permissionLevel }; + } + + /** + * Applies a turn message's model/agent selection (see + * {@link _applyMessageSelection}) and forwards it to the agent's + * `sendMessage`. A rejected send is wired to fail the turn: it logs, + * dispatches {@link ActionType.ChatError} on the turn channel, and marks the + * turn errored. + */ + private async _sendTurnMessage(options: { + agent: IAgent; + /** The agent/session URI the chat lives on (the send target). */ + sessionChannel: ProtocolURI; + /** The channel the turn runs on — where `ChatError` / turn completion are reported. */ + turnChannel: ProtocolURI; + /** Chat channel URI the turn targets. */ + chat: ProtocolURI; + message: Message; + turnId: string; + senderClientId: string | undefined; + }): Promise { + const { agent, turnChannel, chat, message, turnId, senderClientId } = options; + + const chatUri = URI.parse(chat); + + const selectionUpdates: Promise[] = []; + if (message.model) { + selectionUpdates.push(agent.chats.changeModel(chatUri, message.model).catch(err => { + this._logService.error('[AgentSideEffects] changeModel failed', err); + })); + } + selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent).catch(err => { + this._logService.error('[AgentSideEffects] changeAgent failed', err); + })); + + await Promise.all(selectionUpdates); + + await agent.chats.sendMessage(chatUri, message.text, message.attachments, turnId, senderClientId).catch(err => { + const errCode = (err as { code?: number })?.code; + this._logService.error(`[AgentSideEffects] sendMessage failed for session=${turnChannel}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err); + this._stateManager.dispatchServerAction(turnChannel, { + type: ActionType.ChatError, + turnId, + error: buildSendFailedError(err), + }); + this._turnTracker.turnCompleted(turnChannel, turnId, 'error'); + this._toolCallTracker.clearSession(turnChannel); + }); } override dispose(): void { this._toolCallAgents.clear(); + this._toolCallTracker.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts new file mode 100644 index 0000000000000..c06917c841c99 --- /dev/null +++ b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * 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, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { IByokLmBridgeConnection, IByokLmModelInfo } from '../common/agentHostByokLm.js'; + +export const IByokLmBridgeRegistry = createDecorator('byokLmBridgeRegistry'); + +/** + * Node-side registry of renderer {@link IByokLmBridgeConnection}s keyed by + * client id. Populated by the agent host's connection lifecycle (one entry per + * connected renderer) and consumed by {@link IByokLmProxyService} (inference + * routing) and {@link CopilotAgent} (model catalogue). + * + * **Single serving window, multiple connections.** BYOK is serviced by the + * renderer LM API, whose BYOK models are a property of the user's installed + * extensions, not of a particular window — so every window that registers the + * handler exposes the same set. Both the main workbench and the dedicated Agents + * app register it (each runs a full extension host whose LM API holds the same + * BYOK models), so either can serve. A connection that connects without binding + * the handler registers a bridge whose `listModels()` rejects and is treated as + * non-serving. The registry therefore does NOT aggregate per-window model sets; + * it surfaces the models from any one *serving* window (preferring one that + * actually has models) and routes inference there, automatically excluding + * non-serving windows. + * + * A connection becomes "serving" once its `listModels()` resolves (even to an + * empty list). On registration (and whenever a connection reports + * {@link IByokLmBridgeConnection.onDidChangeModels}) the registry enumerates it + * and fires {@link onDidChangeModels} when the serving model set changes. + */ +export interface IByokLmBridgeRegistry { + readonly _serviceBrand: undefined; + + /** Register a renderer connection. Disposing the result removes it. */ + register(clientId: string, connection: IByokLmBridgeConnection): IDisposable; + + /** + * Re-enumerate the connected renderers, refresh the cache, and return the + * serving window's BYOK models. Use this when freshness matters (e.g. + * synthesizing a session's provider config at create time). + */ + listModels(): Promise; + + /** + * The serving window's BYOK models, read synchronously from the cache (no + * enumeration). Use this for fast reads driven by {@link onDidChangeModels}. + */ + getModels(): readonly IByokLmModelInfo[]; + + /** + * A connection that can serve BYOK inference (one whose enumeration has + * resolved), or `undefined` when no connected window can. All serving + * windows expose the same models, so any one of them is a valid target. + */ + getServingConnection(): IByokLmBridgeConnection | undefined; + + /** + * Subscribe to changes in the set of registered connections (a renderer + * connecting or disconnecting) or in the serving window's models, so + * consumers can re-read {@link getModels}. Disposing the result removes the + * listener. + */ + onDidChangeModels(listener: () => void): IDisposable; +} + +/** + * Per-connection registry entry. `models` is `undefined` until the connection's + * first successful enumeration; a connection with defined `models` is "serving" + * (it answered, even if with an empty list). Non-serving windows (those that did + * not register the BYOK handler, whose `listModels()` rejects) keep + * `models === undefined`. + */ +interface IConnectionEntry { + readonly connection: IByokLmBridgeConnection; + models: readonly IByokLmModelInfo[] | undefined; + readonly store: DisposableStore; +} + +export class ByokLmBridgeRegistry implements IByokLmBridgeRegistry { + + declare readonly _serviceBrand: undefined; + + private readonly _entries = new Map(); + private readonly _changeListeners = new Set<() => void>(); + + onDidChangeModels(listener: () => void): IDisposable { + this._changeListeners.add(listener); + return toDisposable(() => { + this._changeListeners.delete(listener); + }); + } + + private _notifyChanged(): void { + // Snapshot first: a listener may unsubscribe (mutating the set) while it + // is being notified. + for (const listener of [...this._changeListeners]) { + listener(); + } + } + + register(clientId: string, connection: IByokLmBridgeConnection): IDisposable { + // Replace any prior entry for the same client id (e.g. a reconnect). + this._entries.get(clientId)?.store.dispose(); + + const store = new DisposableStore(); + const entry: IConnectionEntry = { connection, models: undefined, store }; + this._entries.set(clientId, entry); + + // Re-enumerate whenever the renderer reports its BYOK models changed. + if (connection.onDidChangeModels) { + store.add(connection.onDidChangeModels(() => { + void this._refreshConnection(clientId); + })); + } + + // The connection set changed; enumerate the new connection's models. + this._notifyChanged(); + void this._refreshConnection(clientId); + + return toDisposable(() => { + if (this._entries.get(clientId) === entry) { + this._entries.delete(clientId); + entry.store.dispose(); + this._notifyChanged(); + } + }); + } + + async listModels(): Promise { + // Actively re-enumerate every connection so callers that need freshness + // (e.g. session create) don't race a cold cache. + await Promise.all([...this._entries.keys()].map(clientId => this._refreshConnection(clientId))); + return [...this.getModels()]; + } + + getModels(): readonly IByokLmModelInfo[] { + return this._servingEntry()?.models ?? []; + } + + getServingConnection(): IByokLmBridgeConnection | undefined { + return this._servingEntry()?.connection; + } + + /** + * A connection that has answered an enumeration (`models` defined), preferring + * one whose model set is non-empty. All serving windows expose the same models, + * so any populated one is an equivalent source/target; the preference matters + * when a window that is still starting up (e.g. the Agents app before its BYOK + * extension has registered models) answers with an empty list first — it must + * not shadow a peer that already has them, transiently or permanently. Falls + * back to a serving-but-empty window when none have models yet; non-serving + * windows (those that didn't register the BYOK handler) are skipped. + */ + private _servingEntry(): IConnectionEntry | undefined { + let emptyFallback: IConnectionEntry | undefined; + for (const entry of this._entries.values()) { + if (entry.models === undefined) { + continue; + } + if (entry.models.length > 0) { + return entry; + } + emptyFallback ??= entry; + } + return emptyFallback; + } + + /** + * Enumerate a single connection's models into its cache and notify listeners + * when the result changes. A connection whose `listModels()` rejects (e.g. a + * window that did not register the BYOK handler) is left non-serving. + */ + private async _refreshConnection(clientId: string): Promise { + const entry = this._entries.get(clientId); + if (!entry) { + return; + } + let models: readonly IByokLmModelInfo[]; + try { + models = await entry.connection.listModels(); + } catch { + // The connection didn't answer (e.g. no BYOK handler registered); + // leave it non-serving. + return; + } + // Drop the result if the entry was removed/replaced while in flight. + if (this._entries.get(clientId) !== entry) { + return; + } + // The connection answered, so this entry is serving. Notify only when the + // serving model set actually changed. + if (entry.models === undefined || !modelsEqual(entry.models, models)) { + entry.models = models; + this._notifyChanged(); + } + } +} + +/** Shallow structural comparison of two model lists (order-sensitive). */ +function modelsEqual(a: readonly IByokLmModelInfo[], b: readonly IByokLmModelInfo[]): boolean { + if (a.length !== b.length) { + return false; + } + return a.every((m, i) => { + const n = b[i]; + return m.vendor === n.vendor && m.id === n.id && m.name === n.name && m.maxContextWindowTokens === n.maxContextWindowTokens && m.supportsVision === n.supportsVision; + }); +} + +/** + * No-op {@link IByokLmBridgeRegistry} for agent host entrypoints that do not + * support BYOK — e.g. the remote agent host, where no extension host runs + * alongside the agent host to serve the renderer LM API. + */ +export class NullByokLmBridgeRegistry implements IByokLmBridgeRegistry { + + declare readonly _serviceBrand: undefined; + + register(): IDisposable { + return Disposable.None; + } + + async listModels(): Promise { + return []; + } + + getModels(): readonly IByokLmModelInfo[] { + return []; + } + + getServingConnection(): IByokLmBridgeConnection | undefined { + return undefined; + } + + onDidChangeModels(): IDisposable { + return Disposable.None; + } +} diff --git a/src/vs/platform/agentHost/node/claude/CONTEXT.md b/src/vs/platform/agentHost/node/claude/CONTEXT.md index ceb6c8f54bc88..71f75e7a7354b 100644 --- a/src/vs/platform/agentHost/node/claude/CONTEXT.md +++ b/src/vs/platform/agentHost/node/claude/CONTEXT.md @@ -778,9 +778,79 @@ SDK; the host does not need to gate. | `onClientToolCallComplete(...)` | Host → SDK | Resolves the in-process MCP tool's pending promise | Same mechanism as `respondToUserInputRequest` | | `setCustomizationEnabled(uri, enabled)` | Host → SDK | `Query.reloadPlugins()` (runtime) | **Defer-and-coalesce** when busy: set `_pendingPluginReload`, drain at next yield. Idle path applies immediately. The SDK's `reloadPlugins` returns the refreshed `commands / agents / plugins / mcpServers` — useful as a verification probe but not required for correctness | | `getCustomizations()` | SDK → Host (projection) | `Query.supportedCommands()` / `supportedAgents()` / `mcpServerStatus()` | Compose the live snapshot from runtime SDK queries plus the host plugin manager's enabled set | -| `getSessionCustomizations(session)` | SDK → Host (projection) | Same SDK queries, scoped per-session | Per-session because each Query has its own loaded plugin set | - -**Skills as plugins.** The SDK has no `Options.skills` field. A +| `getSessionCustomizations(session)` | Disk scan (+ SDK filter) → Host (projection) | Disk scan of `~/.claude/**` + `/.claude/**`; live `Query.supportedCommands()` / `supportedAgents()` / `mcpServerStatus()` + `system/init.plugins` used only as a **filter** when materialized | **Phase 16 + 17.** See "Phase 16 — disk-scan customization resolution" and "Phase 17 — hooks + native plugins" below. Pre-materialize: client-pushed ∪ full disk scan (agents/skills/commands/MCP/rules/hooks/native plugins) + curated built-ins. Materialized: client-pushed ∪ (disk scan ∩ SDK-known) + SDK-only / built-in read-only entries; hooks/rules bypass the filter | + +**Phase 16 — disk-scan customization resolution.** As shipped, +`getSessionCustomizations` does **not** project SDK query payloads into +editable items (those carry only name + description, no file path). Instead +it **scans the file system** for the real customization files and ships each +item's real editable `file:` `uri`: + +- **Scanners** (`customizations/scan/`): `claudeAgentSkillScan.ts` (agents + + skills; `.claude/commands/*.md` are folded into skills per the spec), + `claudeMcpScan.ts` (`settings.json` `mcpServers` block + flat `.mcp.json`), + `claudeRuleScan.ts` (CLAUDE.md + `.claude/rules/**`). Reuse the shared + `pluginParsers.ts` frontmatter/MCP parsers. +- **Builder** (`customizations/claudeSessionCustomizationDiscovery.ts`): + `buildDiscoveredCustomizations(...)` maps scanned entries to + `DirectoryCustomization` containers (one per (scope, kind) — Workspace vs + User) with real-file children + top-level `McpServerCustomization`. When a + live pipeline exists it intersects the disk set with the SDK-known set by + `(name, type)` and adds SDK-known-but-not-on-disk items as **non-editable** + `claude-internal:` entries. +- **Built-in tier** (`customizations/claudeBuiltinCommands.ts`): a curated set + of built-in slash commands (`CLAUDE_BUILTIN_COMMANDS`, 13) and agents + (`CLAUDE_BUILTIN_AGENTS`, 5) is surfaced read-only on the `agent-builtin:` + scheme pre-materialize, then superseded by the live SDK set + post-materialize. Built-ins are display-only (`contrib/chat` untouched). +- **Projection is inlined** in `ClaudeAgentSession.getSessionCustomizations` + (no separate projector function): client-pushed entries (with the per-id + enablement overlay) first, then the discovered + built-in tier appended. +- **Watcher** (`ClaudeCustomizationWatcher`): correlated watch on both + `.claude` roots (recursive) + `` narrowed to `.mcp.json`, debounced; + fires `onDidCustomizationsChange` so the list updates live. +- The synthetic-stub `ClaudeSdkCustomizationBundler` (Phase 11) is **deleted**. + +**Phase 17 — hooks + native plugins (surface only).** Phase 17 extends the +Phase 16 disk scan with two more user/workspace-configured tiers. Both are +**surface-only**: the SDK already loads them via `settingSources`, so +`Options.plugins` / `claudeSdkOptions.ts` are **untouched** (plumbing native +plugins in would double-load — `claudeSkills.ts` skips `.claude` dirs). + +- **Hooks** (`scan/claudeHookScan.ts`): reads the `hooks` block from the + user/project/local `settings.json` (+ `settings.local.json`) via the shared + `parseHooksJson`; surfaces one `HookCustomization` per declaring file under a + per-scope `DirectoryCustomization` (`contents: Hook`). There is **no SDK hook + enumeration API**, so hooks are disk-only and **bypass** the post-materialize + filter (like rules). `disableAllHooks` drops a scope; `managed` is excluded. +- **Native plugins** (`scan/claudeNativePluginScan.ts`): resolves + `enabledPlugins` (precedence `user < project < local`, enabled = value + `!== false`) to on-disk roots — marketplace cache + (`~/.claude/plugins/cache////`, newest version by + mtime with a numeric-`localeCompare` tie-break) or in-place `@skills-dir` — + parses each with the shared multi-format `detectPluginFormat` + + `parsePlugin`, and surfaces each as a **top-level** `PluginCustomization` + (not a per-scope directory) whose children are the bundled + agents/skills/instructions/hooks/MCP. `splitPluginId` guards against path + traversal in untrusted-workspace ids. +- **Post-materialize plugin filter matches on `system/init.plugins[].source` + (the `@` id), not `path`.** The SDK `init.plugins` + `path` is unreliable for a workspace-`local`-scoped plugin (the runtime + reports a bogus parent-of-workspace path), so path-matching dropped loaded + plugins. The runtime payload carries an undocumented `source` field (= the + id); `claudeSdkPipeline.ts` captures it type-safely (the `.d.ts` omits it) and + the filter prefers `source === id`, falling back to normalized `path`. Capture + runs on **every** `system/init` (ungated by `_isResumed`). +- **PB-10 — surfaced plugins suppress their own components from the standalone + SDK fallbacks.** Once a plugin is shown as a container, the SDK *also* reports + its agents/commands/MCP in `supportedAgents`/`supportedCommands`/ + `mcpServerStatus`, so the Phase-16 SDK-only fallbacks re-surfaced them as + duplicate standalone rows. The builder suppresses any fallback whose name + matches a surfaced plugin's parsed component name in **both** bare + (`inbox-setup`) and namespaced (`:inbox-setup`) forms (SDK naming is + inconsistent — agents namespaced, skills usually bare). The standalone skill + scan also skips any `.claude/skills//` dir that is itself a plugin root + (PB-8), so a `@skills-dir` plugin's skills surface only under its container. directory containing a `skills/` subfolder *is* a valid plugin from the SDK's point of view (`SdkPluginConfig { type: 'local', path }`). The host can pass a "skills-only plugin" directory via diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index d2696be4cffd9..d089510994655 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -4,45 +4,51 @@ *--------------------------------------------------------------------------------------------*/ import type { CCAModel } from '@vscode/copilot-api'; -import type { Options, SDKSessionInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { ModelInfo, Options, SDKSessionInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { SequencerByKey } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { AgentSessionEntry, decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentPeerChats.js'; +import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../common/claudeModelConfig.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; +import { AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, SubagentChatSignal } from '../../common/agentService.js'; +import { ensureWorkspacelessScratchDir } from '../workspacelessScratchDir.js'; import { ActionType } from '../../common/state/sessionActions.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { isSubagentSession, parseSubagentSessionUri, ChatInputResponseKind, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { isSubagentSession, parseSubagentSessionUri, buildDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, isDefaultChatUri, ChatInputResponseKind, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { projectFromCopilotContext } from '../copilot/copilotGitProject.js'; import { ICopilotApiService } from '../shared/copilotApiService.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; -import { mapSessionMessagesToTurns } from './claudeReplayMapper.js'; +import { buildModelEnumerationOptions } from './claudeSdkOptions.js'; +import { mapSessionMessagesToTurns, resolveForkAnchorUuid } from './claudeReplayMapper.js'; import { getSubagentTranscript } from './claudeSubagentResolver.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { handleCanUseTool } from './claudeCanUseTool.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; +import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { tryParseClaudeModelId } from './claudeModelId.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; -import { IClaudeProxyHandle, IClaudeProxyService } from './claudeProxyService.js'; +import { IClaudeProxyHandle, IClaudeProxyService, type ClaudeTransport } from './claudeProxyService.js'; import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; import { ClaudeSessionMetadataStore, IClaudeSessionOverlay } from './claudeSessionMetadataStore.js'; @@ -93,7 +99,11 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo const supportedEfforts = ((supports as IClaudeModelSupports | undefined)?.reasoning_effort ?? []).filter(isClaudeEffortLevel); const configSchema = createClaudeThinkingLevelSchema(supportedEfforts); const policyState = m.policy?.state as PolicyState | undefined; - const multiplier = m.billing?.multiplier; + const billing = normalizeCAPIBilling(m.billing); + // priceCategory may appear as a top-level model field depending on the CAPI version. + const priceCategory = typeof m.model_picker_price_category === 'string' + ? m.model_picker_price_category + : undefined; return { provider, // CAPI/endpoint format, dotted version (e.g. `claude-haiku-4.5`) — the @@ -102,10 +112,33 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo id: m.id, name: m.name, maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!supports?.vision, ...(configSchema ? { configSchema } : {}), ...(policyState ? { policyState } : {}), - ...(typeof multiplier === 'number' ? { _meta: { multiplierNumeric: multiplier } } : {}), + _meta: createPricingMetaFromBilling(billing, priceCategory), + }; +} + +/** + * Project an SDK {@link ModelInfo} into the agent host's + * {@link IAgentModelInfo} surface for the native (BYO-Anthropic) transport. + * Carries NO commercial metadata (no `policyState`, no pricing `_meta`) — + * those are Copilot/CAPI concepts. Reuses the shared effort-schema helpers so + * the thinking-level picker matches the proxied projection. + */ +export function fromSdkModelInfo(m: ModelInfo, provider: AgentProvider): IAgentModelInfo { + const supportedEfforts = (m.supportedEffortLevels ?? []).filter(isClaudeEffortLevel); + const configSchema = createClaudeThinkingLevelSchema(supportedEfforts); + return { + provider, + // SDK-canonical id (`m.value`, e.g. `claude-sonnet-4-5-20250929`). Native + // ids are SDK format end to end; `toSdkModelId` is identity at this seam. + id: m.value, + name: m.displayName, + supportsVision: false, + ...(configSchema ? { configSchema } : {}), }; } @@ -123,6 +156,40 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo // provisionalConfig). The legacy `IClaudeProvisionalSession` map shape // was retired in Phase 10.5 Step 3a. +/** + * Claude active-client handle. Tools read/write through the live session's + * {@link SessionClientToolsModel}; customization assignment kicks off the + * agent's async sync (via the provided closure). The handle caches the last + * assigned customization inputs so the getter reflects what the client most + * recently published. + */ +class ClaudeActiveClientHandle implements IActiveClient { + private _customizations: readonly ClientPluginCustomization[] = []; + + constructor( + readonly clientId: string, + readonly displayName: string | undefined, + private readonly _getTools: () => readonly ToolDefinition[], + private readonly _setTools: (tools: readonly ToolDefinition[]) => void, + private readonly _syncCustomizations: (customizations: readonly ClientPluginCustomization[]) => void, + ) { } + + get tools(): readonly ToolDefinition[] { + return this._getTools(); + } + set tools(tools: readonly ToolDefinition[]) { + this._setTools(tools); + } + + get customizations(): readonly ClientPluginCustomization[] { + return this._customizations; + } + set customizations(customizations: readonly ClientPluginCustomization[]) { + this._customizations = customizations; + this._syncCustomizations(customizations); + } +} + /** * Phase 4 skeleton {@link IAgent} provider for the Claude Agent SDK. * @@ -144,7 +211,7 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo * of any single review stays small. */ export class ClaudeAgent extends Disposable implements IAgent { - readonly id: AgentProvider = 'claude'; + readonly id: AgentProvider = CLAUDE_AGENT_PROVIDER_ID; private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress = this._onDidSessionProgress.event; @@ -159,6 +226,15 @@ export class ClaudeAgent extends Disposable implements IAgent { private _proxyHandle: IClaudeProxyHandle | undefined; private _serverToolHost: IAgentServerToolHost | undefined; + /** + * Resolved host transport mode (Phase 19). `proxy` (default) routes through + * the Copilot-CAPI proxy; `native` talks to Anthropic directly on the user's + * own credentials. Resolved once from the `ClaudeUseCopilotProxy` root + * config value and kept current by an `onDidRootConfigChange` subscription. + * Config changes affect FUTURE sessions only — never an in-flight subprocess. + */ + private _transportMode: 'proxy' | 'native' = 'proxy'; + /** * Memoized teardown promise. Set on the first call to {@link shutdown}, * returned by every subsequent call. Mirrors `CopilotAgent.shutdown` @@ -182,6 +258,37 @@ export class ClaudeAgent extends Disposable implements IAgent { */ private readonly _sessions = this._register(new DisposableMap()); + /** + * Live, in-memory peer-chat backings keyed by the chat's `ahp-chat` channel + * URI string. Populated by {@link createChat} on creation and by + * {@link materializeChat} on session restore (decoding the opaque + * `providerData` the orchestrator persisted). This is the live source of the + * `chatUri → sdkSessionId` mapping. + */ + private readonly _chatBackings = new Map(); + + /** + * Fires when a peer chat's opaque `providerData` blob changes after creation + * (e.g. a per-chat model switch) so the orchestrator can re-persist the + * refreshed token. See {@link IAgent.onDidChangeChatData}. + */ + private readonly _onDidChangeChatData = this._register(new Emitter()); + readonly onDidChangeChatData: Event = this._onDidChangeChatData.event; + + /** + * Membership channel for chats the agent spawns itself — today the + * sub-agent chats delegated by a `Task`/`Agent` tool call (and, when the + * harness gains them, Claude Teams teammates). Derived from the + * `subagent_started` / `subagent_completed` signals that already flow on + * {@link onDidSessionProgress}, so the orchestrator records the spawn edge + * on the unified chat catalog. See {@link IAgent.onDidSpawnChat}. + */ + private readonly _onDidSpawnChat = this._register(new Emitter()); + readonly onDidSpawnChat: Event = this._onDidSpawnChat.event; + + /** Stable active-client handles, keyed by `${sessionId}\0${clientId}`. */ + private readonly _activeClientHandles = new Map(); + /** * Phase 6: fired once per session when {@link _materializeProvisional} * promotes a provisional record into a real {@link ClaudeAgentSession}. @@ -220,12 +327,103 @@ export class ClaudeAgent extends Disposable implements IAgent { private readonly _metadataStore: ClaudeSessionMetadataStore; /** - * Unified per-session lookup. Returns the session whether it is - * still provisional or already materialized; callers branch on + * Unified per-session lookup. Returns the session's default chat whether it + * is still provisional or already materialized; callers branch on * {@link ClaudeAgentSession.isPipelineReady} when behavior differs. */ private _findAnySession(sessionId: string): ClaudeAgentSession | undefined { - return this._sessions.get(sessionId)?.session; + return this._sessions.get(sessionId)?.defaultChat; + } + + /** + * Resolve the live {@link ClaudeAgentSession} for a chat — the session's + * default (main) chat, or an additional peer chat addressed by its + * `ahp-chat` channel URI — via a single uniform lookup in the owning + * session's chat map. Returns `undefined` when the session (or the chat) is + * not in memory. + */ + private _findChat(session: URI, chat: URI | undefined): ClaudeAgentSession | undefined { + const entry = this._sessions.get(AgentSession.id(session)); + if (!entry) { + return undefined; + } + return entry.getChat((chat ?? URI.parse(buildDefaultChatUri(session))).toString()); + } + + private _getChatContext(chatOrSession: URI): { session: URI; sessionId: string; chatKey: string; target: ClaudeAgentSession | undefined; isPeerChat: boolean } { + // Accept either a chat channel URI or a bare session URI: per the AHP + // convention the default chat's URI equals the session URI, so callers + // that address the default chat by the session URI resolve here in one + // place rather than each operational method re-deriving it. + const chat = parseChatUri(chatOrSession) ? chatOrSession : URI.parse(buildDefaultChatUri(chatOrSession)); + const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); + const sessionId = AgentSession.id(session); + const chatKey = chat.toString(); + const resolved = this._sessions.get(sessionId)?.resolveChat(chatKey); + return { + session, + sessionId, + chatKey, + target: resolved?.chatSession, + isPeerChat: resolved ? !resolved.isDefault : chatKey !== buildDefaultChatUri(session), + }; + } + + /** + * Resolve a live {@link ClaudeAgentSession} by its SDK chat id, + * searching every session entry's default chat and its peer chats. Used by + * SDK-id-addressed callbacks — proxy credit reports and the `canUseTool` + * permission bridge — which carry the SDK session id, not the chat URI. + */ + private _findSessionBySdkId(sdkSessionId: string): ClaudeAgentSession | undefined { + for (const entry of this._sessions.values()) { + for (const chat of entry.allChatSessions()) { + if (chat.sessionId === sdkSessionId) { + return chat; + } + } + } + return undefined; + } + + /** Wrap a {@link ClaudeAgentSession} in a chat-leaf entry and forward its events. */ + private _wireEntry(session: ClaudeAgentSession): ClaudeSessionEntry { + const entry = new ClaudeSessionEntry(session); + entry.addDisposable(session.onDidSessionProgress(signal => { + this._onDidSessionProgress.fire(signal); + this._emitSpawnedChatEvents(signal); + })); + entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); + return entry; + } + + /** + * Create a session container seeding its default (main) chat as the first + * entry in the uniform chat map, keyed by the session's default-chat URI. + */ + private _seedSessionEntry(sessionId: string, session: URI, mainSession: ClaudeAgentSession): ClaudeSessionEntry { + const container = new ClaudeSessionEntry(); + container.setDefaultChat(buildDefaultChatUri(session), this._wireEntry(mainSession)); + this._sessions.set(sessionId, container); + return container; + } + + /** + * Bridges the agent's `subagent_started` signal onto the + * {@link onDidSpawnChat} membership channel. The signals are still forwarded + * verbatim on {@link onDidSessionProgress} (the orchestrator's + * `AgentSideEffects` keeps driving the sub-agent turn + parent tool-call + * content); this event only mirrors the spawn into the unified chat catalog. + * A completed subagent chat stays live and subscribable (it is removed only + * on session teardown), so there is no corresponding end event. The catalog + * add is idempotent so the overlap with the orchestrator's own membership + * sequencing is safe. + */ + private _emitSpawnedChatEvents(signal: AgentSignal): void { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + this._onDidSpawnChat.fire(spawn); + } } constructor( @@ -238,6 +436,7 @@ export class ClaudeAgent extends Disposable implements IAgent { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, @IProductService private readonly _productService: IProductService, + @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, ) { super(); this._metadataStore = _instantiationService.createInstance(ClaudeSessionMetadataStore, this.id); @@ -246,8 +445,39 @@ export class ClaudeAgent extends Disposable implements IAgent { // the originating session by the session id the proxy decoded from // the Bearer token, so the session can surface real per-turn credits. this._register(this._claudeProxyService.onDidReportCredits(e => { - this._findAnySession(e.sessionId)?.recordTurnCredits(e.totalNanoAiu); + this._findSessionBySdkId(e.sessionId)?.recordTurnCredits(e.totalNanoAiu); })); + + // Phase 19: resolve the transport mode now and re-resolve reactively. + // A flip only affects sessions materialized afterwards; in-flight + // subprocesses keep their original transport. When native, kick off an + // initial model refresh since no GitHub auth (which would otherwise + // trigger it) is required. + this._transportMode = this._resolveTransportMode(); + this._register(this._configurationService.onDidRootConfigChange(() => { + const next = this._resolveTransportMode(); + if (next !== this._transportMode) { + this._transportMode = next; + void this._refreshModels(); + } + })); + if (this._transportMode === 'native') { + // Only native bootstraps its model list here. Proxy mode fetches + // models from CAPI, which needs the GitHub token — so its first + // refresh is triggered by `authenticate()` once that token arrives + // (a refresh now would just hit the no-token early-return). Native + // needs no GitHub auth and nothing else triggers a refresh, so we + // kick off the initial enumeration ourselves. (Transport *flips* + // after construction are covered by the `onDidRootConfigChange` + // subscription above.) `queueMicrotask` runs it off the ctor stack. + queueMicrotask(() => { void this._refreshModels(); }); + } + } + + private _resolveTransportMode(): 'proxy' | 'native' { + // Defaults to proxied when the `claudeUseCopilotProxy` root value is unset. + const useProxy = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.ClaudeUseCopilotProxy) ?? true; + return useProxy ? 'proxy' : 'native'; } // #region Descriptor + auth @@ -257,17 +487,32 @@ export class ClaudeAgent extends Disposable implements IAgent { provider: this.id, displayName: localize('claudeAgent.displayName', "Claude"), description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), + capabilities: { multipleChats: { fork: true } }, }; } getProtectedResources(): ProtectedResourceMetadata[] { + // Native (BYO-Anthropic) mode needs no GitHub Copilot auth — the SDK owns + // the Anthropic credential — so the required Copilot resource is dropped. + // The optional repo resource is kept for git operations either way. + if (this._transportMode !== 'proxy') { + return [GITHUB_REPO_PROTECTED_RESOURCE]; + } return [ GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, ]; } - private _ensureAuthenticated(): IClaudeProxyHandle { + /** + * Resolve the active {@link ClaudeTransport}. In native mode the transport + * is always ready (the SDK owns credentials); in proxied mode a started + * proxy handle is required, otherwise {@link AHP_AUTH_REQUIRED} is thrown. + */ + private _ensureAuthenticated(): ClaudeTransport { + if (this._transportMode !== 'proxy') { + return { kind: 'native' }; + } const handle = this._proxyHandle; if (!handle) { throw new ProtocolError( @@ -276,7 +521,7 @@ export class ClaudeAgent extends Disposable implements IAgent { this.getProtectedResources(), ); } - return handle; + return { kind: 'proxy', handle }; } async authenticate(resource: string, token: string): Promise { @@ -286,6 +531,13 @@ export class ClaudeAgent extends Disposable implements IAgent { if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { return false; } + // Native (BYO-Anthropic) mode needs no proxy and no GitHub token. Record + // the token (harmless; lets a later flip back to proxy reuse it) but do + // NOT start the proxy or treat the absence of a token as unauthenticated. + if (this._transportMode !== 'proxy') { + this._githubToken = token; + return true; + } const tokenChanged = this._githubToken !== token; if (!tokenChanged) { this._logService.info('[Claude] Auth token unchanged'); @@ -313,42 +565,86 @@ export class ClaudeAgent extends Disposable implements IAgent { return true; } + /** + * Whether the Claude provider routes through the Copilot-CAPI proxy. + * Reads the resolved {@link _transportMode} (Phase 19), which the + * constructor seeds from the `ClaudeUseCopilotProxy` root config value. + */ + private _isProxyEnabled(): boolean { + return this._transportMode === 'proxy'; + } + private async _refreshModels(): Promise { + const proxyAtStart = this._isProxyEnabled(); const tokenAtStart = this._githubToken; - if (!tokenAtStart) { + if (proxyAtStart && !tokenAtStart) { this._models.set([], undefined); return; } try { - const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`; - const all = await this._copilotApiService.models(tokenAtStart, { headers: { 'User-Agent': userAgent } }); - // Stale-write guard: if `authenticate()` rotated the token - // while we were awaiting the model list, a newer refresh has - // already published the right value — don't overwrite it. - if (this._githubToken !== tokenAtStart) { + const filtered = proxyAtStart + ? await this._fetchProxyModels(tokenAtStart!) + : await this._fetchNativeModels(); + // Stale-write guard: bail if the transport flipped, or (proxy) the + // token rotated, while we were awaiting — a newer refresh already + // published the right list. + if (this._isProxyEnabled() !== proxyAtStart || (proxyAtStart && this._githubToken !== tokenAtStart)) { return; } - // Stable sort surfaces the CAPI-flagged chat-default model - // first. The picker treats `models[0]` as the de facto - // default (modelPicker.ts:144 — `_selectedModel ?? models[0]`) - // since `IAgentModelInfo` carries no explicit `isDefault` - // bit. Stable comparator returns 0 for equal-priority models - // so CAPI's ordering wins on ties. - const filtered = all - .filter(isClaudeModel) - .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default)) - .map(m => toAgentModelInfo(m, this.id)); - this._logService.info(`[Claude] Models refreshed. Count: ${filtered.length}, ${filtered.map(m => m.name).join(', ')}`); this._models.set(filtered, undefined); } catch (err) { this._logService.error(err, '[Claude] Failed to refresh models'); - if (this._githubToken === tokenAtStart) { + if (this._isProxyEnabled() === proxyAtStart && (!proxyAtStart || this._githubToken === tokenAtStart)) { this._models.set([], undefined); } } } + /** + * Native (BYO-Anthropic) model source: enumerate the SDK's built-in / + * subscription models by opening a throwaway {@link IClaudeAgentSdkService.query} + * (workspace-free options that read the user's real `~/.claude` config) and + * calling `Query.supportedModels()` on it, then `close()`. The prompt never + * yields, so no turn runs and no session transcript is written (verified + * Phase 19 E2E). Projected with no commercial metadata. + */ + private async _fetchNativeModels(): Promise { + // A prompt iterable that never yields: enumeration only needs the + // control-request channel (`Query.supportedModels()`), not a real turn. + const neverYieldingPrompt: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => { /* never resolves */ }) }), + }; + const options = buildModelEnumerationOptions(); + const query = await this._sdkService.query({ prompt: neverYieldingPrompt, options }); + try { + const models = await query.supportedModels(); + return models.map(m => fromSdkModelInfo(m, this.id)); + } finally { + // `close()` terminates the subprocess; aborting the controller is a + // belt-and-suspenders teardown for anything `close()` leaves pending. + query.close(); + options.abortController?.abort(); + } + } + + /** + * Proxied (Copilot-CAPI) model source: fetch via {@link ICopilotApiService}, + * keep the Claude family, and surface the CAPI-flagged chat-default first. + * The picker treats `models[0]` as the de facto default (modelPicker.ts:144 + * — `_selectedModel ?? models[0]`) since `IAgentModelInfo` carries no + * explicit `isDefault` bit; the stable comparator returns 0 for equal- + * priority models so CAPI's ordering wins on ties. + */ + private async _fetchProxyModels(token: string): Promise { + const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`; + const all = await this._copilotApiService.models(token, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true }); + return all + .filter(isClaudeModel) + .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default)) + .map(m => toAgentModelInfo(m, this.id)); + } + // #endregion // #region Stubs — implemented in later phases @@ -356,7 +652,7 @@ export class ClaudeAgent extends Disposable implements IAgent { async createSession(config: IAgentCreateSessionConfig = {}): Promise { this._ensureAuthenticated(); if (config.fork) { - throw new Error('TODO: Phase 6.5: fork requires message-UUID lookup via sdk.getSessionMessages'); + return this._forkSession(config, config.fork); } const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); const sessionUri = AgentSession.uri(this.id, sessionId); @@ -374,6 +670,14 @@ export class ClaudeAgent extends Disposable implements IAgent { return { session: sessionUri, workingDirectory: config.workingDirectory }; } + // A workspace-less session (no `workingDirectory` supplied, and not a + // fork) runs in a stable per-session scratch dir shared with the Copilot + // agent; without a cwd Claude throws at materialize. The workspace-less + // marker itself is owned/persisted centrally by the AH service. + const workingDirectory = config.workingDirectory ?? await ensureWorkspacelessScratchDir(this._environmentService.userHome, sessionId); + + // Only probe for a project when the caller supplied a real folder; a + // scratch dir is never a code project. const project = config.workingDirectory ? await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService) : undefined; @@ -383,7 +687,8 @@ export class ClaudeAgent extends Disposable implements IAgent { const session = ClaudeAgentSession.createProvisional( sessionId, sessionUri, - config.workingDirectory, + URI.parse(buildDefaultChatUri(sessionUri)), + workingDirectory, project, config.model, config.agent, @@ -393,19 +698,238 @@ export class ClaudeAgent extends Disposable implements IAgent { this._metadataStore, this._instantiationService, ); - const entry = new ClaudeSessionEntry(session); - entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); - entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); - this._sessions.set(sessionId, entry); + this._seedSessionEntry(sessionId, sessionUri, session); return { session: sessionUri, - workingDirectory: config.workingDirectory, + workingDirectory, provisional: true, ...(project ? { project } : {}), }; } + /** + * In-place "Restore Checkpoint" truncation. Keeps turns + * `[0..turnId]` INCLUSIVE (or removes all turns when `turnId` is + * omitted) on the **same** session id / URI — unlike fork, which mints a + * new id. The `turnId` path resolves the protocol turn to its SDK + * assistant-envelope uuid ({@link resolveForkAnchorUuid}) and stages it + * as a one-shot `resumeSessionAt` anchor that the next turn's rebuild + * applies (the truncation finalizes when the next turn writes the + * branch). Serialized on {@link _sessionSequencer} (same key as + * `sendMessage`) so the `ChatTruncated` → `ChatTurnStarted` dispatch pair + * stays ordered. Provisional sessions short-circuit. + */ + async truncateSession(session: URI, turnId?: string): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + const existing = this._findAnySession(sessionId); + if (existing && !existing.isPipelineReady) { + this._logService.info(`[Claude:${sessionId}] truncateSession on a provisional session — nothing to truncate`); + return; + } + + if (turnId === undefined) { + await this._removeAllTurns(session, sessionId, existing); + return; + } + + const messages = await this._sdkService.getSessionMessages(sessionId, { includeSystemMessages: true }); + const anchor = resolveForkAnchorUuid(messages, turnId); + if (anchor === undefined) { + throw new Error(`Cannot truncate session ${sessionId}: turn ${turnId} not found in transcript`); + } + + // Operate on a live session; cold-resume an unloaded one first so + // there is a single code path that sets the anchor on a live + // pipeline (the next send applies it). + const live = existing ?? await this._resumeSession(sessionId, session); + await live.truncateToTurn(turnId, anchor); + this._logService.info(`[Claude:${sessionId}] truncateSession kept [0..${turnId}] (anchor=${anchor})`); + }); + } + + /** + * Remove-all ("start over") branch of {@link truncateSession}: there is no + * anchor to resume at, so tear down the live Query, delete the on-disk + * transcript via the SDK, then recreate a fresh provisional under the SAME + * id/URI so the next `sendMessage` materializes non-resume `{ sessionId }` + * on a clean transcript (keeps the id stable). `deleteSession` is eagerly + * durable (unlike the lazy `turnId` path), matching its "clear / start + * over" semantic. `existing` is the live session, or `undefined` on the + * cold path (unloaded session). Caller serializes on {@link _sessionSequencer}. + */ + private async _removeAllTurns(session: URI, sessionId: string, existing: ClaudeAgentSession | undefined): Promise { + const info = existing ? undefined : await this._sdkService.getSessionInfo(sessionId); + const workingDirectory = existing?.workingDirectory ?? (info?.cwd ? URI.file(info.cwd) : undefined); + if (!workingDirectory) { + // Mirror `_resumeSession` / fork: fail fast rather than recreate a + // provisional with no cwd that would only fail later at materialize. + throw new Error(`Cannot clear session ${sessionId}: workingDirectory missing (SDK cwd absent and no live session)`); + } + let overlay: IClaudeSessionOverlay = {}; + try { + overlay = await this._metadataStore.read(session); + } catch (err) { + this._logService.warn(`[Claude:${sessionId}] overlay read failed during remove-all; continuing with defaults`, err); + } + + // `shutdownLiveQuery` awaits the subprocess's actual exit (and its final + // transcript flush), so the on-disk `.jsonl` is now stable and safe + // to delete: no live writer can recreate it before the next turn + // respawns a fresh `--session-id `. + await existing?.shutdownLiveQuery(); + this._sessions.deleteAndDispose(sessionId); + await this._sdkService.deleteSession(sessionId); + + await this.createSession({ + session, + workingDirectory, + ...(overlay.model ? { model: overlay.model } : {}), + ...(overlay.agent ? { agent: overlay.agent } : {}), + ...(overlay.permissionMode ? { config: { [ClaudeSessionConfigKey.PermissionMode]: overlay.permissionMode } } : {}), + }); + // Re-fetch (not reuse `existing`): `existing` is the OLD session, already + // torn down by `deleteAndDispose` above, and is `undefined` entirely on + // the cold path. `createSession` registered a fresh instance under the + // same id — prune through that live session so a single path covers both + // warm and cold remove-all. + await this._findAnySession(sessionId)?.pruneAllTurns(); + this._logService.info(`[Claude:${sessionId}] truncateSession removed all turns (deleteSession + fresh same-id)`); + } + + // ---- Chat surface ------------------------------------------------------ + // + // `chats` exposes the per-chat operations addressed by a single, + // concrete chat channel URI (the default chat channel or a peer/subagent + // URI). The default chat's SDK id is still the owning session id, derived + // inside the harness from the chat URI. + + /** + * The chat-addressed operation surface + * ({@link IAgentChats}). Every method addresses a chat by a single, + * already-resolved chat URI; this maps to the `(session, chat)` pair + * the agent's internal SDK storage is keyed by (via + * {@link _resolveChatTarget}). + */ + readonly chats: IAgentChats = { + createChat: (chat, options) => this._createChat(chat, options), + fork: (chat, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions) => + this._createChat(chat, { ...options, fork: source }), + disposeChat: chatUri => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this._disposeChat(session, chat); + }, + sendMessage: (chatUri, prompt, attachments, turnId, senderClientId) => { + return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId); + }, + abort: chatUri => { + return this._abortSession(chatUri); + }, + changeModel: (chatUri, model) => { + return this._changeModel(chatUri, model); + }, + changeAgent: (chatUri, agent) => { + return this._changeAgent(chatUri, agent); + }, + getMessages: chat => this.getSessionMessages(chat), + }; + + /** + * Map an already-resolved chat URI to the `(session, chat)` pair the agent's + * internal SDK storage is keyed by. A peer (or subagent) chat is addressed by + * its own `ahp-chat` channel URI, from which the owning session is recovered. + * The default chat is addressed by its deterministic chat channel URI. + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Claude chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat }; + } + + /** + * NOT started here (CONTEXT M9): `forkSession` writes the transcript to + * disk and we return; the `Query` materializes lazily on the first + * {@link sendMessage} via {@link _resumeSession}. `turnId` is translated + * to the SDK envelope `uuid` by {@link resolveForkAnchorUuid}; + * `config.fork.turnIdMapping` is ignored (the SDK already remaps uuids). + */ + private async _forkSession(config: IAgentCreateSessionConfig, fork: NonNullable): Promise { + if (isSubagentSession(fork.session)) { + throw new Error('Cannot fork a subagent session'); + } + const sourceSessionId = AgentSession.id(fork.session); + const existingSource = this._findAnySession(sourceSessionId); + if (existingSource && !existingSource.isPipelineReady) { + throw new Error('Cannot fork a provisional/never-sent session'); + } + // Serialize against the SOURCE session so the transcript read + fork + // can't race an in-flight `sendMessage` mutating that session. + return this._sessionSequencer.queue(sourceSessionId, async () => { + const messages = await this._sdkService.getSessionMessages(sourceSessionId, { includeSystemMessages: true }); + const upToMessageId = resolveForkAnchorUuid(messages, fork.turnId); + if (upToMessageId === undefined) { + throw new Error(`Cannot fork session ${sourceSessionId}: turn ${fork.turnId} not found in transcript`); + } + const { sessionId: newSessionId } = await this._sdkService.forkSession(sourceSessionId, { upToMessageId }); + const newSessionUri = AgentSession.uri(this.id, newSessionId); + + // Inherit the source's model / permissionMode / agent (create-config + // overrides win) so the lazy `_resumeSession` seeds `Options` from + // it. `customizationDirectory` is NOT inherited — it is the source's + // per-session synced plugin dir (Phase 11); the fork re-syncs its own. + let sourceOverlay: IClaudeSessionOverlay = {}; + try { + sourceOverlay = await this._metadataStore.read(fork.session); + } catch (err) { + this._logService.warn(`[Claude] fork: source overlay read failed for ${sourceSessionId}; continuing with defaults`, err); + } + const model = config.model ?? sourceOverlay.model; + const agent = config.agent ?? sourceOverlay.agent; + const permissionMode = narrowClaudePermissionMode(config.config?.[ClaudeSessionConfigKey.PermissionMode]) ?? sourceOverlay.permissionMode; + await this._metadataStore.write(newSessionUri, { + ...(model ? { model } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(agent ? { agent } : {}), + }); + + // Resolve the forked session's working directory now so we can fail + // fast (rather than at the first `sendMessage` when `_resumeSession` + // requires a cwd). The Query itself starts lazily — see the JSDoc. + const sdkInfo = await this._sdkService.getSessionInfo(newSessionId); + const workingDirectory = sdkInfo?.cwd ? URI.file(sdkInfo.cwd) : config.workingDirectory; + if (!workingDirectory) { + throw new Error(`Cannot fork session ${sourceSessionId}: forked session ${newSessionId} has no working directory (SDK cwd missing and none supplied)`); + } + let project: IAgentSessionProjectInfo | undefined; + try { + project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + } catch (err) { + this._logService.warn(`[Claude] fork: project resolution failed for ${newSessionId}; continuing without project`, err); + } + return { + session: newSessionUri, + workingDirectory, + ...(project ? { project } : {}), + }; + }); + } + + /** + * Builds the SDK `canUseTool` permission bridge for a session/chat. The + * resolver searches both default chats and peer chats by SDK id so a peer + * chat's tool-permission requests reach its own pending-permission registry. + */ + private _makeCanUseTool(sdkSessionId: string): NonNullable { + return (toolName, input, options) => + handleCanUseTool( + { getSession: id => this._findSessionBySdkId(id), configurationService: this._configurationService }, + sdkSessionId, toolName, input, options, + ); + } + /** * Promote a provisional {@link ClaudeAgentSession} into a live one. * Called from {@link sendMessage} inside the {@link _sessionSequencer.queue} @@ -429,16 +953,12 @@ export class ClaudeAgent extends Disposable implements IAgent { if (!session) { throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`); } - const proxyHandle = this._ensureAuthenticated(); + const transport = this._ensureAuthenticated(); - const canUseTool: NonNullable = (toolName, input, options) => - handleCanUseTool( - { getSession: id => this._findAnySession(id), configurationService: this._configurationService }, - sessionId, toolName, input, options, - ); + const canUseTool = this._makeCanUseTool(sessionId); try { - await session.materialize({ proxyHandle, canUseTool, isResume: false, serverToolHost: this._serverToolHost }); + await session.materialize({ transport, canUseTool, isResume: false, serverToolHost: this._serverToolHost }); } catch (err) { this._sessions.deleteAndDispose(sessionId); throw err; @@ -469,7 +989,7 @@ export class ClaudeAgent extends Disposable implements IAgent { */ private async _resumeSession(sessionId: string, sessionUri: URI): Promise { this._logService.info(`[Claude:${sessionId}] _resumeSession — no in-memory state, rebuilding from disk`); - const proxyHandle = this._ensureAuthenticated(); + const transport = this._ensureAuthenticated(); const sdkInfo = await this._sdkService.getSessionInfo(sessionId); if (!sdkInfo) { throw new Error(`Cannot resume unknown session: ${sessionId} (not present in SDK transcript store)`); @@ -497,6 +1017,7 @@ export class ClaudeAgent extends Disposable implements IAgent { const session = ClaudeAgentSession.createProvisional( sessionId, sessionUri, + URI.parse(buildDefaultChatUri(sessionUri)), workingDirectory, project, overlay.model, @@ -507,19 +1028,12 @@ export class ClaudeAgent extends Disposable implements IAgent { this._metadataStore, this._instantiationService, ); - const entry = new ClaudeSessionEntry(session); - entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); - entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); - this._sessions.set(sessionId, entry); + this._seedSessionEntry(sessionId, sessionUri, session); - const canUseTool: NonNullable = (toolName, input, options) => - handleCanUseTool( - { getSession: id => this._findAnySession(id), configurationService: this._configurationService }, - sessionId, toolName, input, options, - ); + const canUseTool = this._makeCanUseTool(sessionId); try { - await session.materialize({ proxyHandle, canUseTool, isResume: true, serverToolHost: this._serverToolHost }); + await session.materialize({ transport, canUseTool, isResume: true, serverToolHost: this._serverToolHost }); } catch (err) { this._sessions.deleteAndDispose(sessionId); throw err; @@ -548,21 +1062,387 @@ export class ClaudeAgent extends Disposable implements IAgent { disposeSession(session: URI): Promise { // Routed through {@link _disposeSequencer} so a concurrent // {@link shutdown} already serializing teardown for this same - // session id awaits this work first (and vice versa). Phase 6 - // adds a provisional branch: when the session has not yet been - // materialized, abort the controller (unblocks any racing - // `await sdk.startup()`) and drop the record. No SDK contact, + // session id awaits this work first (and vice versa). When the session + // has not yet been materialized, abort the controller (unblocks any + // racing `await sdk.startup()`) and drop the record. No SDK contact, // no DB write — symmetric with `createSession`. const sessionId = AgentSession.id(session); return this._disposeSequencer.queue(sessionId, async () => { - const sess = this._findAnySession(sessionId); - if (sess && !sess.isPipelineReady) { - sess.abortController.abort(); + await this._teardownEntry(sessionId); + this._pruneActiveClientHandles(sessionId); + }); + } + + /** + * Abort and dispose a session entry — its default chat and every peer chat. + * Each peer teardown serializes on the peer's own {@link _sessionSequencer} + * key so it waits for any in-flight materialize/send rather than disposing + * the chat under it. + */ + private async _teardownEntry(sessionId: string): Promise { + const entry = this._sessions.get(sessionId); + if (!entry) { + return; + } + const defaultChat = entry.defaultChat; + if (defaultChat && !defaultChat.isPipelineReady) { + defaultChat.abortController.abort(); + } + await Promise.all(entry.peerChatKeys().map(chatKey => + this._sessionSequencer.queue(chatKey, async () => { + const peer = entry.getPeerChat(chatKey); + if (peer) { + if (!peer.isPipelineReady) { + peer.abortController.abort(); + } else { + peer.abort(); + } + } + entry.disposePeerChat(chatKey); + }) + )); + this._sessions.deleteAndDispose(sessionId); + // Drop the live backings for this session's peer chats. The chat URI + // encodes its parent session, so we recover it via `parseChatUri`. + for (const chatKey of [...this._chatBackings.keys()]) { + const parsed = parseChatUri(URI.parse(chatKey)); + if (parsed && AgentSession.id(URI.parse(parsed.session)) === sessionId) { + this._chatBackings.delete(chatKey); } - this._sessions.deleteAndDispose(sessionId); + } + } + + // #region Multi-chat — additional (non-default) peer chats + + /** + * Create an additional peer chat within an existing session. The new chat + * is backed by its own SDK chat (a fresh one, or a fork of the + * source chat at a turn) that shares the parent session's working directory + * and inherited model / agent / permission-mode parentSession. The backing is + * recorded in the live {@link _chatBackings} map and returned as an opaque + * `providerData` blob for the orchestrator to persist; the chat's metadata + * overlay is seeded so a later lazy resume inherits the parent parentSession. The + * live {@link ClaudeAgentSession} is built lazily on the chat's first send + * (mirroring how default sessions materialize lazily). + */ + private async _createChat(chat: URI, options?: IAgentCreateChatOptions): Promise { + this._ensureAuthenticated(); + if (isDefaultChatUri(chat)) { + return; + } + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`[Claude] createChat: malformed chat URI ${chat.toString()}`); + } + const session = URI.parse(parsed.session); + const chatKey = chat.toString(); + const parentSessionId = AgentSession.id(session); + let result: IAgentCreateChatResult | undefined; + await this._sessionSequencer.queue(parentSessionId, async () => { + const existing = this._chatBackings.get(chatKey); + if (existing) { + // Idempotent re-create: hand back the existing backing so the + // orchestrator re-persists a consistent blob. + result = { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) }; + return; + } + const parentSession = await this._resolveParentSession(session, parentSessionId); + const model = options?.model ?? parentSession.model; + + let sdkSessionId: string | undefined; + if (options?.fork) { + // If the fork point can't be resolved, fall through to a fresh + // chat rather than inheriting the whole source backend. + sdkSessionId = await this._forkChat(session, options.fork); + } + sdkSessionId ??= generateUuid(); + + // Record the live backing and hand the opaque blob back to the + // orchestrator to persist. + const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}) }; + this._chatBackings.set(chatKey, backing); + result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) }; + + // Seed the chat's own metadata overlay so a later lazy resume (this + // process or a restart) inherits the parent's parentSession. + await this._metadataStore.write(chat, { + ...(model ? { model } : {}), + ...(parentSession.agent ? { agent: parentSession.agent } : {}), + ...(parentSession.permissionMode ? { permissionMode: parentSession.permissionMode } : {}), + }); + this._logService.info(`[Claude] Created additional chat ${chat.toString()} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`); + }); + return result; + } + + /** + * Dispose an additional peer chat, tearing down its live chat (if + * any) and dropping its live backing. The default chat cannot be disposed in + * isolation — it lives and dies with the session. + * + * Routed through {@link _sessionSequencer} (keyed on the chat URI) so it + * waits for any in-flight {@link _materializeChatLocked} or + * {@link sendMessage} to finish before tearing down — prevents + * use-after-dispose if a send is concurrently in progress. The durable + * peer-chat catalog is owned by the orchestrator now, so this only drops the + * live backing and chat. + */ + private async _disposeChat(session: URI, chat: URI): Promise { + if (isDefaultChatUri(chat)) { + return; + } + const chatKey = chat.toString(); + const parentSessionId = AgentSession.id(session); + await this._sessionSequencer.queue(chatKey, async () => { + const entry = this._sessions.get(parentSessionId); + const peer = entry?.getPeerChat(chatKey); + if (peer) { + if (!peer.isPipelineReady) { + peer.abortController.abort(); + } else { + peer.abort(); + } + entry!.disposePeerChat(chatKey); + } + this._chatBackings.delete(chatKey); + }); + // The Claude SDK exposes no delete-chat RPC, so the forked / + // fresh transcript is left on disk; without a catalog entry it is never + // resumed again. + } + + /** + /** + * Resolve the inherited session settings (working directory, project, model, agent, + * permission mode) a new or resumed peer chat copies from its parent + * session. Prefers the live in-memory parent; falls back to the SDK's + * on-disk session record + metadata overlay for an unloaded parent. + */ + private async _resolveParentSession(session: URI, parentSessionId: string): Promise<{ workingDirectory: URI; project: IAgentSessionProjectInfo | undefined; model: ModelSelection | undefined; agent: AgentSelection | undefined; permissionMode: ClaudePermissionMode }> { + const parent = this._findAnySession(parentSessionId); + let workingDirectory = parent?.workingDirectory; + let project = parent?.project; + if (!workingDirectory) { + const sdkInfo = await this._sdkService.getSessionInfo(parentSessionId); + workingDirectory = sdkInfo?.cwd ? URI.file(sdkInfo.cwd) : undefined; + } + if (!workingDirectory) { + throw new Error(`[Claude] createChat: cannot resolve working directory for parent session ${session.toString()}`); + } + if (!project) { + try { + project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + } catch (err) { + this._logService.warn(`[Claude] createChat: project resolution failed for ${session.toString()}; continuing without project`, err); + } + } + let overlay: IClaudeSessionOverlay = {}; + try { + overlay = await this._metadataStore.read(session); + } catch (err) { + this._logService.warn(`[Claude] createChat: parent overlay read failed for ${session.toString()}; continuing with defaults`, err); + } + const permissionMode = readClaudePermissionMode(this._configurationService, session) ?? overlay.permissionMode ?? 'default'; + return { workingDirectory, project, model: overlay.model, agent: overlay.agent, permissionMode }; + } + + /** + * Fork the source chat's SDK chat at the requested turn into a new + * chat and return its SDK session id. Returns `undefined` (so the + * caller creates a fresh chat instead) when the source chat or the + * fork anchor cannot be resolved. + */ + private async _forkChat(session: URI, fork: IAgentCreateChatOptions['fork'] & {}): Promise { + const sourceSdkId = await this._resolveChatSdkId(session, fork.source); + if (!sourceSdkId) { + this._logService.warn(`[Claude] createChat fork: source ${fork.source.toString()} has no SDK chat; creating fresh chat`); + return undefined; + } + const messages = await this._sdkService.getSessionMessages(sourceSdkId, { includeSystemMessages: true }); + const upToMessageId = resolveForkAnchorUuid(messages, fork.turnId); + if (upToMessageId === undefined) { + this._logService.warn(`[Claude] createChat fork: turn ${fork.turnId} not found in source ${sourceSdkId}; creating fresh chat`); + return undefined; + } + const { sessionId } = await this._sdkService.forkSession(sourceSdkId, { upToMessageId }); + return sessionId; + } + + /** + * Resolve the SDK chat id backing a chat URI — the session's + * default chat (the parent session's own id) or an additional peer chat + * (from the in-memory entry, else the live/legacy backing). + */ + private async _resolveChatSdkId(session: URI, chatUri: URI): Promise { + if (isDefaultChatUri(chatUri) || chatUri.toString() === session.toString()) { + return AgentSession.id(session); + } + const inMemory = this._findChat(session, chatUri)?.sessionId; + if (inMemory) { + return inMemory; + } + return this._resolveChatBacking(chatUri)?.sdkSessionId; + } + + /** + * Resolves the live backing for a peer chat from the in-memory + * {@link _chatBackings} map. Returns `undefined` for a chat that has not been + * materialized via {@link materializeChat}. + */ + private _resolveChatBacking(chat: URI): IPersistedChat | undefined { + return this._chatBackings.get(chat.toString()); + } + + /** + * Return the in-memory entry for a session, creating a provisional (not yet + * materialized) default chat to host its peer chats if none exists — e.g. a + * peer chat is sent to after a restart before the default chat is touched. + * Serialized on the session id so concurrent peer sends share one entry. + */ + private _ensureSessionEntry(session: URI): Promise { + const sessionId = AgentSession.id(session); + return this._sessionSequencer.queue(sessionId, async () => { + const existing = this._sessions.get(sessionId); + if (existing) { + return existing; + } + const parentSession = await this._resolveParentSession(session, sessionId); + const mainSession = ClaudeAgentSession.createProvisional( + sessionId, + session, + URI.parse(buildDefaultChatUri(session)), + parentSession.workingDirectory, + parentSession.project, + parentSession.model, + parentSession.agent, + undefined, + new PendingRequestRegistry(), + parentSession.permissionMode, + this._metadataStore, + this._instantiationService, + ); + return this._seedSessionEntry(sessionId, session, mainSession); }); } + /** + * Build + materialize the peer chat's live {@link ClaudeAgentSession}, + * resuming its persisted SDK chat when one already exists on disk + * (forked or restored chats) or starting fresh otherwise. The caller MUST + * hold the per-chat (`chat.toString()`) {@link _sessionSequencer} lock so + * concurrent first sends collapse into one materialize and teardown can't + * race the build. + */ + private async _materializeChatLocked(session: URI, chat: URI): Promise { + const chatKey = chat.toString(); + const entry = await this._ensureSessionEntry(session); + const existing = entry.getPeerChat(chatKey); + if (existing?.isPipelineReady) { + return existing; + } + const chatSession = existing ?? await this._buildProvisionalChat(session, chat, entry); + // Resume when the SDK already has a transcript for this chat + // (forked or restored); otherwise materialize a fresh one. + const sdkInfo = await this._sdkService.getSessionInfo(chatSession.sessionId); + const transport = this._ensureAuthenticated(); + const canUseTool = this._makeCanUseTool(chatSession.sessionId); + try { + await chatSession.materialize({ transport, canUseTool, isResume: !!sdkInfo, serverToolHost: this._serverToolHost }); + } catch (err) { + entry.disposePeerChat(chatKey); + throw err; + } + return chatSession; + } + + /** + * Build a provisional peer-chat {@link ClaudeAgentSession} from its live (or + * legacy) backing + overlay: its `sessionUri` is the real parent session URI + * and its `chatChannelUri` is the chat's own channel (never overloaded), + * backed by the resolved SDK chat id. Registers it on the owning + * {@link ClaudeSessionEntry}; the caller materializes it. + */ + private async _buildProvisionalChat(session: URI, chat: URI, entry: ClaudeSessionEntry): Promise { + const info = this._resolveChatBacking(chat); + if (!info) { + throw new Error(`[Claude] no backing chat for chat ${chat.toString()}`); + } + const parentSession = await this._resolveParentSession(session, AgentSession.id(session)); + let overlay: IClaudeSessionOverlay = {}; + try { + overlay = await this._metadataStore.read(chat); + } catch (err) { + this._logService.warn(`[Claude] chat overlay read failed for ${chat.toString()}; continuing with defaults`, err); + } + const permissionMode = readClaudePermissionMode(this._configurationService, chat) ?? overlay.permissionMode ?? parentSession.permissionMode; + // Overlay takes precedence over the backing: `changeModel` always writes + // the overlay first (via `setModel` or `_metadataStore.write`) and then + // the backing. If the backing update is lost, the overlay already holds + // the newest model; preferring it here ensures a model change is never + // silently reverted after a restart. + const model = overlay.model ?? info.model; + const chatSession = ClaudeAgentSession.createProvisional( + info.sdkSessionId, + session, + chat, + parentSession.workingDirectory, + parentSession.project, + model, + overlay.agent ?? parentSession.agent, + undefined, + new PendingRequestRegistry(), + permissionMode, + this._metadataStore, + this._instantiationService, + ); + entry.registerPeerChat(chat.toString(), this._wireEntry(chatSession)); + return chatSession; + } + + /** + * Update a peer chat's live backing model and push the refreshed opaque + * `providerData` blob to the orchestrator (via + * {@link onDidChangeChatData}) so the durable catalog stays in sync. + */ + private async _updateChatBackingModel(chat: URI, model: ModelSelection): Promise { + const backing = this._resolveChatBacking(chat); + if (!backing) { + return; + } + const updated: IPersistedChat = { sdkSessionId: backing.sdkSessionId, model }; + this._chatBackings.set(chat.toString(), updated); + this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) }); + } + + /** + * Re-attach the in-memory backing for a peer chat on session restore, + * decoding the opaque `providerData` the orchestrator persisted at creation + * (or the latest {@link onDidChangeChatData}). After this resolves the + * chat's backing SDK chat can be resumed lazily on its first send. + * Best-effort — a corrupt/unknown blob is logged and dropped rather than + * thrown. + */ + async materializeChat(chat: URI, providerData: string | undefined): Promise { + if (isDefaultChatUri(chat)) { + return; + } + const chatInfo = parseChatUri(chat); + if (!chatInfo) { + return; + } + if (providerData === undefined) { + return; + } + const backing = decodeProviderData(providerData); + if (!backing) { + this._logService.warn(`[Claude] materializeChat: dropping corrupt providerData for ${chat.toString()}`); + return; + } + this._chatBackings.set(chat.toString(), backing); + } + + // #endregion + /** * Test-only accessor for the materialized {@link ClaudeAgentSession}. * Phase 6 section 5.1 Test 10 needs to inspect `_isResumed` directly because @@ -572,7 +1452,7 @@ export class ClaudeAgent extends Disposable implements IAgent { * existence; the protocol surface (`IAgent`) does not include it. */ getSessionForTesting(session: URI): ClaudeAgentSession | undefined { - const sess = this._sessions.get(AgentSession.id(session))?.session; + const sess = this._sessions.get(AgentSession.id(session))?.defaultChat; return sess?.isPipelineReady ? sess : undefined; } @@ -585,14 +1465,12 @@ export class ClaudeAgent extends Disposable implements IAgent { * and returns `[]` rather than propagating — mirrors `listSessions`. */ async getSessionMessages(session: URI): Promise { - const sessionId = AgentSession.id(session); - const sess = this._findAnySession(sessionId); - if (sess && !sess.isPipelineReady) { - return []; - } + // Additional peer chat: reconstruct its own SDK chat (resolved + // from the catalog/in-memory), routed to the chat channel URI. Shares + // the same fetch+map path as the default chat via `_reconstructTurns`. if (isSubagentSession(session)) { const parsed = parseSubagentSessionUri(session); - const parentSession = parsed ? this._sessions.get(AgentSession.id(parsed.parentSession))?.session : undefined; + const parentSession = parsed ? this._sessions.get(AgentSession.id(parsed.parentSession))?.defaultChat : undefined; if (!parentSession) { // Parent session is gone (disposed or never materialized). // The registry that holds the agentId cache lives on the @@ -607,32 +1485,62 @@ export class ClaudeAgent extends Disposable implements IAgent { return []; } } - const parentSession = this._sessions.get(sessionId)?.session; + + const chat = parseChatUri(session) ? session : URI.parse(buildDefaultChatUri(session)); + const chatInfo = parseChatUri(chat); + if (!chatInfo) { + return []; + } + const parentSessionUri = URI.parse(chatInfo.session); + const sessionId = AgentSession.id(parentSessionUri); + const context = this._getChatContext(chat); + if (context.isPeerChat) { + const sdkId = await this._resolveChatSdkId(parentSessionUri, chat); + if (!sdkId) { + return []; + } + return this._reconstructTurns(sdkId, chat, context.target); + } + + const sess = context.target; + if (sess && !sess.isPipelineReady) { + return []; + } + // Default chat: its SDK chat id is the session id. + return this._reconstructTurns(sessionId, parentSessionUri, sess); + } + + /** + * Fetch a chat's SDK transcript ({@link sdkSessionId}) and map it to + * protocol {@link Turn}s routed to {@link routingUri} (the session or chat + * channel URI). When {@link primeOn} is supplied (the materialized owning + * session), its subagent registry is primed from the agentId suffixes the + * SDK encoded in Task tool_result blocks. Resilient: any failure warn-logs + * and returns `[]` rather than propagating. + */ + private async _reconstructTurns(sdkSessionId: string, routingUri: URI, primeOn: ClaudeAgentSession | undefined): Promise { let messages; try { - messages = await this._sdkService.getSessionMessages(sessionId, { includeSystemMessages: true }); + messages = await this._sdkService.getSessionMessages(sdkSessionId, { includeSystemMessages: true }); } catch (err) { - this._logService.warn(`[Claude] getSessionMessages SDK fetch failed for ${sessionId}`, err); + this._logService.warn(`[Claude] getSessionMessages SDK fetch failed for ${sdkSessionId}`, err); return []; } let turns: readonly Turn[]; try { - turns = mapSessionMessagesToTurns(messages, session, this._logService); + turns = mapSessionMessagesToTurns(messages, routingUri, this._logService); } catch (err) { // Defensive boundary: a single malformed SDK message must not // blow up the entire transcript read. - this._logService.warn(`[Claude] replay mapper threw for ${sessionId}`, err); + this._logService.warn(`[Claude] replay mapper threw for ${sdkSessionId}`, err); return []; } - // If the parent session is materialized, prime its registry from - // any agentId suffixes the SDK encoded in Task tool_result text - // blocks so subsequent subagent transcript reads can short-circuit - // the strategy chain. A bug in `primeFromTranscript` MUST NOT - // break an otherwise-successful parent transcript read. + // A bug in `primeFromTranscript` MUST NOT break an otherwise-successful + // transcript read. try { - parentSession?.subagents.primeFromTranscript(turns); + primeOn?.subagents.primeFromTranscript(turns); } catch (err) { - this._logService.warn(`[Claude] primeFromTranscript threw for ${sessionId}`, err); + this._logService.warn(`[Claude] primeFromTranscript threw for ${sdkSessionId}`, err); } return turns; } @@ -657,6 +1565,15 @@ export class ClaudeAgent extends Disposable implements IAgent { // sibling Copilot provider gets nuked too. Catch and log instead. let sdkEntries: readonly SDKSessionInfo[]; try { + // Don't trigger a cold SDK download just to populate the session + // list at startup. When the SDK isn't local yet, surface an empty + // list; the download fires (with host-level progress) once the user + // starts a session, and the next `listSessions` — driven by the + // renderer's post-turn refresh — returns the full list. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring session list until a session triggers the download'); + return []; + } sdkEntries = await this._sdkService.listSessions(); } catch (err) { this._logService.warn('[Claude] SDK listSessions failed; surfacing empty list', err); @@ -781,97 +1698,129 @@ export class ClaudeAgent extends Disposable implements IAgent { // not a fresh outer-async wrapper around it. return this._shutdownPromise ??= (async () => { for (const entry of this._sessions.values()) { - if (!entry.session.isPipelineReady) { - entry.session.abortController.abort(); + // Provisional chats (a default or peer whose first send's + // materialize is in-flight) race on their own abort controller — + // abort them up front so a queued `sdk.startup()` unwinds + // promptly rather than running past shutdown until its teardown + // task dequeues. + for (const chat of entry.allChatSessions()) { + if (!chat.isPipelineReady) { + chat.abortController.abort(); + } } } const sessionIds = [...this._sessions.keys()]; await Promise.all(sessionIds.map(sessionId => this._disposeSequencer.queue(sessionId, async () => { - this._sessions.deleteAndDispose(sessionId); + await this._teardownEntry(sessionId); + this._pruneActiveClientHandles(sessionId); }) )); })(); } - async sendMessage(sessionUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise { + // `IAgent.sendMessage` declares `turnId?` but every production caller in + // `AgentSideEffects` supplies one. Generate a fallback so the + // session-side `QueuedRequest.turnId: string` invariant holds even if a + // hypothetical caller forgets it. + const effectiveTurnId = turnId ?? generateUuid(); + const context = this._getChatContext(chat); + + // Additional peer chat: route to its own chat. Its SDK + // `session_id` is the chat's chat id, NOT the parent session's. + // Hold the per-chat lock across BOTH materialize and send (mirroring the + // default-chat path below) so concurrent sends to the same peer chat + // serialize and a racing disposeChat/disposeSession (which queue on the + // same chat key) waits for the in-flight turn instead of disposing the + // session under it. + if (context.isPeerChat) { + return this._sessionSequencer.queue(context.chatKey, async () => { + const chatSession = await this._materializeChatLocked(context.session, chat); + await chatSession.send(this._buildSdkPrompt(chatSession.sessionId, prompt, attachments, effectiveTurnId), effectiveTurnId); + }); + } + // Plan section 3.8. The sequencer scope holds across BOTH materialize // and `session.send` so two concurrent first-message calls on the // same session collapse into one materialize plus two ordered // sends. A `disposeSession` racing a first send reaches its own // dispose-sequencer eventually but the in-flight materialize // completes first. - const sessionId = AgentSession.id(sessionUri); - // `IAgent.sendMessage` declares `turnId?` (agentService.ts:424) but - // every production caller in `AgentSideEffects` supplies one. Generate - // a fallback so the session-side `QueuedRequest.turnId: string` - // invariant holds even if a hypothetical caller forgets it. - const effectiveTurnId = turnId ?? generateUuid(); - return this._sessionSequencer.queue(sessionId, async () => { - const existing = this._findAnySession(sessionId); + return this._sessionSequencer.queue(context.sessionId, async () => { + const existing = this._getChatContext(chat).target; let session: ClaudeAgentSession; if (existing?.isPipelineReady) { session = existing; } else if (existing) { - session = await this._materializeProvisional(sessionId); + session = await this._materializeProvisional(context.sessionId); } else { - session = await this._resumeSession(sessionId, sessionUri); + session = await this._resumeSession(context.sessionId, context.session); } - const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); - const sdkPrompt: SDKUserMessage = { - type: 'user', - message: { role: 'user', content: contentBlocks }, - session_id: sessionId, - parent_tool_use_id: null, - // M1 / Glossary: `Turn.id ↔ SDKUserMessage.uuid`. The SDK - // types this as a branded `${string}-…` template-literal - // alias of Node's `crypto.UUID`; cast at the boundary - // rather than threading the brand up to every caller. - // Mirrors the reference extension at - // `extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts:585`. - uuid: effectiveTurnId as `${string}-${string}-${string}-${string}-${string}`, - }; - - await session.send(sdkPrompt, effectiveTurnId); + await session.send(this._buildSdkPrompt(context.sessionId, prompt, attachments, effectiveTurnId), effectiveTurnId); }); } + /** Builds the SDK user message for a send, addressed to `sdkSessionId`. */ + private _buildSdkPrompt(sdkSessionId: string, prompt: string, attachments: readonly MessageAttachment[] | undefined, turnId: string): SDKUserMessage { + const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); + return { + type: 'user', + message: { role: 'user', content: contentBlocks }, + session_id: sdkSessionId, + parent_tool_use_id: null, + // M1 / Glossary: `Turn.id ↔ SDKUserMessage.uuid`. The SDK types this + // as a branded `${string}-…` template-literal alias of Node's + // `crypto.UUID`; cast at the boundary rather than threading the brand + // up to every caller. + uuid: turnId as `${string}-${string}-${string}-${string}-${string}`, + }; + } + respondToPermissionRequest(requestId: string, approved: boolean): void { // `requestId` is the SDK's `tool_use_id` — globally unique, so a - // single matching session is all we need. Silent on miss - // (workbench may have raced a session dispose). - for (const entry of this._sessions.values()) { - if (entry.session.respondToPermissionRequest(requestId, approved)) { + // single matching chat is all we need. Silent on miss (workbench may + // have raced a session dispose). + for (const sess of this._allLiveSessions()) { + if (sess.respondToPermissionRequest(requestId, approved)) { return; } } } respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): void { - // `requestId` is the SDK's `tool_use_id` (interactive tools - // reuse it as the {@link ChatInputRequest.id}); globally - // unique, so a single matching session is all we need. Silent - // on miss for the same reasons as `respondToPermissionRequest`. - for (const entry of this._sessions.values()) { - if (entry.session.respondToUserInputRequest(requestId, response, answers)) { + // `requestId` is the SDK's `tool_use_id` (interactive tools reuse it as + // the {@link ChatInputRequest.id}); globally unique, so a single + // matching chat is all we need. Silent on miss for the same reasons as + // {@link respondToPermissionRequest}. + for (const sess of this._allLiveSessions()) { + if (sess.respondToUserInputRequest(requestId, response, answers)) { return; } } } - async abortSession(session: URI): Promise { + /** Every live chat — each session's default chat and its peers. */ + private _allLiveSessions(): ClaudeAgentSession[] { + const all: ClaudeAgentSession[] = []; + for (const entry of this._sessions.values()) { + all.push(...entry.allChatSessions()); + } + return all; + } + + private async _abortSession(chat: URI): Promise { // Phase 9 D1: cancel via the abort controller, NOT `Query.interrupt()`. // Abort is a control-plane operation — it must NOT serialize // through `_sessionSequencer` because an in-flight `sendMessage` // task is parked on its turn deferred and would deadlock the abort // behind the very turn it's trying to cancel. Calling - // `entry.session.abort()` directly rejects the in-flight deferred, + // `chat.abort()` directly rejects the in-flight deferred, // which lets the queued sendMessage task complete and frees the // sequencer for the next caller. - const sessionId = AgentSession.id(session); - const sess = this._findAnySession(sessionId); + const sess = this._getChatContext(chat).target; if (!sess) { return; } @@ -882,34 +1831,41 @@ export class ClaudeAgent extends Disposable implements IAgent { sess.abort(); } - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[], chat?: URI): void { // Phase 9 D5: queued messages are intentionally a no-op. CONTEXT.md // M10 + AgentSideEffects confirm queued messages are consumed // server-side; the agent boundary always receives an empty queue. - const sessionId = AgentSession.id(session); - this._logService.info(`[Claude:${sessionId}] setPendingMessages called: steering=${steeringMessage?.id ?? 'none'} queued=${_queuedMessages.length}`); - const entry = this._sessions.get(sessionId); - if (!entry) { - this._logService.warn(`[Claude:${sessionId}] setPendingMessages: session not found`); + // + // Steering targets the chat that owns the in-flight turn: an additional + // peer chat is addressed by its `chat` channel URI, the default chat by + // the session URI. + const isPeerChat = !!chat && !isDefaultChatUri(chat); + const target = this._findChat(session, chat); + this._logService.info(`[Claude] setPendingMessages for ${(chat ?? session).toString()}: steering=${steeringMessage?.id ?? 'none'} queued=${_queuedMessages.length}`); + if (!target) { + this._logService.warn(`[Claude] setPendingMessages: ${isPeerChat ? 'chat' : 'session'} not found for ${(chat ?? session).toString()}`); return; } if (steeringMessage) { - entry.session.injectSteering(steeringMessage); + target.injectSteering(steeringMessage); } } - async changeModel(session: URI, model: ModelSelection): Promise { - // Session owns its own provisional/runtime branching and metadata - // write (see {@link ClaudeAgentSession.setModel}). The agent only - // covers the "external-only session" case where there is no - // in-memory record to delegate to. - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const sess = this._findAnySession(sessionId); + private async _changeModel(chat: URI, model: ModelSelection): Promise { + const context = this._getChatContext(chat); + const queueKey = context.isPeerChat ? context.chatKey : context.sessionId; + await this._sessionSequencer.queue(queueKey, async () => { + const current = this._getChatContext(chat); + const sess = current.target; if (sess) { await sess.setModel(model); + } else if (current.isPeerChat) { + await this._metadataStore.write(chat, { model }); } else { - await this._metadataStore.write(session, { model }); + await this._metadataStore.write(current.session, { model }); + } + if (current.isPeerChat) { + await this._updateChatBackingModel(chat, model); } }); } @@ -920,16 +1876,19 @@ export class ClaudeAgent extends Disposable implements IAgent { * provisional/runtime branching and metadata write * (see {@link ClaudeAgentSession.setAgent}). For external-only * sessions (no in-memory record), the agent is persisted directly to - * the overlay so a later resume picks it up. + * the overlay so a later resume picks it up. When `chat` is an additional + * peer chat, the change targets that chat's chat. */ - async changeAgent(session: URI, agent: AgentSelection | undefined): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const sess = this._findAnySession(sessionId); + private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise { + const context = this._getChatContext(chat); + const queueKey = context.isPeerChat ? context.chatKey : context.sessionId; + await this._sessionSequencer.queue(queueKey, async () => { + const current = this._getChatContext(chat); + const sess = current.target; if (sess) { await sess.setAgent(agent); } else { - await this._metadataStore.write(session, { agent: agent ?? null }); + await this._metadataStore.write(current.isPeerChat ? chat : current.session, { agent: agent ?? null }); } }); } @@ -938,20 +1897,50 @@ export class ClaudeAgent extends Disposable implements IAgent { this._serverToolHost = host; } - setClientTools(session: URI, clientId: string | undefined, tools: ToolDefinition[]): void { + getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient { const sessionId = AgentSession.id(session); - this._logService.info(`[Claude:${sessionId}] setClientTools clientId=${clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`); - const sess = this._findAnySession(sessionId); - if (!sess) { - return; + const key = `${sessionId}\u0000${client.clientId}`; + let handle = this._activeClientHandles.get(key); + if (!handle) { + handle = new ClaudeActiveClientHandle( + client.clientId, + client.displayName, + () => this._findAnySession(sessionId)?.getClientTools(client.clientId) ?? [], + tools => { + this._logService.info(`[Claude:${sessionId}] active client ${client.clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`); + this._findAnySession(sessionId)?.setClientTools(client.clientId, tools); + }, + customizations => { void this.syncClientCustomizations(session, client.clientId, [...customizations]); }, + ); + this._activeClientHandles.set(key, handle); } - sess.setClientTools(tools, clientId); + return handle; + } + + removeActiveClient(session: URI, clientId: string): void { + const sessionId = AgentSession.id(session); + this._activeClientHandles.delete(`${sessionId}\u0000${clientId}`); + // Tools are written synchronously, so remove them immediately. The + // customization sync runs inside the session sequencer, so serialize + // its removal there too — otherwise a late in-flight sync could + // resurrect the removed client's customizations after it has left. + this._findAnySession(sessionId)?.removeClientTools(clientId); + void this._sessionSequencer.queue(sessionId, async () => { + this._findAnySession(sessionId)?.removeClientCustomizations(clientId); + }).catch(() => { /* session torn down */ }); } - onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { - // Walk subagent URIs to the root — nested subagents require iterated - // parsing. `_sessions` is keyed by root session ids only. Mirrors - // copilotAgent.ts:947. + /** Drop cached active-client handles belonging to a session being torn down. */ + private _pruneActiveClientHandles(sessionId: string): void { + const prefix = `${sessionId}\u0000`; + for (const key of [...this._activeClientHandles.keys()]) { + if (key.startsWith(prefix)) { + this._activeClientHandles.delete(key); + } + } + } + + onClientToolCallComplete(session: URI, _chat: URI, toolCallId: string, result: ToolCallResult): void { let target = session; let parsed; while ((parsed = parseSubagentSessionUri(target))) { @@ -961,29 +1950,28 @@ export class ClaudeAgent extends Disposable implements IAgent { const entry = this._sessions.get(sessionId); // `AgentSideEffects` forwards every `ChatToolCallComplete` envelope // (including SDK-owned tools); silent on miss is the expected path. - entry?.session.completeClientToolCall(toolCallId, result); + entry?.defaultChat?.completeClientToolCall(toolCallId, result); } - async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { + async syncClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { const sessionId = AgentSession.id(session); const sess = this._findAnySession(sessionId); if (!sess) { - this._logService.warn(`[Claude:${sessionId}] setClientCustomizations: session not found`); + this._logService.warn(`[Claude:${sessionId}] syncClientCustomizations: session not found`); return []; } // Run inside the session sequencer so that a fire-and-forget - // `setClientCustomizations` from `AgentSideEffects` cannot race - // ahead of a first `sendMessage`: if `sendMessage` is already - // queued, the sync runs first or queues behind it; either way - // the materialize call reads the most recently adopted plugin - // set, never an empty one mid-sync. + // customization sync cannot race ahead of a first `sendMessage`: if + // `sendMessage` is already queued, the sync runs first or queues + // behind it; either way the materialize call reads the most recently + // adopted plugin set, never an empty one mid-sync. return this._sessionSequencer.queue(sessionId, async () => { const synced = await this._pluginManager.syncCustomizations( clientId, customizations, status => this._fireCustomizationUpdated(session, { customization: status }), ); - sess.adoptClientCustomizations(synced); + sess.adoptClientCustomizations(clientId, synced); return synced; }); } @@ -997,7 +1985,7 @@ export class ClaudeAgent extends Disposable implements IAgent { private _fireCustomizationUpdated(session: URI, item: ISyncedCustomization): void { this._onDidSessionProgress.fire({ kind: 'action', - session, + resource: session, action: { type: ActionType.SessionCustomizationUpdated, customization: item.customization, @@ -1007,7 +1995,7 @@ export class ClaudeAgent extends Disposable implements IAgent { setCustomizationEnabled(id: string, enabled: boolean): void { for (const entry of this._sessions.values()) { - entry.session.setClientCustomizationEnabled(id, enabled); + entry.defaultChat.setClientCustomizationEnabled(id, enabled); } } @@ -1060,8 +2048,10 @@ export class ClaudeAgent extends Disposable implements IAgent { // wrapper-before-proxy ordering invariant. This is locked by // test "dispose disposes the proxy handle and is idempotent". for (const entry of this._sessions.values()) { - if (!entry.session.isPipelineReady) { - entry.session.abortController.abort(); + for (const chat of entry.allChatSessions()) { + if (!chat.isPipelineReady) { + chat.abortController.abort(); + } } } super.dispose(); @@ -1073,25 +2063,19 @@ export class ClaudeAgent extends Disposable implements IAgent { } /** - * Bundle of a {@link ClaudeAgentSession} and any per-session disposables - * registered against it (e.g. the agent's forward subscription to the - * session's `onDidSessionProgress` event). One entry per materialized - * session in {@link ClaudeAgent._sessions}; disposing the entry disposes - * the session AND every extra registered via {@link addDisposable}. - * - * Lets new per-session lifecycle bindings (future config listeners, - * abort wirings, etc.) attach to the session's lifetime without growing - * a new parallel `DisposableMap` on the agent. + * Per-session container. Owns the session's default (main) chat and any + * additional peer chats — each a {@link ClaudeAgentSession} plus the + * event-forwarding subscriptions registered against it (e.g. the agent's + * forward subscription to the session's `onDidSessionProgress` event). A single + * {@link ClaudeAgent._sessions} map of these entries keeps all chats of a + * session together (no parallel maps), so dispatch resolves a chat by looking + * up its owning session and then the chat within it. Disposing the entry + * disposes the session AND every extra registered via + * {@link AgentSessionEntry.addDisposable}. */ -class ClaudeSessionEntry extends Disposable { - readonly session: ClaudeAgentSession; - - constructor(session: ClaudeAgentSession) { - super(); - this.session = this._register(session); - } - - addDisposable(disposable: IDisposable): void { - this._register(disposable); +class ClaudeSessionEntry extends AgentSessionEntry { + /** Claude sessions always have a materialized default chat once seeded. */ + override get defaultChat(): ClaudeAgentSession { + return super.defaultChat!; } } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts index 731638f883bfb..15651d2284039 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AnyZodRawShape, GetSessionMessagesOptions, GetSubagentMessagesOptions, InferShape, ListSessionsOptions, ListSubagentsOptions, McpSdkServerConfigWithInstance, Options, SDKSessionInfo, SdkMcpToolDefinition, SessionMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { AnyZodRawShape, ForkSessionOptions, ForkSessionResult, GetSessionMessagesOptions, GetSubagentMessagesOptions, InferShape, ListSessionsOptions, ListSubagentsOptions, McpSdkServerConfigWithInstance, Options, Query, SDKSessionInfo, SDKUserMessage, SdkMcpToolDefinition, SessionMessage, SessionMutationOptions, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { pathToFileURL } from 'url'; import { CancellationToken } from '../../../../base/common/cancellation.js'; @@ -22,6 +22,7 @@ import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; */ export const ClaudeSdkPackage: IAgentSdkPackage = { id: 'claude', + displayName: 'Claude', devOverrideEnvVar: AgentHostClaudeSdkRootEnvVar, hasSeparateMuslLinuxPackage: true, }; @@ -44,10 +45,28 @@ export interface IClaudeAgentSdkService { listSessions(): Promise; getSessionInfo(sessionId: string): Promise; startup(params: { options: Options; initializeTimeoutMs?: number }): Promise; + /** + * 1:1 with the SDK's top-level `query` export. Returns a `Query` whose + * subprocess starts lazily; callers drive control requests on it (e.g. + * `supportedModels()` for model enumeration) and `close()` it when done. + * Async only because the SDK module itself is loaded lazily. + */ + query(params: { prompt: string | AsyncIterable; options?: Options }): Promise; getSessionMessages(sessionId: string, options?: GetSessionMessagesOptions): Promise; listSubagents(sessionId: string, options?: ListSubagentsOptions): Promise; getSubagentMessages(sessionId: string, agentId: string, options?: GetSubagentMessagesOptions): Promise; + /** + * True iff the SDK can be loaded WITHOUT a network download — a dev + * override or dev bare-import is available, or a previously-downloaded SDK + * is cached on disk. Eager / background callers (e.g. `listSessions` at + * startup) gate on this so listing sessions never kicks off a multi-second + * cold download before the user has started a session. + */ + canLoadWithoutDownload(): Promise; + + forkSession(sessionId: string, options?: ForkSessionOptions): Promise; + deleteSession(sessionId: string, options?: SessionMutationOptions): Promise; createSdkMcpServer(options: { name: string; version?: string; @@ -80,9 +99,12 @@ export interface IClaudeSdkBindings { listSessions(options?: ListSessionsOptions): Promise; getSessionInfo(sessionId: string): Promise; startup(params: { options: Options; initializeTimeoutMs?: number }): Promise; + query(params: { prompt: string | AsyncIterable; options?: Options }): Query; getSessionMessages(sessionId: string, options?: GetSessionMessagesOptions): Promise; listSubagents(sessionId: string, options?: ListSubagentsOptions): Promise; getSubagentMessages(sessionId: string, agentId: string, options?: GetSubagentMessagesOptions): Promise; + forkSession(sessionId: string, options?: ForkSessionOptions): Promise; + deleteSession(sessionId: string, options?: SessionMutationOptions): Promise; createSdkMcpServer(options: { name: string; version?: string; @@ -123,6 +145,17 @@ export class ClaudeAgentSdkService implements IClaudeAgentSdkService { return sdk.listSessions(undefined); } + async canLoadWithoutDownload(): Promise { + // A dev override (explicit SDK root) is always local. So is the dev + // bare-import path, which is taken when there is no product config — + // `isAvailable` is false exactly in that case. Otherwise the SDK comes + // from the downloader, which is only local once it has been cached. + if (process.env[AgentHostClaudeSdkRootEnvVar] || !this._downloader.isAvailable(ClaudeSdkPackage)) { + return true; + } + return this._downloader.isSdkResolvableWithoutDownload(ClaudeSdkPackage); + } + async getSessionInfo(sessionId: string): Promise { const sdk = await this._getSdk(); return sdk.getSessionInfo(sessionId); @@ -133,6 +166,11 @@ export class ClaudeAgentSdkService implements IClaudeAgentSdkService { return sdk.startup(params); } + async query(params: { prompt: string | AsyncIterable; options?: Options }): Promise { + const sdk = await this._getSdk(); + return sdk.query(params); + } + async getSessionMessages(sessionId: string, options?: GetSessionMessagesOptions): Promise { const sdk = await this._getSdk(); return sdk.getSessionMessages(sessionId, options); @@ -148,6 +186,16 @@ export class ClaudeAgentSdkService implements IClaudeAgentSdkService { return sdk.getSubagentMessages(sessionId, agentId, options); } + async forkSession(sessionId: string, options?: ForkSessionOptions): Promise { + const sdk = await this._getSdk(); + return sdk.forkSession(sessionId, options); + } + + async deleteSession(sessionId: string, options?: SessionMutationOptions): Promise { + const sdk = await this._getSdk(); + return sdk.deleteSession(sessionId, options); + } + async createSdkMcpServer(options: { name: string; version?: string; diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 21e6e9970e616..abcac13edfdf7 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -9,6 +9,8 @@ import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -18,10 +20,10 @@ import { ClaudeRuntimeEffortLevel, clampEffortForRuntime, resolveClaudeEffort } import { AgentSignal, IAgentSessionProjectInfo } from '../../common/agentService.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import type { Customization, ToolCallResult } from '../../common/state/sessionState.js'; +import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallContributorKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { isDefaultChatUri, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; import { toSdkModelId } from './claudeModelId.js'; @@ -31,10 +33,15 @@ import { convertToolCallResult } from './clientTools/claudeClientToolResult.js'; import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; import { SessionClientCustomizationsDiff } from './customizations/claudeSessionClientCustomizationsModel.js'; -import { projectSessionCustomizations } from './customizations/claudeSessionCustomizationsProjector.js'; -import { ClaudeSdkCustomizationBundler } from './customizations/claudeSdkCustomizationBundler.js'; +import { ClaudeCustomizationWatcher, buildDiscoveredCustomizations, resolveClaudeAgentName } from './customizations/claudeSessionCustomizationDiscovery.js'; +import { findMcpChildId } from '../shared/mcpCustomizationController.js'; +import { scanClaudeDiskCustomizations } from './customizations/scan/claudeAgentSkillScan.js'; +import { scanClaudeHooks } from './customizations/scan/claudeHookScan.js'; +import { scanClaudeMcpServers } from './customizations/scan/claudeMcpScan.js'; +import { scanClaudeNativePlugins } from './customizations/scan/claudeNativePluginScan.js'; +import { scanClaudeRules } from './customizations/scan/claudeRuleScan.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; -import { IClaudeProxyHandle } from './claudeProxyService.js'; +import type { ClaudeTransport } from './claudeProxyService.js'; import { ClaudeSdkPipeline, IRematerializer, type ISdkResolvedCustomizations } from './claudeSdkPipeline.js'; import { SubagentRegistry } from './claudeSubagentRegistry.js'; import { ClaudePermissionKind } from './claudeToolDisplay.js'; @@ -49,7 +56,7 @@ export type { IRematerializer } from './claudeSdkPipeline.js'; * agent's per-session lookup, and the resume-vs-fresh discriminator). */ export interface IMaterializeContext { - readonly proxyHandle: IClaudeProxyHandle; + readonly transport: ClaudeTransport; readonly canUseTool: NonNullable; readonly isResume: boolean; /** @@ -82,7 +89,19 @@ function resolveCurrentPermissionMode( export class ClaudeAgentSession extends Disposable { private _pipeline: ClaudeSdkPipeline | undefined; - private _sdkBundler: ClaudeSdkCustomizationBundler | undefined; + private readonly _chatChannelUri: URI; + + /** + * URI under which this chat's per-chat resources (its session database, + * metadata overlay, config scope and server-tool advertisement) are keyed. + * The default chat uses the real session URI; an additional peer chat uses + * its own `ahp-chat` channel URI so its chat state stays isolated + * from the default chat's. `sessionUri` always remains the real session URI + * and `chatChannelUri` always the chat channel — they are never overloaded. + */ + private get _storageUri(): URI { + return isDefaultChatUri(this._chatChannelUri) ? this.sessionUri : this._chatChannelUri; + } /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */ private _provisionalModel: ModelSelection | undefined; @@ -111,6 +130,7 @@ export class ClaudeAgentSession extends Disposable { static createProvisional( sessionId: string, sessionUri: URI, + chatChannelUri: URI, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, @@ -125,6 +145,7 @@ export class ClaudeAgentSession extends Disposable { ClaudeAgentSession, sessionId, sessionUri, + chatChannelUri, workingDirectory, project, model, @@ -202,6 +223,14 @@ export class ClaudeAgentSession extends Disposable { */ private _currentTurnNanoAiu = 0; + /** + * Transport the session materialized under (Phase 19). Defaults to `proxy` + * until {@link materialize} resolves it from {@link IMaterializeContext}. + * Gates {@link _enrichSignalWithCredits} so native turns never carry a + * Copilot credits overlay (the proxy is the only credit source). + */ + private _transportKind: ClaudeTransport['kind'] = 'proxy'; + /** * Accumulate proxy-reported billed credits for the in-flight turn. * Called from {@link ClaudeAgent} for every proxy `onDidReportCredits` @@ -220,7 +249,7 @@ export class ClaudeAgentSession extends Disposable { * All other signals pass through untouched. */ private _enrichSignalWithCredits(signal: AgentSignal): AgentSignal { - if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatUsage || this._currentTurnNanoAiu <= 0) { + if (this._transportKind !== 'proxy' || signal.kind !== 'action' || signal.action.type !== ActionType.ChatUsage || this._currentTurnNanoAiu <= 0) { return signal; } const usage = signal.action.usage; @@ -239,9 +268,33 @@ export class ClaudeAgentSession extends Disposable { }; } + /** + * Stamps the MCP {@link ToolCallContributor} onto a `ChatToolCallStart` for + * an external `mcp____` call, resolved from this session's + * cached customization snapshot. Owned here because the session owns the + * customization data; the stream mapper stays free of it. (The in-process + * `mcp__client__` server already carries a Client contributor from the mapper.) + */ + private _enrichSignalWithMcpContributor(signal: AgentSignal): AgentSignal { + if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatToolCallStart || signal.action.contributor !== undefined) { + return signal; + } + const toolName = signal.action.toolName; + if (!toolName.startsWith('mcp__')) { + return signal; + } + const serverName = toolName.split('__')[1]; + const customizationId = serverName ? findMcpChildId(this._lastCustomizations, serverName) : undefined; + if (customizationId === undefined) { + return signal; + } + return { ...signal, action: { ...signal.action, contributor: { kind: ToolCallContributorKind.MCP, customizationId } } }; + } + constructor( readonly sessionId: string, readonly sessionUri: URI, + readonly chatChannelUri: URI, readonly workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, @@ -257,8 +310,11 @@ export class ClaudeAgentSession extends Disposable { @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, ) { super(); + this._chatChannelUri = chatChannelUri; this.project = project; this._provisionalModel = model; this._provisionalAgent = agent; @@ -266,6 +322,65 @@ export class ClaudeAgentSession extends Disposable { this.abortController = abortController; this.toolDiff = this._register(toolDiff); this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire())); + + // Watch the on-disk Claude customization sources so edits made outside + // the session (a new `~/.claude/agents/*.md`, an edited skill, a changed + // `.mcp.json`) drive a workbench re-fetch. Active from construction so + // it covers the provisional (pre-materialize) window too. + const customizationWatcher = this._register(new ClaudeCustomizationWatcher( + this.workingDirectory, + this._environmentService.userHome, + this._fileService, + this._logService, + )); + this._register(customizationWatcher.onDidChange(() => this._onDidCustomizationsChange.fire())); + } + + /** + * One-shot SDK assistant-message uuid that the next materialize / rebuild + * resumes *up to and including* (the SDK's `Options.resumeSessionAt`). + * Staged by {@link truncateToTurn}; read by the next build and cleared + * only once that build *succeeds* (so a thrown / cancelled rebuild keeps + * the anchor staged and the next send retries the truncation rather than + * silently proceeding without it and undoing the checkpoint restore). + */ + private _pendingResumeSessionAt: string | undefined; + + /** + * In-place truncation to `turnId` ("Restore Checkpoint"): prune the + * per-turn DB rows (file edits, checkpoint refs) past the boundary AND + * stage the SDK resume anchor that the next rebuild applies via + * `Options.resumeSessionAt`. These two halves are one invariant — pruning + * without staging the anchor would drop DB rows while the SDK still + * replays the truncated turns; staging without pruning would leave stale + * rows — so they live behind a single call rather than two the caller + * could half-invoke. The prune runs first because it is the fallible half: + * a DB failure then rejects without leaving an anchor staged for the next + * turn. `turnId` is the protocol turn id (DB key); `resumeAnchorUuid` is + * the SDK assistant-message uuid the agent resolved for it. + */ + async truncateToTurn(turnId: string, resumeAnchorUuid: string): Promise { + await this._withDatabase(db => db.deleteTurnsAfter(turnId)); + this._pendingResumeSessionAt = resumeAnchorUuid; + } + + /** Prunes all per-turn DB rows (remove-all truncation). */ + async pruneAllTurns(): Promise { + await this._withDatabase(db => db.deleteAllTurns()); + } + + /** + * Runs `fn` against a short-lived, ref-counted session DB handle so the + * write is safe regardless of the pipeline's own dbRef lifecycle (the + * ref-count keeps the shared DB alive; disposing only decrements). + */ + private async _withDatabase(fn: (db: ISessionDatabase) => Promise): Promise { + const ref = this._sessionDataService.openDatabase(this._storageUri); + try { + await fn(ref.object); + } finally { + ref.dispose(); + } } /** @@ -288,9 +403,11 @@ export class ClaudeAgentSession extends Disposable { if (!this.workingDirectory) { throw new Error(`Cannot materialize Claude session ${this.sessionId}: workingDirectory is required`); } + this._transportKind = ctx.transport.kind; - const permissionMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; + const permissionMode = readClaudePermissionMode(this._configurationService, this._storageUri) ?? this._permissionModeFallback; const { mcpServers, allowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); + const agentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); const options = await buildOptions( { @@ -301,12 +418,13 @@ export class ClaudeAgentSession extends Disposable { permissionMode, canUseTool: ctx.canUseTool, isResume: ctx.isResume, + resumeSessionAt: this._pendingResumeSessionAt, mcpServers, allowedTools, plugins: this.clientCustomizationsDiff.consume(), - agent: this._resolveAgentName(this._provisionalAgent), + agent: agentName, }, - ctx.proxyHandle, + ctx.transport, data => this._logService.error(`[Claude SDK stderr] ${data}`), msg => this._logService.info(`[Claude] declining elicitation from MCP server (Phase 7 stub): ${msg}`), ); @@ -320,35 +438,31 @@ export class ClaudeAgentSession extends Disposable { throw new CancellationError(); } - const dbRef = this._sessionDataService.openDatabase(this.sessionUri); + const dbRef = this._sessionDataService.openDatabase(this._storageUri); let pipeline: ClaudeSdkPipeline; try { pipeline = this._register(this._instantiationService.createInstance( ClaudeSdkPipeline, this.sessionId, this.sessionUri, + this._chatChannelUri, warm, this.abortController, dbRef, this.subagents, - this.toolDiff.model.state.get().clientId, + (toolName: string) => this.toolDiff.model.ownerOf(toolName), )); } catch (err) { dbRef.dispose(); await warm[Symbol.asyncDispose](); throw err; } - this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithCredits(s)))); + this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithMcpContributor(this._enrichSignalWithCredits(s))))); this._pipeline = pipeline; - // On-disk Open Plugin bundle for SDK-discovered customizations. - // The bundle directory is content-addressed by the SDK snapshot - // hash and lives under the plugin manager's user-data tree; - // disposing the bundler does NOT delete the on-disk tree (kept - // as a warm cache across sessions on the same workingDirectory). - this._sdkBundler = this._register(this._instantiationService.createInstance( - ClaudeSdkCustomizationBundler, - this.workingDirectory, - )); + // The materialize succeeded with the staged anchor applied to `Options` + // — clear it now so it isn't re-applied. A throw before this point (e.g. + // `startup` / pipeline-create) leaves it staged for the next retry. + this._pendingResumeSessionAt = undefined; // Seed the pipeline's bijective config cache so a rebuild re-applies // the user's last-chosen model / effort without losing the picker @@ -365,10 +479,11 @@ export class ClaudeAgentSession extends Disposable { // upstream and would otherwise overwrite their source. if (!ctx.isResume) { try { - await this._metadataStore.write(this.sessionUri, { + await this._metadataStore.write(this._storageUri, { customizationDirectory: this.workingDirectory, model: this._provisionalModel, permissionMode, + transport: ctx.transport.kind, }); } catch (err) { this._logService.error(`[Claude] Failed to persist customization directory; aborting materialize`, err); @@ -386,9 +501,10 @@ export class ClaudeAgentSession extends Disposable { } pipeline.attachRematerializer(async (_reason) => { - const liveMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; + const liveMode = readClaudePermissionMode(this._configurationService, this._storageUri) ?? this._permissionModeFallback; try { const { mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); + const rebuildAgentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); const rebuildAbort = new AbortController(); const rebuildOptions = await buildOptions( { @@ -399,17 +515,23 @@ export class ClaudeAgentSession extends Disposable { permissionMode: liveMode, canUseTool: ctx.canUseTool, isResume: true, + resumeSessionAt: this._pendingResumeSessionAt, mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools, plugins: this.clientCustomizationsDiff.consume(), - agent: this._resolveAgentName(this._provisionalAgent), + agent: rebuildAgentName, }, - ctx.proxyHandle, + ctx.transport, data => this._logService.error(`[Claude SDK stderr] ${data}`), msg => this._logService.info(`[Claude] declining elicitation from MCP server (Phase 7 stub): ${msg}`), ); this._logService.info(`[Claude] session ${this.sessionId}: resume rebuild agent=${rebuildOptions.agent ?? '(none)'}`); const rebuildWarm = await this._sdkService.startup({ options: rebuildOptions }); + // Rebuild succeeded with the anchor applied — clear it so it + // isn't re-applied. A throw above keeps it staged (handled in the + // catch alongside the tool/customization diffs) so the next send + // retries the truncation instead of dropping the restore. + this._pendingResumeSessionAt = undefined; return { warm: rebuildWarm, abortController: rebuildAbort }; } catch (err) { this.toolDiff.markDirty(); @@ -421,7 +543,7 @@ export class ClaudeAgentSession extends Disposable { // Advertise the agent host's server tools on this session so the client // sees them as server-provided. Execution happens in-process via the // server-tool MCP server built in `_buildStartupToolWiring`. - ctx.serverToolHost?.advertise(this.sessionUri.toString()); + ctx.serverToolHost?.advertise(this._storageUri.toString()); // Surface the SDK-resolved customization tier to the workbench. // Pre-materialize, getSessionCustomizations returns only the @@ -451,7 +573,7 @@ export class ClaudeAgentSession extends Disposable { ): Promise<{ mcpServers: Record | undefined; allowedTools: readonly string[] | undefined }> { const clientServers = await buildClientMcpServers(this.toolDiff, this._pendingClientToolCalls, this._sdkService); const serverToolServer = serverToolHost - ? await buildServerToolMcpServer(serverToolHost, this.sessionUri.toString(), this._sdkService) + ? await buildServerToolMcpServer(serverToolHost, this._storageUri.toString(), this._sdkService) : undefined; const mcpServers = (!clientServers && !serverToolServer) ? undefined @@ -484,6 +606,17 @@ export class ClaudeAgentSession extends Disposable { get isResumed(): boolean { return this._requirePipeline().isResumed; } + /** + * Abort the live SDK subprocess and await its full teardown so the + * session id is released. No-op when the session was never materialized + * (no subprocess to stop). Used by remove-all truncation before it + * recreates a fresh session under the same id — the CLI keeps the id + * locked until the old subprocess exits. + */ + async shutdownLiveQuery(): Promise { + await this._pipeline?.shutdownAndWait(); + } + /** * Seed the pipeline's current + applied config cache from * materialize-time `Options`. The SDK already starts with these @@ -523,10 +656,10 @@ export class ClaudeAgentSession extends Disposable { // New turn: reset the per-turn credit accumulator so proxy reports // for this turn's `/v1/messages` calls sum from zero. this._currentTurnNanoAiu = 0; - if (this.toolDiff.hasDifference || this.clientCustomizationsDiff.hasDifference) { + if (this.toolDiff.hasDifference || this.clientCustomizationsDiff.hasDifference || this._pendingResumeSessionAt !== undefined) { await this._rebindForSyncedState(); } else { - await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this.sessionUri, this._permissionModeFallback)); + await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this._storageUri, this._permissionModeFallback)); } return pipeline.send(prompt, turnId); } @@ -594,7 +727,7 @@ export class ClaudeAgentSession extends Disposable { // (`output_config.effort ... does not support reasoning effort`). await this._pipeline.setEffort(runtimeEffort); } - await this._metadataStore.write(this.sessionUri, { model }); + await this._metadataStore.write(this._storageUri, { model }); } /** @@ -622,34 +755,7 @@ export class ClaudeAgentSession extends Disposable { // runtime hook to swap the agent in place. this.clientCustomizationsDiff.markDirty(); } - await this._metadataStore.write(this.sessionUri, { agent: agent ?? null }); - } - - /** - * Resolve an {@link AgentSelection} URI to the SDK agent name the - * SDK expects on `Options.agent`. Every custom agent the picker can - * surface for a Claude session comes from the SDK side - * ({@link ClaudeSdkCustomizationBundler} populates - * `SessionCustomization.agents` from `Query.supportedAgents()`), - * pointing at on-disk `.../agents/.md` files we wrote - * ourselves, so the name is the file basename. - * - * Returns `undefined` when no agent is selected (or the URI doesn't - * resolve to a known agent file) so the SDK falls back to its default - * (no `--agent` flag). - */ - private _resolveAgentName(agent: AgentSelection | undefined): string | undefined { - if (!agent) { - return undefined; - } - const uri = URI.parse(agent.uri); - const basename = uri.path.split('/').pop() ?? ''; - const name = basename.replace(/\.md$/i, ''); - if (!name) { - this._logService.warn(`[Claude:${this.sessionId}] _resolveAgentName: could not extract agent name from URI '${agent.uri}'`); - return undefined; - } - return name; + await this._metadataStore.write(this._storageUri, { agent: agent ?? null }); } /** @@ -711,7 +817,7 @@ export class ClaudeAgentSession extends Disposable { return this._pendingPermissions.registerAndFire(args.toolUseID, () => { this._onDidSessionProgress.fire({ kind: 'pending_confirmation', - session: this.sessionUri, + chat: this._chatChannelUri, state: args.state, permissionKind: args.permissionKind, ...(args.permissionPath !== undefined ? { permissionPath: args.permissionPath } : {}), @@ -736,7 +842,7 @@ export class ClaudeAgentSession extends Disposable { return this._pendingUserInputs.registerAndFire(request.id, () => { this._onDidSessionProgress.fire({ kind: 'action', - session: this.sessionUri, + resource: this._chatChannelUri, action: { type: ActionType.ChatInputRequested, request, @@ -758,12 +864,24 @@ export class ClaudeAgentSession extends Disposable { // #region Phase 10 — client tools - /** Replace the registered client tools snapshot. */ - setClientTools(tools: readonly ToolDefinition[], clientId?: string): void { - this.toolDiff.model.setTools(tools, clientId); - if (this._pipeline) { - this._pipeline.setClientId(this.toolDiff.model.state.get().clientId); - } + /** Replace a client's registered tools (full replacement). */ + setClientTools(clientId: string, tools: readonly ToolDefinition[]): void { + this.toolDiff.model.setTools(clientId, tools); + } + + /** This client's registered tools (empty when absent). */ + getClientTools(clientId: string): readonly ToolDefinition[] { + return this.toolDiff.model.getTools(clientId); + } + + /** Remove a client's tool contribution from this session. */ + removeClientTools(clientId: string): void { + this.toolDiff.model.removeClient(clientId); + } + + /** Remove a client's customization contribution from this session. */ + removeClientCustomizations(clientId: string): void { + this.clientCustomizationsDiff.model.removeClient(clientId); } /** @@ -820,8 +938,8 @@ export class ClaudeAgentSession extends Disposable { * the resulting snapshot down here. Flips the client-side dirty bit * so the next {@link send} pre-flight reloads SDK plugins. */ - adoptClientCustomizations(synced: readonly ISyncedCustomization[]): void { - this.clientCustomizationsDiff.model.setSyncedCustomizations(synced); + adoptClientCustomizations(clientId: string, synced: readonly ISyncedCustomization[]): void { + this.clientCustomizationsDiff.model.setSyncedCustomizations(clientId, synced); } /** Toggle a **client-pushed** customization on/off for this session. */ @@ -838,6 +956,9 @@ export class ClaudeAgentSession extends Disposable { return this.clientCustomizationsDiff.model.state.get().synced; } + /** Snapshot of the last {@link getSessionCustomizations} result, read by {@link _enrichSignalWithMcpContributor}. */ + private _lastCustomizations: readonly Customization[] = []; + /** * Project the union of (a) **client-pushed** customizations and * (b) the **server-side** (SDK-discovered) view (commands / agents @@ -853,23 +974,46 @@ export class ClaudeAgentSession extends Disposable { */ async getSessionCustomizations(): Promise { const { synced, enablement } = this.clientCustomizationsDiff.model.state.get(); - let bundled: Customization | undefined; - if (this._pipeline && this._sdkBundler) { - let sdk: ISdkResolvedCustomizations | undefined; + const userHome = this._environmentService.userHome; + const [discovered, rules, mcpServers, hooks, nativePlugins] = await Promise.all([ + scanClaudeDiskCustomizations(this.workingDirectory, userHome, this._fileService), + scanClaudeRules(this.workingDirectory, userHome, this._fileService), + scanClaudeMcpServers(this.workingDirectory, userHome, this._fileService), + scanClaudeHooks(this.workingDirectory, userHome, this._fileService), + scanClaudeNativePlugins(this.workingDirectory, userHome, this._fileService, this._logService), + ]); + + // Post-materialize, the live SDK snapshot filters the disk set down to + // what the session actually loaded (and surfaces SDK-only items as + // non-editable). Pre-materialize there is no Query, so the full disk + // set is shown. A transient SDK read failure leaves `sdk` undefined, + // falling back to the unfiltered disk set rather than blanking the UI. + let sdk: ISdkResolvedCustomizations | undefined; + if (this._pipeline) { try { sdk = await this._pipeline.snapshotResolvedCustomizations(); } catch (err) { this._logService.warn(`[Claude:${this.sessionId}] snapshotResolvedCustomizations failed`, err); } - if (sdk) { - try { - bundled = await this._sdkBundler.bundle(sdk); - } catch (err) { - this._logService.warn(`[Claude:${this.sessionId}] SDK bundle failed`, err); - } - } } - return projectSessionCustomizations(synced, enablement, bundled); + + // `buildDiscoveredCustomizations` also folds in the read-only "Built-in" + // surfacing (curated pre-materialize, SDK-derived post-materialize) for + // both agents and skills, so the SDK-vs-curated decision lives in one place. + const discoveredCustomizations = buildDiscoveredCustomizations([...discovered, ...rules], mcpServers, hooks, nativePlugins, this.workingDirectory, userHome, sdk); + + // Final projection: the client-pushed tier (with the per-id enablement + // overlay) first, then the discovered tier appended verbatim — the + // enablement map is deliberately NOT applied to discovered entries. + const result: Customization[] = synced.map(item => ({ + ...item.customization, + enabled: enablement.get(item.customization.id) ?? item.customization.enabled, + })); + result.push(...discoveredCustomizations); + // Cache for the MCP-contributor signal enrichment (see + // {@link _enrichSignalWithMcpContributor}). + this._lastCustomizations = result; + return result; } // #endregion diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index 2802007a05a6e..50a94e21e4a8b 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -9,6 +9,7 @@ import { ChatInputResponseKind, ToolCallPendingConfirmationState, ToolCallStatus import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { buildAskUserSessionInputQuestions, buildExitPlanModeConfirmationState, flattenAskUserAnswers, parseAskUserQuestionInput } from './claudeInteractiveTools.js'; +import { CLAUDE_PLAN_DECLINED_MESSAGE, CLAUDE_QUESTION_CANCELLED_MESSAGE, CLAUDE_USER_DECLINED_MESSAGE } from './claudeToolDenial.js'; import { getClaudeConfirmationTitle, getClaudeInvocationMessage, getClaudePermissionKind, getClaudeToolDisplayName, getClaudeToolInputString, getClaudeToolPath, INTERACTIVE_CLAUDE_TOOLS, buildClaudeToolMeta } from './claudeToolDisplay.js'; /** @@ -147,7 +148,7 @@ async function dispatchCanUseTool( }); return approved ? { behavior: 'allow', updatedInput: input } - : { behavior: 'deny', message: 'User declined' }; + : { behavior: 'deny', message: CLAUDE_USER_DECLINED_MESSAGE }; } /** @@ -238,7 +239,7 @@ async function handleExitPlanMode( }); return { behavior: 'allow', updatedInput: input }; } - return { behavior: 'deny', message: 'The user declined the plan, maybe ask why?' }; + return { behavior: 'deny', message: CLAUDE_PLAN_DECLINED_MESSAGE }; } /** @@ -265,12 +266,12 @@ async function handleAskUserQuestion( questions: buildAskUserSessionInputQuestions(askInput), }, parentToolCallId); if (answer.response !== ChatInputResponseKind.Accept || !answer.answers) { - return { behavior: 'deny', message: 'The user cancelled the question' }; + return { behavior: 'deny', message: CLAUDE_QUESTION_CANCELLED_MESSAGE }; } const answers = flattenAskUserAnswers(askInput, answer.answers); if (Object.keys(answers).length === 0) { - return { behavior: 'deny', message: 'The user cancelled the question' }; + return { behavior: 'deny', message: CLAUDE_QUESTION_CANCELLED_MESSAGE }; } return { behavior: 'allow', updatedInput: { ...input, answers } }; } diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index c066e43275848..8a239922f293a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -14,6 +14,7 @@ import { buildTopLevelSubagentReadyAction, emitInnerAssistantSignals, mapSubagen import type { SubagentRegistry } from './claudeSubagentRegistry.js'; import { stripClientToolNamePrefix, hasClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js'; import { buildClaudeToolMeta, getClaudePastTenseMessage, getClaudeToolDisplayName } from './claudeToolDisplay.js'; +import { claudeToolDenialCode } from './claudeToolDenial.js'; import { ClaudeToolCallRegistry } from './claudeToolCallRegistry.js'; import { ToolCallConfirmationReason, ToolCallContributorKind, type StringOrMarkdown } from '../../common/state/protocol/state.js'; @@ -214,12 +215,12 @@ export class ClaudeMapperState { */ export function mapSDKMessageToAgentSignals( message: SDKMessage, - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, logService: ILogService, registry: SubagentRegistry, - clientId?: string, + clientToolOwner?: (toolName: string) => string | undefined, ): AgentSignal[] { if (logService.getLevel() <= LogLevel.Trace) { try { @@ -232,31 +233,31 @@ export function mapSDKMessageToAgentSignals( switch (message.type) { case 'stream_event': return tagWithParent( - mapStreamEvent(message.event, session, turnId, state, logService, message.parent_tool_use_id, registry, clientId), - session, + mapStreamEvent(message.event, chat, turnId, state, logService, message.parent_tool_use_id, registry, clientToolOwner), + chat, message.parent_tool_use_id, registry, ); case 'result': - return mapResult(message, session, turnId, state, logService, registry); + return mapResult(message, chat, turnId, state, logService, registry); case 'assistant': return tagWithParent( - mapAssistantCanonical(message, session, turnId, state, message.parent_tool_use_id, registry), - session, + mapAssistantCanonical(message, chat, turnId, state, message.parent_tool_use_id, registry), + chat, message.parent_tool_use_id, registry, ); case 'user': return tagWithParent( - mapUserMessage(message, session, state, logService, registry), - session, + mapUserMessage(message, chat, state, logService, registry), + chat, message.parent_tool_use_id, registry, ); default: // Phase 12 step 7 — system subtypes for subagent task discrimination. if (message.type === 'system') { - return mapSubagentSystemMessage(message, session, registry); + return mapSubagentSystemMessage(message, chat, registry); } return []; } @@ -284,7 +285,7 @@ export function mapSDKMessageToAgentSignals( */ function mapAssistantCanonical( message: Extract, - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, parentToolUseId: string | null, @@ -296,11 +297,11 @@ function mapAssistantCanonical( if (block.type !== 'tool_use' || !SUBAGENT_SPAWNING_TOOL_NAMES.has(block.name)) { continue; } - top.push(buildTopLevelSubagentReadyAction(block, session, turnId, registry)); + top.push(buildTopLevelSubagentReadyAction(block, chat, turnId, registry)); } return top; } - return emitInnerAssistantSignals(message, session, turnId, state, parentToolUseId, registry); + return emitInnerAssistantSignals(message, chat, turnId, state, parentToolUseId, registry); } /** @@ -316,7 +317,7 @@ function mapAssistantCanonical( */ function mapUserMessage( message: Extract, - session: URI, + chat: URI, state: ClaudeMapperState, logService: ILogService, registry: SubagentRegistry, @@ -343,12 +344,20 @@ function mapUserMessage( content.push(fileEdit); } const info = state.toolCalls.lookup(block.tool_use_id)?.info; + const resultText = content + .filter((c): c is { type: ToolResultContentType.Text; text: string } => c.type === ToolResultContentType.Text) + .map(c => c.text) + .join('\n'); const pastTenseMessage: StringOrMarkdown = info - ? getClaudePastTenseMessage(info.toolName, info.displayName, info.parsedInput, !isError) + ? getClaudePastTenseMessage(info.toolName, info.displayName, info.parsedInput, !isError, resultText) : `${getClaudeToolDisplayName(tracked.toolName)} finished`; + // A denied/cancelled tool surfaces as an `is_error` result whose content + // is the deny `message` we returned from `canUseTool`; classify it so the + // telemetry reports `userCancelled` rather than a generic error. + const denialCode = isError ? claudeToolDenialCode(resultText) : undefined; signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallComplete, turnId: tracked.turnId, @@ -357,6 +366,7 @@ function mapUserMessage( success: !isError, pastTenseMessage, content: content.length > 0 ? content : undefined, + ...(denialCode ? { error: { message: resultText, code: denialCode } } : {}), }, }, }); @@ -369,7 +379,7 @@ function mapUserMessage( if (spawn && !spawn.background && spawn.markCompleted()) { signals.push({ kind: 'subagent_completed', - session, + chat, toolCallId: block.tool_use_id, }); registry.removeSpawn(block.tool_use_id); @@ -431,7 +441,7 @@ function mapResult( // `_meta.copilotUsage.totalNanoAiu` (the key the workbench reads). signals.push({ kind: 'action', - session, + resource: session, action: { type: ActionType.ChatUsage, turnId, @@ -455,7 +465,7 @@ function mapResult( if (errorText !== undefined) { signals.push({ kind: 'action', - session, + resource: session, action: { type: ActionType.ChatError, turnId, @@ -498,13 +508,13 @@ function getResultErrorText(message: Extract): s function mapStreamEvent( event: Extract['event'], - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, logService: ILogService, parentToolUseId: string | null, registry: SubagentRegistry, - clientId: string | undefined, + clientToolOwner: ((toolName: string) => string | undefined) | undefined, ): AgentSignal[] { switch (event.type) { case 'message_start': @@ -516,7 +526,7 @@ function mapStreamEvent( if (block.type === 'text') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -531,7 +541,7 @@ function mapStreamEvent( if (block.type === 'thinking') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -581,10 +591,10 @@ function mapStreamEvent( // produced by `buildClaudeToolMeta` because // `getClaudeToolKind('Task') === 'subagent'`. const meta = buildClaudeToolMeta(toolName); - const toolClientId = isClientTool ? clientId : undefined; + const toolClientId = isClientTool ? clientToolOwner?.(toolName) : undefined; return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallStart, turnId, @@ -603,7 +613,7 @@ function mapStreamEvent( if (event.delta.type === 'text_delta') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatDelta, turnId, @@ -615,7 +625,7 @@ function mapStreamEvent( if (event.delta.type === 'thinking_delta') { return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatReasoning, turnId, @@ -633,7 +643,7 @@ function mapStreamEvent( state.appendToolBlockInputDelta(event.index, event.delta.partial_json); return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallDelta, turnId, @@ -660,7 +670,7 @@ function mapStreamEvent( const meta = buildClaudeToolMeta(tracked.toolName); return [{ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallReady, turnId, diff --git a/src/vs/platform/agentHost/node/claude/claudeProxyService.ts b/src/vs/platform/agentHost/node/claude/claudeProxyService.ts index 6723baf47cd2c..c5cba59fc9e15 100644 --- a/src/vs/platform/agentHost/node/claude/claudeProxyService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeProxyService.ts @@ -55,6 +55,22 @@ export interface IClaudeProxyHandle extends ILoopbackProxyHandle { readonly nonce: string; } +/** + * How the Claude provider reaches Anthropic, resolved once per session at + * materialize time and threaded as data through `IMaterializeContext` into + * `buildOptions` / `buildSubprocessEnv`. + * + * - `proxy`: Copilot-routed Claude (the default). All `messages` traffic goes + * through the local {@link IClaudeProxyHandle} → Copilot CAPI. + * - `native`: BYO-Anthropic (Phase 19). The SDK talks to Anthropic directly on + * the user's own credentials (`ANTHROPIC_API_KEY`, or a subscription OAuth + * token in `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`); no proxy is + * involved. The SDK's bundled `claude` CLI runs the turn. + */ +export type ClaudeTransport = + | { readonly kind: 'proxy'; readonly handle: IClaudeProxyHandle } + | { readonly kind: 'native' }; + /** * A per-request credits report. CAPI returns the actual billed credits * for a `/v1/messages` request as `copilot_usage.total_nano_aiu` on the @@ -253,7 +269,7 @@ export class ClaudeProxyService extends LoopbackProxyServer { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; + const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true }; let message: Anthropic.Message; try { message = await this._copilotApiService.messages(runtime.state.githubToken, body, options); @@ -427,7 +443,7 @@ export class ClaudeProxyService extends LoopbackProxyServer { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; + const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true }; let stream: AsyncGenerator; try { stream = this._copilotApiService.messages(runtime.state.githubToken, body, options); diff --git a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts index d32822db166c2..75825a6c0500c 100644 --- a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts +++ b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts @@ -58,6 +58,42 @@ export function mapSessionMessagesToTurns( return builder.finish(); } +/** + * Phase 6.5 — translate a protocol `turnId` (the last KEPT turn N) into the + * SDK envelope `uuid` that `forkSession({ upToMessageId })` accepts + * (INCLUSIVE). Returns the `uuid` of turn N's last `'assistant'` envelope, + * or `turnId` itself when turn N has no assistant reply (still a valid + * inclusive anchor), or `undefined` when `turnId` is not in the transcript. + * Reuses {@link parseSessionMessage} so the turn-boundary rule matches + * {@link ReplayBuilder}; always returns an envelope `uuid`, never a `msg_…` id. + */ +export function resolveForkAnchorUuid(messages: readonly SessionMessage[], turnId: string): string | undefined { + let seenTarget = false; + let lastAssistantUuid: string | undefined; + for (const msg of messages) { + const parsed = parseSessionMessage(msg); + if (parsed === undefined) { + continue; + } + if (parsed.kind === 'user-text') { + if (seenTarget) { + // First genuine user-text after turn N started → turn N is over. + break; + } + if (parsed.uuid === turnId) { + seenTarget = true; + } + } else if (parsed.kind === 'assistant' && seenTarget) { + lastAssistantUuid = parsed.uuid; + } + // 'user-tool-results' / 'system-notification' never flip the turn. + } + if (!seenTarget) { + return undefined; + } + return lastAssistantUuid ?? turnId; +} + // #region Parsed message union — narrow-at-the-seam adapter interface UserTextBlock { readonly type: 'text'; readonly text: string } @@ -76,7 +112,7 @@ interface AssistantBlock { readonly type: string; readonly text?: string; readon type ParsedSessionMessage = | { readonly kind: 'user-text'; readonly uuid: string; readonly text: string } | { readonly kind: 'user-tool-results'; readonly uuid: string; readonly results: readonly UserToolResultBlock[] } - | { readonly kind: 'assistant'; readonly uuid: string; readonly blocks: readonly AssistantBlock[] } + | { readonly kind: 'assistant'; readonly uuid: string; readonly blocks: readonly AssistantBlock[]; readonly isInner: boolean } | { readonly kind: 'system-notification'; readonly uuid: string; readonly subtype: string; readonly text: string }; function parseSessionMessage(msg: SessionMessage): ParsedSessionMessage | undefined { @@ -114,7 +150,11 @@ function parseAssistantMessage(msg: SessionMessage): ParsedSessionMessage | unde if (blocks === undefined || blocks.length === 0) { return undefined; } - return { kind: 'assistant', uuid: msg.uuid, blocks }; + // Subagent transcripts (from `getSubagentMessages`) carry a + // `parent_tool_use_id` on every envelope and have no synthetic spawning + // user prompt, so they legitimately open with an assistant message — + // `isInner` lets the builder synthesize a turn instead of dropping it. + return { kind: 'assistant', uuid: msg.uuid, blocks, isInner: msg.parent_tool_use_id !== null }; } function parseSystemMessage(msg: SessionMessage): ParsedSessionMessage | undefined { @@ -231,9 +271,25 @@ class ReplayBuilder { private _consumeAssistant(msg: ParsedSessionMessage & { kind: 'assistant' }): void { if (this._active === undefined) { - // Assistant message without a preceding user message — defensive: synthesize an empty user turn keyed on the assistant's parent uuid would be wrong; just drop with a warn. - this._logService.warn(`[claudeReplayMapper] assistant envelope ${msg.uuid} arrived before any user message; dropping`); - return; + if (!msg.isInner) { + // Top-level assistant envelope without a preceding user message — + // anomalous; synthesizing an empty user turn would be wrong, so + // drop with a warn. + this._logService.warn(`[claudeReplayMapper] assistant envelope ${msg.uuid} arrived before any user message; dropping`); + return; + } + // Subagent transcript: every envelope carries `parent_tool_use_id` + // and the SDK omits the synthetic spawning prompt, so the transcript + // legitimately opens with an assistant message. Synthesize an + // empty-prompt turn to hold the subagent's reply instead of dropping + // it (which would lose the entire subagent transcript on replay). + this._active = { + id: msg.uuid, + userText: '', + responseParts: [], + pendingToolUseIds: new Set(), + toolCallParts: new Map(), + }; } let textPartCounter = 0; let reasoningPartCounter = 0; @@ -305,6 +361,10 @@ class ReplayBuilder { const previousState = part.toolCall; const isSubagent = readToolCallMeta(previousState).toolKind === 'subagent'; const content: ToolResultContent[] = extractToolResultContent(block.content) ?? []; + const resultText = content + .filter((c): c is { type: ToolResultContentType.Text; text: string } => c.type === ToolResultContentType.Text) + .map(c => c.text) + .join('\n'); if (isSubagent) { content.push({ type: ToolResultContentType.Subagent, @@ -321,7 +381,7 @@ class ReplayBuilder { toolInput: previousState.status === ToolCallStatus.Streaming ? undefined : previousState.toolInput, confirmed: ToolCallConfirmationReason.NotNeeded, success: !isError, - pastTenseMessage: getClaudePastTenseMessage(previousState.toolName, previousState.displayName, entry.parsedInput, !isError), + pastTenseMessage: getClaudePastTenseMessage(previousState.toolName, previousState.displayName, entry.parsedInput, !isError, resultText), content: content.length > 0 ? content : undefined, ...(previousState._meta ? { _meta: previousState._meta } : {}), }; diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts index 450c96a4835bb..c84a49dd5a97b 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts @@ -36,25 +36,26 @@ export class ClaudeSdkMessageRouter extends Disposable { private readonly _editObserver: ClaudeFileEditObserver; private readonly _mapperState = new ClaudeMapperState(); - private _clientId: string | undefined; + private _clientToolOwner: ((toolName: string) => string | undefined) | undefined; constructor( - private readonly _sessionUri: URI, + sessionUri: URI, + private readonly _chatChannelUri: URI, dbRef: IReference, private readonly _subagents: SubagentRegistry, - clientId: string | undefined = undefined, + clientToolOwner: ((toolName: string) => string | undefined) | undefined = undefined, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, ) { super(); - this._clientId = clientId; + this._clientToolOwner = clientToolOwner; this._editObserver = this._register( - instantiationService.createInstance(ClaudeFileEditObserver, _sessionUri.toString(), dbRef), + instantiationService.createInstance(ClaudeFileEditObserver, sessionUri.toString(), dbRef), ); } - setClientId(clientId: string | undefined): void { - this._clientId = clientId; + setClientToolOwner(clientToolOwner: ((toolName: string) => string | undefined) | undefined): void { + this._clientToolOwner = clientToolOwner; } async handle(message: SDKMessage, turnId: string | undefined): Promise { @@ -69,12 +70,12 @@ export class ClaudeSdkMessageRouter extends Disposable { try { const signals = mapSDKMessageToAgentSignals( message, - this._sessionUri, + this._chatChannelUri, turnId, this._mapperState, this._logService, this._subagents, - this._clientId, + this._clientToolOwner, ); for (const signal of signals) { this._onDidProduceSignal.fire(signal); diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts index 6c6d15be9e17f..2850ae55a2f70 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts @@ -5,6 +5,7 @@ import type { McpSdkServerConfigWithInstance, Options } from '@anthropic-ai/claude-agent-sdk'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { tmpdir } from 'os'; import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { rgDiskPath } from '../../../../base/node/ripgrep.js'; @@ -15,7 +16,7 @@ import type { ModelSelection } from '../../common/state/protocol/state.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { buildClientToolMcpServer } from './clientTools/claudeClientToolMcpServer.js'; import { toSdkModelId } from './claudeModelId.js'; -import { IClaudeProxyHandle } from './claudeProxyService.js'; +import type { ClaudeTransport } from './claudeProxyService.js'; import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; /** @@ -32,6 +33,16 @@ export interface IBuildOptionsInput { readonly permissionMode: ClaudePermissionMode; readonly canUseTool: NonNullable; readonly isResume: boolean; + /** + * One-shot SDK assistant-message uuid to resume *up to and including* + * (the SDK's `Options.resumeSessionAt`). Only meaningful with + * {@link isResume}; truncates the loaded transcript to this anchor so + * the next turn continues from the restored point on the same session + * id. Omitted in the non-resume (`sessionId`) branch and on ordinary + * resumes. Set by `truncateSession` for the rebuild that immediately + * precedes the post-restore turn. + */ + readonly resumeSessionAt?: string; readonly mcpServers: Record | undefined; /** * SDK-prefixed tool names to auto-approve without prompting (projected @@ -78,15 +89,25 @@ export interface IBuildOptionsInput { */ export async function buildOptions( input: IBuildOptionsInput, - proxyHandle: IClaudeProxyHandle, + transport: ClaudeTransport, logStderr: (data: string) => void, logElicitation: (msg: string) => void, ): Promise { - const subprocessEnv = buildSubprocessEnv(); + const isProxy = transport.kind === 'proxy'; + const subprocessEnv = buildSubprocessEnv(isProxy); const resolvedRgDiskPath = await rgDiskPath(); const settingsEnv: Record = { - ANTHROPIC_BASE_URL: proxyHandle.baseUrl, - ANTHROPIC_AUTH_TOKEN: `${proxyHandle.nonce}.${input.sessionId}`, + // Proxied (Copilot-routed) mode points the SDK at the local proxy on a + // per-session bearer. Native (BYO-Anthropic) mode omits both so the SDK + // uses its own credential resolution from the subprocess env + // (`ANTHROPIC_API_KEY`, or `CLAUDE_CODE_OAUTH_TOKEN` from `claude + // setup-token` — both forwarded by `buildSubprocessEnv`). + ...(transport.kind === 'proxy' + ? { + ANTHROPIC_BASE_URL: transport.handle.baseUrl, + ANTHROPIC_AUTH_TOKEN: `${transport.handle.nonce}.${input.sessionId}`, + } + : {}), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', USE_BUILTIN_RIPGREP: '0', PATH: `${dirname(resolvedRgDiskPath)}${delimiter}${process.env.PATH ?? ''}`, @@ -111,7 +132,7 @@ export async function buildOptions( effort: resolveClaudeEffort(input.model), permissionMode: input.permissionMode, ...(input.isResume - ? { resume: input.sessionId } + ? { resume: input.sessionId, ...(input.resumeSessionAt ? { resumeSessionAt: input.resumeSessionAt } : {}) } : { sessionId: input.sessionId }), ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}), ...(input.allowedTools && input.allowedTools.length > 0 ? { allowedTools: [...input.allowedTools] } : {}), @@ -141,35 +162,75 @@ export async function buildClientMcpServers( registry: PendingRequestRegistry, sdkService: IClaudeAgentSdkService, ): Promise | undefined> { - const { tools } = toolDiff.consume(); - if (!tools || tools.length === 0) { + const tools = toolDiff.consume(); + if (tools.length === 0) { return undefined; } const server = await buildClientToolMcpServer(tools, id => registry.register(id), sdkService); return { client: server }; } +/** + * Build a minimal {@link Options} bag for an ephemeral model-enumeration + * query (Phase 19, native transport). No workspace (`cwd = os.tmpdir()`), no + * proxy env, and the user's `ANTHROPIC_API_KEY` preserved so the SDK can + * authenticate. Reads the user's real `~/.claude` config so subscription + * models (e.g. Opus) surface; verified not to write any session transcript + * because the enumeration never iterates a turn. The caller (`_fetchNativeModels`) + * aborts the returned `abortController` during teardown, alongside `query.close()`. + */ +export function buildModelEnumerationOptions(): Options { + return { + cwd: tmpdir(), + executable: process.execPath as 'node', + env: buildSubprocessEnv(false), + abortController: new AbortController(), + systemPrompt: { type: 'preset', preset: 'claude_code' }, + settings: { + env: { + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + }, + }, + }; +} + /** * Build the {@link Options.env} payload for the Claude subprocess. * - * The agent host runs in an Electron utility process; the spawn env - * inherits the parent's env which contains `NODE_OPTIONS`, - * `ELECTRON_*`, and `VSCODE_*` variables that break the Claude - * subprocess (it's a plain Node script driven by Electron's - * `process.execPath` + `ELECTRON_RUN_AS_NODE`). Strip them via - * {@link Options.env} `undefined` semantics (sdk.d.ts:1075-1078: - * "Set a key to `undefined` to remove an inherited variable"). + * SDK >= 0.3 **replaces** the subprocess environment with `Options.env` — it is + * NOT merged with `process.env` (sdk.d.ts:1402-1405: "this value REPLACES the + * subprocess environment entirely … Spread `process.env` yourself"). Keys whose + * value is `undefined` are dropped from the spawned env. * - * Mirror of CopilotAgent's strip pattern at copilotAgent.ts:434-450. + * Two modes, gated by `proxied`: + * + * - **Proxied (Copilot-routed), `true` (default):** a *sparse* env. Credentials + * reach the CLI via `settings.env` (the per-session proxy bearer), so the + * subprocess env stays minimal and the user's personal `ANTHROPIC_API_KEY` + * must not leak to the Copilot proxy (stripped). `PATH` for ripgrep is + * supplied through `settings.env`, not here. + * + * - **Native (BYO-Anthropic), `false`:** inherit the real `process.env` so the + * user's own credentials (`CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`, + * or `ANTHROPIC_API_KEY`) and `PATH` actually reach the `claude` subprocess. + * Without this spread, replace semantics wipe the inherited token and the CLI + * reports "Not logged in". + * + * In both modes the agent host's own `NODE_OPTIONS`, `ELECTRON_*`, and + * `VSCODE_*` variables are stripped (they break the Electron-node subprocess), + * and `ELECTRON_RUN_AS_NODE=1` is set. Mirror of CopilotAgent's strip pattern + * at copilotAgent.ts:434-450. * * Exported for unit testing as a pure function over `process.env`. */ -export function buildSubprocessEnv(): Record { - const env: Record = { - ELECTRON_RUN_AS_NODE: '1', - NODE_OPTIONS: undefined, - ANTHROPIC_API_KEY: undefined, - }; +export function buildSubprocessEnv(proxied: boolean = true): Record { + // Proxy mode: a sparse env (creds arrive via settings.env), and the user's + // personal ANTHROPIC_API_KEY must not leak to the Copilot proxy. + // Native mode: inherit the real env so the user's own credentials + PATH + // reach the subprocess (replace semantics wipe anything not present here). + const env: Record = proxied + ? { ELECTRON_RUN_AS_NODE: '1', NODE_OPTIONS: undefined, ANTHROPIC_API_KEY: undefined } + : { ...process.env, ELECTRON_RUN_AS_NODE: '1', NODE_OPTIONS: undefined }; for (const key of Object.keys(process.env)) { if (key === 'ELECTRON_RUN_AS_NODE') { continue; } if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts index c617234be86a0..5a21d6e492e0d 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts @@ -68,6 +68,19 @@ export interface ISdkResolvedCustomizations { readonly commands: readonly SlashCommand[]; readonly agents: readonly AgentInfo[]; readonly mcpServers: readonly McpServerStatus[]; + /** + * Native plugins the live session actually loaded, as reported by the + * SDK `system/init` message. Used to filter the disk-discovered native + * plugins post-materialize: a plugin declared in `enabledPlugins` but + * absent here (bad path, manifest error, untrusted workspace) is hidden. + * + * `source` is the plugin id (`@`) and is the + * authoritative match key — the SDK's `path` is unreliable for + * workspace-`local`-scoped plugins (it can report a non-cache path). The + * SDK `.d.ts` types the element as `{ name, path }` but the runtime adds + * `source`, so it is captured as optional. + */ + readonly plugins: readonly { readonly name: string; readonly path: string; readonly source?: string }[]; } export class ClaudeSdkPipeline extends Disposable { @@ -99,23 +112,45 @@ export class ClaudeSdkPipeline extends Disposable { query.supportedAgents(), query.mcpServerStatus(), ]); - return { commands, agents, mcpServers }; + return { commands, agents, mcpServers, plugins: this._initPlugins }; } /** - * Bind the SDK Query if the previous one has unwound (e.g. after a - * terminal result message). Mirrors the lazy bind in {@link send} - * so pre-flight helpers can call into the SDK without first having - * to issue a user prompt. + * Bind the SDK Query if needed, recovering a dead one first. Mirrors the + * gate in {@link send}: if the pipeline is marked for rebind (after an + * abort/crash the `_query` handle is retained for teardown but its stream + * is dead), rebuild via the rematerializer so pre-flight helpers never + * operate on a disposed stream. Then lazily bind if nothing is bound yet. */ private async _ensureQueryBound(): Promise { + if (this._needsRebind) { + await this._rebindQuery('recover'); + } if (!this._query) { - this._query = this._warm.query(this._queue.iterable); + this._bindWarmQuery(); await this._replayCurrentConfig(); } - return this._query; + return this._query!; + } + + /** + * Bind a fresh SDK stream off the current warm subprocess. The stream is + * long-lived: it spans every turn until a rebind swaps the subprocess (the + * prompt iterable parks between turns rather than ending), so {@link _query} + * tracks the lifetime of {@link _warm} and is only swapped here. + */ + private _bindWarmQuery(): Query { + const query = this._warm.query(this._queue.iterable); + this._query = query; + return query; } + /** + * The SDK stream bound to the current {@link _warm} subprocess, or + * `undefined` before the first bind. Health is tracked separately by + * {@link _needsRebind}: a non-`undefined` `_query` with `_needsRebind` + * set is a *dead* stream awaiting rebuild. Cleared only on dispose. + */ private _query: Query | undefined; private _warm: WarmQuery; private _abortController: AbortController; @@ -125,6 +160,14 @@ export class ClaudeSdkPipeline extends Disposable { /** Flips to `true` on the first `system:init` SDK message. Drives `Options.resume` decisions for downstream phases. */ private _isResumed = false; + /** + * Native plugins reported by the most recent `system:init` message. + * Captured on *every* init (including resume) so the post-materialize + * native-plugin filter always reflects the live set. `source` is the + * plugin id and is the reliable match key (see {@link ISdkResolvedCustomizations}). + */ + private _initPlugins: readonly { readonly name: string; readonly path: string; readonly source?: string }[] = []; + /** Last model / effort / permission mode applied to the SDK via the runtime setters. Reset on rebind. */ private _appliedModel: string | undefined; private _appliedEffort: ClaudeRuntimeEffortLevel | undefined; @@ -161,11 +204,12 @@ export class ClaudeSdkPipeline extends Disposable { constructor( readonly sessionId: string, readonly sessionUri: URI, + readonly chatChannelUri: URI, warm: WarmQuery, abortController: AbortController, dbRef: IReference, subagents: SubagentRegistry, - clientId: string | undefined = undefined, + clientToolOwner: ((toolName: string) => string | undefined) | undefined = undefined, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, ) { @@ -179,12 +223,12 @@ export class ClaudeSdkPipeline extends Disposable { () => this._abortController.signal, (pendingId: string) => this._onDidProduceSignal.fire({ kind: 'steering_consumed', - session: this.sessionUri, + chat: this.chatChannelUri, id: pendingId, }), )); this._router = this._register(instantiationService.createInstance( - ClaudeSdkMessageRouter, sessionUri, dbRef, subagents, clientId, + ClaudeSdkMessageRouter, sessionUri, chatChannelUri, dbRef, subagents, clientToolOwner, )); this._register(this._router.onDidProduceSignal(s => this._onDidProduceSignal.fire(s))); // Dispose chain → abort → SDK cleanup. Reads the *current* @@ -200,6 +244,28 @@ export class ClaudeSdkPipeline extends Disposable { get isAborted(): boolean { return this._abortController.signal.aborted; } + /** + * Abort the live SDK subprocess and **await its actual exit**. + * + * `WarmQuery[Symbol.asyncDispose]()` calls the query's `close()`, which + * *fires* the SDK cleanup but does not await it — so it returns while the + * subprocess is still shutting down (and still re-flushing its transcript). + * `Query.return()` awaits the same (memoized) cleanup, which in turn awaits + * `transport.waitForExit()` — the OS process actually exiting after its + * final transcript flush. Awaiting that is what lets a caller safely reuse + * the `--session-id` (the CLI rejects a fresh spawn while `.jsonl` + * still exists, and the dying process would otherwise recreate it). + */ + async shutdownAndWait(): Promise { + this._abortController.abort(); + try { + await this._warm[Symbol.asyncDispose](); + await this._query?.return(undefined); + } catch (err) { + this._logService.warn(`[ClaudeSdkPipeline:${this.sessionId}] shutdownAndWait: teardown failed`, err); + } + } + /** * Phase 10 \u2014 narrow public wrapper around the internal * {@link _rebindQuery} so {@link ClaudeAgentSession.rebindForClientTools} @@ -211,13 +277,11 @@ export class ClaudeSdkPipeline extends Disposable { } /** - * Phase 10 — update the workbench `clientId` that the stream mapper - * stamps onto subsequent `ChatToolCallStart` events. Called by the - * session whenever {@link SessionClientToolsModel} receives a new - * clientId via `setClientTools`. + * Phase 10 — update the resolver the stream mapper uses to stamp the + * owning workbench `clientId` onto subsequent `ChatToolCallStart` events. */ - setClientId(clientId: string | undefined): void { - this._router.setClientId(clientId); + setClientToolOwner(clientToolOwner: ((toolName: string) => string | undefined) | undefined): void { + this._router.setClientToolOwner(clientToolOwner); } /** Attach the rematerializer hook for abort / crash recovery. Optional — tests that exercise only the dispose path skip this. */ @@ -248,7 +312,7 @@ export class ClaudeSdkPipeline extends Disposable { */ async setModel(model: string): Promise { this._currentModel = model; - if (this._query && model !== this._appliedModel) { + if (this._query && !this._needsRebind && model !== this._appliedModel) { try { await this._query.setModel(model); this._appliedModel = model; @@ -272,7 +336,7 @@ export class ClaudeSdkPipeline extends Disposable { */ async setEffort(effort: ClaudeRuntimeEffortLevel | undefined): Promise { this._currentEffort = effort; - if (this._query && effort !== this._appliedEffort) { + if (this._query && !this._needsRebind && effort !== this._appliedEffort) { try { await this._query.applyFlagSettings({ effortLevel: effort ?? null }); this._appliedEffort = effort; @@ -297,7 +361,7 @@ export class ClaudeSdkPipeline extends Disposable { throw new CancellationError(); } if (!this._query) { - this._query = this._warm.query(this._queue.iterable); + this._bindWarmQuery(); await this._replayCurrentConfig(); } this._ensureConsumerLoop(); @@ -364,7 +428,8 @@ export class ClaudeSdkPipeline extends Disposable { } this._abortController.abort(); this._queue.failAll(new CancellationError()); - this._query = undefined; + // Mark unhealthy but keep the `_query` handle: the next `send` rebinds, + // and `shutdownAndWait` still needs it to await the subprocess exit. this._needsRebind = true; } @@ -375,7 +440,7 @@ export class ClaudeSdkPipeline extends Disposable { */ async setPermissionMode(mode: PermissionMode): Promise { this._currentPermissionMode = mode; - if (this._query && mode !== this._appliedPermissionMode) { + if (this._query && !this._needsRebind && mode !== this._appliedPermissionMode) { await this._query.setPermissionMode(mode); this._appliedPermissionMode = mode; } @@ -392,9 +457,34 @@ export class ClaudeSdkPipeline extends Disposable { return; } this._consumerLoopRunning = true; + this._runConsumerLoop(); + } + + /** + * Runs one {@link _processMessages} pass over the live {@link _query} and, + * when it ends, decides whether to hand off to a fresh pass. + * + * A rebind ({@link _rebindQuery}) swaps in a new `_query` while the loop is + * still draining the OLD (now-disposed) one; that old pass then ends with + * the "stream ended without a result" guard. Because `_consumerLoopRunning` + * stays `true` for the whole handoff, the {@link send} that queued the + * post-rebind prompt already saw {@link _ensureConsumerLoop} no-op — so if + * this pass just stopped, nothing would ever read the new query and `send` + * would hang. Detect the swap (current `_query` differs from the one this + * pass bound) and re-arm for it instead. Abort / crash / dispose leave + * `_query` cleared (or the store disposed), so they fall through to stop. + */ + private _runConsumerLoop(): void { + const boundQuery = this._query; void this._processMessages() .catch(err => this._logService.error(`[ClaudeSdkPipeline:${this.sessionId}] _processMessages crashed: ${err}`)) - .finally(() => { this._consumerLoopRunning = false; }); + .finally(() => { + if (!this._store.isDisposed && this._query && this._query !== boundQuery) { + this._runConsumerLoop(); + } else { + this._consumerLoopRunning = false; + } + }); } /** @@ -458,7 +548,6 @@ export class ClaudeSdkPipeline extends Disposable { void Promise.resolve(oldWarm[Symbol.asyncDispose]()).catch((err: unknown) => this._logService.warn(`[ClaudeSdkPipeline:${this.sessionId}] previous WarmQuery dispose failed during aborted rebind: ${err}`)); this._queue.failAll(new CancellationError()); - this._query = undefined; this._needsRebind = true; throw new CancellationError(); } @@ -476,7 +565,7 @@ export class ClaudeSdkPipeline extends Disposable { this._appliedModel = undefined; this._appliedEffort = undefined; this._appliedPermissionMode = undefined; - this._query = this._warm.query(this._queue.iterable); + this._bindWarmQuery(); await this._replayCurrentConfig(); } @@ -504,8 +593,13 @@ export class ClaudeSdkPipeline extends Disposable { if (this._abortController.signal.aborted) { throw new CancellationError(); } - if (message.type === 'system' && message.subtype === 'init' && !this._isResumed) { - this._isResumed = true; + if (message.type === 'system' && message.subtype === 'init') { + // Capture the loaded native-plugin list on every init (incl. + // resume / post-rebind) so the post-materialize filter is fresh. + this._initPlugins = message.plugins ?? []; + if (!this._isResumed) { + this._isResumed = true; + } } const turnId = this._queue.peekParent()?.turnId; try { @@ -522,7 +616,7 @@ export class ClaudeSdkPipeline extends Disposable { if (completed && this._queue.isEmpty) { this._onDidProduceSignal.fire({ kind: 'action', - session: this.sessionUri, + resource: this.chatChannelUri, action: { type: ActionType.ChatTurnComplete, turnId: completed.turnId, @@ -534,15 +628,24 @@ export class ClaudeSdkPipeline extends Disposable { if (this._abortController.signal.aborted) { throw new CancellationError(); } + // A rebind ({@link _rebindQuery}) swaps in a fresh `_query` and + // disposes the old one, ending THIS pass's stream cleanly. That is + // expected — return quietly and let {@link _runConsumerLoop} hand + // off to the new query. Only an unexpected end of the *current* + // query (no swap) is the real "stream ended without a result" + // failure that should mark the pipeline for recovery. + if (this._query !== query) { + return; + } throw new Error('Claude SDK stream ended without a result message'); } catch (err) { const fatal = err instanceof Error ? err : new Error(String(err)); - // A previous unwinding loop must NOT clobber a freshly - // rebound query. Identity-check against the local capture so - // only the loop that owns the live query nulls it. + // Only the loop that still owns the live query reacts: a later + // unwinding pass whose query was already swapped by a rebind must + // not clobber the fresh one. Mark unhealthy (keep the handle for + // teardown); the next `send` rebinds. if (this._query === query) { this._queue.failAll(fatal); - this._query = undefined; this._needsRebind = true; } if (!isCancellationError(fatal)) { diff --git a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts index 048bb0322cc0c..7cb4093b2bbeb 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts @@ -20,6 +20,13 @@ export interface IClaudeSessionOverlay { readonly model?: ModelSelection; readonly permissionMode?: ClaudePermissionMode; readonly agent?: AgentSelection; + /** + * Transport the session most recently materialized under (Phase 19). + * Forward-compat only — written at materialize time but NOT read for + * transport resolution in v1 (transport is resolved host-level). Lets a + * future per-session-transport feature land without a data migration. + */ + readonly transport?: 'proxy' | 'native'; } /** @@ -32,6 +39,7 @@ export interface IClaudeSessionOverlayUpdate { readonly model?: ModelSelection; readonly permissionMode?: ClaudePermissionMode; readonly agent?: AgentSelection | null; + readonly transport?: 'proxy' | 'native'; } /** @@ -58,6 +66,7 @@ export class ClaudeSessionMetadataStore { private static readonly KEY_MODEL = 'claude.model'; private static readonly KEY_PERMISSION_MODE = 'claude.permissionMode'; private static readonly KEY_AGENT = 'claude.agent'; + private static readonly KEY_TRANSPORT = 'claude.transport'; constructor( private readonly _provider: AgentProvider, @@ -90,6 +99,9 @@ export class ClaudeSessionMetadataStore { fields.agent === null ? '' : JSON.stringify({ uri: fields.agent.uri }), )); } + if (fields.transport) { + work.push(db.setMetadata(ClaudeSessionMetadataStore.KEY_TRANSPORT, fields.transport)); + } await Promise.all(work); } finally { dbRef.dispose(); @@ -109,17 +121,19 @@ export class ClaudeSessionMetadataStore { return {}; } try { - const [customizationDirectoryRaw, modelRaw, permissionModeRaw, agentRaw] = await Promise.all([ + const [customizationDirectoryRaw, modelRaw, permissionModeRaw, agentRaw, transportRaw] = await Promise.all([ ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_CUSTOMIZATION_DIRECTORY), ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_MODEL), ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_PERMISSION_MODE), ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_AGENT), + ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_TRANSPORT), ]); return { customizationDirectory: customizationDirectoryRaw ? URI.parse(customizationDirectoryRaw) : undefined, model: parseModelSelection(modelRaw), permissionMode: narrowClaudePermissionMode(permissionModeRaw), agent: parseAgentSelection(agentRaw), + transport: transportRaw === 'proxy' || transportRaw === 'native' ? transportRaw : undefined, }; } finally { ref.dispose(); @@ -139,8 +153,6 @@ export class ClaudeSessionMetadataStore { summary: entry.customTitle ?? entry.summary, workingDirectory: entry.cwd ? URI.file(entry.cwd) : undefined, customizationDirectory: overlay.customizationDirectory, - model: overlay.model, - agent: overlay.agent, }; } } diff --git a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts index a048ac6021051..0f4316d6228fc 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts @@ -39,7 +39,7 @@ export const SUBAGENT_SPAWNING_TOOL_NAMES: ReadonlySet = SUBAGENT_TOOL_N */ export function tagWithParent( signals: AgentSignal[], - session: URI, + chat: URI, parentToolUseId: string | null, registry: SubagentRegistry, ): AgentSignal[] { @@ -61,11 +61,20 @@ export function tagWithParent( } const started: IAgentSubagentStartedSignal = { kind: 'subagent_started', - session, + chat, toolCallId: parentToolUseId, agentName: spawn.subagentType ?? 'subagent', agentDisplayName: spawn.subagentType ?? 'Subagent', agentDescription: spawn.description, + // The Task tool's short `description` input doubles as the concise + // per-task tab title for the subagent's read-only peer chat. + taskDescription: spawn.description, + // When the spawning Task tool is itself an inner tool of another + // subagent, its parent Task (one level up) is the tool call in + // whose chat this spawning tool lives. The host uses it to route + // the discovery content block to that immediate parent chat, at + // any nesting depth. + parentToolCallId: registry.getParentSpawn(parentToolUseId)?.toolUseId, }; return [started, ...tagged]; } @@ -84,7 +93,7 @@ export function tagWithParent( */ export function mapSubagentSystemMessage( message: Extract, - session: URI, + chat: URI, registry: SubagentRegistry, ): AgentSignal[] { const sub = (message as { subtype?: string }).subtype; @@ -111,7 +120,7 @@ export function mapSubagentSystemMessage( } const toolUseId = m.tool_use_id; registry.removeSpawn(toolUseId); - return [{ kind: 'subagent_completed', session, toolCallId: toolUseId }]; + return [{ kind: 'subagent_completed', chat, toolCallId: toolUseId }]; } return []; } @@ -139,7 +148,7 @@ export function mapSubagentSystemMessage( */ export function buildTopLevelSubagentReadyAction( block: Extract, - session: URI, + chat: URI, turnId: string, registry: SubagentRegistry, ): AgentSignal { @@ -160,7 +169,7 @@ export function buildTopLevelSubagentReadyAction( } return { kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallReady, turnId, @@ -195,7 +204,7 @@ export function buildTopLevelSubagentReadyAction( */ export function emitInnerAssistantSignals( message: Extract, - session: URI, + chat: URI, turnId: string, state: ClaudeMapperState, parentToolUseId: string, @@ -208,7 +217,7 @@ export function emitInnerAssistantSignals( if (block.type === 'text') { signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -224,7 +233,7 @@ export function emitInnerAssistantSignals( if (block.type === 'thinking') { signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatResponsePart, turnId, @@ -256,7 +265,7 @@ export function emitInnerAssistantSignals( const toolInputStr = getClaudeToolInputString(toolName, block.input); signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallStart, turnId, @@ -268,7 +277,7 @@ export function emitInnerAssistantSignals( }); signals.push({ kind: 'action', - session, + resource: chat, action: { type: ActionType.ChatToolCallReady, turnId, diff --git a/src/vs/platform/agentHost/node/claude/claudeToolDenial.ts b/src/vs/platform/agentHost/node/claude/claudeToolDenial.ts new file mode 100644 index 0000000000000..a2b4ddad3bc8f --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeToolDenial.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Deny `message` returned to the SDK when the user declines a tool + * permission. The SDK surfaces it as the `is_error` tool_result content. + */ +export const CLAUDE_USER_DECLINED_MESSAGE = 'User declined'; +/** Deny `message` when the user declines an `ExitPlanMode` plan. */ +export const CLAUDE_PLAN_DECLINED_MESSAGE = 'The user declined the plan, maybe ask why?'; +/** Deny `message` when the user cancels an `AskUserQuestion` prompt. */ +export const CLAUDE_QUESTION_CANCELLED_MESSAGE = 'The user cancelled the question'; + +/** + * Classifies a failed tool's result message into a `languageModelToolInvoked` + * cancellation code (`denied`/`cancelled`), or `undefined` for a genuine tool + * error. The input is the `message` a denied `canUseTool` returns, which the + * SDK echoes back as the `is_error` tool_result content — so matching the + * known deny strings distinguishes a user cancellation from a tool failure. + */ +export function claudeToolDenialCode(message: string): 'denied' | 'cancelled' | undefined { + switch (message) { + case CLAUDE_USER_DECLINED_MESSAGE: + case CLAUDE_PLAN_DECLINED_MESSAGE: + return 'denied'; + case CLAUDE_QUESTION_CANCELLED_MESSAGE: + return 'cancelled'; + default: + return undefined; + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts index 782adae5b8b4e..6bd29af90d664 100644 --- a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts +++ b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts @@ -10,6 +10,7 @@ import { truncate } from '../../../../base/common/strings.js'; import { URI } from '../../../../base/common/uri.js'; import { toToolCallMeta, type IToolCallMeta, type ToolKind } from '../../common/meta/agentToolCallMeta.js'; import type { StringOrMarkdown } from '../../common/state/protocol/state.js'; +import { getServerToolDisplay } from '../shared/serverToolGroups.js'; /** * Phase 7 S4 — pure tool-name → display/permission helpers for Claude. @@ -147,6 +148,10 @@ export function getClaudePermissionKind(toolName: string): ClaudePermissionKind * the server/tool pair. */ export function getClaudeToolDisplayName(toolName: string): string { + const serverDisplay = getServerToolDisplay(toolName, undefined)?.displayName; + if (serverDisplay !== undefined) { + return serverDisplay; + } switch (toolName) { case 'Bash': return localize('claude.tool.bash', "Run shell command"); case 'BashOutput': return localize('claude.tool.bashOutput', "Read shell output"); @@ -337,6 +342,10 @@ export function getClaudeInvocationMessage( displayName: string, input: unknown, ): StringOrMarkdown { + const serverDisplay = getServerToolDisplay(toolName, input)?.invocationMessage; + if (serverDisplay !== undefined) { + return serverDisplay; + } switch (toolName) { case 'Bash': { const firstLine = firstShellLine(input); @@ -422,10 +431,15 @@ export function getClaudePastTenseMessage( displayName: string, input: unknown, success: boolean, + resultText?: string, ): StringOrMarkdown { if (!success) { return localize('claude.toolComplete.failed', "\"{0}\" failed", displayName); } + const serverDisplay = getServerToolDisplay(toolName, input, { text: resultText, success })?.pastTenseMessage; + if (serverDisplay !== undefined) { + return serverDisplay; + } switch (toolName) { case 'Bash': { const firstLine = firstShellLine(input); diff --git a/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts b/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts index f309b1aba2f6a..df21a7efaa01c 100644 --- a/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts +++ b/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts @@ -6,63 +6,66 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { autorun, IObservable, ISettableObservable, observableValueOpts } from '../../../../../base/common/observable.js'; import type { ToolDefinition } from '../../../common/state/protocol/state.js'; -import { structuralToolsEqual } from '../../activeClientState.js'; +import { ActiveClientToolSet, structuralToolsEqual } from '../../activeClientState.js'; /** - * Combined snapshot of the workbench-registered client-tool definitions - * and the workbench `clientId` that owns them. The two travel as one - * value so consumers can read both with a single `.get()` and so that an - * update to either field is observed as a single change. - */ -export interface ISessionClientToolsState { - readonly tools: readonly ToolDefinition[] | undefined; - readonly clientId: string | undefined; -} - -const INITIAL_STATE: ISessionClientToolsState = { tools: undefined, clientId: undefined }; - -/** - * Pure state holder for the workbench-registered client-tool snapshot - * and the workbench `clientId` that owns it. Exposes the pair as a - * single {@link IObservable} so consumers can react to changes without - * polling. + * Pure state holder for the workbench-registered client-tool snapshots + * contributed by potentially several active clients, keyed by `clientId`. + * Exposes the merged tool set as a single {@link IObservable} (deduplicated + * by name, first-inserted client wins) so consumers can react to changes + * without polling, and {@link ownerOf} so tool calls can be routed back to + * the contributing client. * - * The `state` observable dedupes structurally-equivalent writes: tool - * snapshots compare on `name + description + inputSchema` - * (order-insensitive, `undefined` equivalent to `[]`); `clientId` compares strictly. - * A re-send of the same `(tools, clientId)` pair therefore does NOT fire - * downstream subscribers. + * The `merged` observable dedupes structurally-equivalent writes: tool + * snapshots compare on `name + description + inputSchema` (order-insensitive, + * `undefined` equivalent to `[]`). A re-send of a structurally identical set + * therefore does NOT fire downstream subscribers. * * Knows nothing about diffing or the SDK — pair with - * {@link SessionClientToolsDiff} to track "has the snapshot changed + * {@link SessionClientToolsDiff} to track "has the merged snapshot changed * since the last successful SDK build". */ export class SessionClientToolsModel { - private readonly _state: ISettableObservable = observableValueOpts( - { owner: this, equalsFn: stateEqual }, - INITIAL_STATE, + private readonly _toolSet = new ActiveClientToolSet(); + private readonly _merged: ISettableObservable = observableValueOpts( + { owner: this, equalsFn: (a, b) => structuralToolsEqual(a, b) }, + [], ); - readonly state: IObservable = this._state; + readonly merged: IObservable = this._merged; + + /** Replace `clientId`'s contributed tools (full replacement). */ + setTools(clientId: string, tools: readonly ToolDefinition[]): void { + this._toolSet.set(clientId, tools); + this._merged.set(this._toolSet.merged(), undefined); + } - setTools(tools: readonly ToolDefinition[] | undefined, clientId?: string): void { - const current = this._state.get(); - this._state.set({ - tools, - clientId: clientId ?? current.clientId, - }, undefined); + /** This client's contributed tools (empty when absent). */ + getTools(clientId: string): readonly ToolDefinition[] { + return this._toolSet.get(clientId); + } + + /** Remove a client's tool contribution. */ + removeClient(clientId: string): void { + if (this._toolSet.delete(clientId)) { + this._merged.set(this._toolSet.merged(), undefined); + } + } + + /** The `clientId` that owns the tool named `toolName`, or `undefined`. */ + ownerOf(toolName: string, preferredClientId?: string): string | undefined { + return this._toolSet.ownerOf(toolName, preferredClientId); } } /** - * Tracks "has {@link SessionClientToolsModel.state} changed since the - * last successful {@link build}?". Subscribes to the model's observable - * and flips a private dirty bit on every change; {@link build} captures - * the current snapshot, hands it to the supplied builder, and clears - * the bit on success — preserving the C6 pin invariant from the - * previous `ClientToolDiff` implementation: a `setTools` call that - * races the in-flight builder re-flips the bit via the autorun, so the - * next sendMessage detects the stale set and triggers another rebind. + * Tracks "has {@link SessionClientToolsModel.merged} changed since the + * last successful {@link consume}?". Subscribes to the model's observable + * and flips a private dirty bit on every change; {@link consume} captures + * the current merged snapshot and clears the bit — preserving the C6 pin + * invariant from the previous `ClientToolDiff` implementation: a `setTools` + * call that races the in-flight builder re-flips the bit via the autorun, so + * the next sendMessage detects the stale set and triggers another rebind. * * On builder throw the bit is left set — the SDK is still running with * the previous snapshot, so the next sendMessage should retry. @@ -77,21 +80,18 @@ export class SessionClientToolsDiff extends Disposable { // dirty before any `setTools` has happened. private _ignoreNextFire = true; // Structural tool snapshot last marked applied (via {@link consume}). - // The dirty bit only flips when the live tools differ structurally from - // this — a `clientId`-only change (same tools, new window) must NOT - // trigger a yield-restart even though the observable still fires. - private _lastAppliedTools: readonly ToolDefinition[] | undefined = undefined; + private _lastAppliedTools: readonly ToolDefinition[] = []; constructor() { super(); this._register(autorun(reader => { - const state = this.model.state.read(reader); + const merged = this.model.merged.read(reader); if (this._ignoreNextFire) { this._ignoreNextFire = false; - this._lastAppliedTools = state.tools; + this._lastAppliedTools = merged; return; } - if (!structuralToolsEqual(state.tools, this._lastAppliedTools)) { + if (!structuralToolsEqual(merged, this._lastAppliedTools)) { this._dirty = true; } })); @@ -102,18 +102,18 @@ export class SessionClientToolsDiff extends Disposable { } /** - * Read the current state and mark it as the applied snapshot. A - * subsequent {@link SessionClientToolsModel.setTools} re-flips dirty + * Read the current merged tool set and mark it as the applied snapshot. + * A subsequent {@link SessionClientToolsModel.setTools} re-flips dirty * via the autorun, so callers do NOT need to compare snapshots * themselves to detect a race. If the caller's downstream work * (e.g. SDK rebuild) fails, call {@link markDirty} to surface the * stale state so the next sendMessage retries. */ - consume(): ISessionClientToolsState { - const state = this.model.state.get(); + consume(): readonly ToolDefinition[] { + const merged = this.model.merged.get(); this._dirty = false; - this._lastAppliedTools = state.tools; - return state; + this._lastAppliedTools = merged; + return merged; } /** @@ -125,7 +125,3 @@ export class SessionClientToolsDiff extends Disposable { this._dirty = true; } } - -function stateEqual(a: ISessionClientToolsState, b: ISessionClientToolsState): boolean { - return a.clientId === b.clientId && structuralToolsEqual(a.tools, b.tools); -} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts new file mode 100644 index 0000000000000..54967763c6b8c --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.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 { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { CustomizationType } from '../../../common/state/protocol/channels-session/state.js'; +import { CustomizationLoadStatus, customizationId, type DirectoryCustomization, type SkillCustomization } from '../../../common/state/sessionState.js'; + +/** + * URI scheme for synthetic "built-in" customizations that have no editable + * file on disk. These entries appear in the customization list purely for + * discovery (their name and description); they carry no openable content. + */ +const AGENT_BUILTIN_SCHEME = 'agent-builtin'; + +/** + * A Claude built-in slash command backed by the Skill tool, used to seed the + * **pre-materialize** built-in list. These ship compiled into the Claude + * CLI/SDK — they have no editable file on disk and, before a live session + * exists, the SDK can't tell us its real command set, so we show a curated + * best-guess list for discoverability. + * + * Once a session materializes, {@link buildSdkBuiltinSkillsContainer} replaces + * this list with the runtime's actual built-ins (the SDK commands we don't + * discover on disk), so this list only matters pre-session and may safely + * drift from the CLI over time. + * + * This list covers the Skill-tool built-ins only. The CLI-level built-ins + * typed directly in the terminal (`/help`, `/clear`, `/compact`, `/config`, + * `/fast`, `/model`, `/tasks`, `/workflows`) are intentionally excluded — + * they are not skills and would be mislabeled in a Skills container. + */ +interface IClaudeBuiltinCommand { + readonly name: string; + /** + * User-facing description, resolved lazily so `localize()` runs at call + * time rather than module-init (which would freeze the bundle locale). + */ + readonly description: () => string; +} + +const CLAUDE_BUILTIN_COMMANDS: readonly IClaudeBuiltinCommand[] = [ + { name: 'init', description: () => localize('claude.builtin.init', "(Built-In) Scan the codebase and generate a `CLAUDE.md` file with project structure, conventions, and instructions for future sessions.") }, + { name: 'review', description: () => localize('claude.builtin.review', "(Built-In) Review a pull request or set of changes.") }, + { name: 'security-review', description: () => localize('claude.builtin.securityReview', "(Built-In) Complete a security review of the pending changes on the current branch.") }, + { name: 'code-review', description: () => localize('claude.builtin.codeReview', "(Built-In) Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at a chosen effort level (low→max). Pass `--comment` to post findings as inline PR comments, or `--fix` to apply them to the working tree.") }, + { name: 'simplify', description: () => localize('claude.builtin.simplify', "(Built-In) Review changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it doesn't hunt for bugs (use `/code-review` for that).") }, + { name: 'verify', description: () => localize('claude.builtin.verify', "(Built-In) Run the app and observe behavior to confirm a code change actually does what it's supposed to. Use to verify a PR, confirm a fix, or validate local changes before pushing.") }, + { name: 'run', description: () => localize('claude.builtin.run', "(Built-In) Launch and drive the project's app to see a change working — run, start, or screenshot the app, or confirm a change works in the real app (not just tests).") }, + { name: 'loop', description: () => localize('claude.builtin.loop', "(Built-In) Run a prompt or slash command on a recurring interval (e.g. `/loop 5m /foo`, defaults to 10m). For recurring tasks or polling status — not one-off work.") }, + { name: 'claude-api', description: () => localize('claude.builtin.claudeApi', "(Built-In) Reference for the Claude API / Anthropic SDK: model IDs, pricing, params, streaming, tool use, MCP, agents, caching, token counting, migration.") }, + { name: 'fewer-permission-prompts', description: () => localize('claude.builtin.fewerPermissionPrompts', "(Built-In) Scan transcripts for common read-only Bash/MCP calls and add a prioritized allowlist to project `.claude/settings.json` to reduce permission prompts.") }, + { name: 'update-config', description: () => localize('claude.builtin.updateConfig', "(Built-In) Configure the Claude Code harness via `settings.json`: hooks for automated behaviors, permissions, env vars, and hook troubleshooting.") }, + { name: 'keybindings-help', description: () => localize('claude.builtin.keybindingsHelp', "(Built-In) Customize keyboard shortcuts, rebind keys, add chord bindings, or modify `~/.claude/keybindings.json`.") }, + { name: 'write-a-skill', description: () => localize('claude.builtin.writeASkill', "(Built-In) Author a new skill.") }, +]; + +/** + * A Claude built-in subagent, used to seed the **pre-materialize** agent list. + * These ship compiled into the Claude CLI/SDK (no editable file on disk), so + * before a live session exists we surface a curated best-guess set for + * discovery and selection. Once a session materializes, the live + * `supportedAgents()` set supersedes this (see the SDK fallback in + * `buildDiscoveredCustomizations`), so the list may safely drift over time. + * + * Includes the SDK default (`general-purpose`) for completeness; the discovery + * layer hides it (selecting it is equivalent to "no selection"). The model + * each agent runs on is folded into the description since the customization + * surface has no model field. + */ +export interface IClaudeBuiltinAgent { + readonly name: string; + /** User-facing description, resolved lazily (see {@link IClaudeBuiltinCommand.description}). */ + readonly description: () => string; +} + +export const CLAUDE_BUILTIN_AGENTS: readonly IClaudeBuiltinAgent[] = [ + { name: 'claude', description: () => localize('claude.builtinAgent.claude', "(Built-In) Catch-all for any task that doesn't fit a more specific agent — the default when no agent name is typed.") }, + { name: 'claude-code-guide', description: () => localize('claude.builtinAgent.claudeCodeGuide', "(Built-In) Answers questions about the Claude Agent SDK, and the Claude/Anthropic API — features, hooks, slash commands, MCP servers, settings, IDE integrations, SDK agent-building, and API usage. Model: Haiku.") }, + { name: 'Explore', description: () => localize('claude.builtinAgent.explore', "(Built-In) Read-only search agent for broad fan-out searches across many files when you only need the conclusion; it locates code rather than reviewing it. Model: Haiku.") }, + { name: 'general-purpose', description: () => localize('claude.builtinAgent.generalPurpose', "(Built-In) General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks.") }, + { name: 'Plan', description: () => localize('claude.builtinAgent.plan', "(Built-In) Software architect agent for designing implementation plans — step-by-step plans, critical files, and architectural trade-offs.") }, +]; + +/** + * A resolved built-in skill entry ready to become a read-only customization. + * Structurally matches the SDK's `SlashCommand` (`{ name, description }`), so + * the live command set can be passed straight through. + */ +interface IBuiltinSkillEntry { + readonly name: string; + readonly description: string; +} + +/** + * Builds the read-only "Built-in" skills container from resolved + * `{ name, description }` entries. Each child is a {@link CustomizationType.Skill} + * on the {@link AGENT_BUILTIN_SCHEME}; the name and description shown in the + * list are the discovery information it carries (the entries have no openable + * content). Returns `undefined` when there are no entries. + */ +function buildBuiltinSkillsContainer(entries: readonly IBuiltinSkillEntry[]): DirectoryCustomization | undefined { + if (entries.length === 0) { + return undefined; + } + + const children: SkillCustomization[] = entries.map(entry => { + const uri = URI.from({ scheme: AGENT_BUILTIN_SCHEME, path: `/skill/${encodeURIComponent(entry.name)}` }).toString(); + return { + type: CustomizationType.Skill, + id: customizationId(uri), + uri, + name: entry.name, + description: entry.description, + }; + }); + + const containerUri = URI.from({ scheme: AGENT_BUILTIN_SCHEME, path: '/skills' }).toString(); + return { + type: CustomizationType.Directory, + id: customizationId(containerUri), + uri: containerUri, + name: 'builtin', + enabled: true, + contents: CustomizationType.Skill, + writable: false, + load: { kind: CustomizationLoadStatus.Loaded }, + children, + }; +} + +/** + * The curated, hardcoded built-in container. Used ONLY pre-materialize — + * before a live SDK snapshot exists, this is our best guess at the runtime's + * built-in slash commands so the user can still discover them. Once a session + * materializes, {@link buildSdkBuiltinSkillsContainer} supersedes it with the + * runtime's real command set. + * + * Curated commands that collide with a discovered disk skill are excluded so + * a user's editable `/` is never duplicated by a read-only built-in + * (mirrors {@link buildSdkBuiltinSkillsContainer} and the built-in-agent path). + * Returns `undefined` when nothing remains. + * + * @param diskSkillNames Names of skills discovered on disk, to exclude. + */ +export function buildClaudeBuiltinSkillsContainer(diskSkillNames: ReadonlySet): DirectoryCustomization | undefined { + return buildBuiltinSkillsContainer( + CLAUDE_BUILTIN_COMMANDS + .filter(cmd => !diskSkillNames.has(cmd.name)) + .map(cmd => ({ name: cmd.name, description: cmd.description() })) + ); +} + +/** + * The post-materialize built-in container, derived from the live SDK command + * set. A command the SDK reports but that we do NOT discover on disk as an + * editable skill is a genuine runtime built-in (it has no editable file), so + * it is surfaced read-only via {@link AGENT_BUILTIN_SCHEME} using the SDK's + * own description. Commands backed by a discovered disk skill are excluded — + * they are already shown as their editable selves. This auto-includes runtime + * built-ins we never hardcoded and self-heals as the CLI evolves. + * + * @param commands The live SDK command set (`supportedCommands()`). + * @param diskSkillNames Names of skills discovered on disk, to exclude. + */ +export function buildSdkBuiltinSkillsContainer( + commands: readonly IBuiltinSkillEntry[], + diskSkillNames: ReadonlySet, +): DirectoryCustomization | undefined { + const seen = new Set(); + const entries: IBuiltinSkillEntry[] = []; + for (const command of commands) { + if (diskSkillNames.has(command.name) || seen.has(command.name)) { + continue; + } + seen.add(command.name); + entries.push({ name: command.name, description: command.description }); + } + return buildBuiltinSkillsContainer(entries); +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts deleted file mode 100644 index 050b8ef4ac326..0000000000000 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts +++ /dev/null @@ -1,182 +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 { VSBuffer } from '../../../../../base/common/buffer.js'; -import { hash } from '../../../../../base/common/hash.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { IFileService } from '../../../../files/common/files.js'; -import { IAgentPluginManager } from '../../../common/agentPluginManager.js'; -import { CustomizationLoadStatus, CustomizationType, customizationId, type AgentCustomization, type PluginCustomization, type SkillCustomization } from '../../../common/state/sessionState.js'; -import type { ISdkResolvedCustomizations } from '../claudeSdkPipeline.js'; - -const PLUGIN_NAME = 'claude-discovered'; -const DISPLAY_NAME = localize('claude.discovered.displayName', "Discovered in Claude"); -const DISCOVERED_DIR = 'claude-discovered'; - -/** - * The Claude SDK's built-in default agent. Hidden from the picker: - * selecting it would be equivalent to "no selection" since the SDK - * uses it as the fallback when `Options.agent` is omitted. - */ -export const CLAUDE_SDK_DEFAULT_AGENT_NAME = 'general-purpose'; - -/** - * Bundles the Claude SDK's currently-resolved customization view - * (commands + agents from `Query.supportedCommands()` / - * `supportedAgents()` / `mcpServerStatus()`) into a synthetic on-disk - * [Open Plugin](https://open-plugins.com/) layout, so the workbench's - * plugin expander can scan it and emit per-type child items - * (`PromptsType.agent` / `PromptsType.skill` / `PromptsType.prompt`). - * - * Returns a single {@link Customization} (plugin container) whose `name` - * is `"Discovered in Claude"` and whose URI points at the on-disk bundle - * root. The `children` array is populated directly from the SDK snapshot - * so the agent picker can list Claude-native agents and skills without - * waiting on filesystem expansion. - * - * The directory is namespaced by a hash of the working directory so - * concurrent sessions on different folders don't collide. Repeated - * {@link bundle} calls with the same SDK snapshot reuse the prior - * bundle (nonce match) and skip the rewrite. - */ -export class ClaudeSdkCustomizationBundler extends Disposable { - - private readonly _rootUri: URI; - private _lastNonce: string | undefined; - - constructor( - workingDirectory: URI, - @IFileService private readonly _fileService: IFileService, - @IAgentPluginManager pluginManager: IAgentPluginManager, - ) { - super(); - const authority = `claude-${hash(workingDirectory.toString())}`; - this._rootUri = URI.joinPath(pluginManager.basePath, DISCOVERED_DIR, authority); - } - - async bundle(snapshot: ISdkResolvedCustomizations): Promise { - if (snapshot.commands.length === 0 && snapshot.agents.length === 0) { - return undefined; - } - - const hashParts: string[] = []; - for (const agent of snapshot.agents) { - hashParts.push(`agent:${agent.name}\n${agent.description}\n${agent.model ?? ''}`); - } - for (const cmd of snapshot.commands) { - hashParts.push(`command:${cmd.name}\n${cmd.description}\n${cmd.argumentHint ?? ''}`); - } - hashParts.sort(); - const nonce = String(hash(hashParts.join('\n'))); - - if (this._lastNonce !== nonce) { - try { - await this._fileService.del(this._rootUri, { recursive: true }); - } catch { - // First bundle — directory may not exist. - } - // Vendor-neutral manifest path per Open Plugins spec - // (`.plugin/plugin.json`). `name` is the only required field - // and must be lowercase alphanumeric / `-` / `.` only. - const manifestUri = URI.joinPath(this._rootUri, '.plugin', 'plugin.json'); - await this._fileService.writeFile(manifestUri, VSBuffer.fromString(JSON.stringify({ - name: PLUGIN_NAME, - description: 'Customizations discovered by the Claude agent', - }, null, '\t'))); - - for (const agent of snapshot.agents) { - const fileUri = URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`); - await this._fileService.writeFile(fileUri, VSBuffer.fromString(agentMarkdown(agent.name, agent.description))); - } - for (const cmd of snapshot.commands) { - // Treat Claude slash commands as skills: each becomes its - // own `skills//SKILL.md` subdirectory per the Agent - // Skills format. Conceptually they're the same thing — - // a named, model-invocable capability — and the workbench - // buckets them under skills. - const dirName = safeName(cmd.name); - const fileUri = URI.joinPath(this._rootUri, 'skills', dirName, 'SKILL.md'); - await this._fileService.writeFile(fileUri, VSBuffer.fromString(skillMarkdown(dirName, cmd.description, cmd.argumentHint))); - } - this._lastNonce = nonce; - } - - // Hide the SDK's built-in default agent — see - // {@link CLAUDE_SDK_DEFAULT_AGENT_NAME} for the full rationale. - // `uri` is the on-disk path of the file we just wrote — the - // workbench's customization harness reads it via `parseNew` to - // hydrate `ICustomAgent`, so a synthetic identity scheme would - // fail to parse and the agents would never reach the picker. - const agentChildren: AgentCustomization[] = snapshot.agents - .filter(agent => agent.name !== CLAUDE_SDK_DEFAULT_AGENT_NAME) - .map(agent => { - const agentUri = URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`).toString(); - return { - type: CustomizationType.Agent, - id: customizationId(agentUri), - uri: agentUri, - name: agent.name, - description: agent.description, - }; - }); - const skillChildren: SkillCustomization[] = snapshot.commands.map(cmd => { - const dirName = safeName(cmd.name); - const skillUri = URI.joinPath(this._rootUri, 'skills', dirName, 'SKILL.md').toString(); - return { - type: CustomizationType.Skill, - id: customizationId(skillUri), - uri: skillUri, - name: dirName, - description: cmd.description, - }; - }); - - const rootUriString = this._rootUri.toString(); - return { - type: CustomizationType.Plugin, - id: customizationId(rootUriString), - uri: rootUriString, - name: DISPLAY_NAME, - enabled: true, - load: { kind: CustomizationLoadStatus.Loaded }, - children: [...agentChildren, ...skillChildren], - }; - } -} - -function safeName(name: string): string { - return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'unnamed'; -} - -/** - * Open Plugins agent frontmatter: `name` (1-64 chars, kebab-case) and - * `description` (max 1024 chars). The body is the agent's system - * prompt; the SDK doesn't surface it, so we leave the body empty. - */ -function agentMarkdown(name: string, description: string): string { - return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n`; -} - -/** - * Agent Skills `SKILL.md` frontmatter: `name` (MUST match the - * containing directory name) and `description`. The SDK's - * `argumentHint` is rendered as a `$ARGUMENTS` usage hint in the body. - */ -function skillMarkdown(name: string, description: string, argumentHint: string | undefined): string { - const body = argumentHint ? `\nUsage: \`${argumentHint}\`\n` : ''; - return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n${body}`; -} - -function yamlString(s: string): string { - // Quote always; escape backslashes and double quotes. Single-line: drop newlines. - const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, ' '); - return `"${escaped}"`; -} - -function truncate(s: string, max: number): string { - return s.length <= max ? s : `${s.slice(0, max - 1)}…`; -} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts index ace08dfade4cb..d9b2da83bdd23 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts @@ -46,6 +46,9 @@ const INITIAL_STATE: ISessionCustomizationsState = { synced: [], enablement: new */ export class SessionClientCustomizationsModel { + /** Per-client synced customizations, keyed by `clientId`, merged into `state.synced`. */ + private readonly _byClient = new Map(); + private readonly _state: ISettableObservable = observableValueOpts( { owner: this, equalsFn: stateEqual }, INITIAL_STATE, @@ -77,10 +80,40 @@ export class SessionClientCustomizationsModel { }, ); - /** Replace the client-pushed customization snapshot for this session. */ - setSyncedCustomizations(synced: readonly ISyncedCustomization[]): void { + /** + * The union of every client's synced customizations, deduplicated by + * customization `id` with the first-inserted client winning. Order + * follows client insertion order. + */ + private _mergedSynced(): readonly ISyncedCustomization[] { + const seen = new Set(); + const result: ISyncedCustomization[] = []; + for (const synced of this._byClient.values()) { + for (const item of synced) { + if (seen.has(item.customization.id)) { + continue; + } + seen.add(item.customization.id); + result.push(item); + } + } + return result; + } + + /** Replace a single client's pushed customization snapshot for this session. */ + setSyncedCustomizations(clientId: string, synced: readonly ISyncedCustomization[]): void { + this._byClient.set(clientId, synced); + const cur = this._state.get(); + this._state.set({ synced: this._mergedSynced(), enablement: cur.enablement }, undefined); + } + + /** Remove a client's pushed customizations from this session. */ + removeClient(clientId: string): void { + if (!this._byClient.delete(clientId)) { + return; + } const cur = this._state.get(); - this._state.set({ synced, enablement: cur.enablement }, undefined); + this._state.set({ synced: this._mergedSynced(), enablement: cur.enablement }, undefined); } /** Toggle a client-pushed customization on/off for this session. */ diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts new file mode 100644 index 0000000000000..ec25df9f3e7e7 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts @@ -0,0 +1,491 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { isEqualOrParent } from '../../../../../base/common/resources.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { makeMcpServerCustomization, parseAgentFile, toParsedAgent, type IParsedAgent, type IParsedRule, type IParsedSkill } from '../../../../agentPlugins/common/pluginParsers.js'; +import { CustomizationType, type AgentSelection, type McpServerCustomization } from '../../../common/state/protocol/channels-session/state.js'; +import { CustomizationLoadStatus, customizationId, type AgentCustomization, type ChildCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type PluginCustomization, type RuleCustomization, type SkillCustomization } from '../../../common/state/sessionState.js'; +import type { ISdkResolvedCustomizations } from '../claudeSdkPipeline.js'; +import { deriveMcpState } from './scan/claudeMcpScan.js'; +import { claudeMemoryFiles } from './scan/claudeRuleScan.js'; +import type { IResolvedNativePlugin } from './scan/claudeNativePluginScan.js'; +import { CLAUDE_BUILTIN_AGENTS, buildClaudeBuiltinSkillsContainer, buildSdkBuiltinSkillsContainer } from './claudeBuiltinCommands.js'; + +/** + * The Claude SDK's built-in default agent. Hidden from the picker: + * selecting it would be equivalent to "no selection" since the SDK + * uses it as the fallback when `Options.agent` is omitted. + */ +export const CLAUDE_SDK_DEFAULT_AGENT_NAME = 'general-purpose'; + +/** + * Scheme for synthetic, non-openable URIs that mark SDK-only customizations + * the disk scan couldn't locate (Decision D2). It has no file provider, so + * the workbench renders such entries read-only. The writer ({@link nonEditableUri}) + * and reader ({@link resolveClaudeAgentName}) share this constant so the two + * never drift. + */ +const CLAUDE_INTERNAL_SCHEME = 'claude-internal'; + +function makeDirectory(base: URI, sub: string, contents: CustomizationType.Agent | CustomizationType.Skill | CustomizationType.Rule | CustomizationType.Hook, children: readonly (AgentCustomization | SkillCustomization | RuleCustomization | HookCustomization)[]): DirectoryCustomization { + const uri = URI.joinPath(base, '.claude', sub).toString(); + return { + type: CustomizationType.Directory, + id: customizationId(uri), + uri, + name: sub, + enabled: true, + contents, + writable: true, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [...children], + }; +} + +/** + * Projects a resolved Claude-native plugin into a top-level + * {@link PluginCustomization} (its own protocol container type — *not* a + * per-scope {@link DirectoryCustomization}, mirroring how MCP servers are + * top-level). The container `uri` is the real plugin root directory; its + * `name` is the `enabledPlugins` id (the manifest carries no display name + * through {@link IResolvedNativePlugin}). Children are the plugin's bundled + * components, deduped by id (a plugin's hooks share one settings-file + * customization, so the groups would otherwise repeat). + */ +function makePlugin(plugin: IResolvedNativePlugin): PluginCustomization { + const uri = plugin.root.toString(); + const children: ChildCustomization[] = []; + const seen = new Set(); + const push = (child: ChildCustomization) => { + if (!seen.has(child.id)) { + seen.add(child.id); + children.push(child); + } + }; + for (const agent of plugin.parsed.agents) { push(agent.customization); } + for (const skill of plugin.parsed.skills) { push(skill.customization); } + for (const rule of plugin.parsed.instructions) { push(rule.customization); } + for (const hook of plugin.parsed.hooks) { push(hook.customization); } + for (const mcp of plugin.parsed.mcpServers) { push(mcp.customization); } + return { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: plugin.id, + enabled: true, + load: { kind: CustomizationLoadStatus.Loaded }, + children, + }; +} + +/** + * The scope a discovered customization belongs to, derived from which + * `.claude/` tree contains its source file. + */ +const enum ClaudeCustomizationScope { + Workspace = 'workspace', + User = 'user', +} + +/** + * Attributes a discovered file to the scope whose `.claude/` directory + * contains it. SDK-only (`claude-internal:`) and any out-of-tree URIs fall + * back to the user scope. Drives per-scope grouping so the workbench can + * label containers "Workspace" vs "User". + */ +function scopeOf(uri: URI, workingDirectory: URI | undefined): ClaudeCustomizationScope { + return workingDirectory && uri.scheme === workingDirectory.scheme && isEqualOrParent(uri, workingDirectory) + ? ClaudeCustomizationScope.Workspace + : ClaudeCustomizationScope.User; +} + +/** + * Maps the disk-discovered customizations into the protocol + * {@link Customization} surface. Agents, skills and rules are wrapped in + * {@link DirectoryCustomization} containers (the protocol's `Customization` + * union has no bare agent/skill/rule member), one container per (scope, kind): + * the container `uri` is the real `/.claude/` directory so the + * workbench derives the "Workspace" vs "User" label from it (mirroring + * CopilotAgent). Each child carries its real source-file `uri` so the + * workbench can open it for editing. MCP servers are top-level entries. + */ +export function mapDiscoveredCustomizations( + discovered: readonly (IParsedAgent | IParsedSkill | IParsedRule)[], + mcpServers: readonly McpServerCustomization[], + hooks: readonly HookCustomization[], + nativePlugins: readonly IResolvedNativePlugin[], + workingDirectory: URI | undefined, + userHome: URI, +): readonly Customization[] { + const buckets = new Map([ + [ClaudeCustomizationScope.Workspace, { agents: [], skills: [], rules: [], hooks: [] }], + [ClaudeCustomizationScope.User, { agents: [], skills: [], rules: [], hooks: [] }], + ]); + for (const d of discovered) { + const bucket = buckets.get(scopeOf(d.uri, workingDirectory))!; + if (d.customization.type === CustomizationType.Agent) { + bucket.agents.push(d.customization); + } else if (d.customization.type === CustomizationType.Skill) { + bucket.skills.push(d.customization); + } else { + bucket.rules.push(d.customization); + } + } + // Hooks arrive already projected (one per declaring settings file); they + // carry no `IParsed*` wrapper, so attribute them to scope via their source + // settings-file uri. + for (const hook of hooks) { + buckets.get(scopeOf(URI.parse(hook.uri), workingDirectory))!.hooks.push(hook); + } + + const result: Customization[] = []; + // Workspace containers first (precedence), then user. `base` is the scope + // root the container `.claude/` uri is built from. + const orderedScopes: readonly (readonly [ClaudeCustomizationScope, URI | undefined])[] = [ + [ClaudeCustomizationScope.Workspace, workingDirectory], + [ClaudeCustomizationScope.User, userHome], + ]; + for (const [scope, base] of orderedScopes) { + if (!base) { + continue; + } + const bucket = buckets.get(scope)!; + if (bucket.agents.length > 0) { + result.push(makeDirectory(base, 'agents', CustomizationType.Agent, bucket.agents)); + } + if (bucket.skills.length > 0) { + result.push(makeDirectory(base, 'skills', CustomizationType.Skill, bucket.skills)); + } + if (bucket.rules.length > 0) { + result.push(makeDirectory(base, 'rules', CustomizationType.Rule, bucket.rules)); + } + if (bucket.hooks.length > 0) { + result.push(makeDirectory(base, 'hooks', CustomizationType.Hook, bucket.hooks)); + } + } + + // Native plugins are top-level entries (like MCP servers), each carrying + // its bundled components as children. + for (const plugin of nativePlugins) { + result.push(makePlugin(plugin)); + } + + result.push(...mcpServers); + return result; +} + +/** + * A synthetic, non-openable URI that marks an SDK-only customization the + * disk scan couldn't locate. The `claude-internal:` scheme has no file + * provider, so the workbench renders the entry read-only (Decision D2). + */ +function nonEditableUri(kind: string, name: string): URI { + return URI.from({ scheme: CLAUDE_INTERNAL_SCHEME, path: `/${kind}/${encodeURIComponent(name)}` }); +} + +/** + * Resolves an {@link AgentSelection} URI to the SDK agent name the SDK + * expects on `Options.agent`. {@link AgentSelection} carries only a `uri`, + * so the name is recovered from the source: + * + * - A `claude-internal:` URI — an SDK-only agent the disk scan couldn't + * locate (Decision D2); the name is the path segment encoded by + * {@link nonEditableUri} (this is its inverse). + * - A real `file:` agent — the SDK keys agents by their frontmatter + * `name`, which may differ from the filename, so it is parsed (falling + * back to the basename when the file can't be read). + * + * Returns `undefined` when no agent is selected (or the name can't be + * recovered) so the SDK falls back to its default (no `--agent` flag). + */ +export async function resolveClaudeAgentName( + agent: AgentSelection | undefined, + fileService: IFileService, + logService: ILogService, + sessionId: string, +): Promise { + if (!agent) { + return undefined; + } + const uri = URI.parse(agent.uri); + + // SDK-only (non-editable) agents encode the name in the path: + // `claude-internal:/agent/` (inverse of nonEditableUri). + if (uri.scheme === CLAUDE_INTERNAL_SCHEME) { + const last = uri.path.split('/').pop() ?? ''; + const name = last ? decodeURIComponent(last) : ''; + if (!name) { + logService.warn(`[Claude:${sessionId}] resolveClaudeAgentName: could not extract agent name from URI '${agent.uri}'`); + return undefined; + } + return name; + } + + // Real on-disk agent: the SDK identifies it by its frontmatter `name`, + // which the filename need not match. + try { + const parsed = await parseAgentFile(uri, fileService); + if (parsed.name) { + return parsed.name; + } + } catch (err) { + logService.warn(`[Claude:${sessionId}] resolveClaudeAgentName: failed to parse agent file '${agent.uri}', falling back to basename`, err); + } + + const basename = uri.path.split('/').pop() ?? ''; + const name = basename.replace(/\.md$/i, ''); + if (!name) { + logService.warn(`[Claude:${sessionId}] resolveClaudeAgentName: could not extract agent name from URI '${agent.uri}'`); + return undefined; + } + return name; +} + +/** + * Builds the discovered-customization projection for a session, applying + * the live SDK snapshot as a post-materialize filter. + * + * - `sdk === undefined` (provisional): the full disk-discovered set is + * returned unfiltered — no live session yet to say what's active. + * - `sdk` present (materialized): disk entries are kept only when the live + * session knows them (matched by name, per type — agents against the SDK + * agent set; skills against the SDK command set; MCP against the SDK + * server set, enriched with live state). SDK-known AGENTS and MCP servers + * with no matching disk file are surfaced as NON-EDITABLE entries + * (`claude-internal:` — Decision D2): a non-editable agent is still + * selectable and a non-editable MCP server still shows status. SDK-only + * SKILLS (Claude's built-in slash commands like `/init`) are NOT mixed in + * among the editable disk skills — instead they appear, read-only, in the + * separate "Built-in" skills container this function appends. The SDK's + * built-in default agent is hidden. Rules (CLAUDE.md + `.claude/rules`) + * have no SDK counterpart and are always kept. + * + * The "Built-in" surfacing for BOTH agents and skills is decided here (the + * single place that has the disk set and the optional `sdk` snapshot): built-in + * agents merge into the agent set (selectable, `claude-internal:`); built-in + * skills are a separate read-only container appended to the result. + */ +export function buildDiscoveredCustomizations( + discovered: readonly (IParsedAgent | IParsedSkill | IParsedRule)[], + mcpServers: readonly McpServerCustomization[], + hooks: readonly HookCustomization[], + nativePlugins: readonly IResolvedNativePlugin[], + workingDirectory: URI | undefined, + userHome: URI, + sdk: ISdkResolvedCustomizations | undefined, +): readonly Customization[] { + // Native plugins the live session actually loaded → surfaced as top-level + // containers (passed to the mapper at the end). The SDK `init.plugins` + // reports each loaded plugin's `source` (its `@` id) + // and a `path`. Match on `source` against the resolved plugin id first — it + // is exact and stable — and fall back to a normalized `path` match (older + // SDKs without `source`). The `path` alone is unreliable: for a + // workspace-`local`-scoped plugin the SDK can report a non-cache path that + // never matches the resolved root. The plugin is the atomic filtering unit. + // + // A plugin's bundled components are ALSO reported by the live SDK as + // agents / commands / MCP servers. Collect each surfaced plugin's own + // parsed component names so those SDK entries are suppressed from the + // standalone fallbacks below — each component then appears once, under its + // plugin container, not also loose in the per-scope lists (Decision PB-10). + // The SDK names plugin components inconsistently (agents namespaced as + // `:`, skills usually bare), so both forms are registered. + // Only *surfaced* plugins contribute, so a loaded-but-unsurfaced plugin's + // components are never silently dropped. A single pass matches each native + // plugin to its SDK entry, building the visible set and the suppression + // name sets together (no second `find`). + const visiblePlugins: IResolvedNativePlugin[] = []; + const pluginAgentNames = new Set(); + const pluginSkillNames = new Set(); + const pluginMcpNames = new Set(); + if (sdk) { + for (const p of nativePlugins) { + const sdkPlugin = sdk.plugins.find(s => s.source === p.id || URI.file(s.path).fsPath === p.root.fsPath); + if (!sdkPlugin) { + continue; + } + visiblePlugins.push(p); + const ns = sdkPlugin.name; + const add = (set: Set, name: string) => { set.add(name); if (ns) { set.add(`${ns}:${name}`); } }; + for (const a of p.parsed.agents) { add(pluginAgentNames, a.name); } + for (const s of p.parsed.skills) { add(pluginSkillNames, s.name); } + for (const m of p.parsed.mcpServers) { add(pluginMcpNames, m.name); } + } + } else { + visiblePlugins.push(...nativePlugins); + } + + // The read-only "Built-in" skills container: pre-materialize the curated + // list, post-materialize the live SDK command set minus the disk skills + // (and minus plugin-contributed skills, which belong to a plugin container). + // Appended to whichever projection is returned below so the SDK-vs-curated + // decision for built-in skills sits next to the one for built-in agents. + const diskSkillNames = new Set( + discovered.filter(d => d.customization.type === CustomizationType.Skill).map(d => d.name) + ); + const builtinSkills = sdk + ? buildSdkBuiltinSkillsContainer(sdk.commands.filter(c => !pluginSkillNames.has(c.name)), diskSkillNames) + : buildClaudeBuiltinSkillsContainer(diskSkillNames); + const withBuiltinSkills = (list: readonly Customization[]): readonly Customization[] => + builtinSkills ? [...list, builtinSkills] : list; + + if (!sdk) { + // Pre-materialize there is no live agent set, so seed the curated + // built-in agents alongside the disk agents. They use the same + // non-editable `claude-internal:` shape the SDK fallback produces + // post-materialize (selectable, name round-trips), so the same agent + // looks identical before and after materialize. A disk agent of the + // same name wins; the SDK default agent is hidden. + const diskAgentNames = new Set( + discovered.filter(d => d.customization.type === CustomizationType.Agent).map(d => d.name) + ); + const builtinAgents = CLAUDE_BUILTIN_AGENTS + .filter(a => a.name !== CLAUDE_SDK_DEFAULT_AGENT_NAME && !diskAgentNames.has(a.name)) + .map(a => toParsedAgent({ uri: nonEditableUri('agent', a.name), name: a.name, description: a.description() })); + return withBuiltinSkills(mapDiscoveredCustomizations([...discovered, ...builtinAgents], mcpServers, hooks, nativePlugins, workingDirectory, userHome)); + } + + const agentNames = new Set(sdk.agents.map(a => a.name)); + const commandNames = new Set(sdk.commands.map(c => c.name)); + const mcpByName = new Map(sdk.mcpServers.map(s => [s.name, s] as const)); + + // Keep disk entries the live session actually loaded. A loaded skill + // surfaces in the SDK's `supportedCommands()` set, so disk skills are + // matched against `commandNames`. + const seenAgents = new Set(); + const entries: (IParsedAgent | IParsedSkill | IParsedRule)[] = []; + for (const d of discovered) { + if (d.customization.type === CustomizationType.Agent) { + // Hide the SDK's built-in default agent even when a same-named + // file exists on disk — selecting it is equivalent to "no + // selection", so it must never reach the picker post-materialize. + if (d.name === CLAUDE_SDK_DEFAULT_AGENT_NAME) { + continue; + } + if (agentNames.has(d.name)) { + entries.push(d); + seenAgents.add(d.name); + } + } else if (d.customization.type === CustomizationType.Skill) { + if (commandNames.has(d.name)) { + entries.push(d); + } + } else { + // Rules (CLAUDE.md + `.claude/rules`) have no SDK counterpart, so + // they are never filtered — always keep them. + entries.push(d); + } + } + + // SDK-known-but-not-on-disk AGENTS → non-editable fallback (Decision D2): + // still selectable as the session agent even without an editable file. + // (Skills get no such fallback — see the doc comment: a non-openable + // skill entry is only ever a dead link.) + for (const agent of sdk.agents) { + if (agent.name === CLAUDE_SDK_DEFAULT_AGENT_NAME || seenAgents.has(agent.name) || pluginAgentNames.has(agent.name)) { + continue; + } + entries.push(toParsedAgent({ uri: nonEditableUri('agent', agent.name), name: agent.name, ...(agent.description ? { description: agent.description } : {}) })); + } + + // MCP: keep disk servers the SDK loaded (enriched with live state); add + // SDK-only servers as non-editable entries (status is still informative). + const seenMcp = new Set(); + const servers: McpServerCustomization[] = []; + for (const server of mcpServers) { + const sdkServer = mcpByName.get(server.name); + if (!sdkServer) { + continue; + } + seenMcp.add(server.name); + servers.push({ ...server, state: deriveMcpState(sdkServer.status) }); + } + for (const [name, sdkServer] of mcpByName) { + if (seenMcp.has(name) || pluginMcpNames.has(name)) { + continue; + } + servers.push({ ...makeMcpServerCustomization(nonEditableUri('mcp', name), name), state: deriveMcpState(sdkServer.status) }); + } + + // Native plugins were matched to the live SDK set at the top of this + // function (`visiblePlugins`); surface them as top-level containers. + return withBuiltinSkills(mapDiscoveredCustomizations(entries, servers, hooks, visiblePlugins, workingDirectory, userHome)); +} + +/** + * Watches a session's on-disk Claude customization sources and fires + * {@link onDidChange} (debounced) whenever any of them is created, edited, + * or removed, so the workbench re-fetches `getSessionCustomizations`. + * + * Watched roots: + * - `/.claude` and `/.claude` (recursive) — cover the + * agents / skills / commands trees, the `.claude/rules` + `.claude/CLAUDE.md` + * instruction sources, plus the inline `settings.json` MCP config. + * - `` (non-recursive) — watched to catch the sibling `.mcp.json` and + * the root `CLAUDE.md` / `CLAUDE.local.md` memory files; the triggers are + * narrowed to those files so unrelated edits in the workspace root don't + * force a re-scan. + */ +export class ClaudeCustomizationWatcher extends Disposable { + + private static readonly DEBOUNCE_MS = 300; + + readonly onDidChange: Event; + + constructor( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, + logService: ILogService, + debounceMs: number = ClaudeCustomizationWatcher.DEBOUNCE_MS, + ) { + super(); + + // URIs whose subtree (or exact file, for `.mcp.json`) signals a re-scan. + const triggers: URI[] = []; + const watch = (uri: URI, recursive: boolean) => { + try { + this._register(fileService.watch(uri, { recursive, excludes: [] })); + } catch (err) { + logService.warn(`[ClaudeCustomizationWatcher] failed to watch '${uri.toString()}': ${err instanceof Error ? err.message : String(err)}`); + } + }; + + if (workingDirectory) { + const projectClaude = URI.joinPath(workingDirectory, '.claude'); + watch(projectClaude, true); + triggers.push(projectClaude); + watch(workingDirectory, false); + triggers.push(URI.joinPath(workingDirectory, '.mcp.json')); + } + const userClaude = URI.joinPath(userHome, '.claude'); + watch(userClaude, true); + triggers.push(userClaude); + + // Memory files (CLAUDE.md / CLAUDE.local.md) — reuse the scanner's + // canonical list so the watcher never drifts from what it actually + // reads. Entries already under a recursively-watched `.claude` root + // (e.g. `.claude/CLAUDE.md`) are harmless duplicate triggers. + triggers.push(...claudeMemoryFiles(workingDirectory, userHome)); + + // Collapse the raw file-change stream into a single debounced signal. + // The `DisposableStore` argument is required because `onDidChange` is a + // public property (see the `Event.debounce` leak-safety note). + this.onDidChange = Event.signal(Event.debounce( + Event.filter(fileService.onDidFilesChange, e => triggers.some(t => e.affects(t)), this._store), + (_last, e) => e, + debounceMs, + undefined, + undefined, + undefined, + this._store, + )); + } +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts deleted file mode 100644 index a5682d59bc773..0000000000000 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts +++ /dev/null @@ -1,39 +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 { ISyncedCustomization } from '../../../common/agentPluginManager.js'; -import type { Customization } from '../../../common/state/protocol/state.js'; - -/** - * Project the union of (a) client-pushed customizations and - * (b) the on-disk discovery bundle (server-provided) onto the - * protocol's {@link Customization} surface. - * - * Client-pushed entries get the per-id enablement overlay applied - * (`enablement.get(id) ?? customization.enabled`). The discovery - * bundle is surfaced verbatim — it is a single synthetic plugin URI - * pointing at an on-disk Open Plugin layout (`agents/`, `skills/`, - * `commands/`, `rules/`) the workbench's plugin expander scans to - * emit per-type child items. Per-file enablement happens - * workbench-side; we surface only the bundle URI. - */ -export function projectSessionCustomizations( - synced: readonly ISyncedCustomization[], - enablement: ReadonlyMap, - discovered: Customization | undefined, -): readonly Customization[] { - const result: Customization[] = []; - - for (const item of synced) { - const enabled = enablement.get(item.customization.id) ?? item.customization.enabled; - result.push({ ...item.customization, enabled }); - } - - if (discovered) { - result.push(discovered); - } - - return result; -} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts new file mode 100644 index 0000000000000..ffa9c86383624 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { dirname } from '../../../../../../base/common/resources.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { detectPluginFormat, readAgentComponents, readSkills, toParsedAgent, toParsedSkill, type INamedPluginResource, type IParsedAgent, type IParsedSkill } from '../../../../../agentPlugins/common/pluginParsers.js'; + +/** + * The `.claude/` directories one scope (project or user) contributes + * customizations from. `commands` holds slash commands, which are a + * variant of skills (folded into the skill set, skills winning conflicts). + */ +function scopeRoots(scope: URI): { readonly agents: URI; readonly skills: URI; readonly commands: URI } { + const base = URI.joinPath(scope, '.claude'); + return { + agents: URI.joinPath(base, 'agents'), + skills: URI.joinPath(base, 'skills'), + commands: URI.joinPath(base, 'commands'), + }; +} + +/** + * Merges parsed components into a name→component map. First-seen wins, so + * scanning project before user (and skills before commands) makes the + * earlier entry shadow same-named later ones deterministically. + */ +function collectByName(into: Map, items: readonly T[]): void { + for (const item of items) { + if (!into.has(item.name)) { + into.set(item.name, item); + } + } +} + +/** + * Drops any skill whose `.claude/skills//` directory is itself a + * native plugin (it holds a plugin manifest in any supported format). Such + * a directory is an in-place `@skills-dir` plugin, so its skills are + * surfaced under its own `PluginCustomization` container rather than as + * duplicate standalone skill rows (Decision PB-8). The check is + * self-contained (manifest presence, via the shared + * {@link detectPluginFormat}) so it holds for Claude / Open Plugins / + * Copilot layouts and regardless of whether the plugin is enabled. + */ +async function excludeNativePluginSkills(skills: readonly INamedPluginResource[], fileService: IFileService): Promise { + const isPluginDir = await Promise.all(skills.map(async skill => { + const dir = dirname(skill.uri); + const format = await detectPluginFormat(dir, fileService); + return fileService.exists(URI.joinPath(dir, format.manifestPath)); + })); + return skills.filter((_, i) => !isPluginDir[i]); +} + +/** + * Scans a Claude session's `.claude/{agents,skills,commands}` directories + * (project + user scope) and returns the discovered customizations with + * their real source-file URIs and parsed names/descriptions. + * + * Reuses the shared parsers in `pluginParsers.ts`: + * - agents — flat `.md` files, frontmatter name/description. + * - skills — `/SKILL.md` layout, frontmatter name/description. + * - commands — flat `.md` files; a variant of skills, so they are + * folded into the skill set (skills win on a name conflict). + * + * Each scope is scanned independently and merged project-before-user so + * precedence is deterministic (the shared readers parallelize/sort + * internally, so handing them both scopes at once would NOT guarantee + * which scope wins on a name clash). Within a scope, skills are collected + * before commands so skills win same-name conflicts. MCP servers are + * resolved separately (they live inline in `settings.json` / `.mcp.json`, + * not as directory entries). + */ +export async function scanClaudeDiskCustomizations( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, +): Promise { + // Project scope first so it wins precedence over user scope. + const scopes = workingDirectory ? [workingDirectory, userHome] : [userHome]; + const agents = new Map(); + const skills = new Map(); + + for (const scope of scopes) { + const { agents: agentsDir, skills: skillsDir, commands: commandsDir } = scopeRoots(scope); + const [agentRes, skillRes, commandRes] = await Promise.all([ + readAgentComponents([agentsDir], fileService), + // pluginRoot = the skills dir itself, so the readSkills fallback + // targets `/SKILL.md` (a legit single-skill dir), never + // an unrelated `/SKILL.md`. + readSkills(skillsDir, [skillsDir], fileService), + readAgentComponents([commandsDir], fileService), + ]); + collectByName(agents, agentRes.map(toParsedAgent)); + // Skills before commands so a same-named skill wins (spec section 3). + // Drop `@skills-dir` plugin dirs first — they surface as plugins (PB-8). + const standaloneSkills = await excludeNativePluginSkills(skillRes, fileService); + collectByName(skills, standaloneSkills.map(toParsedSkill)); + collectByName(skills, commandRes.map(toParsedSkill)); + } + + return [...agents.values(), ...skills.values()]; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeHookScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeHookScan.ts new file mode 100644 index 0000000000000..28059ce558491 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeHookScan.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { parseHooksJson, readJsonFile } from '../../../../../agentPlugins/common/pluginParsers.js'; +import { type HookCustomization } from '../../../../common/state/protocol/channels-session/state.js'; + +/** + * The JSON files a Claude session reads hook declarations from, in + * precedence order (project before user). Mirrors the SDK's + * `settingSources: ['user', 'project', 'local']` scopes (the `managed` + * scope is intentionally excluded). `settings.local.json` is the + * gitignored project-local override; the SDK loads it alongside + * `settings.json`. + */ +function claudeHookFiles(workingDirectory: URI | undefined, userHome: URI): URI[] { + const files: URI[] = []; + if (workingDirectory) { + files.push(URI.joinPath(workingDirectory, '.claude', 'settings.json')); + files.push(URI.joinPath(workingDirectory, '.claude', 'settings.local.json')); + } + files.push(URI.joinPath(userHome, '.claude', 'settings.json')); + return files; +} + +/** + * Scans a Claude session's `settings.json` / `settings.local.json` files + * (project + user scope) for a `hooks` block and surfaces each declaring + * file as a single {@link HookCustomization} pointing at the real settings + * file (so the workbench can open it for editing). + * + * Hooks already *fire* at runtime — the SDK loads them via `settingSources` + * — so this is discovery only. There is no SDK enumeration API for hooks, + * so the result is never filtered against the live session (unlike + * agents / skills / MCP servers); it mirrors the rules tier. + * + * Parsing (including the `disableAllHooks` short-circuit and the canonical + * event-name mapping) is delegated to the shared {@link parseHooksJson}; + * a scope that declares no recognized hooks contributes no entry. + */ +export async function scanClaudeHooks( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, +): Promise { + const result: HookCustomization[] = []; + const seen = new ResourceSet(); + for (const uri of claudeHookFiles(workingDirectory, userHome)) { + if (seen.has(uri)) { + // The same settings file can be reached from two scopes (e.g. the + // user opened their home directory as the workspace) — surface it + // once. Mirrors the dedupe in `scanClaudeMcpServers`. + continue; + } + seen.add(uri); + const raw = await readJsonFile(uri, fileService); + if (raw === undefined) { + continue; + } + // All groups parsed from one file share a single file-level + // `customization`, so the first group carries the entry we surface. + const groups = parseHooksJson(uri, raw, workingDirectory, userHome); + if (groups.length > 0) { + result.push(groups[0].customization); + } + } + return result; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts new file mode 100644 index 0000000000000..da51e6cbf4a66 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeMcpScan.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { makeMcpServerCustomization, readJsonFile } from '../../../../../agentPlugins/common/pluginParsers.js'; +import { McpServerStatus, type McpServerCustomization, type McpServerState } from '../../../../common/state/protocol/channels-session/state.js'; + +/** + * The JSON files a Claude session reads MCP server declarations from, + * in precedence order (project before user — first-seen wins). + */ +function claudeMcpFiles(workingDirectory: URI | undefined, userHome: URI): URI[] { + const files: URI[] = []; + if (workingDirectory) { + files.push(URI.joinPath(workingDirectory, '.claude', 'settings.json')); + files.push(URI.joinPath(workingDirectory, '.mcp.json')); + } + files.push(URI.joinPath(userHome, '.claude', 'settings.json')); + return files; +} + +/** + * Extracts the `{ name: config }` MCP server map from one parsed JSON + * file. `settings.json` carries many unrelated top-level keys, so we + * ONLY treat its explicit `mcpServers` block as servers. A bare + * `.mcp.json` MAY instead be a flat `{ name: config }` map. + */ +function extractMcpServerMap(uri: URI, raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + const obj = raw as Record; + if (Object.hasOwn(obj, 'mcpServers')) { + const servers = obj.mcpServers; + return (servers && typeof servers === 'object' && !Array.isArray(servers)) ? servers as Record : undefined; + } + return uri.path.endsWith('.mcp.json') ? obj : undefined; +} + +/** + * Scans a Claude session's `settings.json` / `.mcp.json` files (project + + * user scope) for declared MCP servers and returns them as + * {@link McpServerCustomization} entries pointing at the real source file. + * + * Pre-materialize, the state is {@link McpServerStatus.Starting} (declared + * but not yet connected). Post-materialize, the live connection status is + * enriched from the SDK's `mcpServerStatus()` by the projector. + */ +export async function scanClaudeMcpServers( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, +): Promise { + const seen = new Set(); + const result: McpServerCustomization[] = []; + for (const uri of claudeMcpFiles(workingDirectory, userHome)) { + const raw = await readJsonFile(uri, fileService); + const servers = extractMcpServerMap(uri, raw); + if (!servers) { + continue; + } + for (const [name, config] of Object.entries(servers)) { + if (!config || typeof config !== 'object' || Array.isArray(config) || seen.has(name)) { + continue; + } + seen.add(name); + result.push(makeMcpServerCustomization(uri, name)); + } + } + result.sort((a, b) => a.name.localeCompare(b.name)); + return result; +} + +/** + * Maps an SDK MCP connection status onto the protocol MCP state. Precise + * `Error` / `AuthRequired` states (which carry extra payload) are deferred; + * anything not clearly `connected` / `disabled` reports as `Starting`. + */ +export function deriveMcpState(status: string): McpServerState { + switch (status) { + case 'connected': + return { kind: McpServerStatus.Ready }; + case 'disabled': + return { kind: McpServerStatus.Stopped }; + default: + return { kind: McpServerStatus.Starting }; + } +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts new file mode 100644 index 0000000000000..6ca52c8c1d858 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { ILogService } from '../../../../../log/common/log.js'; +import { detectPluginFormat, parsePlugin, readJsonFile, type IParsedPlugin } from '../../../../../agentPlugins/common/pluginParsers.js'; + +/** + * A Claude-native plugin enabled via `enabledPlugins` and resolved to its + * real on-disk root, paired with its parsed components. + */ +export interface IResolvedNativePlugin { + /** The `enabledPlugins` id, e.g. `telegram@claude-plugins-official`. */ + readonly id: string; + /** The resolved plugin root directory (a real, openable `file:` URI). */ + readonly root: URI; + /** The plugin's parsed components (skills / agents / hooks / MCP / rules). */ + readonly parsed: IParsedPlugin; +} + +/** + * The marketplace id used by Claude for in-place `@skills-dir` plugins, + * which live under `/.claude/skills//` rather than the + * marketplace cache. + */ +const SKILLS_DIR_MARKETPLACE = 'skills-dir'; + +/** + * The settings files a Claude session reads `enabledPlugins` from, in + * **ascending precedence** (`user < project < local`, per the SDK: a + * `false` in `.claude/settings.local.json` disables a plugin that project + * or user settings enabled). Iterating in this order and letting later + * scopes overwrite earlier ones yields the effective enabled set. The + * `managed` scope is intentionally excluded. + */ +function claudeSettingsFilesByPrecedence(workingDirectory: URI | undefined, userHome: URI): URI[] { + const files: URI[] = [URI.joinPath(userHome, '.claude', 'settings.json')]; + if (workingDirectory) { + files.push(URI.joinPath(workingDirectory, '.claude', 'settings.json')); + files.push(URI.joinPath(workingDirectory, '.claude', 'settings.local.json')); + } + return files; +} + +/** + * Computes the effective set of enabled plugin ids across the settings + * scopes. A plugin's value may be `true`, a `string[]` (version + * constraint), or an object (extended form) — all of which mean + * **enabled**; only `false` disables. Later scopes override earlier ones. + */ +async function resolveEnabledPluginIds(workingDirectory: URI | undefined, userHome: URI, fileService: IFileService): Promise { + const effective = new Map(); + const seenFiles = new ResourceSet(); + for (const uri of claudeSettingsFilesByPrecedence(workingDirectory, userHome)) { + if (seenFiles.has(uri)) { + // The same settings file can be reached from two scopes (cwd === + // userHome) — read it once. Mirrors the per-scanner dedupe. + continue; + } + seenFiles.add(uri); + const raw = await readJsonFile(uri, fileService); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + continue; + } + const enabledPlugins = (raw as Record)['enabledPlugins']; + if (!enabledPlugins || typeof enabledPlugins !== 'object' || Array.isArray(enabledPlugins)) { + continue; + } + for (const [id, value] of Object.entries(enabledPlugins as Record)) { + effective.set(id, value !== false); + } + } + return [...effective].filter(([, enabled]) => enabled).map(([id]) => id); +} + +/** Splits an `enabledPlugins` id into its `plugin` and `marketplace` parts. */ +function splitPluginId(id: string): { readonly plugin: string; readonly marketplace: string } | undefined { + const at = id.lastIndexOf('@'); + if (at <= 0 || at === id.length - 1) { + return undefined; + } + const plugin = id.slice(0, at); + const marketplace = id.slice(at + 1); + // The segments are joined into filesystem paths below, so reject anything + // that could escape the plugin roots (path separators or `..`). Plugin and + // marketplace ids are plain identifiers; a settings file (incl. an + // untrusted workspace `.claude/settings.local.json`) must not redirect the + // scan outside `~/.claude/plugins/cache` / `.claude/skills`. + const isUnsafeSegment = (s: string) => s.includes('/') || s.includes('\\') || s.includes('..'); + if (isUnsafeSegment(plugin) || isUnsafeSegment(marketplace)) { + return undefined; + } + return { plugin, marketplace }; +} + +/** + * Whether `dir` contains a readable plugin manifest in any supported + * format. Reuses the shared {@link detectPluginFormat} so the scanner + * recognizes Claude (`.claude-plugin/plugin.json`), Open Plugins + * (`.plugin/plugin.json`), and Copilot (`plugin.json`) layouts — the same + * formats {@link parsePlugin} can parse — rather than only the Claude one. + */ +async function hasManifest(dir: URI, fileService: IFileService): Promise { + const format = await detectPluginFormat(dir, fileService); + return fileService.exists(URI.joinPath(dir, format.manifestPath)); +} + +/** + * Resolves an in-place `@skills-dir` plugin to its root, preferring the + * workspace scope over the user scope. Accepts a candidate only when it + * holds a plugin manifest. + */ +async function resolveSkillsDirRoot(plugin: string, workingDirectory: URI | undefined, userHome: URI, fileService: IFileService): Promise { + const candidates: URI[] = []; + if (workingDirectory) { + candidates.push(URI.joinPath(workingDirectory, '.claude', 'skills', plugin)); + } + candidates.push(URI.joinPath(userHome, '.claude', 'skills', plugin)); + for (const candidate of candidates) { + if (await hasManifest(candidate, fileService)) { + return candidate; + } + } + return undefined; +} + +/** + * Resolves a marketplace plugin to its installed cache root. The real + * layout is `~/.claude/plugins/cache////`, + * so the id maps directly to `/`; the version dir is + * the (single, or newest-`mtime`) child that holds a plugin manifest. The + * base dir itself is accepted when it directly holds a manifest (no + * version subdir). Returns `undefined` (caller warns + skips) when no + * manifest-bearing candidate is found. + */ +async function resolveMarketplaceCacheRoot(plugin: string, marketplace: string, userHome: URI, fileService: IFileService): Promise { + const base = URI.joinPath(userHome, '.claude', 'plugins', 'cache', marketplace, plugin); + if (await hasManifest(base, fileService)) { + return base; + } + let stat; + try { + stat = await fileService.resolve(base); + } catch { + return undefined; + } + if (!stat.isDirectory || !stat.children) { + return undefined; + } + let best: { readonly uri: URI; readonly mtime: number; readonly name: string } | undefined; + for (const child of stat.children) { + if (!child.isDirectory || !(await hasManifest(child.resource, fileService))) { + continue; + } + const mtime = child.mtime ?? 0; + // Newest install wins; the dir name breaks ties deterministically when + // mtimes collide or are missing. Compare numerically (`{ numeric: true }`) + // so version dirs order naturally — `0.0.10` after `0.0.9`, not before. + if (!best || mtime > best.mtime || (mtime === best.mtime && child.name.localeCompare(best.name, undefined, { numeric: true }) > 0)) { + best = { uri: child.resource, mtime, name: child.name }; + } + } + return best?.uri; +} + +/** + * Scans a Claude session's `enabledPlugins` (user / project / local + * settings) and resolves each enabled native plugin to its on-disk root, + * parsing its bundled components with the shared {@link parsePlugin}. + * + * This is **discovery only** — native `.claude` plugins are already loaded + * by the SDK runtime via `settingSources`, so the result is never fed into + * `Options.plugins`; it only surfaces the plugins (and their components) + * in the customization list with real, editable URIs. The post-materialize + * filter (against the live SDK `system/init.plugins` list) hides any plugin + * the live session did not actually load. + * + * Resolution is fail-soft: an id whose root cannot be located, or whose + * manifest fails to parse, is logged and skipped rather than throwing. + */ +export async function scanClaudeNativePlugins( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, + logService: ILogService, +): Promise { + const ids = await resolveEnabledPluginIds(workingDirectory, userHome, fileService); + const result: IResolvedNativePlugin[] = []; + const seenRoots = new ResourceSet(); + for (const id of ids) { + const parts = splitPluginId(id); + if (!parts) { + logService.warn(`[claudeNativePluginScan] skipping malformed plugin id '${id}'`); + continue; + } + const root = parts.marketplace === SKILLS_DIR_MARKETPLACE + ? await resolveSkillsDirRoot(parts.plugin, workingDirectory, userHome, fileService) + : await resolveMarketplaceCacheRoot(parts.plugin, parts.marketplace, userHome, fileService); + if (!root) { + logService.warn(`[claudeNativePluginScan] could not resolve an on-disk root for enabled plugin '${id}'`); + continue; + } + if (seenRoots.has(root)) { + continue; + } + seenRoots.add(root); + try { + const parsed = await parsePlugin(root, fileService, workingDirectory, userHome, root); + result.push({ id, root, parsed }); + } catch (err) { + logService.warn(`[claudeNativePluginScan] failed to parse plugin '${id}' at '${root.toString()}': ${err instanceof Error ? err.message : String(err)}`); + } + } + result.sort((a, b) => a.id.localeCompare(b.id)); + return result; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts new file mode 100644 index 0000000000000..ca07c707e58fb --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { basename } from '../../../../../../base/common/resources.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { parseRuleFile, pathExists, type IParsedRule } from '../../../../../agentPlugins/common/pluginParsers.js'; +import { CustomizationType } from '../../../../common/state/protocol/channels-session/state.js'; +import { customizationId, type RuleCustomization } from '../../../../common/state/sessionState.js'; + +/** + * The CLAUDE.md memory files a session loads as always-on rules. The + * documented primary locations only — user `~/.claude/CLAUDE.md`, project + * `./CLAUDE.md` / `./.claude/CLAUDE.md`, and the personal `./CLAUDE.local.md`. + * (Managed-policy OS-level CLAUDE.md and the ancestor-directory walk are + * intentionally out of scope.) + * + * Exported so the change-watcher can reuse the exact same list and never + * drift from what {@link scanClaudeRules} actually reads. + */ +export function claudeMemoryFiles(workingDirectory: URI | undefined, userHome: URI): URI[] { + const files = [URI.joinPath(userHome, '.claude', 'CLAUDE.md')]; + if (workingDirectory) { + files.push( + URI.joinPath(workingDirectory, 'CLAUDE.md'), + URI.joinPath(workingDirectory, '.claude', 'CLAUDE.md'), + URI.joinPath(workingDirectory, 'CLAUDE.local.md'), + ); + } + return files; +} + +/** + * Recursively collects `*.md` files under `dir` (sorted by path). Follows + * symlinked subdirectories — `.claude/rules` supports them — with a + * visited-set guard plus a hard depth cap so circular symlinks terminate. + * (The visited-set alone is insufficient: a symlink cycle like + * `rules/a -> rules` produces an ever-deeper, never-repeating path, so the + * depth cap is the real termination guarantee.) + */ +const MAX_RULE_SCAN_DEPTH = 32; + +async function readMarkdownFilesRecursive(dir: URI, fileService: IFileService, seen = new Set(), depth = 0): Promise { + const key = dir.toString(); + if (depth > MAX_RULE_SCAN_DEPTH || seen.has(key)) { + return []; + } + seen.add(key); + + let stat; + try { + stat = await fileService.resolve(dir); + } catch { + return []; + } + if (!stat.isDirectory || !stat.children) { + return []; + } + + const files: URI[] = []; + for (const child of stat.children) { + if (child.isDirectory) { + files.push(...await readMarkdownFilesRecursive(child.resource, fileService, seen, depth + 1)); + } else if (child.isFile && child.resource.path.toLowerCase().endsWith('.md')) { + files.push(child.resource); + } + } + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * Scans a session's Claude "memory" rules — the {@link CustomizationType.Rule} + * tier — from both forms: + * - CLAUDE.md instruction files ({@link claudeMemoryFiles}): always-on, + * no path scoping. + * - `.claude/rules/**\/*.md` (project + user, recursive): path-scoped when + * a `paths` frontmatter glob is present (parsed via {@link parseRuleFile}), + * otherwise always-on. + * + * Each rule carries its real source-file `uri` so the workbench can open it + * for editing. Rules are additive (not deduped by name) — the SDK has no + * rule concept, so they are never filtered post-materialize. + */ +export async function scanClaudeRules( + workingDirectory: URI | undefined, + userHome: URI, + fileService: IFileService, +): Promise { + const result: IParsedRule[] = []; + + // CLAUDE.md memory files — always-on, no path scoping, no frontmatter. + for (const uri of claudeMemoryFiles(workingDirectory, userHome)) { + if (await pathExists(uri, fileService)) { + const ruleUri = uri.toString(); + const name = basename(uri); + const customization: RuleCustomization = { + type: CustomizationType.Rule, + id: customizationId(ruleUri), + uri: ruleUri, + name, + alwaysApply: true, + }; + result.push({ uri, name, customization }); + } + } + + // `.claude/rules/**\/*.md` — path-scoped when `paths` frontmatter present. + const scopes = workingDirectory ? [workingDirectory, userHome] : [userHome]; + for (const scope of scopes) { + const files = await readMarkdownFilesRecursive(URI.joinPath(scope, '.claude', 'rules'), fileService); + for (const uri of files) { + const parsed = await parseRuleFile(uri, fileService); + const ruleUri = uri.toString(); + const hasGlobs = !!parsed.globs?.length; + const customization: RuleCustomization = { + type: CustomizationType.Rule, + id: customizationId(ruleUri), + uri: ruleUri, + name: parsed.name, + ...(parsed.description ? { description: parsed.description } : {}), + ...(hasGlobs ? { globs: parsed.globs } : {}), + alwaysApply: !hasGlobs, + }; + result.push({ uri, name: parsed.name, ...(parsed.description ? { description: parsed.description } : {}), customization }); + } + } + + return result; +} diff --git a/src/vs/platform/agentHost/node/claude/phase15-plan.md b/src/vs/platform/agentHost/node/claude/phase15-plan.md new file mode 100644 index 0000000000000..8fb753af0034c --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase15-plan.md @@ -0,0 +1,232 @@ +# Phase 15 — SDK Distribution via `product.json` + main.vscode-cdn.net + +> **Retrospective**, not a forward plan. Phase 15 shipped without a +> dedicated `phaseN-plan.md`; this file documents what actually landed so +> the roadmap's Phase 15 link resolves and future readers have the same +> contract-level record the other phases carry. Source of truth for the +> code is the files cited inline. +> Last updated: 2026-06-17. + +**Status:** ✅ done — runtime downloader and the per-platform build +pipeline both landed. Unit tests green (16 across `resolveSdkTarget` + +`AgentSdkDownloader` in +[`agentSdkDownloader.test.ts`](../../test/node/agentSdkDownloader.test.ts)), +`typecheck-client` clean. Shipped under the previously-tracked +"per-platform" work (the earlier roadmap referenced a +`phase15-per-platform-plan.md` that was never written — this file replaces +those dangling links). + +## Goal + +Ship the Claude and Codex agent SDKs to end users without bundling them in +the app and without asking users to hand-install anything. A fresh +Insiders install should be able to pick a Claude or Codex model and have +the SDK fetched on demand, while developers keep a zero-friction local +override. + +The constraint that shaped the whole design: **macOS Universal bundles +share a single `product.json` across arm64 and x64 launches**, so the +shipped config cannot hard-code a per-arch URL. The runtime must resolve +the right tarball per launch. + +## Scope + +**In scope** +- `product.agentSdks.` config shape (`{ version, urlTemplate }`) in + [`product.ts`](../../../../base/common/product.ts). +- Runtime downloader `AgentSdkDownloader` + `resolveSdkTarget` in + [`agentSdkDownloader.ts`](../agentSdkDownloader.ts): dev-override → + cache → CDN download, with negative-cache latching and in-process + download de-duplication. +- Per-package strategy object `IAgentSdkPackage` so the downloader never + branches on provider id (`ClaudeSdkPackage`, `CodexSdkPackage` live in + their owning agent modules). +- Provider-registration gate via `IAgentSdkDownloader.isAvailable(pkg)`. +- Three-tier SDK resolution in + [`claudeAgentSdkService.ts`](./claudeAgentSdkService.ts#L186) `_loadSdk` + (env override → downloader → dev bare-import) and the Codex equivalent. +- Build pipeline under [`build/agent-sdk/`](../../../../../../build/agent-sdk/README.md): + per-SDK pinned `{package.json, package-lock.json}`, `produce.ts`, + `package.ts` (deterministic tar), `upload.ts` (idempotent CDN publish), + `common.ts` (shared helpers incl. `readAgentSdkResults`). +- Azure Pipelines integration: + [`agent-sdk-produce.yml`](../../../../../../build/azure-pipelines/common/agent-sdk-produce.yml) + before each `gulp vscode---min-ci`, and the + `packageTask` `jsonEditor` stamp in + [`gulpfile.vscode.ts`](../../../../../../build/gulpfile.vscode.ts#L308). + +**Out of scope** +- Per-tarball sha256 in `product.json` — replaced by `product.checksums` + (covers the shipped `product.json`) + HTTPS to a Microsoft-controlled + CDN as the trust chain. +- REH-web `agentSdks` stamping — the agent host is node-only; the + browser-served server has no consumer. +- A separate `AgentSDK` pipeline stage / `aggregate.ts` — rejected in + favour of an inline `readAgentSdkResults()` call inside the existing + `jsonEditor` callback, keeping `packageTask` a sync stream-returning + function. +- A standalone `verify-determinism.ts` CI gate — the determinism comes + from `npm ci` against the committed lockfile + reproducible + node-tar/gzip flags in `package.ts`; no separate enforcement script + shipped. + +## What shipped + +### Runtime config shape + +`product.agentSdks` is an optional map keyed by package id +([`product.ts`](../../../../base/common/product.ts#L81)): + +```typescript +interface IAgentSdkProductConfig { + readonly version: string; + readonly urlTemplate: string; // format2() template, e.g. + // https://main.vscode-cdn.net/agent-sdk/claude/0.3.169/{sdkTarget}.tgz +} +``` + +`urlTemplate` carries a single recognised placeholder, `{sdkTarget}`. The +same template ships on every platform; the runtime substitutes the +placeholder per launch. `vscode-distro` and OSS `product.json` both omit +`agentSdks` — the build IS the distribution, so only built products carry +it. + +### `resolveSdkTarget` — per-launch target resolution + +[`resolveSdkTarget(pkg, host?)`](../agentSdkDownloader.ts) maps the +running host `(platform, arch, libc)` to the build's tarball suffix: + +- Claude on glibc Linux → `linux-x64` / `linux-arm64` +- Claude on musl Linux → `linux-x64-musl` / `linux-arm64-musl` +- Codex Linux (any libc) → `linux-x64` / `linux-arm64` (its Linux binary + is statically musl-linked, so one SKU runs on both) +- everywhere else → `-` +- unsupported (`armhf`, web, …) → `undefined` → provider not registered + +The only per-SDK knob is `IAgentSdkPackage.hasSeparateMuslLinuxPackage` +(Claude: `true`, Codex: `false`). This runtime function is the deliberate +mirror of the build-time `getSdkTargetForBuild` in +[`build/agent-sdk/common.ts`](../../../../../../build/agent-sdk/common.ts) — +the two tables must stay in lockstep when a new SKU is added. + +### `AgentSdkDownloader` — resolution order + resilience + +[`loadSdkRoot(pkg, token)`](../agentSdkDownloader.ts) returns the absolute +SDK root (the dir containing `node_modules/`); callers resolve the +package-internal entrypoint themselves: + +1. **Dev override** — `process.env[pkg.devOverrideEnvVar]` returned + unchanged. +2. **Cache hit** — `/agent-host/sdk-cache////` + with a `.complete` sentinel. The `sdkTarget` segment keeps Universal + launches that resolve to different targets from thrashing one cache. +3. **Download** — fetch `format2(config.urlTemplate, { sdkTarget })`, + extract, write the sentinel. + +Resilience details that shipped: +- **Negative-cache latch** (`LOAD_FAILURE_NEGATIVE_CACHE_MS = 30s`, + keyed by `pkg.id`) so a broken CDN isn't hammered by poll-driven UIs. + Cancellations are not latched. +- **In-flight de-dup** keyed by `cacheDir` so concurrent `loadSdkRoot` + calls share one download; distinct targets get distinct entries. +- **Rename-winner** publish so concurrent first-launch downloads don't + corrupt the cache; the loser returns the winner's published dir. + +`isAvailable(pkg)` is the cheap synchronous startup gate: `true` iff the +dev override is set, OR (`product.agentSdks?.[pkg.id]` populated AND +`resolveSdkTarget(pkg)` resolves). Used to decide whether to register the +provider at all — no download triggered. + +### `_loadSdk` three-tier resolution + +[`claudeAgentSdkService.ts:186`](./claudeAgentSdkService.ts#L186): + +1. `AgentHostClaudeSdkRootEnvVar` override → import + `/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`. +2. `this._downloader.isAvailable(ClaudeSdkPackage)` → download root, + import the same entrypoint off it. Errors propagate as-is so a CDN + outage / corrupt cache surfaces actionable diagnostics, not a + misleading "cannot find module". +3. Dev bare-import `@anthropic-ai/claude-agent-sdk` (resolves via this + repo's `node_modules` devDependency) — reached only in dev launches. + +Codex mirrors this in `CodexAgent._startConnection`, resolving +`@openai/codex/bin/codex.js` off the root. + +### Build pipeline (`build/agent-sdk/`) + +- **`agents//{package.json, package-lock.json}`** — one folder per + SDK; the folder set IS the SDK list (no parallel array). Each + `package.json` declares exactly one dependency at an exact version + (no `^`/`~` — ranges would break the content-addressed CDN upload). + Today: Claude `0.3.169`, Codex `0.135.0`. +- **`package.ts` `buildOne`** — runs on any OS: `npm ci` with + `npm_config_libc/os/cpu` fetches the foreign-platform binary verbatim + from the locked graph, then reproducible node-tar+gzip. +- **`upload.ts` `uploadOne`** — HEAD-then-decide: absent → upload; + matching sha → skip (idempotent re-runs); drifted sha → fail loud + rather than overwrite content-addressed history. +- **`produce.ts`** — per-`(vscode-platform, arch)` entry. Iterates SDKs + in parallel, builds + (conditionally) uploads, writes the results JSON, + emits `##vso[task.setvariable variable=AGENT_SDK_RESULTS_FILE]`. + Behaviour splits on `VSCODE_PUBLISH`: `true` → build+upload+stamp; + unset → build-only (tarballs published as inspection artifacts, + `product.json` ships without `agentSdks`, runtime falls back to the + dev-override path — same UX as a local `gulp` build). +- **`gulpfile.vscode.ts` `packageTask`** — the existing `jsonEditor` + callback (the one that injects `commit`/`date`/`checksums`/`version`) + calls `readAgentSdkResults()` and, when non-empty, merges + `json.agentSdks`. REH writes it only for `type === 'reh'`. + +## Trade-offs accepted + +- **`urlTemplate` + `{sdkTarget}` over per-platform `{url, sha256}`.** + The original design stamped a concrete URL + hash per platform. That + breaks macOS Universal, where one `product.json` serves two arches. + Moving target resolution to launch time (and dropping the per-tarball + sha for `product.checksums` + HTTPS) is what makes Universal work with + a single shipped config. +- **No per-tarball sha256 verification at runtime.** Trust rests on the + signed app bundle covering `product.json` via `product.checksums`, plus + HTTPS to a Microsoft-controlled CDN. Accepted as equivalent to how the + rest of `product.json`'s URLs are trusted. +- **Inline gulp stamping over a dedicated pipeline stage.** Keeps + `packageTask` synchronous and avoids an `aggregate.ts`; the cost is + that the `produce.ts` ↔ `getSdkTargetForBuild` ↔ runtime + `resolveSdkTarget` triple must be kept in sync by convention. +- **Dev override retained indefinitely.** `chat.agentHost.claudeAgent.path` + → `AgentHostClaudeSdkRootEnvVar` (and the Codex equivalent) survive as + the SDK-development bypass; they are no longer the primary distribution + path. + +## Tests + +- [`agentSdkDownloader.test.ts`](../../test/node/agentSdkDownloader.test.ts) + — 16 tests: `resolveSdkTarget` table (platform/arch/musl/unsupported), + `isAvailable` gating, `loadSdkRoot` dev-override / template-substitution + / cache-miss-download / cache-hit / Universal-target-separation / + missing-config error / bad-placeholder error / cancellation cleanup / + concurrent-share / rename-loser. +- Build-side determinism is enforced structurally (committed lockfile + + reproducible tar flags); no separate determinism test script shipped. + +## Exit criteria (met) + +- Fresh Insiders install can use Claude/Codex without installing the SDK + or setting any path. +- SDK version bumps are a build-pipeline change: edit the exact version in + `build/agent-sdk/agents//package.json`, refresh the lockfile + (`npm install --package-lock-only --ignore-scripts`), commit both. The + per-platform `produce.ts` step republishes the tarballs and + `packageTask` re-stamps `product.agentSdks[pkg]`. +- Dev override keeps working for SDK development. + +## Bumping an SDK version (operational note) + +1. Edit `dependencies` version in + `build/agent-sdk/agents//package.json` to the new exact version. +2. From that directory: + `npm install --package-lock-only --ignore-scripts` to refresh + `package-lock.json`. +3. Commit both files. The next publish build produces + uploads the new + tarballs and stamps the new version into `product.json`. diff --git a/src/vs/platform/agentHost/node/claude/phase16-plan.md b/src/vs/platform/agentHost/node/claude/phase16-plan.md new file mode 100644 index 0000000000000..a0211c6456fca --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase16-plan.md @@ -0,0 +1,275 @@ +# Phase 16 — Editable Customization Resolution via Disk Scan + +> Generated by super-planner. Source: [roadmap.md](./roadmap.md) (phase 16, redesigned 2026-06-17). +> Council: GPT-5.5, Claude Opus 4.6, GPT-5.3-Codex (3-model consensus). +> Supersedes the original "eager session materialization at create time" plan, which was retired — see roadmap Phase 16. + +**Status:** implemented + +## Goal + +Resolve a Claude session's customizations (agents, skills, slash commands, MCP servers) by **scanning the file system for the real customization files** and shipping each item's **real editable `uri`** + parsed name/description — instead of trusting the SDK query APIs (which return only name+description, no path, no content). The live SDK list (`supportedCommands` / `supportedAgents` / `mcpServerStatus`) becomes a **post-materialize filter**, not the data source. Result: opening a discovered customization opens the user's real `~/.claude/agents/foo.md`, not a generated stub. + +## Scope + +**In scope** +- A Claude customization **disk-scan resolver** (Claude-specific module reusing the shared parsers in [pluginParsers.ts](../../../agentPlugins/common/pluginParsers.ts)), covering Claude's roots (scoped by session `cwd` + user home, honoring `settingSources: ['user','project','local']`): + - agents — `/.claude/agents/**`, `~/.claude/agents/**` + - skills — `/.claude/skills/**`, `~/.claude/skills/**` + - slash commands — `/.claude/commands/**`, `~/.claude/commands/**` (flat `.md`) + - rules (memory / instructions) — CLAUDE.md files (`~/.claude/CLAUDE.md`, `/CLAUDE.md`, `/.claude/CLAUDE.md`, `/CLAUDE.local.md`) + `.claude/rules/**/*.md` (project + user, recursive). CLAUDE.md → always-on `Rule`; `.claude/rules` files are path-scoped when a `paths` frontmatter glob is present (`alwaysApply` derived as `!globs`). + - MCP servers — `~/.claude/settings.json`, `/.claude/settings.json`, `/.mcp.json` +- Each resolved item carries a real `file:` `uri` + parsed name/description, projected as `DirectoryCustomization` containers with child customizations (agents/skills/rules) and top-level `McpServerCustomization` entries. Rules have no SDK counterpart, so they bypass the post-materialize filter (always kept). +- `getSessionCustomizations` two-mode behavior: + - **provisional (no pipeline):** client-pushed ∪ full disk scan (no filter). + - **materialized (live pipeline):** client-pushed ∪ (disk scan ∩ SDK-known set, name-matched per type). +- **Delete the synthetic-stub `ClaudeSdkCustomizationBundler` entirely** (Decision D2). The SDK-only non-editable fallback (items the live SDK reports that the disk scan couldn't locate) is generated **declaratively inline** in `buildDiscoveredCustomizations` via `claude-internal:` URIs — no stub files are ever written to disk. +- **Read-only "Built-in" surfacing** (beyond the original plan): curated built-in slash commands (13) and built-in agents (5) are surfaced as non-editable entries on the `agent-builtin:` scheme pre-materialize, then replaced by the SDK's real `supportedCommands()` / `supportedAgents()` set post-materialize. Lives in `node/claude/customizations/claudeBuiltinCommands.ts`; each description is prefixed `(Built-In) `. +- Watcher-backed refresh so the customization list updates live on disk changes (fires `onDidCustomizationsChange`). + +**Out of scope** +- **Any lifecycle change.** `createSession` stays provisional + cold; no eager/warm materialization, no `_sessionSequencer` change, no `onDidMaterializeSession` rework, no JSONL-write-timing work. (The retired original Phase 16 owned those — explicitly dropped.) +- **CopilotAgent** behavior — unchanged. If a shared module is factored out, Copilot keeps identical behavior behind its own config. +- **Shipping file content bytes** through the protocol — like CopilotAgent, we ship the editable `uri` only; the client reads content on open. +- **Client-pushed customization sync** (`setClientCustomizations` / `IAgentPluginManager`) — unchanged; only the *discovered* tier changes. + +## Prerequisites + +- Phase 11 (customizations surface, projector, `ISdkResolvedCustomizations` snapshot) ✅. +- `pluginParsers.ts` exports `parseAgentFile` / `parseSkillFile` / `readSkills` / `readMarkdownComponents` / `parseMcpServerDefinitionMap` (verified present). +- No external dependencies. + +## Approach + +Build a Claude-specific disk resolver that reuses the shared frontmatter/MCP parsers (`pluginParsers.ts`) — the genuinely reusable layer — and encodes Claude's own roots (`~/.claude/**`, `.claude/commands`, `settings.json`/`.mcp.json` MCP), rather than forcing the Copilot-convention-coded `SessionCustomizationDiscovery` onto Claude. `getSessionCustomizations` returns `client-pushed ∪ disk-resolved`, where the disk-resolved set is unfiltered while provisional and intersected with the live SDK snapshot (by name, per type) once a `ClaudeSdkPipeline` exists. The synthetic-stub bundler is retired (real URIs replace stub URIs). + +## Steps + +1. ✓ **Claude disk-scan resolver.** New module under `node/claude/customizations/` that walks Claude's roots (scoped by `cwd` + user home) and parses each file via `pluginParsers.ts` into discovered entries carrying the real `file:` URI + parsed name/description + source-kind (agent/skill/command). Reuse `readSkills` / `readMarkdownComponents` / `parseAgentFile` / `parseSkillFile`; commands use **flat `.md`** discovery (not `/SKILL.md`). + - Files: create `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` + - Depends on: none + - Done when: a unit test points the resolver at a temp tree (`~/.claude/agents/a.md`, `.claude/skills/s/SKILL.md`, `.claude/commands/c.md`) and gets entries with the correct real URIs + names. + +2. ✓ **Claude MCP-from-JSON scanner.** Read `~/.claude/settings.json`, `/.claude/settings.json`, `/.mcp.json`; call `parseMcpServerDefinitionMap` ([pluginParsers.ts](../../../agentPlugins/common/pluginParsers.ts)); emit `McpServerCustomization[]` with the real source-file URI (+ `range` for the entry span where feasible). Pre-materialize entries carry config but no live connection status. + - Files: `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` (or a sibling `claudeMcpDiscovery.ts`) + - Depends on: none (parallel with step 1) + - Done when: a unit test parses a `settings.json` with `mcpServers` and yields entries with real URIs. + +3. ✓ **Discovered → protocol mapper.** Convert discovered entries into `Customization` objects: `DirectoryCustomization` containers (agents/skills/commands) with child `uri` = real source file, plus top-level `McpServerCustomization`. Mirror CopilotAgent's `toDiscoveredChildCustomization` shape ([copilotAgent.ts:2258-2310](../copilot/copilotAgent.ts#L2258)). + - Files: `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` (mapper section) + - Depends on: steps 1, 2 + - Done when: mapper output validates against the `Customization` union ([state.ts](../../common/state/protocol/channels-session/state.ts)); child URIs are real files. + +4. ✓ **Rework `projectSessionCustomizations` / add `buildDiscoveredCustomizations`.** Pre-materialize (no SDK) → `synced ∪ discovered`. Post-materialize (SDK snapshot) → `synced ∪ (discovered ∩ SDK-known by (name,type)) ∪ sdkOnlyFallback`, where `sdkOnlyFallback` is the SDK-known names with no matching disk entry, rendered **non-editable** via a `claude-internal:` URI (Decision D2). + - Files: [claudeSessionCustomizationsProjector.ts](./customizations/claudeSessionCustomizationsProjector.ts) + - Depends on: step 3 + - Done when: unit tests cover both modes incl. the per-type name match (commands+skills filtered by the SDK command-name set; agents by agent set; MCP by server set) and the non-editable SDK-only fallback entries. + +5. ✓ **Wire resolver into `ClaudeAgentSession.getSessionCustomizations`.** Replaced `_sdkBundler`: scan disk (project + user) via `scanClaudeDiskCustomizations` + `scanClaudeMcpServers`, build via `buildDiscoveredCustomizations` (SDK snapshot used as filter post-materialize), project via `projectSessionCustomizations`. Session now requires `@IFileService` + `@INativeEnvironmentService`. + - Files: [claudeAgentSession.ts](./claudeAgentSession.ts), [claudeAgent.ts](./claudeAgent.ts) (construct/own the resolver per session) + - Depends on: steps 3, 4 + - Done when: `getSessionCustomizations` on a provisional session returns real-URI disk items; on a materialized session it filters by the SDK set. + +6. ✓ **Watcher-backed refresh.** `ClaudeCustomizationWatcher` (in the discovery module) watches `/.claude` + `/.claude` (recursive) and `` (non-recursive, trigger narrowed to `.mcp.json`) via `fileService.watch` + `onDidFilesChange`, debounced (`RunOnceScheduler`, 300ms; injectable for tests). Constructed in the session ctor (covers the provisional window) and registered as a session disposable; its `onDidChange` fires `_onDidCustomizationsChange`. + +7. ✓ **Retire the stub bundler + fix `_resolveAgentName`.** The non-editable SDK-only fallback now lives in `buildDiscoveredCustomizations` (Step 4), so `ClaudeSdkCustomizationBundler` (the synthetic-tree writer) was fully **dead** — deleted along with its test; `CLAUDE_SDK_DEFAULT_AGENT_NAME` relocated into the discovery module (its only consumer). `_resolveAgentName` is now async: for a real `file:` agent it parses the frontmatter `name` via `parseAgentFile` (the SDK keys agents by frontmatter name, not filename; falls back to the basename on read failure); for a `claude-internal:` URI it decodes the name from the path. Both materialize + resume-rebuild call sites updated to await it. + +8. **Tests.** New resolver/mapper/projector tests; update `FakeQuery` (which throws for `supportedAgents()`/`mcpServerStatus()` — [claudeAgent.test.ts:~459](../../test/node/claudeAgent.test.ts#L459)) to model SDK snapshots; update/replace the existing bundler tests; add the pre- vs post-materialize projection tests. + - Files: create `test/node/claudeSessionCustomizationDiscovery.test.ts`; modify [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts); delete/replace `test/node/customizations/claudeSdkCustomizationBundler.test.ts` + - Depends on: steps 1-7 + - Done when: `node` agentHost suite green; new resolver + projector tests pass. + +## Files to Modify or Create + +> Reflects the **final shipped** file set. The numbered Steps above are the +> original plan; the structure consolidated during implementation (the +> projector was inlined, the disk scan split into a `scan/` subfolder, and a +> built-ins module was added). See Implementation Notes for the full deviation +> log. + +| Path | Change | Notes | +|------|--------|-------| +| `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` | create | Discovered→protocol mapper (`mapDiscoveredCustomizations`), pre/post-materialize logic (`buildDiscoveredCustomizations`), `resolveClaudeAgentName`, `ClaudeCustomizationWatcher`, `CLAUDE_SDK_DEFAULT_AGENT_NAME`, `nonEditableUri`/`scopeOf` | +| `node/claude/customizations/scan/claudeAgentSkillScan.ts` | create | Agents + skills (+ commands folded into skills) disk scan, reusing `pluginParsers.ts` | +| `node/claude/customizations/scan/claudeMcpScan.ts` | create | MCP-from-JSON scan (`settings.json` `mcpServers` block + flat `.mcp.json`) | +| `node/claude/customizations/scan/claudeRuleScan.ts` | create | Rules scan (CLAUDE.md + `.claude/rules/**`); exports `claudeMemoryFiles` | +| `node/claude/customizations/claudeBuiltinCommands.ts` | create | Read-only "Built-in" surfacing: `CLAUDE_BUILTIN_COMMANDS` (13), `CLAUDE_BUILTIN_AGENTS` (5), `buildClaudeBuiltinSkillsContainer` / `buildSdkBuiltinSkillsContainer`; `AGENT_BUILTIN_SCHEME` inlined here | +| [claudeAgentSession.ts](./claudeAgentSession.ts) | modify | Two-mode `getSessionCustomizations` (scan disk + optional SDK filter + projection inlined — no separate projector); requires `@IFileService` + `@INativeEnvironmentService`; wires the watcher; calls `resolveClaudeAgentName` | +| `test/node/customizations/claudeSessionCustomizationDiscovery.test.ts` | create | Resolver + mapper + SDK-filter tests | +| `test/node/customizations/claudeBuiltinCommands.test.ts` | create | Built-in container construction (curated + SDK-derived) | +| [test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | modify | Disk-scan/built-in assertions; `FakeQuery` snapshot modeling | +| ~~`customizations/claudeSdkCustomizationBundler.ts`~~ | delete | Synthetic-stub bundler **deleted** (not reduced); fallback is now declarative inline in `buildDiscoveredCustomizations` | +| ~~`test/node/customizations/claudeSdkCustomizationBundler.test.ts`~~ | delete | Deleted with the bundler | + +## Decisions + +- **D1 — Claude-specific resolver reusing `pluginParsers.ts` (grill-confirmed).** Reuse `pluginParsers.ts` (the genuinely provider-neutral layer); write Claude's own root-walking + watcher under `node/claude/customizations/`. Do **not** reuse or factor `node/copilot/sessionCustomizationDiscovery.ts` — it's Copilot-convention-coded and couples the providers. Least coupling, no layering risk. +- **D2 — SDK-known-but-not-on-disk → show as a non-editable entry (grill-confirmed).** Items the live SDK reports but the disk scan can't locate (built-ins, plugin-provided, unscanned dirs) are surfaced with name+description and a synthetic non-`file:` URI so the workbench renders them **read-only** — keeping the active capability list complete while only locatable items are editable. As shipped, the stub bundler was **deleted entirely** (not merely reduced): the fallback is generated declaratively inline in `buildDiscoveredCustomizations` via `claude-internal:` / `agent-builtin:` URIs, so no stub files are written to disk. (Council majority leaned *drop*; user chose *show* to keep the list honest.) +- **D2b — Read-only built-in agents + skills (post-plan addition).** Beyond the SDK-only fallback, a **curated** set of built-in slash commands (`CLAUDE_BUILTIN_COMMANDS`, 13) and built-in agents (`CLAUDE_BUILTIN_AGENTS`, 5) is surfaced read-only **pre-materialize** for discoverability (the SDK can't report its command/agent set before a live session exists). Post-materialize, the live `supportedCommands()` / `supportedAgents()` set supersedes the curated list. Lives in `claudeBuiltinCommands.ts` on the `agent-builtin:` scheme; each description is literally prefixed `(Built-In) `. These entries are display-only — `contrib/chat` is deliberately untouched, so clicking a built-in does not render content (accepted limitation; a read-only editor view is a future request). +- **D3 — Commands ARE skills (no separate category).** Per the customizations spec (`src/vs/sessions/copilot-customizations-spec.md` §3), slash commands in `.claude/commands/*.md` are *a variant of skills, treated internally as skills*, with skills winning same-name conflicts. The resolver therefore scans `.claude/commands/` but maps the results to `ClaudeCustomizationKind.Skill` (no `Command` kind, no separate commands container) and collects skills before commands so skills win. The SDK's `supportedCommands()` set (which represents loaded skills) filters disk skills. Commands are scanned as **flat `.md`**. +- **D4 — Watcher-backed refresh.** Reuse the correlated-watcher + debounce pattern; scan + watch, fire `onDidCustomizationsChange` on change. Unanimous council. +- **D5 — Lifecycle untouched.** `createSession` stays provisional/cold; the disk scan needs no warm SDK. No changes to materialize / sequencer / `onDidMaterializeSession` / `provisional`. +- **D6 — MCP status asymmetry.** Pre-materialize MCP entries carry declared config from `settings.json` but **no live connection status**; post-materialize, status is enriched/filtered from `mcpServerStatus()` by name. + +## Risks + +- **`_resolveAgentName` depends on the bundler's synthetic URI.** Switching to real URIs can break agent selection if the basename→SDK-agent-name extraction assumed the stub path. *Mitigation:* step 7 audits and rebinds it to the discovered entry; test agent-select end-to-end. ([claudeAgentSession.ts:~577](./claudeAgentSession.ts#L577)) +- **Name-match false positives in the post-materialize filter.** SDK names matched against disk entries by name alone could collide across kinds or aliases (`SlashCommand.aliases`). *Mitigation:* match by `(name, type)` case-insensitively; keep source-kind on each entry (D3). +- **Layering (`node/claude` → shared/copilot).** If any factoring reaches into `node/copilot`, `valid-layers-check` may complain. *Mitigation:* keep the resolver Claude-local (D1); run `npm run valid-layers-check`. +- **`FakeQuery` throws for `supportedAgents`/`mcpServerStatus`.** Live-filter tests need modeled snapshots. *Mitigation:* step 8 updates the fake. ([claudeAgent.test.ts:~459](../../test/node/claudeAgent.test.ts#L459)) +- **Watcher leaks / event storms.** Unbounded re-scan on rapid disk changes. *Mitigation:* debounce + cancelable scan (the Copilot `SessionDiscoveredEntry` pattern); register watcher as a session disposable. +- **`settings.json` MCP format drift vs `.mcp.json`.** `parseMcpServerDefinitionMap` normalizes `{mcpServers:…}` and flat maps; confirm Claude's `settings.json` MCP block matches. *Mitigation:* defensive parse + fall through on shape mismatch. + +## Verification + +### Unit / Integration +- Unit (runTests tool, `node` agentHost suite): resolver yields real URIs for agents/skills/commands/MCP from a temp tree; `projectSessionCustomizations` pre-mode returns the full set, post-mode intersects by `(name,type)`; MCP parsed from `settings.json`; commands scanned as flat `.md`. +- Integration: `getSessionCustomizations` on a provisional session → disk items with real URIs, no SDK filter; after materialize → on-disk items the live session didn't load are hidden; client-pushed tier unchanged. +- Type/layer/hygiene: `npm run typecheck-client`, `npm run valid-layers-check`, gulp `hygiene` clean. + +### E2E +Workspace skills (project scope): `launch` (drive Code OSS Agents window), `code-oss-logs` (read `agenthost.log`). +- **Scenario:** + 1. Put a real customization on disk (e.g. `~/.claude/agents/explore.md` with frontmatter + a body). + 2. `launch` the Agents window, authenticate, pick **Claude**, create a session, and **without sending a message** open the customization list. + 3. Confirm `explore` appears and **opening it opens the real `~/.claude/agents/explore.md`** (with its body) — not an empty stub. + 4. Send a message (materialize). Add a deliberately-broken `~/.claude/agents/broken.md` the SDK won't load; confirm it is **hidden** post-materialize while `explore` remains. + 5. Edit `explore.md` on disk; confirm the list refreshes (watcher). + 6. `code-oss-logs`: confirm no `[Claude]` customization warnings and the scan ran without the stub bundler. + +### Manual +- Confirm an MCP server declared in `~/.claude/settings.json` appears (config) pre-materialize and shows live status post-materialize. + +## Open Questions + +_None — both resolved during grill (2026-06-17): D1 (Claude-specific resolver reusing `pluginParsers.ts`) and D2 (SDK-known-but-not-on-disk shown as non-editable)._ + +## References + +- Roadmap: [roadmap.md](./roadmap.md) (Phase 16, redesigned) +- Phase 11 customizations: [phase11-plan.md](./phase11-plan.md) +- CopilotAgent disk-scan reference: [sessionCustomizationDiscovery.ts](../copilot/sessionCustomizationDiscovery.ts), [copilotAgent.ts](../copilot/copilotAgent.ts) (`toDiscoveredChildCustomization` ~L2258) +- Shared parsers: [pluginParsers.ts](../../../agentPlugins/common/pluginParsers.ts) +- Protocol type: [state.ts](../../common/state/protocol/channels-session/state.ts) (`Customization`, `DirectoryCustomization`, `McpServerCustomization`) +- E2E skills used: `launch`, `code-oss-logs` + +## Implementation Notes + +_Status: in progress — Steps 1–7 of 8 complete (resolver + SDK filter + wiring + watcher + bundler retired + agent-name fix)._ +### Files actually changed +- **created** `node/claude/customizations/claudeSessionCustomizationDiscovery.ts` — the resolver module: + - Step 1: `scanClaudeDiskCustomizations(workingDirectory, userHome, fileService)` + `ClaudeCustomizationKind` + `IClaudeDiscoveredCustomization`. Reuses `readAgentComponents` (agents + commands) / `readSkills` (skills). + - Step 2: `scanClaudeMcpServers(workingDirectory, userHome, fileService)` — reads `settings.json` (explicit `mcpServers` block only) + `.mcp.json` (flat map allowed) via `readJsonFile`, builds `McpServerCustomization[]` with real URIs + `state: Starting`. + - Step 3: `mapDiscoveredCustomizations(discovered, mcpServers, userHome)` — wraps agents/skills in `DirectoryCustomization` containers (skills + commands together), real-file children, top-level MCP. +- **created** `test/node/claudeSessionCustomizationDiscovery.test.ts` — 3 tests. + +### Tests written +- *scans Claude agent / skill / command roots with real URIs and parsed names* — Step 1. **Green.** +- *scans MCP servers from settings.json with real URIs, ignoring unrelated settings keys* — Step 2 (regression-guards the settings.json non-`mcpServers` keys). **Green.** +- *maps discovered entries into Directory containers with real child URIs + top-level MCP* — Step 3. **Green.** +- *project scope shadows user scope on name clash* — precedence regression (council review). **Green.** +- *scans a flat .mcp.json server map* — council review gap. **Green.** +- *settings.json without an mcpServers block yields no servers* — council review gap. **Green.** +- *ignores array-valued MCP entries* — council review gap. **Green.** +- runTests: 7 passed. + +### Council review (Steps 1–3, 2 models: GPT-5.5 + Opus 4.6) +Must-fixes found and **resolved**: +- 🔴 `readSkills` precedence was nondeterministic (parallel `Promise.all` over dirs with a shared `seen` set → user could shadow project). **Fixed:** scan each scope independently, merge project-first via a per-kind name map. Covered by the precedence test. +- 🔴 `readSkills(userHome, …)` could match a spurious `/SKILL.md` via the no-skills fallback. **Fixed:** pass the real `/.claude/skills` dir as `pluginRoot`. +- 🔴 commands were folded into a `skills` container whose URI misrepresented their source. **Fixed:** commands now get their own `DirectoryCustomization` (`contents: Skill`, `uri: .claude/commands`). +- 🟡 array-valued MCP configs passed the object guard. **Fixed:** added `Array.isArray(config)` exclusion. Covered by test. +- 🟡 test gaps (precedence, flat `.mcp.json`, settings-without-`mcpServers`, array config) — **all added.** + +### Deviations from the plan +- Step 1 resolver is a free function (not a class) — matches `pluginParsers.ts` style; the watcher (Step 6) will wrap it. +- Agents + commands use `readAgentComponents` (= `readMarkdownComponents` + frontmatter enrichment), not the bare `readMarkdownComponents` the plan named — we want descriptions. +- **Step 2 does NOT use `parseMcpServerDefinitionMap`** (the plan named it). That function requires an `IPluginFormatConfig` + does env-var interpolation we don't need. `scanClaudeMcpServers` reads the JSON directly with a deliberate guard so `settings.json`'s unrelated top-level keys are not mistaken for servers (only the explicit `mcpServers` block counts), but builds each entry with the now-**exported** shared `makeMcpServerCustomization` (so Claude MCP entries carry `DEFAULT_MCP_APP` + a consistent id, fixing an earlier gap where the hand-built entries omitted `mcpApp`). + +### pluginParsers reuse (post-review dedup) +`IClaudeDiscoveredCustomization` was retired entirely: the scan now returns the shared `IParsedAgent | IParsedSkill` (resource + built child customization), the agent/skill `kind` discriminator becomes `customization.type`, and the mapper/fallbacks push `entry.customization` instead of hand-building `{ type, id, uri, name, description }` literals. Three `pluginParsers` builders were promoted from private to **exported** (`toParsedAgent`, `toParsedSkill`, `makeMcpServerCustomization`) with new unit tests in `pluginParsers.test.ts`. The MCP reuse also closed the missing-`mcpApp` inconsistency. +- **Step 3 emits one container per kind** (an Agents `DirectoryCustomization` + a Skills one + a Commands one) rather than one-per-source-directory like CopilotAgent. The protocol `Customization` union has no bare agent/skill member, so wrapping is required; per-kind containers keep each container `uri` honest (`.claude/{agents,skills,commands}`) and the **child URIs remain the real files** (the load-bearing requirement). Container `uri` = user-home `.claude/` (`writable: true`). + +### Steps 4–5 (SDK filter + session wiring) +- **Step 4** lives in `claudeSessionCustomizationDiscovery.ts` as `buildDiscoveredCustomizations(discovered, mcpServers, userHome, sdk?)` rather than inside the projector: `sdk` undefined → full mapped set; `sdk` present → disk filtered by `(name, type)` against the SDK agent/command sets, SDK-known-not-on-disk added as **non-editable** entries via `nonEditableUri('agent'|'skill'|'mcp', name)` (`claude-internal:` scheme), `CLAUDE_SDK_DEFAULT_AGENT_NAME` ('general-purpose') hidden, MCP state enriched via `deriveMcpState(status)`. `projectSessionCustomizations` was widened to take `discovered: readonly Customization[]` (was a single optional) and just spreads them. +- **Step 5**: `ClaudeAgentSession` constructor gained required `@IFileService` + `@INativeEnvironmentService` (VS Code DI has **no `optional` decorator** — confirmed; an `@optional` attempt was reverted). `getSessionCustomizations` now scans disk (project + user) in parallel, pulls the SDK snapshot when a pipeline exists (swallowing failures → unfiltered fallback), builds, and projects. `_sdkBundler` field + its construction removed. +- **Test harness**: added a `claudeFileEnvServices(disposables)` helper (in-memory `FileService` on `Schemas.file` + mock `INativeEnvironmentService` with `userHome = /mock-home`) spread into the `createTestContext` and `buildCtxWith` `ServiceCollection`s. Nothing is seeded under the mock home, so the disk scan is deterministically empty → existing projection tests still assert `length === 1` (client-pushed only). + +### Tests (Steps 4–5) +- `claudeSessionCustomizationDiscovery.test.ts`: now **8 green** (added post-materialize SDK-filter test). +- `claudeAgent.test.ts`: **124 green** after wiring the two services into the harness (was 122 + 2 failing on `userHome` of undefined). + +### Step 6 (watcher) +- `ClaudeCustomizationWatcher extends Disposable` added to the discovery module: recursive watch on both `.claude` roots + a non-recursive `` watch whose trigger is narrowed to `/.mcp.json` (so unrelated workspace-root edits don't re-scan). `affects()` matches descendants, so any change under a `.claude` root triggers; debounce via `RunOnceScheduler` (300ms default, last constructor arg overridable → tests use 5ms). Failed `fileService.watch` calls are warn-logged, not fatal. +- Wired in the session ctor (`new ClaudeCustomizationWatcher(this.workingDirectory, userHome, this._fileService, this._logService)`), both the watcher and its `onDidChange` subscription `_register`ed. +- **DI note:** VS Code's `InstantiationService` injects `undefined` for an unregistered service rather than throwing — so adding the watcher (which reads `userHome` at construction) surfaced 4 more session-constructing test `ServiceCollection`s that lacked the env service (previously only the 2 `getSessionCustomizations` tests failed). Wired `claudeFileEnvServices` into those 4 blocks (2007 / 3122 / 3173 / 3530). +- Test: *watcher fires once (debounced) for changes under watched roots and ignores unrelated edits* — **green** (9 total in the discovery suite). `claudeAgent.test.ts` back to **124 green**. + +### Step 7 (retire bundler + agent-name resolution) +- **Deleted** `claudeSdkCustomizationBundler.ts` + `customizations/claudeSdkCustomizationBundler.test.ts` (the synthetic-tree writer is fully superseded by the D2 fallback in `buildDiscoveredCustomizations`). `CLAUDE_SDK_DEFAULT_AGENT_NAME` moved into `claudeSessionCustomizationDiscovery.ts`. Net: the implementation **retires** rather than "reduces" the bundler — cleaner, since the fallback is generated declaratively (no stub files written to disk). +- `_resolveAgentName` rewritten async: `claude-internal:` → decode name from path; `file:` → `parseAgentFile` frontmatter `name` (basename fallback on read error). Both buildOptions call sites (materialize + resume rebuild) now `await` it. +- Test: *materialize resolves the SDK agent name from the file frontmatter, not the filename* — seeds `~/.claude/agents/foo.md` with `name: my-real-agent`, asserts `capturedStartupOptions[0].agent === 'my-real-agent'`. `createTestContext` now exposes its in-memory `fileService` so tests can seed `.claude/**`. **125 + 9 = 134 green.** + +### Council review (Steps 4–7, 3 models: GPT-5.5 + Opus 4.6 + GPT-5.3-Codex) +Consensus + single-but-evidenced findings, all **resolved**: +- 🔴 **(all 3) stale projector test.** `customizations/claudeSessionCustomizationsProjector.test.ts` still called the old single-optional signature (`undefined` / a bare `Customization`) after Step 4 widened it to `readonly Customization[]` — compile error + would crash on `push(...undefined)`. **Fixed:** updated all four call sites to `[]` / `[entry]`. +- 🟡 **(Codex) default agent not hidden when on disk.** `CLAUDE_SDK_DEFAULT_AGENT_NAME` was only suppressed in the SDK-only fallback loop, so a real `~/.claude/agents/general-purpose.md` could still surface post-materialize. **Fixed:** skip it in the disk-match agent loop too. +- 🟢 renamed `seenSkills` → `seenCommandNames` + added a D3 comment (the set tracks the SDK's single command-name space matched by both disk skills and commands). +- 🟡 *(SDK-only commands route to the skills container)* — acknowledged as inherent: `supportedCommands()` doesn't distinguish skill vs command, and these entries are non-editable anyway, so the container is cosmetic. Left as-is per D3. +- 🟡 *(no scan cache; rescans per fetch)* — accepted; the watcher gates refetch frequency and the sets are small. Not worth a cache layer now. + +### Type-safety catch (post-review) +`runTests` (esbuild) is transpile-only and the LSP/`Core - Typecheck` watch showed **stale** diagnostics, so an authoritative `npm run typecheck-client` was run — it surfaced **3 real type errors** that the green test run had masked: (1) `agent: { uri: agentFile }` needed `.toString()` (config wants a string URI); (2) `buildCtxWith` didn't return the new required `ITestContext.fileService` (inlined its file service like `createTestContext`); (3) the discovery test's `mcp` literal needed an explicit `McpServerCustomization[]` annotation. All fixed; `typecheck-client` now clean. **138 green** across the three suites. + +### Step 8 status +- Bundler tests: **deleted** (Step 7). Pre/post-materialize projection: covered by the discovery suite's SDK-filter test + the projector suite. Agent-name resolution: covered by the new materialize test. +- `FakeQuery` SDK-snapshot *success-path* modeling (live post-materialize filter end-to-end) is **deferred to the E2E** (launch skill) — the filter logic itself is exhaustively unit-tested via `buildDiscoveredCustomizations`, and the existing *swallows SDK snapshot failure* test covers the failure path. + +### E2E (launch skill — Agents window, real product) ✓ +Launched the Agents window from sources (`launch.sh --agents`) signed-in, drove it with `@playwright/cli`, with two real agents already on disk at `~/.claude/agents/{dead-code-detector,unit-test-writer}.md` (user scope). +- ✅ **Disk scan surfaces real files.** The composer's agent picker ("Pick Mode, Agent") lists both real agents; the header shows **"Agents 2" / "Skills 13"** counts from the scan (user + project scope). +- ✅ **Real names + descriptions parsed.** "Configure Custom Agents…" opens the **"Agent Customizations for Claude [Agent Host]"** dialog listing both agents with their **full real frontmatter descriptions** (the old stub bundler had none) — confirming `scanClaudeDiskCustomizations` parsed the actual files. +- ✅ **Editable real file opens (not a stub).** Clicking the `dead-code-detector` entry logged `customizationsDebug.log … [user] /Users/tyleonha/.claude/agents/dead-code-detector.md` and `renderer.log … [text file model] resolve() - enter file:///Users/tyleonha/.claude/agents/dead-code-detector.md` — i.e. the workbench opened the **real** `~/.claude/agents/dead-code-detector.md` for editing, the core requirement. (Old behaviour opened a synthetic `claude-discovered//…` stub.) +- ✅ **No regressions in logs.** `agenthost.log` shows no customization/scan/`_resolveAgentName` errors and no `claude-discovered` stub-bundler activity (only unrelated launcher noise: `vscode-userdata` provider + sticky-bit). +- Scenarios 2 (post-materialize filter) and 4 (live watcher) were left to the exhaustive unit coverage (`buildDiscoveredCustomizations` filter tests + the debounced watcher test) rather than a full SDK materialize run. +- Launch gotcha (recorded in repo memory): macOS `$TMPDIR` is too long for the IPC `.sock` (`listen EINVAL`) — relaunch with a short `TMPDIR=/tmp/...`. + +### Post-review correction — commands are skills (D3 revised) +The initial implementation modelled a separate "commands" category (`Command` kind + a `.claude/commands` container). The customizations spec (§3) is explicit: commands are *a variant of skills, treated internally as skills*. **Removed** the `Command` kind and the commands container; the resolver still scans `.claude/commands/*.md` but folds the results into skills (collected after skills so skills win same-name conflicts). Tests updated (commands-folded-into-skills + a skill-wins-over-same-named-command priority test); **139 green**. + +### E2E — skills (follow-up to the agents-only first pass) ✓ +Re-launched against the vscode repo (whose `.claude/skills` → `.agents/skills` symlink exposes the `launch` skill at workspace scope). The Claude session's **"Skills 13"** = 1 workspace (`launch`) + 12 user-scope (`~/.claude/skills/*`), each with its real parsed description. Clicking `launch` opened the **real workspace file** `renderer.log: [text file model] resolve() - enter file:///Users/…/vscode/.claude/skills/launch/SKILL.md` — confirming workspace-scope skills resolve through the symlinked layout and open for editing. + +### Per-scope containers (Workspace vs User) +Follow-up: `mapDiscoveredCustomizations` originally rooted every container at `userHome/.claude/`, so the agents-window editor grouped all entries under "User". Fixed by mirroring CopilotAgent's [toDiscoveredDirectoryCustomizations](../copilot/copilotAgent.ts#L2236): emit **one `DirectoryCustomization` per (scope, kind)** whose `uri` is the real `/.claude/` directory (`scopeOf` attributes each child via `isEqualOrParent(uri, workingDirectory)`; `claude-internal:` SDK-only entries fall to User). The workbench derives the "Workspace" / "User" label from the container `uri`. `buildDiscoveredCustomizations` / `getSessionCustomizations` now thread `workingDirectory`. E2E re-verified: the Skills section shows **"Workspace, 1 items"** (`launch`) separate from **"User, 12 items"**. + +### Post-plan: read-only built-in agents + skills, consolidation, and structure +After the 8 planned steps, the surface grew a curated built-in tier and the +files were consolidated. Net shipped structure differs from the Steps/old +table above (now corrected): +- **Built-ins (`claudeBuiltinCommands.ts`).** New module surfacing + `CLAUDE_BUILTIN_COMMANDS` (13 slash-command skills) and + `CLAUDE_BUILTIN_AGENTS` (5 agents) as **read-only** entries on the + `agent-builtin:` scheme. `buildClaudeBuiltinSkillsContainer` seeds the + curated set pre-materialize; `buildSdkBuiltinSkillsContainer` takes the live + SDK command set post-materialize (filtering out anything discovered on + disk). Each description literally starts with `(Built-In) ` — a localized + string prefix (no concatenation), chosen over a wrapper helper for + simplicity. `AGENT_BUILTIN_SCHEME = 'agent-builtin'` is inlined here (a + short-lived `common/agentBuiltinCustomizations.ts` was created then deleted). +- **Scan split.** Disk scanning moved into `customizations/scan/` + (`claudeAgentSkillScan.ts`, `claudeMcpScan.ts`, `claudeRuleScan.ts`); the + discovery module imports from there. +- **Projector inlined.** There is **no** `claudeSessionCustomizationsProjector.ts` + and **no** `projectSessionCustomizations` function — the projection (merge + client-pushed + discovered, apply the per-id enablement overlay to + client-pushed only) is inlined directly in + `ClaudeAgentSession.getSessionCustomizations`. +- **Agent-name resolution.** `resolveClaudeAgentName` lives in the discovery + module (not `claudeAgentSession.ts`); both materialize + resume-rebuild call + sites await it. +- **`contrib/chat` untouched.** Built-ins are display-only; clicking one does + not render content. Accepted limitation — a read-only editor view is a + future request. + +_Status: **implemented** — all 8 steps + the built-in tier complete; `typecheck-client` clean; ESLint clean; 3-model council review applied; E2E validated in the real Agents window._ diff --git a/src/vs/platform/agentHost/node/claude/phase17-plan.md b/src/vs/platform/agentHost/node/claude/phase17-plan.md new file mode 100644 index 0000000000000..6a36a33f9273e --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase17-plan.md @@ -0,0 +1,777 @@ +# Phase 17 — User/workspace hooks + Claude-native plugins via disk scan + +> Generated by super-planner. Source: `roadmap.md` (phase 17). +> Last updated: 2026-06-23 after council-plan + grill session. + +**Status:** **Phase 17 complete.** Part A (hooks) **shipped** as PR #322637 — unit-tested, +E2E-verified (launch + code-oss-logs), council-reviewed (one consensus dedupe finding fixed). +Part B (native plugins) **shipped** as PR #322766 — all steps implemented, unit + automated +end-to-end tested, council-reviewed, and **live-E2E verified** (launch + code-oss-logs): the real +`telegram@claude-plugins-official` plugin surfaces in the Agents-window customization modal +("Plugins 1", status *Loaded*, resolved to its real `~/.claude/plugins/cache/.../0.0.6/` root), +and a workspace-`settings.local.json` disable makes it disappear via the watcher and reappear on +restore. Typecheck + eslint clean. See `Implementation Notes → Part B`. + +> **Grill correction (load-bearing):** an earlier draft proposed *plumbing* +> enabled native plugins into `Options.plugins`. That is wrong — native +> `~/.claude` plugins are **auto-loaded by the SDK runtime via +> `settingSources`** (proof: `claudeSkills.ts` comment *"The Claude SDK loads +> `.claude` directories automatically — skip them to avoid duplicates"* and its +> `isClaudeDirectory` filter on `Options.plugins`). `Options.plugins` is +> exclusively for **client/host-provided** plugins *outside* `.claude` +> (Phase 11's `clientCustomizationsDiff`). Phase 17 is therefore **surface-only** +> for both hooks and native plugins. + +## Goal + +Surface the **hooks** and **Claude-native plugins** that a user or workspace has +configured (their `~/.claude` / workspace `.claude`) in a Claude session's +customization list, with real editable `file:` URIs. Closes the Phase-16 gap: +hooks and native plugins are invisible today even though the runtime already +fires/loads them. Mirrors Phase 16's provisional/materialize (M9) semantics and +its "real editable URI, no synthetic stub" rule. **No SDK loading changes** — +both tiers already run; this phase only makes them visible and editable. + +## Scope + +**In scope** + +- **Part A — Hooks (surface only).** Scan the `hooks` block of user/project/local + `settings.json` (+ `settings.local.json`) and surface each as an editable + customization. Hooks already *fire* (the SDK loads them via `settingSources`), + so this is discovery only. +- **Part B — Native plugins (surface only).** Resolve `enabledPlugins` from the + same settings scopes to on-disk plugin roots (marketplace cache + `@skills-dir`), + parse each via the shared plugin parser, and surface the plugin + its bundled + components with real URIs. The SDK already loads these via `settingSources`. +- Provisional = show the full disk set; materialized = hide native plugins the live + session did not load (filter against the SDK `system/init` plugin list). +- Watcher refresh of the customization list on settings/plugin edits. + +**Out of scope** + +- **Plumbing native `enabledPlugins` into `Options.plugins`.** They are auto-loaded + by the runtime via `settingSources`; passing them would **double-load** (proof: + `claudeSkills.ts` filters `.claude` dirs out of `Options.plugins`). `Options.plugins` + stays exclusively client-provided (Phase 11) — **untouched** by Phase 17. +- **Mutating the running plugin/hook set.** Making a mid-session `enabledPlugins` + edit take effect is the runtime's job on its next `settingSources` reload / restart; + Phase 17 only refreshes the *displayed list* via the watcher. No new dirty/rebind + machinery. +- `managed`-policy hooks/plugins — excluded, matching the existing + `settingSources: ['user','project','local']` non-goal (roadmap Non-goals). +- Skill-/agent-frontmatter hooks — they ride with their owning component, already + surfaced by Phase 16 (agents/skills disk scan). +- Client-pushed customizations / the in-process client-tool MCP server — owned by + Phase 10 / Phase 11. +- A `HookCustomization`-as-top-level protocol change — hooks stay child-only + (see Decisions). + +## Prerequisites + +- Phase 16 shipped: `scanClaudeMcpServers`, `scanClaudeDiskCustomizations`, + `buildDiscoveredCustomizations` / `mapDiscoveredCustomizations`, + `ClaudeCustomizationWatcher` exist under + `node/claude/customizations/`. +- Phase 11 shipped: `clientCustomizationsDiff` + `rebindForRestart()` restart path, + `Options.plugins` projected from `IBuildOptionsInput.plugins`. +- Pinned `@anthropic-ai/claude-agent-sdk`: `SDKSystemMessage` (subtype `init`) carries + `plugins: { name, path }[]` (`sdk.d.ts` ~L3943); `enabledPlugins` setting shape in + `sdk.d.ts` ~L4897. + +## Approach + +Add two Claude-specific disk scanners under `customizations/scan/` — +`claudeHookScan.ts` and `claudeNativePluginScan.ts` — that reuse the shared parser +layer in [pluginParsers.ts](../../agentPlugins/common/pluginParsers.ts) +(`parseHooksJson`, `parsePlugin`, `readJsonFile`). Wire their output into the +existing `getSessionCustomizations` → `mapDiscoveredCustomizations` / +`buildDiscoveredCustomizations` projection: hooks become per-scope +`DirectoryCustomization` containers (`contents: Hook`), native plugins become +`PluginCustomization` containers carrying their bundled components. Post-materialize, +capture the SDK `system/init.plugins` list in the pipeline and filter native plugins +by path (so a plugin the live session did not load is hidden). This is purely a +discovery/projection layer — `Options.plugins` and the SDK loading path are +**untouched**; the runtime already loads these via `settingSources`. + +## Steps + +1. ✓ **Export the minimal shared hook-parser surface.** `parseHooksJson` and + `makeHookCustomization` are currently **private** in `pluginParsers.ts` + (~L246, ~L651); only `readJsonFile` (~L762) and `parsePlugin` (~L1109) are exported. + Export the two hook helpers (and the `IParsedHookGroup` type if not already public) + so the new scanner can reuse Claude hook parsing (`disableAllHooks`, `HOOK_TYPE_MAP` + canonicalization, nested `{ matcher, hooks }` extraction) instead of reimplementing it. + - Files: [pluginParsers.ts](../../agentPlugins/common/pluginParsers.ts) + - Depends on: none + - Done when: `parseHooksJson` / `makeHookCustomization` importable from + `node/claude/`; `pluginParsers.test.ts` covers the now-public surface. + +2. ✓ **Hook scanner `scan/claudeHookScan.ts`.** Mirror `claudeMcpScan.ts`: read the + `hooks` block from `/.claude/settings.json`, + `/.claude/settings.local.json`, and `~/.claude/settings.json` (project-first + precedence) via `readJsonFile`; skip a scope whose `disableAllHooks === true`; call + `parseHooksJson(settingsUri, json, workspaceRoot, userHome)`. Return + `readonly HookCustomization[]` (one per source file with hooks), each carrying the + real settings-file `file:` URI. Exclude `managed`. + - Files: `node/claude/customizations/scan/claudeHookScan.ts` (create) + - Depends on: step 1 + - Done when: unit test parses a temp `settings.json` + `settings.local.json` into + `HookCustomization[]` with real URIs; `disableAllHooks` drops a scope; managed + never read. + +3. **Native-plugin scanner `scan/claudeNativePluginScan.ts`.** Read `enabledPlugins` + from the same three settings scopes (union, first-scope-wins per plugin id). For + each enabled plugin resolve its on-disk root: + - marketplace → `~/.claude/plugins/cache/<...>/`; **defensive**: accept a candidate + dir only if it contains a parseable `.claude-plugin/plugin.json`; on multiple + version dirs pick newest mtime; on zero/ambiguous skip with `logService.warn`; + - `@skills-dir` → `~/.claude/skills//.claude-plugin/plugin.json` and + `/.claude/skills//.claude-plugin/plugin.json`. + Call `parsePlugin(root, fileService, workspaceRoot, userHome, boundaryUri)` (already + detects Claude format via `.claude-plugin/plugin.json` and returns + `{ hooks, mcpServers, skills, agents, instructions }`). Return + `{ name, root: URI, parsed: IParsedPlugin }[]` — consumed by the projection + (step 5) for surfacing; not used for loading (the runtime auto-loads). + - Files: `node/claude/customizations/scan/claudeNativePluginScan.ts` (create) + - Depends on: none (parser already exported) + - Done when: unit test resolves a temp marketplace-cache + skills-dir plugin to its + root, parses it, and yields components with real URIs; a missing/ambiguous dir is + skipped, not errored. + +4. **Capture SDK `system/init.plugins` in the pipeline.** Today the pipeline only flips + `_isResumed` on the init message (`claudeSdkPipeline.ts` ~L507) and + `snapshotResolvedCustomizations` returns commands/agents/mcpServers only (~L95). + Stash `message.plugins` (`{ name, path }[]`) on the pipeline at init and add a + `plugins` field to `ISdkResolvedCustomizations`. A rebind creates a fresh pipeline, + so the new init re-captures — no staleness. + - Files: [claudeSdkPipeline.ts](./claudeSdkPipeline.ts) + - Depends on: none + - Done when: pipeline exposes the loaded-plugin list; unit/integration test asserts + it is populated from a stubbed init. + +5. **Projection: hooks + native plugins in the discovery mapper.** *(hooks half ✓; + native-plugin half pending Part B)* In `claudeSessionCustomizationDiscovery.ts`: + - add a `hooks` input to `mapDiscoveredCustomizations`; wrap per-scope hooks in a + `DirectoryCustomization` (`contents: CustomizationType.Hook`, `writable: true`, + children = the real-URI `HookCustomization`s) using the same `makeDirectory` + pattern as agents/skills/rules; + - add a `nativePlugins` input; map each resolved plugin to a `PluginCustomization` + (`type: CustomizationType.Plugin`, `uri` = plugin root, children from + `IParsedPlugin` agents/skills/hooks/mcp/rules); + - in `buildDiscoveredCustomizations`, **hooks bypass** the SDK filter (like rules); + **native plugins** are filtered post-materialize by matching their resolved + `root.fsPath` against the captured `sdk.plugins[].path`. + - Files: [claudeSessionCustomizationDiscovery.ts](./customizations/claudeSessionCustomizationDiscovery.ts) + - Depends on: steps 2, 3, 4 + - Done when: discovery test shows a hook directory container + a plugin container + pre-materialize; post-materialize a plugin absent from `sdk.plugins` is hidden, + hooks are not. + +6. **Wire scanners into `getSessionCustomizations`.** *(hooks wired ✓; + `scanClaudeNativePlugins` pending Part B)* Extend the existing + `Promise.all` (~L841) with `scanClaudeHooks(...)` and `scanClaudeNativePlugins(...)`, + pass results into `mapDiscoveredCustomizations` / `buildDiscoveredCustomizations`, + keeping the client-pushed tier first. **No `Options.plugins` / materialize changes** + — the runtime already loads these via `settingSources`. + - Files: [claudeAgentSession.ts](./claudeAgentSession.ts) + - Depends on: steps 2, 3, 5 + - Done when: provisional `getSessionCustomizations` returns hooks + native plugins; + materialized hides unloaded native plugins; `Options.plugins` is unchanged from + today. + +7. ✓ **Watcher coverage check (no new triggers).** `ClaudeCustomizationWatcher` + already watches `/.claude` and `~/.claude` recursively (~L363), covering + `settings.json`, `settings.local.json`, `@skills-dir` plugins, and + `~/.claude/plugins/cache`. Confirm with a test; add an explicit trigger only if a + resolved plugin root falls outside those subtrees. The watcher only refreshes the + *displayed list* (`onDidCustomizationsChange`) — it does not drive any rebind. + - Files: [claudeSessionCustomizationDiscovery.ts](./customizations/claudeSessionCustomizationDiscovery.ts) (test only, unless a gap is found) + - Depends on: step 6 + - Done when: a watcher test shows a `settings.json` hooks/`enabledPlugins` edit fires + `onDidChange`. + +8. **Tests.** *(hook scanner + projection tests ✓; native-plugin tests pending Part B)* + New scanner tests + extend discovery tests (see Verification). + - Files: `test/node/customizations/scan/claudeHookScan.test.ts` (create), + `test/node/customizations/scan/claudeNativePluginScan.test.ts` (create), + `test/node/customizations/claudeSessionCustomizationDiscovery.test.ts` (modify), + `test/common/pluginParsers.test.ts` (modify — public hook surface) + - Depends on: all prior + - Done when: `node` agentHost suite green. + +> **PR sequencing (grilled).** Ship **Part A (hooks) first** (steps 1, 2, 5-hook, +> 6-hook, 8), then **Part B (native plugins)** (steps 3, 4, 5-plugin, 6-plugin, 8). +> Both are surface-only; Part A is the smaller, lower-risk slice and proves the +> projection wiring before Part B adds the marketplace-cache resolver and the +> `init.plugins` capture. + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| `src/vs/platform/agentPlugins/common/pluginParsers.ts` | modify | Export `parseHooksJson` + `makeHookCustomization` (currently private) | +| `src/vs/platform/agentHost/node/claude/customizations/scan/claudeHookScan.ts` | create | `scanClaudeHooks` — hooks from user/project/local `settings.json` | +| `src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts` | create | `scanClaudeNativePlugins` — resolve `enabledPlugins` → roots → `parsePlugin` | +| `src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts` | modify | Hook directory containers + plugin containers; native-plugin post-materialize filter | +| `src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts` | modify | Capture `system/init.plugins`; add `plugins` to `ISdkResolvedCustomizations` | +| `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` | modify | Call new scanners in `getSessionCustomizations`; pass hooks/plugins into the projection (no `Options.plugins` change) | +| `src/vs/platform/agentHost/test/node/customizations/scan/claudeHookScan.test.ts` | create | Hook scan unit tests | +| `src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts` | create | Native-plugin resolver unit tests | +| `src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts` | modify | Hook/plugin projection + post-materialize filter | +| `src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts` | modify | Cover newly public hook parser surface | + +> `claudeSdkOptions.ts` is **not** modified — `Options.plugins` stays +> client-only (native plugins are auto-loaded via `settingSources`). + +## Decisions + +- **Native `~/.claude` plugins are NOT plumbed into `Options.plugins` — they are + auto-loaded by the SDK runtime via `settingSources`.** Grilled and verified: + `extensions/copilot/.../claudeSkills.ts` states *"The Claude SDK loads `.claude` + directories automatically — skip them to avoid duplicates"* and filters `.claude` + dirs out of `Options.plugins` (`isClaudeDirectory`). The production + `claudeCodeAgent.ts` builds `Options.plugins` only from + `claudePluginService.getPluginLocations()` (client/host-provided, non-`.claude`). + Passing native plugins in would **double-load**. `Options.plugins` therefore stays + exactly as Phase 11 left it; Phase 17 is **surface-only**. (The SDK docs' "must pass + `Options.plugins`" caveat applies to a bare SDK app *without* `settingSources`, which + is not our configuration.) +- **No native-plugin dirty bit / rebind for loading.** Since we do not feed + `Options.plugins`, an `enabledPlugins` edit needs no host-side rebind to *load*; the + runtime picks it up on its next `settingSources` read (same path as any settings.json + change). The watcher only refreshes the **displayed list**. (This moots the + council's union-vs-separate-field and dirty-mechanism debate — grilled.) +- **PR sequencing: Part A (hooks) first, then Part B (native plugins).** Grilled. Both + surface-only; A is smaller/lower-risk and proves the projection wiring before B adds + the marketplace-cache resolver + `init.plugins` capture. +- **Hooks are surfaced as child customizations under per-scope `DirectoryCustomization` + containers, not a new top-level protocol type.** The `Customization` union only + admits `PluginCustomization` / `DirectoryCustomization` / top-level + `McpServerCustomization`; `HookCustomization` is child-only + (`state.ts` ~L720/~L873). Wrapping in a `DirectoryCustomization` + (`contents: Hook`) mirrors how agents/skills/rules are surfaced — no protocol change. +- **Post-materialize plugin filtering uses the captured SDK `system/init.plugins` + list, not command-name inference.** The init message carries `{ name, path }[]` + (`sdk.d.ts` ~L3943); the SDK explicitly notes command snapshots can go stale, so + matching loaded plugins by `path` is the reliable signal. Hooks bypass the filter + (no SDK hook enumeration exists). +- **Reuse the shared parser; do not reimplement Claude hook/plugin parsing.** + `parseHooksJson` already handles `disableAllHooks` + `HOOK_TYPE_MAP` + nested + matcher form; `parsePlugin` already detects Claude plugins and returns bundled + components. Net-new work is only the Claude settings-scope roots and the + marketplace-cache resolver. +- **`managed` scope is excluded** for both hooks and native plugins, consistent with + the existing `settingSources` non-goal. +- **Marketplace-cache resolution is manifest-validated and fail-soft.** Accept a cache + candidate only when it contains a parseable `.claude-plugin/plugin.json`; on multiple + version dirs pick newest mtime; on zero/ambiguous skip with a warn log so a broken + cache entry never blanks the list. + +## Risks + +- **Private parser surface.** `parseHooksJson` / `makeHookCustomization` are private + today — step 1 must export them; verify no layer/visibility lint regressions + (`valid-layers-check`). +- **Double-load trap (resolved).** Do NOT add native plugin roots to `Options.plugins` + — they are auto-loaded via `settingSources`; plumbing them duplicates loading + (`claudeSkills.ts` `isClaudeDirectory` filter). The plan keeps `claudeSdkOptions.ts` + untouched precisely to avoid this. +- **`system/init.plugins` capture vs resume.** The pipeline currently special-cases + init only to flip `_isResumed`. Ensure the plugin capture runs on **every** fresh + `Query` (including resumed sessions and post-rebind), since a rebind builds a new + pipeline — add a test asserting capture on resume. +- **Marketplace cache layout is undocumented.** Mitigated by the manifest-validated, + fail-soft resolver (Decisions). Confirm the real `~/.claude/plugins/cache` layout on + a machine with an installed plugin before locking the resolver. +- **Part A correctness depends on hooks already firing.** Hooks load via + `settingSources` (set in `claudeSdkOptions.ts` ~L122). `includeHookEvents` only + affects whether hook events surface on the stream, not whether hooks run — Part A is + surface-only, so it does not depend on it. Confirm `settingSources` includes the + hook-bearing scopes during the E2E check. + +## Verification + +### Unit / Integration +- Run the agentHost `node` suite for the touched files: + - `./scripts/test.sh --run src/vs/platform/agentHost/test/node/customizations/scan/claudeHookScan.test.ts` + - `./scripts/test.sh --run src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts` + - `./scripts/test.sh --run src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts` + - `./scripts/test.sh --run src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts` +- Assertions to cover: hook scan (3 scopes, `disableAllHooks`, managed excluded); + native-plugin resolver (marketplace cache + skills-dir, fail-soft skip); discovery + projection (hook directory container, plugin container) pre-materialize; post-materialize + hides an unloaded plugin, keeps hooks; watcher fires on a `settings.json` + hooks/`enabledPlugins` edit. **No `Options.plugins` assertion changes** — the existing + `claudeSdkOptions.test.ts` should still pass unmodified (proof we did not touch the + loading path). +- Hygiene gate before declaring done: `npm run typecheck-client`, `npm run gulp hygiene` + (or watch-task output), `npm run valid-layers-check`, eslint clean. + +### E2E +Launch + log skills discovered in this workspace (project scope): + +- **Launch skill**: `launch` (`.agents/skills/launch/SKILL.md`) — Playwright-driven Code + OSS automation against the Agents window; see the macOS short-`TMPDIR` gotcha in repo + memory. +- **Log skill**: `code-oss-logs` (`.github/skills/code-oss-logs/SKILL.md`) — find/read + agent-host + renderer dev-build logs. +- **Repo runbook**: `/memories/repo/e2e-claude-checklist.md` — exact launch command, the + two-`Claude` picker selection, the Monaco `editContext` input method, and the + customization-modal verification (the "editable real file, not a stub" check). + +**Scenario (run in the Agents window, Claude → Local Agent Host):** +1. **Hooks surface + fire.** Add a `PostToolUse` hook to `~/.claude/settings.json`. Open a + Claude session; via the composer's customization modal ("Skills/Agents" → manage), confirm + a **Hooks** group lists the hook and clicking it opens the real `settings.json` (renderer + log: `[text file model] resolve() - enter file://…/.claude/settings.json`). Send "list + files in this directory" (forces a tool) and confirm the hook runs (its side effect / a + `PreToolUse`/`PostToolUse` entry in `agenthost.log`). +2. **Native plugin surface (auto-loaded).** With a marketplace plugin installed + enabled via + the Claude CLI (or a temp `@skills-dir` plugin under `~/.claude/skills//.claude-plugin/`), + open a session; confirm the plugin + its bundled skills appear in the list with real URIs, + and the agent can invoke a plugin skill. Verify the plugin is present in the captured SDK + `system/init.plugins` (i.e. the runtime auto-loaded it) — **without** any new + `Options.plugins` entry from the host. +3. **List refresh on toggle.** Disable the plugin in `settings.json`; confirm the customization + list refreshes (watcher `onDidChange`) and the plugin disappears post-materialize (absent + from `init.plugins`). Note: the *loaded* set changes on the runtime's next settings reload / + session restart — Phase 17 owns the list refresh, not the reload. +4. **Regression guard.** Re-run scenarios 1–5 of `/memories/repo/e2e-claude-checklist.md` + (plain text, built-in tool, client tool, tool-change rebind, subagent) to confirm no + customization/rebind regression. Required-absent greps must stay 0. Critically, confirm + **client** plugins/tools (Phase 11 `Options.plugins`) still load — proving Phase 17 left + that path untouched. + +### Manual +- Confirm on a real machine the `~/.claude/plugins/cache` directory layout matches the + resolver assumption (single current version dir + possible orphans) before merge. + +## Open Questions + +*(none — all resolved in Decisions)* + +## References + +- Roadmap: `./roadmap.md` (Phase 17) +- Context glossary: `./CONTEXT.md` (M9 provisional/materialize, M11 + hot-swap/defer/restart-required) +- Phase 11 plan: `./phase11-plan.md` (restart-required plugin contract, + `rebindForRestart()`) +- Phase 16 plan: `./phase16-plan.md` (disk-scan resolver, `mapDiscoveredCustomizations`) +- Claude Code docs: hooks (`/en/hooks`), plugins-reference (`/en/plugins-reference`), + agent-sdk plugins/hooks +- E2E skills: `launch`, `code-oss-logs`; runbook `/memories/repo/e2e-claude-checklist.md` + +## Implementation Notes + +### Part A — Hooks (shipped in this change set) + +**Files actually changed** (matches the plan's `Files to Modify or Create`, Part A rows only): +- `src/vs/platform/agentPlugins/common/pluginParsers.ts` — exported the previously-private + `parseHooksJson` (+ doc comment). No behavior change. (`makeHookCustomization` was **not** + exported — the scanner reads the customization off the parsed group's `.customization`, + so the extra export was unnecessary; minimal-surface deviation from the plan's step 1.) +- `src/vs/platform/agentHost/node/claude/customizations/scan/claudeHookScan.ts` — **new.** + `scanClaudeHooks(workingDirectory, userHome, fileService)`; reads + `/.claude/settings.json`, `/.claude/settings.local.json`, + `/.claude/settings.json`; dedupes by file URI; one `HookCustomization` per + declaring file; delegates `disableAllHooks` + canonicalization to `parseHooksJson`. +- `src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts` + — `mapDiscoveredCustomizations` + `buildDiscoveredCustomizations` take a new + `hooks: readonly HookCustomization[]`; `makeDirectory` widened to `Hook` + + `HookCustomization`; per-scope `Hooks` `DirectoryCustomization` (`contents: Hook`); hooks + bypass the SDK filter (like rules). +- `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` — `getSessionCustomizations` + calls `scanClaudeHooks` in its parallel scan and passes the result to + `buildDiscoveredCustomizations`. `Options.plugins` / materialize untouched. + +**Tests:** +- `…/test/node/customizations/scan/claudeHookScan.test.ts` — **new.** 6 tests: per-file + surfacing with real URI, `settings.local.json` precedence, `disableAllHooks` short-circuit, + no-hooks-block → no entry, the `cwd === userHome` dedupe regression (council finding), and + no-workspace (`undefined` cwd) → user scope only. +- `…/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts` — updated all 6 + existing `map/buildDiscoveredCustomizations` call sites for the new `hooks` param + added a + per-scope `Hooks` container projection test **and** a post-materialize "hooks survive the SDK + filter" test (mirrors the rules test — asserts the bypass Decision). +- `…/test/common/pluginParsers.test.ts` — added a `parseHooksJson` sub-suite (5 tests: + non-object/missing-block/`disableAllHooks` → `[]`, camelCase→PascalCase canonicalization + + unknown-event drop, nested-matcher extraction + empty-group drop, flat command form, shared + file-level customization). (`parseHooksJson` is now a public export, so it gets direct + contract coverage independent of the scanner.) + +**Deviations from plan:** +- Step 1: exported only `parseHooksJson`, not `makeHookCustomization` (minimal surface). +- `scanClaudeHooks` dedupes candidate files via `ResourceSet` (URI-identity set) rather than a + hand-rolled `Set` + `toString()`. + +**E2E (launch + code-oss-logs):** Agents window, Claude → **Local Agent Host**, against the +vscode repo with a workspace hook seeded in the gitignored `.claude/settings.json` and the +real user hook in `~/.claude/settings.json`. Confirmed: composer shows **"Hooks 2"**; the +**"Agent Customizations for Claude [Agent Host]"** modal shows a **Hooks, 2 items** section +grouped **Workspace · 1** (`.claude/settings.json`) / **User · 1** (`~/.claude/settings.json`); +clicking the workspace entry opened the **real editable file** (`renderer.log`: +`text file model] resolveFromFile()` + `doCreateTextModel()` on the actual +`…/.claude/settings.json`). All seeded artifacts cleaned up; `git status` clean. + +**Council review (council-review skill, 3 models):** one **consensus** finding — duplicate +hook entry when `workingDirectory === userHome` (no dedupe; sibling `claudeMcpScan.ts` +dedupes). **Fixed** via a `seen` URI set + regression test (severity split: 2 reviewers +blocking, 1 cosmetic). All other axes (missing files, malformed JSON, `disableAllHooks`, +scope attribution via `URI.parse`, layering of the `parseHooksJson` export, test +correctness) returned **no blocking issues**. + +**`avoid-private-methods`:** skill not installed in this environment; manual check — Part A +introduces no new private methods or classes (the scanner is a pure exported function; +discovery changes extend existing free functions). **clean.** + +### Part B — Native plugins (planned: council + grill, 2026-06-23) + +> Stacked second PR (after the Part A hooks PR). **Surface-only** — native `.claude` plugins +> enabled via `enabledPlugins` are already auto-loaded by the SDK via `settingSources`; this +> adds discovery/projection + a post-materialize filter and **does not touch `Options.plugins` +> or `claudeSdkOptions.ts`** (its test must pass unmodified). Synthesized from a 3-model +> planning council (GPT-5.5, Claude Opus 4.6, GPT-5.3-Codex) — all three converged on the same +> architecture; divergences resolved in **Decisions** below. + +**Ready-made local fixture:** the user's `~/.claude/settings.json` already declares +`enabledPlugins: { "telegram@claude-plugins-official": true }`, installed under +`~/.claude/plugins/cache` — confirm that real layout before locking the resolver (Manual +verification item). + +#### Verified SDK facts (read from `node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts`) + +- **`enabledPlugins`** (`sdk.d.ts` L4899): `{ [pluginId]: string[] | boolean | { [k]: unknown } }`. + Id format is `plugin-id@marketplace-id`. A plugin is **enabled** when its value is `true`, a + `string[]` (version constraint), or an object (extended form); **disabled** only when `false`. + **Precedence is `user < project < local`** (later scope wins) — so `false` in + `.claude/settings.local.json` disables a plugin that `.claude/settings.json` or + `~/.claude/settings.json` enabled. This is the **opposite** of the hook scanner's + "first-seen-wins" dedupe (Decision PB-1). +- **`system/init.plugins`** (`sdk.d.ts` L3943): `{ name: string; path: string }[]` — name + path + only (no `source`). This is the post-materialize source of truth for "which native plugins did + the live session actually load". + +#### Part B Approach + +Add one read-only disk scanner (`scan/claudeNativePluginScan.ts`) that computes the effective +enabled-plugin set across the three settings scopes (correct precedence), resolves each enabled +plugin id to a **manifest-validated** on-disk root (marketplace cache or `@skills-dir`), and +parses it with the shared `parsePlugin` (`CLAUDE_FORMAT`). Project each as a **top-level** +`PluginCustomization` container (new `makePlugin` helper — `PluginCustomization` is a distinct +protocol container type, **not** a `DirectoryCustomization`) whose children come straight from +`IParsedPlugin` (`skills`/`agents`/`instructions`/`hooks`/`mcpServers` `.customization`). +Capture `system/init.plugins` in `ClaudeSdkPipeline`; post-materialize, keep only plugins whose +resolved `root.fsPath` matches a captured `init.plugins[].path`. Provisional (pre-send) shows +the full disk set; hooks/rules keep bypassing the filter. Because `@skills-dir` plugins live in +the same `.claude/skills/` tree the Phase 16 skill scanner walks, the standalone skill scan is +taught to **skip any `.claude/skills//` dir that is itself a plugin** (has a +`.claude-plugin/plugin.json`) so a skills-dir plugin's skills surface **only** under its plugin +container, never as duplicate standalone rows (Decision PB-8). **Both** resolver paths +(`@skills-dir` + marketplace cache) ship in this one PR (grilled). + +#### Part B Steps + +3. ✓ **Native-plugin scanner `scan/claudeNativePluginScan.ts`** *(create)*. + - `scanClaudeNativePlugins(workingDirectory, userHome, fileService, logService)` reads + `enabledPlugins` from the same three settings files as `claudeHookScan.ts` + (`/.claude/settings.json`, `/.claude/settings.local.json`, + `/.claude/settings.json`), merging into one effective map with **local > project + > user** precedence (Decision PB-1). Final value `!== false` ⇒ enabled. + - For each enabled id `plugin@marketplace`, resolve its root via a `resolvePluginRoot` helper: + **`@skills-dir`** marketplace ⇒ in-place `/.claude/skills//` or + `/.claude/skills//`, accepted only if `.claude-plugin/plugin.json` reads. + **Marketplace cache** ⇒ search `/.claude/plugins/cache/` for a directory + containing a parseable `.claude-plugin/plugin.json`; on multiple version dirs pick newest + `mtime` (via `fileService.resolve`/`stat`); on zero/ambiguous `logService.warn` + skip + (fail-soft, Decision PB-5). + - Call `parsePlugin(root, fileService, workingDirectory, userHome.fsPath)` inside a + `try/catch` — a malformed manifest warns + skips, never throws out of the scan (Risk PB-R4). + - Return `readonly IResolvedNativePlugin[]` = `{ id: string; root: URI; parsed: IParsedPlugin }`. + - Dedupe resolved roots by `ResourceSet` (cwd-can-equal-userHome lesson; mirror Part A). + - Depends on: none (parser already exported). Done when: unit test resolves a temp + marketplace-cache + a `@skills-dir` plugin to real-URI children; missing/ambiguous skipped. + +4. ✓ **Capture `system/init.plugins` in `claudeSdkPipeline.ts`** *(modify)*. + - Add `private _initPlugins: readonly { name: string; path: string }[] = []`. Set it on the + init branch (`claudeSdkPipeline.ts` ~L507) **unconditionally** — i.e. NOT gated behind the + existing `!this._isResumed` guard, so a resumed/re-init session re-captures the latest list + (Decision PB-4 / Risk PB-R3). + - Add `readonly plugins: readonly { name: string; path: string }[]` to + `ISdkResolvedCustomizations` (L62) and return `this._initPlugins` from + `snapshotResolvedCustomizations()` (L95). A rebind builds a fresh pipeline, so capture is + never stale. + - Done when: a pipeline test asserts `snapshotResolvedCustomizations().plugins` is populated + from a stubbed init message. + +5. ✓ **Projection: plugin containers + post-materialize filter** in + `claudeSessionCustomizationDiscovery.ts` *(modify)*. + - Add a `nativePlugins: readonly IResolvedNativePlugin[]` param to both + `mapDiscoveredCustomizations` and `buildDiscoveredCustomizations`. + - New `makePlugin(id, root, parsed)` helper → `PluginCustomization` + (`type: CustomizationType.Plugin`, `id: customizationId(root.toString())`, + `uri: root.toString()`, `name: id`, `enabled: true`, `load: { kind: Loaded }`, + `children: [...parsed.skills, ...parsed.agents, ...parsed.instructions, ...parsed.hooks, + ...parsed.mcpServers].map(c => c.customization)`). Top-level entries, **not** wrapped in a + per-scope `DirectoryCustomization` (Decision PB-2) — mirrors how MCP servers are top-level. + - In `buildDiscoveredCustomizations`: pre-materialize (`sdk === undefined`) keep all native + plugins; post-materialize keep only those whose `root.fsPath` ∈ + `new Set(sdk.plugins.map(p => URI.file(p.path).fsPath))` (normalize both sides through + `URI.file().fsPath`, Decision PB-3 / Risk PB-R2). Hooks + rules continue to bypass. + - Depends on: steps 3, 4. Done when: discovery test shows a plugin container pre-materialize; + post-materialize a plugin absent from `sdk.plugins` is hidden, hooks/rules are not. + +5b. ✓ **De-duplicate `@skills-dir` plugins from the standalone skill scan** in + `scan/claudeAgentSkillScan.ts` *(modify)* (Decision PB-8). + - When walking `/.claude/skills/`, **skip any immediate `/` dir that contains a + readable `.claude-plugin/plugin.json`** — that dir is a native plugin, so its skills belong + to the plugin container (step 5), not the standalone Skills list. This is a self-contained + check (no dependency on the plugin resolver's enabled-set), so a disabled-but-present + skills-dir plugin is also kept out of the standalone list. + - Depends on: none (independent of the native-plugin scanner). Done when: a skill scan test + with a `.claude/skills//.claude-plugin/plugin.json` present yields no standalone Skill + for ``. + +6. ✓ **Wire `scanClaudeNativePlugins` into `getSessionCustomizations`** in `claudeAgentSession.ts` + *(modify)*. + - Extend the `Promise.all` (~L841) with + `scanClaudeNativePlugins(this.workingDirectory, userHome, this._fileService, this._logService)`; + pass the result into `buildDiscoveredCustomizations`. **No `Options.plugins`/materialize + change.** Depends on: steps 3, 5. + +7. ✓ **Watcher** — no production change expected. `ClaudeCustomizationWatcher` already watches + `/.claude` + `/.claude` recursively (~L363), covering `settings*.json`, + `~/.claude/plugins/cache`, and `@skills-dir`. Add a test asserting an `enabledPlugins` edit + fires `onDidChange`; add an explicit trigger only if a resolved root falls outside those + subtrees. + +8. ✓ **Tests** *(create + modify)* — see Part B Verification. + +#### Part B Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| `…/customizations/scan/claudeNativePluginScan.ts` | create | `scanClaudeNativePlugins` + `resolvePluginRoot` (cache + `@skills-dir`, manifest-validated, fail-soft); `IResolvedNativePlugin` | +| `…/claude/claudeSdkPipeline.ts` | modify | `_initPlugins` capture (ungated); `plugins` on `ISdkResolvedCustomizations`; return from `snapshotResolvedCustomizations` | +| `…/customizations/claudeSessionCustomizationDiscovery.ts` | modify | `nativePlugins` param on map/build; `makePlugin` helper (top-level, root-dir URI); post-materialize path-match filter | +| `…/customizations/scan/claudeAgentSkillScan.ts` | modify | skip `.claude/skills//` dirs that are plugin roots (`.claude-plugin/plugin.json`) — Decision PB-8 | +| `…/claude/claudeAgentSession.ts` | modify | call `scanClaudeNativePlugins` in `getSessionCustomizations`; pass into projection (no `Options.plugins` change) | +| `…/test/node/customizations/scan/claudeNativePluginScan.test.ts` | create | resolver tests (cache version-pick, `@skills-dir`, fail-soft, precedence/disable) | +| `…/test/node/customizations/scan/claudeAgentSkillScan.test.ts` | modify | assert a `.claude/skills/` plugin dir is skipped by the standalone skill scan (PB-8) | +| `…/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts` | modify | plugin projection + post-materialize filter; add `plugins: []` to existing `sdk` literals | +| `…/test/node/claudeSdkPipeline.test.ts` | modify | assert `init.plugins` capture surfaces in `snapshotResolvedCustomizations()` | + +> `claudeSdkOptions.ts` + `claudeSdkOptions.test.ts` are **not** modified — proof the loading +> path is untouched. + +#### Part B Decisions + +- **PB-1 — Effective enabled set uses `local > project > user` precedence (later scope wins), + enabled = value `!== false`.** Verified in `sdk.d.ts` L4897 ("user < project < local … set it + to false in `.claude/settings.local.json`"). This is the **opposite** of the hook scanner's + first-seen dedupe, so the native-plugin scanner merges scopes into one effective map rather + than first-wins. A `string[]`/object value counts as enabled (version-constraint / extended + form); only `false` disables. +- **PB-2 — Native plugins are top-level `PluginCustomization` entries, not per-scope + `DirectoryCustomization` wrappers.** `PluginCustomization` is a distinct protocol container + (`state.ts` L580) with its own identity/edit target; wrapping plugins in scope directories + (as hooks/agents/skills are) would lose plugin identity. Mirrors top-level MCP servers. (No + Workspace/User grouping label for plugins — the plugin root URI carries the location.) +- **PB-3 — Post-materialize plugin filtering matches resolved `root.fsPath` against the captured + SDK `init.plugins[].path`, normalized through `URI.file().fsPath` on both sides.** The init + list is purpose-built `{name,path}` data; name-matching `supportedCommands()`/`supportedAgents()` + is rejected (a plugin bundles many components; the SDK warns command snapshots go stale). +- **PB-4 — `init.plugins` capture is ungated by `_isResumed`.** The existing init branch only + flips `_isResumed` once; the plugin capture must run on every init so resumed/re-init sessions + refresh. A rebind creates a fresh pipeline, so cross-instance staleness is impossible. +- **PB-5 — Marketplace-cache resolution is manifest-validated + fail-soft.** Accept a cache + candidate only when it has a parseable `.claude-plugin/plugin.json`; newest `mtime` on multiple + version dirs; `logService.warn` + skip on zero/ambiguous so a broken cache entry never blanks + the list. (`~/.claude/plugins/cache` layout is undocumented in-repo — confirm on the user's + machine before locking.) +- **PB-6 — Plugin container `name` is the `enabledPlugins` id** (e.g. + `telegram@claude-plugins-official`). `parsePlugin`/`IParsedPlugin` does not return the manifest + display name, and the id is what the user wrote + what the `/plugins` UI shows — stable and + reliable. (Reading the manifest `name` is a possible later polish.) +- **PB-7 — `Options.plugins` and `claudeSdkOptions.ts` are untouched.** Native plugins are + auto-loaded via `settingSources`; plumbing them would double-load (`claudeSkills.ts` + `isClaudeDirectory` filter). Surface-only. +- **PB-8 — A `@skills-dir` plugin wins over the Phase 16 standalone skill scan (no duplicate + rows).** `@skills-dir` plugins live under `/.claude/skills//`, the same tree + `scanClaudeDiskCustomizations` walks (`claudeAgentSkillScan.ts` `scopeRoots().skills`). The + skill scan skips any immediate `/` dir containing a `.claude-plugin/plugin.json`, so a + skills-dir plugin's skills surface **only** under its `PluginCustomization` container. The + check is self-contained (manifest-presence, not the resolver's enabled-set), so the standalone + list stays clean even for a disabled skills-dir plugin. (Grilled — preferred over URI-dedupe + in the projection or accepting the overlap.) +- **PB-9 — Both resolver paths (`@skills-dir` + marketplace cache) ship in one PR.** The + fail-soft cache resolver is fully designed and a real installed plugin + (`telegram@claude-plugins-official`) is available to confirm the layout during E2E, so a split + adds overhead without meaningful risk reduction. (Grilled.) + +#### Part B Risks + +- **PB-R1 — Marketplace cache layout undocumented.** Mitigated by manifest-validated, fail-soft + resolver (PB-5). Confirm the real `~/.claude/plugins/cache/<…>` structure on the user's machine + (has `telegram@claude-plugins-official` installed) before locking the resolver. +- **PB-R2 — Path normalization drift.** SDK reports `init.plugins[].path` as a string; scanner + roots are `URI.fsPath`. Symlinks (`~/.claude` may be a symlink), trailing separators, or case + could over-filter and hide a loaded plugin. Mitigation: normalize both via `URI.file().fsPath`; + add a trailing-slash test. If real-path divergence is observed in E2E, fall back to comparing + realpath-resolved paths. +- **PB-R3 — `init.plugins` capture vs resume.** Capture must run on every fresh `Query` init, + including resumed/post-rebind. Mitigation: ungate from `_isResumed` (PB-4) + a resume-capture + test. +- **PB-R4 — `parsePlugin` throw on a malformed manifest.** A single bad plugin must not crash the + whole scan. Mitigation: `try/catch` per plugin, `logService.warn` + skip. +- **PB-R5 — `ISdkResolvedCustomizations` shape change breaks existing fixtures.** Adding `plugins` + requires `plugins: []` in every existing `sdk` literal in + `claudeSessionCustomizationDiscovery.test.ts` (+ pipeline/init test utils). Mechanical, but + must be exhaustive or typecheck fails. +- **PB-R6 — PB-8 exclusion depends on the skill scanner's traversal depth.** Confirm at + implementation time whether `readSkills` (`pluginParsers.ts`) descends past the immediate + `.claude/skills//` level. If it only reads `/SKILL.md`, the duplicate surfaces only + when a skills-dir plugin also has a top-level `SKILL.md`; if it recurses, bundled + `/skills//SKILL.md` could also collide. The manifest-presence skip at the `/` + level covers both, but the test must reflect the actual traversal. + +#### Part B Verification + +**Unit:** +- `…/scan/claudeNativePluginScan.test.ts` (in-memory file service via + `claudeCustomizationTestUtils`): effective-set precedence (local `false` disables a + project-enabled plugin; `string[]`/object value enables); marketplace-cache resolution + (newest-mtime version pick, manifest-validated, fail-soft skip on missing/ambiguous); + `@skills-dir` resolution (user + workspace); malformed manifest warns + skips; resolved + children carry real URIs; cwd===userHome dedupe. +- `…/customizations/claudeSessionCustomizationDiscovery.test.ts`: plugin container projection + pre-materialize (top-level entry, root-dir URI, children from parsed components); + post-materialize hides a plugin absent from `sdk.plugins` while keeping one present; hooks/rules + still bypass. Add `plugins: []` to existing `sdk` literals. +- `…/scan/claudeAgentSkillScan.test.ts`: a `.claude/skills//.claude-plugin/plugin.json` + present ⇒ `` is **not** surfaced as a standalone Skill (PB-8). +- `…/claudeSdkPipeline.test.ts`: `snapshotResolvedCustomizations().plugins` populated from a + stubbed `system/init`; capture refreshes on a second init (resume). +- **Regression:** `claudeSdkOptions.test.ts` passes **unmodified**. +- Gate before done: `npm run typecheck-client`, eslint, `npm run valid-layers-check`. + +**E2E (launch + code-oss-logs; runbook `/memories/repo/e2e-claude-checklist.md`):** +- With `telegram@claude-plugins-official` enabled in `~/.claude/settings.json`, open a Claude → + Local Agent Host session: the plugin + its bundled skills appear in the customization list with + real URIs; it is present in the captured `init.plugins` (auto-loaded) with **no** host-added + `Options.plugins` entry. Disable it in `settings.local.json` → list refreshes (watcher) and the + plugin disappears post-materialize. +- Regression: re-run scenarios 1–5 of the checklist; confirm **client** plugins/tools (Phase 11 + `Options.plugins`) still load — proving the loading path is untouched. + +### Part B — Native plugins (shipped in this change set) + +**Confirmed disk layout (de-risks PB-R1/PB-5).** Inspected the user's real +`~/.claude/plugins/cache` on 2026-06-23: marketplace plugins live at +`cache////` (e.g. +`cache/claude-plugins-official/telegram/0.0.6/.claude-plugin/plugin.json`). So the +`enabledPlugins` id `plugin@marketplace` maps **directly** to `cache///` and +the resolver just selects the (single / newest-`mtime`) version subdir — far more deterministic +than the planned blind cache search. `enabledPlugins` precedence (`user < project < local`) and +the `system/init.plugins` `{name,path}` shape were both verified in `sdk.d.ts` (L4899 / L3943). + +**Files actually changed** (matches the Part B `Files to Modify or Create` table): +- `customizations/scan/claudeNativePluginScan.ts` — **new.** `scanClaudeNativePlugins` + + `IResolvedNativePlugin`; effective-enabled-set merge (local > project > user, `!== false`), + `splitPluginId`, `@skills-dir` resolver (workspace before user), marketplace-cache resolver + (id → `cache///`, newest-mtime version dir, name tie-break, manifest + validated, fail-soft warn+skip), per-plugin `parsePlugin` in `try/catch`, root dedupe via + `ResourceSet`. +- `claudeSdkPipeline.ts` — `plugins` added to `ISdkResolvedCustomizations`; `_initPlugins` + captured on **every** `system/init` (ungated from `_isResumed`, PB-4); returned from + `snapshotResolvedCustomizations`. +- `customizations/claudeSessionCustomizationDiscovery.ts` — `makePlugin` helper (top-level + `PluginCustomization`, root-dir URI, name = id, children deduped by id); `nativePlugins` param + on `mapDiscoveredCustomizations` + `buildDiscoveredCustomizations`; post-materialize filter + `root.fsPath ∈ sdk.plugins[].path` (normalized via `URI.file().fsPath`). +- `customizations/scan/claudeAgentSkillScan.ts` — `excludeNativePluginSkills` drops standalone + skills whose `.claude/skills//` dir holds a `.claude-plugin/plugin.json` (PB-8). +- `claudeAgentSession.ts` — `scanClaudeNativePlugins` added to the parallel scan; result passed + to `buildDiscoveredCustomizations`. `Options.plugins` / `claudeSdkOptions.ts` untouched. + +**Tests:** +- `…/scan/claudeNativePluginScan.test.ts` — **new, 9:** cache resolve (id → version dir), + newest-mtime pick, bundled skills/MCP with real URIs, local-`false` disable (precedence), + `string[]`/object = enabled, `@skills-dir` (workspace-preferred), fail-soft skip, no-block. +- `…/customizations/claudeSessionCustomizationDiscovery.test.ts` — +3: top-level plugin + projection with bundled children, post-materialize keep-loaded/hide-unloaded by path, + pre-materialize shows all. All existing `map/build` call sites updated for the `nativePlugins` + param + `plugins: []` added to the 4 `sdk` literals. +- `…/scan/claudeAgentSkillScan.test.ts` — +1: a `.claude/skills/` plugin dir is not + surfaced as a standalone skill (PB-8). +- `…/claudeAgent.test.ts` — +1 end-to-end: seeds an enabled plugin under the mock cache, drives + a `system/init` whose `plugins[].path` matches the resolved root, and asserts the + `PluginCustomization` survives `getSessionCustomizations` — proving capture → snapshot → filter. +- Regression: `claudeSdkOptions.test.ts` passes **unmodified**; full `claudeAgent.test.ts` (132) + + `claudeSdkPipeline.test.ts` green. + +**Deviations from plan:** +- Marketplace resolver is **deterministic by id** (`cache///`), not a blind + scan of `cache/` — enabled by confirming the real on-disk layout (above). Version selection adds + a dir-name tie-break on equal `mtime` (the in-memory test provider collides sub-ms writes). +- **Multi-format manifest detection (post-E2E fix).** The manifest-presence check (`hasManifest` + in the scanner, and the PB-8 skill-scan dedupe) initially hardcoded `.claude-plugin/plugin.json` + (Claude format) and so **silently skipped Open-Plugins-format plugins** (`.plugin/plugin.json`, + e.g. `github-inbox@vscode-team-kit`) — they were absent from the Plugins container even though + their skills/agents surfaced standalone (the SDK loads them via `settingSources`). Fixed by + reusing the shared `detectPluginFormat` (probes `.plugin/` → `.claude-plugin/` → `plugin.json`) + and checking the detected format's `manifestPath`. Regression test added; re-verified E2E + (**Plugins 2**: `telegram@claude-plugins-official` + `github-inbox@vscode-team-kit`, both + *Loaded*). +- **Post-materialize match by `source`, not `path` (post-E2E fix, supersedes PB-3).** The SDK + `init.plugins` `path` is **unreliable**: for a workspace-`local`-scoped plugin (enabled in + `/.claude/settings.local.json`) the runtime reports a bogus path + (`///`, not the cache root), so the resolved-root path + match dropped the plugin post-materialize. The runtime payload also carries a **`source`** field + (= the `@` id) that the `.d.ts` omits; captured as optional (no cast). The + filter now matches `source === plugin.id` first, with normalized `path` as a fallback. +- **PB-10 — suppress plugin components from the standalone fallbacks (post-E2E fix).** Once a + plugin is surfaced as a container, the SDK *also* reports its agents/commands/MCP in + `supportedAgents`/`supportedCommands`/`mcpServerStatus`, so the Phase-16 SDK-only fallbacks + (`claude-internal:` agents, Built-in skills, non-editable MCP) re-surfaced them **standalone** — + duplicate rows in the User/Workspace lists. Fixed by suppressing any SDK fallback whose name + matches a *surfaced* plugin's own parsed component name, in both **bare** (`inbox-setup`) and + **namespaced** (`:inbox-setup`) forms (SDK naming is inconsistent — agents namespaced, + skills usually bare — so prefix-matching alone is insufficient). Only surfaced plugins suppress, + so a loaded-but-unsurfaced plugin's components are never dropped. Verified E2E: post-materialize + **Agents 17→11, Skills 90→75, 0** standalone `inbox-`/`github-inbox:` leaks, both plugins under + **Remote**. +- The end-to-end capture test lives in `claudeAgent.test.ts` (its harness already drives the SDK + message stream and exposes a seedable `fileService`) rather than `claudeSdkPipeline.test.ts` + (whose `FakeQuery` deliberately leaves `supportedCommands`/message-stream "not modeled"). This + covers the same `init.plugins` → snapshot → filter seam end-to-end. + +**`avoid-private-methods`:** skill not installed; manual check — Part B adds only module-level +free functions (scanner helpers, `makePlugin`, `excludeNativePluginSkills`) and one private +field (`_initPlugins`); no new private class methods. **clean.** + +**Council review (council-review skill, 3 models):** no consensus *blocking* bug. Fixes applied: +(1) **security** — `splitPluginId` now rejects `enabledPlugins` ids whose segments contain path +separators or `..`, so an untrusted workspace `settings.local.json` cannot redirect the scan +outside the plugin roots (+ traversal regression test); (2) **perf (consensus)** — +`excludeNativePluginSkills` now probes skills in parallel (`Promise.all`) instead of serially; +(3) **cleanup** — `buildDiscoveredCustomizations` matches each native plugin to its SDK entry in +a single pass (dropped the redundant second `sdk.plugins.find`); (4) **coverage** — added the +no-workspace and path-traversal scanner tests. Declined: the "Copilot bare-`plugin.json`" +finding (unreachable — native Claude plugins are only Claude/Open-Plugins format, and +`parsePlugin` would mis-detect a bare manifest under `.claude` regardless) and the trailing-slash +path-fallback nit (mitigated — `source` is the primary match key). + +**Status of E2E:** **verified** (launch + code-oss-logs, 2026-06-23). Agents window → Claude +(Local Agent Host): composer Customizations bar showed **Plugins 1** (alongside Part A's +**Hooks 1**); the "Agent Customizations for Claude [Agent Host]" modal listed a **Plugins, 1 +items** section with **`telegram@claude-plugins-official`** (status *Loaded*); the renderer log +confirmed the resolved root is the real +`~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6/` (the `agents`/`commands`/`rules` +ENOENT traces are benign optional-subdir probes); `customizationsDebug.log` listed the +`plugins` section. Disabling via the workspace `.claude/settings.local.json` +(`enabledPlugins: { "telegram@…": false }`, local > user precedence) flipped the bar to +**Plugins** / "No plugins configured" via the watcher, and restoring the file brought it back — +proving the precedence + watcher + projection path end-to-end. All seeded state restored +byte-identically; `git status .claude` clean. diff --git a/src/vs/platform/agentHost/node/claude/phase18-plan.md b/src/vs/platform/agentHost/node/claude/phase18-plan.md new file mode 100644 index 0000000000000..0397dba5fb4cd --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase18-plan.md @@ -0,0 +1,211 @@ +# Phase 18 — Transport-branched model source (SDK discovery workaround) + +> Generated by super-planner; re-scoped 2026-06-24 after the original +> approach hit a confirmed SDK bug. Source: [roadmap.md](./roadmap.md) (phase 18). + +**Status:** implemented + +## Goal + +Introduce the **single transport-branch seam** that selects how `ClaudeAgent` +obtains its model list — **proxy → `ICopilotApiService.models()`** (today's +path, unchanged) vs **native → `Query.supportedModels()`** (built-ins) — with +the proxy hardcoded **on** (`TODO(Phase 19)`). This replaces the abandoned +"unify on gateway-discovery `supportedModels()`" plan (a confirmed Claude SDK +bug makes that unworkable — see Background). The seam is structured so that, +once Anthropic fixes the SDK, collapsing proxied onto `supportedModels()` is a +couple-line change. + +## Background — why the original approach was abandoned + +The original Phase 18 set `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` so the +SDK would discover models from `ClaudeProxyService`'s `/v1/models` and the +agent would read them via `Query.supportedModels()` — one model call for both +modes. **Proven not to work on a brand-new query** (SDK `0.3.169` *and* latest +`0.3.191` / bundled CLI `2.1.191`): + +- `initialize()` does **not** await gateway discovery — timing probe shows + `startup()` resolves ~1.5s before the delayed `/v1/models` response arrives. + Discovery is fire-and-forget; discovered models land in + `~/.claude/cache/gateway-models.json` *after* `this.initialization` resolved. +- `supportedModels()` is a one-shot snapshot (`return (await + this.initialization).models`) with **no `models_changed` push** to refresh it + (unlike `commands_changed`), no refresh control request, and no blocking flag. +- So a cold first query returns only built-in aliases; discovered models appear + only on a *warm-cache* second startup. `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` + (set by proxied `buildOptions`) also suppresses discovery entirely. + +Standalone repro for the upstream team: `~/claude-sdk-model-discovery-repro` +(prints "BUG CONFIRMED"). Until Anthropic ships a fix (await-before-init, or a +`models_changed` push), we do **not** route any model list through +discovery-`supportedModels()`. + +## Scope + +**In scope** + +- `ClaudeAgent._isProxyEnabled(): boolean` returning `true` (hardcoded) with + `// TODO(Phase 19): read RootConfigState ClaudeUseCopilotProxy`. +- Refactor the model-refresh path so the **source is chosen at that one + branch point**: proxy → existing `ICopilotApiService.models()` path; native → + `throw new Error('TODO: Phase 19')` (unreachable while the flag is `true`). +- Factor the effort/config-schema construction (`createClaudeThinkingLevelSchema` + / `isClaudeEffortLevel`, [common/claudeModelConfig.ts:105-126](../../common/claudeModelConfig.ts)) + so a future `ModelInfo → IAgentModelInfo` projection can reuse it. No native + projection is built here. + +**Out of scope** (all Phase 19) + +- The native `Query.supportedModels()` branch + `fromSdkModelInfo` projection + + the enumeration-query lifecycle. +- The real `RootConfigState` (`ClaudeUseCopilotProxy`) read, `onDidRootConfigChange`, + and the `ClaudeTransport` refactor. +- Gateway discovery, the proxy `/v1/models` enrichment, any picker-behavior + change. (Retired with the original approach.) +- Post-SDK-fix unification (collapsing proxied onto `supportedModels()`) — a + documented follow-up gated on the upstream fix. + +## Prerequisites + +- Phases 4–6 ✅ (`ClaudeAgent`, proxy, `_refreshModels` exist). +- No new dependencies. No SDK behavior relied on (this phase keeps proxied on + CAPI exactly as today). + +## Approach + +Extract the model-list source behind `_isProxyEnabled()`. The proxy branch is +literally today's `_refreshModels` body (CAPI `models()` → `isClaudeModel` +filter → `toAgentModelInfo` projection → `is_chat_default` sort → publish, with +the `tokenAtStart` stale-write guard). The native branch is a typed +`TODO: Phase 19` throw. Pull the effort-schema construction into a shared helper +so Phase 19's `ModelInfo` projection can call the same code. Nothing else +changes — no new env vars, no enumeration query, no proxy edits. + +## Steps + +1. **Add `_isProxyEnabled()` + branch the model refresh.** ✓ Introduce + `private _isProxyEnabled(): boolean { return true; /* TODO(Phase 19): read RootConfigState ClaudeUseCopilotProxy */ }`. + In `_refreshModels` (optionally rename to `_resolveModels`), branch: if proxy + → existing CAPI path; else → `throw new Error('TODO: Phase 19')`. + - Files: [claudeAgent.ts:316-340](./claudeAgent.ts) (`_refreshModels`); add the helper near it. + - Depends on: none. + - Done when: `models` observable still populates from CAPI in proxy mode; a unit test that forces the native branch (e.g. via a test seam or by temporarily flipping the helper) asserts it throws `TODO: Phase 19`. + +2. **Factor the effort/config-schema helper for reuse.** ✓ Ensure the effort + schema construction used by `toAgentModelInfo` is a standalone function + (`createClaudeThinkingLevelSchema` already is) callable from a future + `ModelInfo` projection, so Phase 19 doesn't duplicate it. No behavior change. + - Files: [claudeAgent.ts:85-115](./claudeAgent.ts) (`toAgentModelInfo`), [common/claudeModelConfig.ts](../../common/claudeModelConfig.ts). + - Depends on: step 1. + - Done when: `toAgentModelInfo` builds its `configSchema` via the shared helper; existing model tests still green. + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| [claudeAgent.ts](./claudeAgent.ts) | modify | Add `_isProxyEnabled()` (hardcoded `true` + `TODO(Phase 19)`); branch `_refreshModels`; native branch throws `TODO: Phase 19` | +| [common/claudeModelConfig.ts](../../common/claudeModelConfig.ts) | modify (maybe) | Confirm effort-schema construction is a reusable standalone helper | +| [test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | modify | Keep the proxied model suite green; add a test that the native branch throws `TODO: Phase 19` | + +## Decisions + +- **Workaround over unification (user-directed).** Proxy → CAPI, native → + `supportedModels()`, chosen at one branch point; proxy hardcoded `true` with a + `TODO(Phase 19)`. The SDK gateway-discovery bug (Background) makes the original + single-`supportedModels()` unification unworkable today. Structured so the + eventual fix is a couple-line swap. +- **No SDK exposure in proxied mode.** Proxied behavior is byte-identical to + today (direct CAPI). No enumeration query, no discovery flag, no proxy edits — + so no first-paint regression and no dependency on the buggy code path. +- **Native branch is a stub, not an implementation.** Built in Phase 19. It is + unreachable while `_isProxyEnabled()` returns `true`, so shipping the throw is + safe. + +## Risks + +- **R1 — over-engineering the seam.** Keep it to a single boolean branch point; + do not introduce the `ClaudeTransport` union or projection machinery here + (that's Phase 19). → Scope is deliberately tiny. +- **R2 — native branch accidentally reachable.** If a future edit flips + `_isProxyEnabled()` before Phase 19, the throw surfaces. → The `TODO: Phase 19` + message is explicit; a unit test pins the throw. + +## Verification + +### Unit / Integration +- `claudeAgent.test.ts` proxied model suite passes unchanged (list, default + ordering, multiplier/policy metadata, stale-write guard). +- New unit test: forcing the native branch throws `TODO: Phase 19`. +- `npm run typecheck-client`, `npm run valid-layers-check`. + +### E2E +- **Launch skill**: `launch` (`.agents/skills/launch`). **Log skill**: + `code-oss-logs` (`.github/skills/code-oss-logs`). +- **Scenario:** launch Code OSS `--agents`, sign in to Copilot, open the Agents + window with **Claude** selected, open the model picker → confirm the list, + default ordering, and cost-multiplier rendering are **identical to today** + (this phase must not change proxied behavior). `code-oss-logs` → agent host + log shows the usual CAPI `models()` fetch, no SDK enumeration subprocess. + +### Manual +- Send a Claude turn; confirm nothing about model selection / streaming changed. + +## Open Questions + +- **OQ1 — keep Phase 18 as a thin seam phase, or fold into Phase 19?** With + gateway discovery dead, Phase 18's surface is ~1 helper + 1 branch. The user + chose to keep it as the seam (proxy hardcoded `true` + `TODO`). owner: user + (resolved — keep as thin phase); resolve by: n/a. +- **OQ2 — `_isProxyEnabled()` naming.** Mirror the Phase 19 `RootConfigState` + key (`ClaudeUseCopilotProxy`) so the flip is a one-line body change. owner: + implementer, resolve by: step 1. + +## References + +- Roadmap: [roadmap.md](./roadmap.md) (Phase 18 revised; Phase 19 builds the native branch + flips the flag) +- Context glossary: [CONTEXT.md](./CONTEXT.md) — *Claude Agent*, *Claude Proxy*, *CAPI* +- SDK bug repro (outside repo): `~/claude-sdk-model-discovery-repro` +- E2E skills: `launch`, `code-oss-logs`, `unit-tests` + +## Implementation Notes + +> super-implementer run 2026-06-24. The original gateway-discovery approach was +> empirically disproven (see Background); the phase was re-scoped to this +> workaround. **Implemented** under the new scope. + +- **SDK bug confirmed on 0.3.169 and 0.3.191** (bundled CLI 2.1.191): a brand-new + query's `supportedModels()` does not include gateway-discovered models because + `initialize()` doesn't await discovery and there is no `models_changed` push. + Timing/cold-warm evidence captured in the standalone repro + (`~/claude-sdk-model-discovery-repro`). Filed for upstream. +- **Decision:** proxy → CAPI (today), native → `supportedModels()` (Phase 19), + one branch point, proxy hardcoded `true` + `TODO(Phase 19)`. Trivial future + unification once the SDK is fixed. + +### What shipped + +- **Step 1 ✓** — `claudeAgent.ts`: added `private _isProxyEnabled(): boolean` + (hardcoded `true`, `TODO(Phase 19)` to read RootConfigState + `ClaudeUseCopilotProxy`). Extracted the transport branch as a **pure exported + function** `resolveClaudeModelList(isProxyEnabled, fetchProxyModels)` — + proxy → `fetchProxyModels()`, native → `throw new Error('TODO: Phase 19')`. + `_refreshModels` now calls it, delegating the proxied CAPI path to a new + `_fetchProxyModels(token)` (today's body verbatim: `models()` → `isClaudeModel` + → `is_chat_default` sort → `toAgentModelInfo`, behind the `tokenAtStart` + stale-write guard). The pure function (not a private method) is the test seam, + per the repo guideline against testing private methods directly. +- **Step 2 ✓ (no code change)** — `createClaudeThinkingLevelSchema` and + `isClaudeEffortLevel` are already standalone exports in + [common/claudeModelConfig.ts](../../common/claudeModelConfig.ts), and + `toAgentModelInfo` already builds its `configSchema` via the former. The helper + is already reusable by a future Phase 19 `ModelInfo` projection; nothing to factor. +- **Tests** — `test/node/claudeAgent.test.ts`: added two unit tests on + `resolveClaudeModelList` (proxy source returns the proxy list; native branch + throws `TODO: Phase 19`). Full file suite green (132 passing), including the + unchanged proxied model suite (list, default ordering, multiplier/policy + metadata, stale-write guard). +- **Validation** — `npm run typecheck-client` clean; `npm run valid-layers-check` + clean. E2E launch (`launch` + `code-oss-logs`) intentionally skipped: proxied + behavior is byte-identical to today (the workaround only adds an unreachable + native branch), so the existing proxied model suite + manual parity are the + effective regression gate. diff --git a/src/vs/platform/agentHost/node/claude/phase19-plan.md b/src/vs/platform/agentHost/node/claude/phase19-plan.md new file mode 100644 index 0000000000000..6bf3f11497a12 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase19-plan.md @@ -0,0 +1,395 @@ +# Phase 19 — Direct (non-proxied) Claude access (BYO Anthropic) + +> Generated by super-planner (council-plan: GPT-5.5 + Claude Opus 4.6 + GPT-5.3-Codex). +> Source: [roadmap.md](./roadmap.md) (phase 19). +> Last updated: 2026-06-24 after grill session. + +**Status:** implemented + +## Goal + +Make the Copilot-CAPI proxy **optional**, selected by a single host-level +`RootConfigState` property, so the Claude provider can talk to Anthropic +**directly** on the user's own credentials (`ANTHROPIC_API_KEY`, a `claude +login` OAuth session in `~/.claude`, or a cloud-provider config). All +proxy-only plumbing is quarantined behind a `ClaudeTransport` discriminated +union, and the native model picker that Phase 18 stubbed (`TODO: Phase 19`) +is built on the SDK's `Query.supportedModels()`. The proxied path stays the +unchanged default. + +## Scope + +**In scope** + +- New `AgentHostConfigKey.ClaudeUseCopilotProxy` RootConfigState boolean + (schema `default: true`), read via `IAgentConfigurationService.getRootValue` + and made reactive via `onDidRootConfigChange`. +- A `ClaudeTransport` discriminated union + (`{ kind: 'proxy'; handle: IClaudeProxyHandle } | { kind: 'native' }`) + resolved **once** into a `_transportMode` on `ClaudeAgent` and threaded as + data through `IMaterializeContext` → `buildOptions` / `buildSubprocessEnv`. +- Mode-aware `authenticate`, `getProtectedResources`, `_ensureAuthenticated`, + and credits-subscription gating. +- The native model branch: a 1:1 `IClaudeAgentSdkService.query(...)` passthrough + to the SDK's `query` export; `ClaudeAgent._fetchNativeModels` owns the + enumeration lifecycle (`query(neverYield) → Query.supportedModels() → + Query.close()`), plus a `fromSdkModelInfo(...)` projection reusing the Phase 18 + effort-schema helpers. Flip `_isProxyEnabled()` to the real config read and + branch the native model source in `_refreshModels`. +- Native usage path: credits enrichment no-ops; `ChatUsage` carries the SDK + `result` usage/cost; surface `apiKeySource` from `system/init` for + diagnostics. +- A forward-compat `claude.transport` tag persisted on the session overlay at + materialize time (host-level resolution only; no per-session *selection*). + +**Out of scope** + +- **Per-session transport *selection*** — host-level only for v1. A forward-compat + `claude.transport` tag **is** persisted on the session overlay now (decided + during grilling) so the follow-up needs no migration, but no per-session + *resolution* is implemented. (Roadmap Phase 19 "Open questions".) +- **Explicit Bedrock/Vertex support** — v1 ships direct-key + OAuth only; the + SDK's own credential resolution handles cloud-provider env vars implicitly, + but they are not a tested/documented v1 surface (decided during grilling). +- **Post-SDK-fix unification** (collapsing the proxied path onto + `supportedModels()` through the proxy) — a documented follow-up gated on the + upstream SDK discovery bug (Phase 18 Background). +- **Gateway model discovery** — retired in Phase 18; native uses built-ins. +- **Changing the proxied path's behavior** — it must remain byte-identical. + +## Prerequisites + +- **Phase 18 ✅** — the seam exists: `resolveClaudeModelList(isProxyEnabled, + fetchProxyModels)` throws `TODO: Phase 19` on the native branch; + `_isProxyEnabled()` is hardcoded `true`; `_fetchProxyModels(token)` is the + CAPI path; `createClaudeThinkingLevelSchema` / `isClaudeEffortLevel` are + standalone in [common/claudeModelConfig.ts](../../common/claudeModelConfig.ts). +- RootConfigState plumbing exists: `getRootValue` / + [agentConfigurationService.ts:194](../agentConfigurationService.ts), + `onDidRootConfigChange` / + [agentConfigurationService.ts:46](../agentConfigurationService.ts), + fired on `RootConfigChanged` (`:145`). +- The Claude Agent SDK exposes `Query.supportedModels(): Promise` + ([sdk.d.ts:1808](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L1808)). + +## Approach + +Resolve the transport **once** from `ClaudeUseCopilotProxy` into a +`_transportMode: 'proxy' | 'native'` on `ClaudeAgent`, kept current by an +`onDidRootConfigChange` subscription that re-resolves and refreshes models +(affecting **future** sessions only — never an in-flight subprocess). Build a +`ClaudeTransport` value at each materialize/resume and pass it as data through +`IMaterializeContext` into `buildOptions` / `buildSubprocessEnv`, where the +proxy branch is preserved byte-for-byte and the native branch omits +`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` and keeps the inherited +`ANTHROPIC_API_KEY` (while still stripping `VSCODE_*` / `ELECTRON_*` / +`NODE_OPTIONS`). The native model picker is fed by a new +`IClaudeAgentSdkService.supportedModels()` that runs the ephemeral +`startup() → warm.query() → Query.supportedModels() → +teardown` lifecycle and projects `ModelInfo` → `IAgentModelInfo` with no +commercial-metadata overlay. + +## Steps + +1. ✓ **Add the `ClaudeUseCopilotProxy` root config key.** Extend + `AgentHostConfigKey` and `agentHostCustomizationConfigSchema` with a boolean + property (`schemaProperty({ type: 'boolean', …, default: true })`), + mirroring `EnableCustomTerminalTool` / `RubberDuck`. Do **not** add it to + `defaultAgentHostCustomizationConfigValues` (that object only seeds + `Customizations`); the schema `default: true` is what `getRootValue` + returns when the key is absent. + - Files: [common/agentHostCustomizationConfig.ts](../../common/agentHostCustomizationConfig.ts) (enum ~L15, schema ~L79). + - Depends on: none. + - Done when: `getRootValue(agentHostCustomizationConfigSchema, ClaudeUseCopilotProxy)` returns `true` when unset, `false` when persisted `false`. + +2. ✓ **Define the `ClaudeTransport` union.** A discriminated union + `{ kind: 'proxy'; handle: IClaudeProxyHandle } | { kind: 'native' }`. Place + it in a small new module [claudeTransport.ts](./claudeTransport.ts) so + `claudeAgent.ts`, `claudeAgentSession.ts`, and `claudeSdkOptions.ts` share + it without an import cycle. + - Files: [claudeTransport.ts](./claudeTransport.ts) (create). + - Depends on: none. + - Done when: type compiles; no consumers yet. + +3. ✓ **Refactor `buildOptions` / `buildSubprocessEnv` to consume the transport.** + Replace the `proxyHandle: IClaudeProxyHandle` parameter on `buildOptions` + with `transport: ClaudeTransport`. Proxy branch: identical env (set + `ANTHROPIC_BASE_URL` = `handle.baseUrl`, `ANTHROPIC_AUTH_TOKEN` = + `${handle.nonce}.${sessionId}`, strip `ANTHROPIC_API_KEY`). Native branch: + omit both proxy vars and do **not** strip `ANTHROPIC_API_KEY`. Give + `buildSubprocessEnv` a `stripAnthropicApiKey: boolean` parameter (default + `true` so existing callers/tests are unaffected); native passes `false`. + Both modes keep stripping `VSCODE_*` / `ELECTRON_*` / `NODE_OPTIONS`. + - Files: [claudeSdkOptions.ts](./claudeSdkOptions.ts) (`buildOptions` L69, env block L82-86, `buildSubprocessEnv` L148-168). + - Depends on: step 2. + - Done when: proxy-branch option/env output is byte-identical to today (snapshot test); native-branch env omits proxy vars and preserves `ANTHROPIC_API_KEY`. + +4. ✓ **Thread the transport through materialization.** Replace + `IMaterializeContext.proxyHandle` with `transport: ClaudeTransport`. Update + `materialize()` and the rematerializer closure to pass `ctx.transport` into + `buildOptions`. + - Files: [claudeAgentSession.ts](./claudeAgentSession.ts) (`IMaterializeContext` L57-69, `materialize` ~L315, rematerializer ~L405). + - Depends on: step 3. + - Done when: both materialize and rematerialize compile with `transport` and the proxied path's captured `Options` are unchanged. + +5. ✓ **Flip `_isProxyEnabled()` to the real config read + reactivity.** Resolve a + `_transportMode: 'proxy' | 'native'` field in the constructor from + `getRootValue(...) ?? true`. Subscribe to `onDidRootConfigChange`: re-read, + update `_transportMode`, and on change re-run `_refreshModels()` and + re-resolve proxy ownership (acquire/dispose the proxy handle as needed). + `_isProxyEnabled()` becomes `this._transportMode === 'proxy'`. Config + changes affect **future** sessions only. + - Files: [claudeAgent.ts](./claudeAgent.ts) (`_isProxyEnabled` ~L384, constructor ~L300). + - Depends on: step 1. + - Done when: a unit test flipping the root value updates `_transportMode` and re-refreshes models; in-flight sessions are untouched. + +6. ✓ **Make auth / protected-resources / credits transport-aware.** In native + mode: `authenticate()` does **not** call `IClaudeProxyService.start`; + `_ensureAuthenticated()` returns the native transport without requiring a + proxy handle; `getProtectedResources()` omits + `GITHUB_COPILOT_PROTECTED_RESOURCE` (keep `GITHUB_REPO_PROTECTED_RESOURCE`); + the constructor's `onDidReportCredits` subscription is inert (no proxy fires + it) — guard `_enrichSignalWithCredits` on proxied transport for explicit + intent. `_materializeProvisional` / `_resumeSession` build a + `ClaudeTransport` from `_transportMode` and put it in `IMaterializeContext`. + Persist a `claude.transport` (`'proxy' | 'native'`) tag on the session + overlay at materialize time (forward-compat; not read for resolution in v1). + - Files: [claudeAgent.ts](./claudeAgent.ts) (`getProtectedResources` ~L325, `_ensureAuthenticated` ~L332, `authenticate` ~L344, credits sub ~L310, `_materializeProvisional` ~L577, `_resumeSession` ~L620); [claudeAgentSession.ts](./claudeAgentSession.ts) (`_enrichSignalWithCredits` L227); [claudeSessionMetadataStore.ts](./claudeSessionMetadataStore.ts) (new `transport` overlay field). + - Depends on: steps 4, 5. + - Done when: native mode never calls `start`, never subscribes credits, omits the Copilot resource, never throws `AHP_AUTH_REQUIRED`; the overlay carries the transport tag; proxied path unchanged. + +7. ✓ **Build the native model source.** Add + `IClaudeAgentSdkService.supportedModels(opts): Promise` + that owns the ephemeral lifecycle `startup({ options }) → warm.query() → query.supportedModels() → teardown` + (`supportedModels` is on `Query`, reached via `warm.query()` — **not** on + `WarmQuery`). Add `fromSdkModelInfo(m: ModelInfo, provider): IAgentModelInfo` + (exported, beside `toAgentModelInfo`): `id ← m.value`, `name ← + m.displayName`, `supportsVision: false`, `configSchema ← + createClaudeThinkingLevelSchema((m.supportedEffortLevels ?? + []).filter(isClaudeEffortLevel))`, no `policyState`, no `_meta`. Replace the + `resolveClaudeModelList` native `throw` with `() => + this._fetchNativeModels()`. + - Files: [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) (`IClaudeAgentSdkService` L41-60 + impl); [claudeAgent.ts](./claudeAgent.ts) (`fromSdkModelInfo` near `toAgentModelInfo`, `_fetchNativeModels`, `resolveClaudeModelList` native branch L126-135). + - Depends on: steps 3, 5. + - Done when: with the native branch forced, `models` populates from a stubbed `supportedModels()`; no `ICopilotApiService.models()` call; no new `IClaudeSdkBindings` drift-guard entry (`supportedModels` is a `Query` method, not a module export). + +8. ◐ **Native usage + diagnostics.** `recordTurnCredits` / + `_enrichSignalWithCredits` no-op in native (no proxy reports); `ChatUsage` + carries the SDK `result` usage/cost the live mapper already extracts. + Surface `system/init.apiKeySource` (`SDKSystemMessage`) for diagnostics / + per-turn metadata. + - Files: [claudeAgentSession.ts](./claudeAgentSession.ts) (`recordTurnCredits` L215, `_enrichSignalWithCredits` L227); [claudeMapSessionEvents.ts](./claudeMapSessionEvents.ts) (system/init handling). + - Depends on: step 6. + - Done when: a native turn's `ChatUsage` has no `_meta.copilotUsage` and carries SDK usage/cost; `apiKeySource` is logged/surfaced. + +9. ✓ **Tests.** Make `FakeQuery.supportedModels()` programmable (it throws + today); add `supportedModelsResult` to `FakeClaudeAgentSdkService`. Replace + the Phase 18 `TODO: Phase 19` native-branch assertion with a native + projection assertion. Add: transport resolution from the root value; + `onDidRootConfigChange` flip; native `authenticate` makes no `start` call; + native `getProtectedResources` omits Copilot; proxy-vs-native `buildOptions` + env snapshots; `buildSubprocessEnv(false)` preserves `ANTHROPIC_API_KEY`; + `fromSdkModelInfo` projection; native `ChatUsage` has no `copilotUsage`; + credential-absent (SDK 401) surfaces an actionable error and leaves `models` + empty. + - Files: [test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts), [test/node/claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts). + - Depends on: steps 1-8. + - Done when: existing proxied suite green; native coverage added. + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| [common/agentHostCustomizationConfig.ts](../../common/agentHostCustomizationConfig.ts) | modify | `ClaudeUseCopilotProxy` enum + boolean schema (`default: true`) | +| [claudeTransport.ts](./claudeTransport.ts) | create | `ClaudeTransport` discriminated union (+ optional `isProxyTransport` guard) | +| [claudeSdkOptions.ts](./claudeSdkOptions.ts) | modify | `buildOptions(transport)`, `buildSubprocessEnv(stripAnthropicApiKey)`; native omits proxy env, keeps API key | +| [claudeAgentSession.ts](./claudeAgentSession.ts) | modify | `IMaterializeContext.transport` replaces `.proxyHandle`; gate credit enrichment on proxy | +| [claudeAgent.ts](./claudeAgent.ts) | modify | `_transportMode` + reactive read; mode-aware auth/resources/credits; `fromSdkModelInfo`; `_fetchNativeModels`; native `resolveClaudeModelList` branch | +| [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) | modify | `supportedModels()` enumeration-lifecycle method (no new `IClaudeSdkBindings` entry) | +| [claudeSessionMetadataStore.ts](./claudeSessionMetadataStore.ts) | modify | persist forward-compat `claude.transport` overlay tag | +| [claudeMapSessionEvents.ts](./claudeMapSessionEvents.ts) | modify (maybe) | surface `system/init.apiKeySource` | +| [test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | modify | programmable `FakeQuery.supportedModels`; native transport/model/auth/credits suite | +| [test/node/claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts) | modify | proxy-vs-native env snapshots | + +## Decisions + +- **`supportedModels()` is on `Query`, not `WarmQuery`** — + [sdk.d.ts:1808](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L1808); + `WarmQuery` only exposes `query(prompt): Query` and `close()` + ([sdk.d.ts:4796](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L4796)). + Native enumeration must `startup() → warm.query() → + query.supportedModels() → teardown`, encapsulated in + `IClaudeAgentSdkService` so `ClaudeAgent` never touches the raw lifecycle. + (One council member assumed `warm.supportedModels()`; that method does not + exist.) +- **No new `IClaudeSdkBindings` / drift-guard entry.** `supportedModels` is a + `Query` method, not a top-level export of `@anthropic-ai/claude-agent-sdk`, + so the binding-drift assertion in + [claudeAgentSdkService.ts:241-257](./claudeAgentSdkService.ts) is unaffected; + the new method composes existing `startup` + `WarmQuery.query` bindings. +- **`ModelInfo → IAgentModelInfo` projection** — `ModelInfo` carries `value`, + `displayName`, `description`, `supportsEffort?`, + `supportedEffortLevels?: ('low'|'medium'|'high'|'xhigh'|'max')[]`, + `supportsAdaptiveThinking?` + ([sdk.d.ts:903](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L903)). + Map `value→id`, `displayName→name`, effort levels through + `createClaudeThinkingLevelSchema`; `supportsVision: false` (not in + `ModelInfo`); no token limits, no `policyState`, no `_meta`. +- **`apiKeySource` comes from `system/init`** (`SDKSystemMessage.apiKeySource`, + [sdk.d.ts:2944](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L2944)), + not a separate `Query.accountInfo()` call — it rides the init message the + session already receives. +- **Transport is data, not a DI service.** A discriminated union resolved once + and threaded down; no new injected service (rejected as boilerplate by two + council members). +- **Config default via schema, not `defaultAgentHostCustomizationConfigValues`.** + Boolean keys (`EnableCustomTerminalTool`, etc.) rely on the schema `default`; + `getRootValue` returns it when the key is absent. +- **RootConfigState, not a setting/env var/starter change** — it's the host's + own config plane (`getRootValue` + `onDidRootConfigChange`), reactive and + identical for local and remote hosts. (Roadmap-mandated; council unanimous.) +- **Native projection does not reuse `toAgentModelInfo`** — that projector + depends on `CCAModel` pricing/policy fields; `ModelInfo` is a different + shape. A separate `fromSdkModelInfo` keeps native metadata-free. +- **(Grilled) Native mode works with zero GitHub sign-in.** The SDK owns the + Anthropic credential, so in native mode `_ensureAuthenticated` returns the + always-ready native transport (never throws `AHP_AUTH_REQUIRED`) and + `getProtectedResources` drops the required `GITHUB_COPILOT_PROTECTED_RESOURCE` + (keeps the optional repo resource). Resolves R5. +- **(Grilled) Keep `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` in native mode.** + Most-private default — suppress nonessential Anthropic traffic even though the + user is on their own credentials. (Note: this also suppresses gateway + discovery, which native mode does not use — built-ins are unaffected.) +- **(Grilled) v1 = direct-key + OAuth only; no explicit Bedrock/Vertex.** Native + passes the user env through and lets the SDK resolve credentials; cloud + providers may work implicitly but are not a tested/documented v1 surface. +- **(Grilled) Persist a `claude.transport` overlay tag now.** v1 resolves + transport host-level, but the per-session tag is written at materialize time + so a future per-session-transport follow-up needs no data migration. + +## Risks + +- **R1 — proxied regression in `buildOptions`/`buildSubprocessEnv`.** The + proxy env block ([claudeSdkOptions.ts:82-86](./claudeSdkOptions.ts)) and the + `ANTHROPIC_API_KEY` strip ([:148-168](./claudeSdkOptions.ts)) are + load-bearing. → Keep the proxy branch byte-identical; lock with a snapshot + test and a `buildSubprocessEnv` default `stripAnthropicApiKey = true`. +- **R2 — enumeration query pollutes `~/.claude` sessions / `listSessions`.** + `warm.query()` may write a transcript. → Resolve via the cwd / + `CLAUDE_CONFIG_DIR` decision (see Decisions) and a `finally` teardown; + add a test asserting native model refresh does not grow `listSessions`. +- **R3 — `_transportMode` live-flip races a mid-materialize session.** → + Snapshot the transport at materialize entry; the flip only affects new + sessions. Document; never hot-swap a running subprocess. +- **R4 — native restored model id unknown to the current catalogue.** SDK + `value` ids differ from CAPI-dotted ids. → The picker already degrades to + `models[0]` (modelPicker.ts:144); `toSdkModelId` normalizes at the SDK seam. +- **R5 — dropping the Copilot protected resource breaks the auth gate.** + `createSession` calls `_ensureAuthenticated` immediately. → In native mode + `_ensureAuthenticated` returns the always-ready native transport (the SDK + owns credentials); it must not throw `AHP_AUTH_REQUIRED`. +- **R6 — binding-drift guard.** Adding raw SDK access could trip + `_assertBindingsMatchSdk`. → Avoided by composing existing bindings (R-free), + per Decisions. + +## Verification + +### Unit / Integration +- `runTests` on [test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) + and [test/node/claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts). +- `npm run typecheck-client`; `npm run valid-layers-check`. +- Coverage required: default proxied regression (env snapshot + credits + overlay), root-config flip → native, native makes no `start` call, native env + keeps `ANTHROPIC_API_KEY`, `fromSdkModelInfo` projection, native `ChatUsage` + without `copilotUsage`, credential-absent error. + +### E2E +- **Launch skill**: `launch` (`.agents/skills/launch`) — Playwright-drive Code + OSS `--agents` with isolated profile. +- **Log skill**: `code-oss-logs` (`.github/skills/code-oss-logs`) — read the + agent host log from the latest dev run. +- **Scenario (native):** + 1. With a real `ANTHROPIC_API_KEY` (or `claude login`) in the environment, + set `claudeUseCopilotProxy` to `false` in `agent-host-config.json` (or via + an AHP client `updateRootConfig`). Use `launch` to open Code OSS + `--agents`, select **Claude**, open the model picker. + 2. Use `code-oss-logs` to read the agent host log: confirm **no** + `ClaudeProxyService.start` line, **no** CAPI `models()` fetch, and a + `supportedModels` enumeration instead; confirm the SDK subprocess has no + `ANTHROPIC_BASE_URL` (e.g. `lsof`/log inspection) and the `system/init` + log shows `apiKeySource`. + 3. Send a turn; confirm it completes on the user's credentials and the usage + pill shows SDK cost (no Copilot credits). +- **Scenario (proxied regression):** flip `claudeUseCopilotProxy` back to + `true` → new sessions show Copilot-routed behavior, the proxy binds, the + model picker and credits render exactly as before. + +### Manual +- Resume a session created in one mode under the other → picker shows the + current-mode catalogue, resume succeeds, an unknown model id degrades to the + default. +- Credential-absent: with no Anthropic credential and proxy off, the first + turn fails with an actionable error and `models` stays empty. + +## Open Questions + +- **OQ1 — enumeration-query isolation (RESOLVED via E2E).** The initial decision + isolated `CLAUDE_CONFIG_DIR`. A real-SDK E2E against the user's signed-in + `claude` subscription overturned it: reading the **real `~/.claude`** (a) + surfaces the user's subscription models (e.g. `opus`) that the isolated dir + hides, and (b) writes **no** session (`listSessions` delta = 0 across two + enumerations) because the query is never iterated. **Final:** no isolation — + `buildModelEnumerationOptions()` uses `cwd = os.tmpdir()` and the real config + dir; the temp-dir dance was removed. +- **OQ2 — native model-list memoization (RESOLVED).** Re-enumerate on each + `_refreshModels` (no cache); the ephemeral query is cheap and `_refreshModels` + fires only on auth / `onDidRootConfigChange`. No invalidation surface added. + +## References + +- Roadmap: [roadmap.md](./roadmap.md) (Phase 19; Phase 18 for the SDK-bug background) +- Context glossary: [CONTEXT.md](./CONTEXT.md) — *Claude Agent*, *Claude Proxy*, *CAPI*, *Materialization* (M9), *M8* (usage asymmetry), *M11* (Options↔Query duality) +- SDK types: [sdk.d.ts](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts) — `ModelInfo` (903), `Query.supportedModels` (1808), `WarmQuery` (4796), `SDKSystemMessage.apiKeySource` (2944) +- E2E skills: `launch`, `code-oss-logs` + +## Implementation Notes + +> super-implementer run 2026-06-24. TDD per step; unit suite green throughout. + +### Files actually changed (production) +- [common/agentHostCustomizationConfig.ts](../../common/agentHostCustomizationConfig.ts) — `ClaudeUseCopilotProxy` enum + boolean schema (`default: true`). +- [claudeProxyService.ts](./claudeProxyService.ts) — **new** `ClaudeTransport` discriminated union (lives next to `IClaudeProxyHandle`, half of its union). +- [claudeSdkOptions.ts](./claudeSdkOptions.ts) — `buildOptions(transport)`, `buildSubprocessEnv(proxied = true)`, new `buildModelEnumerationOptions()`. +- [claudeAgentSession.ts](./claudeAgentSession.ts) — `IMaterializeContext.transport` replaces `.proxyHandle`; `_transportKind` gates `_enrichSignalWithCredits`; persists `claude.transport` overlay tag at materialize. +- [claudeAgent.ts](./claudeAgent.ts) — `_transportMode` + `_resolveTransportMode` (reactive via `onDidRootConfigChange`); mode-aware `getProtectedResources`/`_ensureAuthenticated` (now returns `ClaudeTransport`)/`authenticate`; `fromSdkModelInfo`; `_fetchNativeModels` (owns the `query(neverYield) → supportedModels() → close()` enumeration lifecycle, with a module-local never-yielding prompt). +- [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) — 1:1 `query(params)` passthrough to the SDK's `query` export (no bespoke enumeration lifecycle). +- [claudeSessionMetadataStore.ts](./claudeSessionMetadataStore.ts) — `claude.transport` overlay key (write + read). + +### Tests written +- [test/node/claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts) — native `buildSubprocessEnv(false)` keeps `ANTHROPIC_API_KEY`; proxy vs native `buildOptions` env (no `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN`, no key strip); existing proxy calls moved to `ClaudeTransport`. +- [test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) — 3-arg `resolveClaudeModelList` (proxy + native branches); `fromSdkModelInfo` projection (no `policyState`/`_meta`); native `getProtectedResources` omits Copilot; native models populate from `supportedModels()` with no proxy start / no CAPI call; native `authenticate` makes no `start` call. Harness gained a `rootConfig` override; `FakeClaudeAgentSdkService` gained programmable `supportedModels`. + +### Deviations / drift (surfaced) +- **Necessary drift (compile fixes, not in the plan's file list):** `IClaudeAgentSdkService.supportedModels` is implemented by two other test fakes — added stub methods + `ModelInfo` imports to [test/node/claudeSubagentResolver.test.ts](../../test/node/claudeSubagentResolver.test.ts) and [test/node/claudeAgent.integrationTest.ts](../../test/node/claudeAgent.integrationTest.ts). +- **`_resolveTransportMode` uses optional chaining on `_configurationService`** — several pre-existing minimal test service collections omit `IAgentConfigurationService` (DI injects `undefined` under non-strict mode). Production always provides it; absence resolves to the proxied default. +- **Step 8 partial (◐):** the credits no-op in native mode is **done** (the `_transportKind !== 'proxy'` guard plus the natural no-report path). Surfacing `system/init.apiKeySource` for diagnostics is **deferred** — see Deferred refactors; it's a non-blocking diagnostic that needs a small `claudeMapSessionEvents.ts` touch. + +### Deferred refactors +- **`apiKeySource` surfacing** — log/emit `SDKSystemMessage.apiKeySource` on `system/init` for native diagnostics. Deferred to keep the mapper untouched this pass; low risk, no behavior dependency. + +### E2E verification +- **Native model source (real SDK, real creds) — PASSED.** A direct probe ran the exact native enumeration lifecycle (`startup → warm.query(neverYield) → query.supportedModels() → teardown`) against the repo's `@anthropic-ai/claude-agent-sdk` and the user's signed-in `claude` subscription. `supportedModels()` returned a populated catalogue; reading the real `~/.claude` surfaced the subscription's **`opus`** model; `listSessions` delta was **0** (no pollution). This overturned OQ1 (removed the ephemeral `CLAUDE_CONFIG_DIR`). +- **Validation:** `runTests` claudeAgent.test.ts + claudeSdkOptions.test.ts + claudeSubagentResolver.test.ts = **166 passing**; `npm run typecheck-client` clean; `npm run valid-layers-check` clean. +- **Agents-window UI launch (native mode) — PASSED.** Launched Code OSS + `--agents` with `claudeUseCopilotProxy: false` (seeded, restored after). + The model picker rendered the native catalogue ("Default (recommended)" + selected — an SDK model name, not a CAPI one), and the agent host log + confirmed: `[Claude] Models refreshed. Count: 5, Default (recommended), + Sonnet, Sonnet (1M context), Haiku, Opus` (the user's subscription **Opus** + surfaced) with **no Claude proxy start** (the only "Auth token updated" line + is the sibling `[Copilot]` agent). Drove via `launch` + `@playwright/cli`; + read via `code-oss-logs`. + +### avoid-private-methods +- **Clean.** The new private methods (`_resolveTransportMode`, `_fetchNativeModels`, `_fetchProxyModels`, `_ensureAuthenticated`) are thin single-purpose orchestration; all genuinely testable logic is already extracted as pure exported functions (`fromSdkModelInfo`, `resolveClaudeModelList`, `buildModelEnumerationOptions`, `buildSubprocessEnv`) and unit-tested directly. No findings. diff --git a/src/vs/platform/agentHost/node/claude/phase6.5-plan.md b/src/vs/platform/agentHost/node/claude/phase6.5-plan.md new file mode 100644 index 0000000000000..e96553dda5b47 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase6.5-plan.md @@ -0,0 +1,209 @@ +# Phase 6.5 — Fork + +> Generated by super-planner. Source: [roadmap.md](./roadmap.md) (Phase 6.5). +> Last updated: 2026-06-24 after 3-model council synthesis (GPT-5.5, Claude Opus 4.6, GPT-5.3-Codex) + grill-me session. + +**Status:** ✅ done — unit/integration green (171 tests), typecheck/layers/eslint clean, live E2E (fork-and-continue + restart restore) verified 2026-06-24. See Implementation Notes. + +## Goal + +Make `ClaudeAgent.createSession({ fork })` work. Today it throws `TODO: Phase 6.5` at [claudeAgent.ts](./claudeAgent.ts) (the `if (config.fork)` branch in `createSession`). Fork takes an existing Claude session plus a protocol `turnId` (the last KEPT turn N — keep `[0..N]` INCLUSIVE), forks the SDK's JSONL transcript at the matching SDK envelope `uuid`, and brings up a brand-new session that the SDK loads via `Options.resume`. This honours the workbench's "truncate-by-fork" semantic — Claude has no in-place `truncateSession`, so fork is the only truncate path. + +The chosen approach walks the source transcript on demand (via `getSessionMessages`) to translate `turnId` → SDK envelope `uuid`, because the SDK's `forkSession({ upToMessageId })` accepts an envelope uuid, not a protocol turn id, and offers no translation helper. A prior heuristic that inferred turn boundaries by scanning raw JSONL frame shapes was fully reverted; this plan is the contract-based replacement and **must not reintroduce that heuristic**. + +## Scope + +**In scope** + +- New SDK binding `forkSession(sessionId, options?)` on `IClaudeAgentSdkService` + `IClaudeSdkBindings` + the passthrough impl. +- A pure resolver that walks `SessionMessage[]` and returns the fork-anchor `upToMessageId` for a given protocol `turnId` (the uuid of the **last `type:'assistant'` envelope of turn N**, INCLUSIVE). +- The `createSession({ fork })` branch: source-session sequencing, transcript fetch, anchor resolution, `forkSession` call, eager materialization via `_resumeSession`, inherited-metadata write. +- Unit tests for the resolver (fixture-driven) + agent-level fork tests + a fake-SDK `forkSession`. + +**Out of scope** + +- **In-place `truncateSession`** — explicitly NOT implemented (owned by the roadmap non-goals; truncate is fork-by-another-name; see [phase13-plan.md](./phase13-plan.md)). +- **Subagent forking** — forking a `/subagent/` URI is not supported; reject early (Phase 12 owns subagent transcripts). +- **Per-turn SDK-event-id remapping** — Copilot's `remapTurnIds` ([copilotAgent.ts](../copilot/copilotAgent.ts) ~1156) operates on a per-turn event-id DB that Claude does not maintain. `config.fork.turnIdMapping` is a no-op for Claude. +- **Persisting a `turnId → uuid` map** — reverted in Phase 13 and not reintroduced. Fork reconstructs the mapping on demand (one JSONL read per fork; fork is rare and cold). +- **Whether the workbench shows/hides the fork affordance for Claude** — workbench-side concern, tracked with workbench owners. + +## Prerequisites + +- Phase 4 (provider scaffold) ✅, Phase 5 (provisional/materialize lifecycle + SDK service) ✅, Phase 6 (live pipeline) ✅, Phase 9 (abort/steering) ✅, Phase 13 (replay mapper + `getSessionMessages` SDK binding) ✅. +- SDK 0.3.x exposes `forkSession(_sessionId, _options?)` ([sdk.d.ts:685](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L685)), `ForkSessionOptions.upToMessageId` (INCLUSIVE, [sdk.d.ts:690-693](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L690)), and `ForkSessionResult.sessionId` (resumable via `query({ options: { resume } })`, [sdk.d.ts:700-703](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L700)). + +## Approach + +Add `forkSession` to the Claude SDK shim. Add a pure transcript resolver — co-located in [claudeReplayMapper.ts](./claudeReplayMapper.ts) so it shares `parseSessionMessage` and the exact turn-boundary rule the replay builder uses (`Turn.id = SessionMessage.uuid` of the `user-text` envelope at [claudeReplayMapper.ts:199](./claudeReplayMapper.ts#L199)). In `createSession`, replace the throw with: queue on the **source** session id, fetch the source transcript with `{ includeSystemMessages: true }`, resolve the anchor uuid, call `forkSession(sourceId, { upToMessageId })`, write the inherited overlay (model / permissionMode / agent / customizationDirectory) to the **new** session URI, then delegate to `_resumeSession(newSessionId, newSessionUri)` — which materializes eagerly with `Options.resume` and fires `onDidMaterializeSession`. Fork is therefore non-provisional: it returns a fully-materialized session, not a deferred one (CONTEXT M9). + +## Steps + +1. ✓ **Add the `forkSession` SDK binding.** Extend the interface, the narrowed bindings, and the passthrough. + - Files: [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) + - Add `forkSession(sessionId: string, options?: ForkSessionOptions): Promise` to `IClaudeAgentSdkService` (line 41) and `IClaudeSdkBindings` (line 79); add the passthrough on `ClaudeAgentSdkService` (delegates to `sdk.forkSession`). Import `ForkSessionOptions` / `ForkSessionResult` types (line 6). + - Depends on: none + - Done when: a stubbed binding returns a `{ sessionId }` and `typecheck-client` is clean (the bindings interface is structurally checked against the SDK module on `import()`). + +2. ✓ **Add the pure fork-anchor resolver.** New exported function in the replay mapper. + - Files: [claudeReplayMapper.ts](./claudeReplayMapper.ts) + - Signature: `resolveForkAnchorUuid(messages: readonly SessionMessage[], turnId: string): string | undefined`. + - Algorithm (single linear pass, reusing `parseSessionMessage`): + - Track `currentTurnId`; on every `'user-text'` parsed message set `currentTurnId = msg.uuid` (the same rule that makes `Turn.id` at [claudeReplayMapper.ts:199](./claudeReplayMapper.ts#L199)). + - While `currentTurnId === turnId`, record each `'assistant'` message's `uuid` as the running candidate. `'user-tool-results'` and `'system-notification'` do **not** flip the turn (mirrors [claudeReplayMapper.ts:103-126](./claudeReplayMapper.ts#L103)). + - Stop when the next `'user-text'` after the target turn is seen, or at end-of-array. + - Return the last recorded assistant uuid for the target turn. If the target turn was seen but had **no** assistant envelope (user-only / aborted-mid-turn), fall back to the `turnId` (the user-text envelope uuid) itself — INCLUSIVE keep of `[0..N]` still holds. If `turnId` was **never** seen, return `undefined`. + - Depends on: none (pure) + - Done when: the resolver unit fixtures (Verification) pass. + +3. ✓ **Implement the fork branch in `createSession`.** Replace the throw. + - Files: [claudeAgent.ts](./claudeAgent.ts) + - Reject early (clear error, no SDK contact) when: `isSubagentSession(config.fork.session)` is true; or the source session is present in `_sessions` but still provisional (no transcript to anchor on). + - `_sessionSequencer.queue(sourceSessionId, async () => { … })` — serialize against the source like Copilot ([copilotAgent.ts](../copilot/copilotAgent.ts) ~1118) so a concurrent `sendMessage` can't mutate the transcript mid-read. + - Inside the queue: `const messages = await this._sdkService.getSessionMessages(sourceSessionId, { includeSystemMessages: true })`; `const upToMessageId = resolveForkAnchorUuid(messages, config.fork.turnId)`; throw a clear error if `undefined`. + - `const { sessionId: newSessionId } = await this._sdkService.forkSession(sourceSessionId, { upToMessageId })`. + - Read the source overlay (`this._metadataStore.read(config.fork.session)`), then write the inherited fields to the new session URI: `this._metadataStore.write(newSessionUri, { model, permissionMode, agent })` **before** `_resumeSession` (resume reads the new URI's overlay and `materialize({ isResume: true })` skips overlay writes — [claudeAgentSession.ts](./claudeAgentSession.ts) materialize). Do **not** inherit `customizationDirectory` (see Decisions). Apply `config.model` / `config.agent` overrides over the inherited values when supplied. + - `await this._resumeSession(newSessionId, newSessionUri)` — eager materialize + `onDidMaterializeSession` fire. + - Return `{ session: newSessionUri, workingDirectory, ...(project ? { project } : {}) }` (NOT `provisional: true`). + - Add a one-line comment documenting that `config.fork.turnIdMapping` is intentionally ignored (no Claude per-turn event-id store). + - Depends on: steps 1, 2 + - Done when: the agent-level fork integration tests pass. + +4. ✓ **Extend the test fake + add fork tests.** + - Files: [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) + - Add `forkSession` to `FakeClaudeAgentSdkService` (capture calls, programmable result + rejection; default `{ sessionId: 'forked-…' }`). It already has `sessionMessagesById` / `getSessionMessagesCalls` for transcript fixtures. + - Convert the existing `'createSession({ fork }) throws TODO: Phase 6.5 with no side effects'` test (~line 1308) into success-path assertions. + - New agent tests: fork calls `getSessionMessages(src, { includeSystemMessages: true })`; calls `forkSession` with the resolved uuid; materializes with `resume` in the startup options; fires `onDidMaterializeSession` with the new URI; returns a non-provisional result; inherits source model/permissionMode; subagent-URI source rejects; provisional source rejects; `turnId` not found rejects. + - Depends on: steps 1–3 + - Done when: the suite is green. + +5. ✓ **Add resolver unit tests.** + - Files: NEW section in [claudeReplayMapper.test.ts](../../test/node/claudeReplayMapper.test.ts) (the resolver lives in the same module). + - Fixtures: 3-turn transcript → fork at turn 0 / 1 / 2 each returns the correct last-assistant uuid; a turn with multiple assistant envelopes (text envelope then tool_use envelope) returns the **last** one; `user-tool-results` between assistant envelopes does not flip the turn; a `system-notification` mid-turn does not flip the turn; user-only target turn (no assistant) falls back to the user-text uuid; `turnId` not found → `undefined`; empty transcript → `undefined`; CLI-echo user envelopes are skipped by the shared parser. + - Depends on: step 2 + - Done when: fixtures green, `typecheck-client` / `valid-layers-check` / `eslint` / `hygiene` clean. + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) | modify | Add `forkSession` to interface + bindings + passthrough | +| [claudeReplayMapper.ts](./claudeReplayMapper.ts) | modify | Add exported `resolveForkAnchorUuid()` pure helper | +| [claudeAgent.ts](./claudeAgent.ts) | modify | Replace the `config.fork` throw in `createSession` with the fork flow | +| [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | modify | Fake `forkSession`; convert the throw test; add fork tests | +| [claudeReplayMapper.test.ts](../../test/node/claudeReplayMapper.test.ts) | modify | Add `resolveForkAnchorUuid` fixtures | +| [claudeAgent.integrationTest.ts](../../test/node/claudeAgent.integrationTest.ts) | modify | Add a `forkSession` no-op stub on `ProxyRoundTripSdkService` to satisfy the widened interface | + +## Decisions + +Resolved during the grill-me session: + +- **Resolver location — `claudeReplayMapper.ts`, not a new module.** 2 of 3 council agents and the grill agreed: the resolver must use the *exact* turn-boundary rule the replay builder uses (`user-text` flips the turn, `user-tool-results` / `system-notification` do not). Co-locating with `parseSessionMessage` keeps that rule as a single source of truth; a separate module would risk parser drift (the precise failure mode the reverted heuristic hit). The dissenting model's "avoid overloading the mapper" concern is outweighed by the drift risk. +- **Last-turn fork — pass the resolved last-assistant uuid, do NOT omit `upToMessageId`.** Omitting means "full copy" ([sdk.d.ts:692](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L692)), which for the last turn is *equivalent* — but passing the explicit uuid keeps one code path for all turns and avoids a "compute total turn count" special case. Rationale: fewer branches, identical result. +- **User-only / aborted-mid-turn target turn — fall back to the user-text uuid.** `upToMessageId` is INCLUSIVE and accepts any envelope uuid; anchoring on the user-text envelope keeps turns `[0..N]` including that user message, which is the correct keep semantic. Throwing here would needlessly fail a legitimate "fork after my last (unanswered) message" case. +- **`turnIdMapping` is a no-op for Claude.** Copilot's `remapTurnIds` rewrites a per-turn SDK-event-id DB; Claude stores no such map and the SDK's `forkSession` already remaps every internal message uuid. Document with a one-line comment at the call site. +- **Metadata inheritance — write the source overlay to the new URI before `_resumeSession`.** `materialize({ isResume: true })` skips the overlay write and `_resumeSession` reads the *new* session's overlay (which is empty right after fork). Writing the inherited `{ model, permissionMode, agent }` first (then overlaying `config.model` / `config.agent` overrides) makes the forked session start with the parent's selections. `_metadataStore.write` is only-write-on-defined ([claudeSessionMetadataStore.ts:72](./claudeSessionMetadataStore.ts#L72)). +- **`customizationDirectory` is NOT inherited.** The source overlay's `customizationDirectory` points at the per-session synced client-customization dir from Phase 11; copying it would couple two sessions to one mutable on-disk dir that may be stale or cleaned up independently. The forked session re-syncs its own customizations on first `setClientCustomizations` / materialize, matching how a fresh session bootstraps. Inherit only `model` / `permissionMode` / `agent`. +- **Provisional / never-sent source is rejected with a clear error.** The agent *service* normally drops `config.fork` when the source has no turns (treating it as a fresh create — see [copilotAgent.ts](../copilot/copilotAgent.ts) ~1110), so this case shouldn't reach the agent. When it does, a hard error (`Cannot fork a provisional/never-sent session`) surfaces the contract violation rather than masking it with a silent fresh-create fallback. +- **Forked-session `cwd` — rely on `_resumeSession`, verify in E2E.** Reuse `_resumeSession`'s SDK `cwd` read rather than speculatively threading an explicit `workingDirectory`. The E2E run (Verification step 5) confirms cwd survives the fork; only if it doesn't does the fork branch grow an explicit-workingDirectory resume path. Avoids speculative defensive code. +- **Reuse `_resumeSession`, do not build a parallel materialize path.** It already constructs the provisional session, wires the progress/customization events, registers in `_sessions`, calls `materialize({ isResume: true })`, and fires `onDidMaterializeSession` ([claudeAgent.ts](./claudeAgent.ts) `_resumeSession`). Fork is "prepare overlay, then delegate." +- **Source-session sequencing.** Queue on the source id (`_sessionSequencer.queue(sourceSessionId, …)`) so the transcript read + fork can't race an in-flight `sendMessage` on the source. The new session id is SDK-generated and registered inside the closure; no key collision. + +## Risks + +- **Forked-session `cwd` survival.** `_resumeSession` reads `cwd` from `getSessionInfo(newSessionId)` and **throws** if it's missing ([claudeAgent.ts](./claudeAgent.ts) `_resumeSession`); `SDKSessionInfo.cwd` is optional ([sdk.d.ts:3929](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L3929)). `forkSession` copies transcript messages, so the forked session file *should* carry the parent's `cwd` — but this is unverified SDK behavior. **Mitigation:** the E2E scenario explicitly checks fork succeeds and the working directory matches; if `getSessionInfo` returns no `cwd` for forks, the fork branch must pass `config.workingDirectory ?? sourceWorkingDirectory` explicitly into a fork-aware resume rather than relying on `_resumeSession`'s SDK read. Decide based on the live run before declaring done. +- **Parser drift between resolver and replay builder.** If the resolver's turn-boundary detection diverges from `ReplayBuilder.consume`, a fork anchors on the wrong turn. **Mitigation:** the resolver reuses `parseSessionMessage` and lives in the same file; add a cross-check fixture that runs a transcript through both `mapSessionMessagesToTurns` (asserting `Turn.id`s) and `resolveForkAnchorUuid` (asserting the matching anchor) so a future change to one without the other fails. +- **Wrong id-class to `forkSession`.** `upToMessageId` is the SDK **envelope uuid** (`SessionMessage.uuid`), not the Anthropic `msg_…` id ([phase13-plan.md](./phase13-plan.md) id-correctness note). **Mitigation:** the resolver only ever returns `msg.uuid` values; never `message.id`. A fixture asserts the returned value equals an envelope uuid, not a `msg_…` string. +- **Provisional / external / missing source.** A provisional in-memory source has no transcript ([claudeAgent.ts](./claudeAgent.ts) `getSessionMessages` returns `[]` for provisional). **Mitigation:** reject provisional sources before the SDK call; external CLI sources with no overlay DB still fork fine (overlay read returns `{}`); `_resumeSession` already throws clearly when the SDK session info is absent. + +## Verification + +### Unit / Integration + +- **Resolver fixtures** ([claudeReplayMapper.test.ts](../../test/node/claudeReplayMapper.test.ts)): the 9 cases in Step 5. +- **Agent fork integration** ([claudeAgent.test.ts](../../test/node/claudeAgent.test.ts)): the cases in Step 4 — correct `forkSession` uuid, `resume` startup option, `onDidMaterializeSession` fire, non-provisional result, metadata inheritance, and the three rejection paths (subagent source, provisional source, turnId-not-found). +- **Hygiene** (per [.github/copilot-instructions.md](../../../../../../.github/copilot-instructions.md)): `typecheck-client`, `valid-layers-check`, `npm run eslint`, `npm run gulp hygiene` — all clean. + +Run via the `runTests` tool against the two test files, or `scripts/test.sh --grep "Phase 6.5|fork|resolveForkAnchorUuid"`. + +### E2E + +Workspace skills available: + +- **Launch skill**: [`launch`](../../../../../../.agents/skills/launch/SKILL.md) — Playwright-driven Code OSS automation (open the Agents window, drive chat, screenshot). +- **Log skill**: [`code-oss-logs`](../../../../../../.github/skills/code-oss-logs/SKILL.md) — read agent-host / renderer logs from the dev build. + +**Scenario — fork-and-continue** (mirrors [smoke.md](./smoke.md) conventions): + +1. Use `launch` to start Code OSS Agents (`./scripts/code.sh --agents`), open the Agents window, pick the **Claude** agent, create a session, and send **three** sequential prompts so the transcript has ≥3 turns (e.g. "say apple", "say banana", "say cherry"), letting each turn settle. Screenshot the full transcript. +2. Trigger fork at turn 2 (the "banana" turn) via the workbench's fork/edit-and-resubmit affordance (keep `[0..1]` inclusive semantic) — or, if the affordance isn't wired for Claude yet, drive `createSession({ fork: { session, turnId, turnIndex } })` through the test harness against the live agent host. +3. Confirm a **new** session appears, pre-populated with turns 0–1 (apple, banana) and **not** turn 2's follow-up (cherry). Screenshot for comparison. +4. Send a new prompt on the forked session ("say date") and confirm it lands cleanly and the assistant responds — validates the forked session is live (`resume` worked) and accepts new turns. +5. Use `code-oss-logs` to read the agent-host log for the run; grep for `[Claude]` warnings during fork — none expected on the happy path. Confirm the forked session's working directory matches the source (validates the `cwd`-survival risk). +6. Quit and relaunch the agent host; re-open the forked session and confirm its transcript restores (turns 0–1 + the new turn), exercising fork ✕ Phase 13 restoration together. + +### Manual + +- **Fork the last turn.** Fork at the final turn of a 2-turn session; confirm both turns carry over and a new turn appends — exercises the "no next user-text" path of the resolver. +- **Fork a turn with no assistant reply.** Abort a turn mid-stream (Phase 9 abort), then fork at that turn; confirm the user message carries over (user-text fallback anchor). + +## Open Questions + +_None remaining._ All grilling-phase questions resolved — see Decisions. The one residual unknown (forked-session `cwd` survival) is tracked as a **Risk** with a concrete decision point in the E2E run, not an open design question. + +## References + +- Roadmap: [./roadmap.md](./roadmap.md) (Phase 6.5) +- Architecture context: [./CONTEXT.md](./CONTEXT.md) (M7 transcript grouping, M9 provisional/materialize lifecycle, glossary `Turn.id`) +- Phase 13 plan (mapper + `getSessionMessages` binding, id-correctness note): [./phase13-plan.md](./phase13-plan.md) +- Copilot fork reference (NOT directly portable — uses `getNextTurnEventId` + DB `vacuumInto` + `remapTurnIds`): [../copilot/copilotAgent.ts](../copilot/copilotAgent.ts) (`createSession` fork branch ~1108-1170) +- Production-extension reference (NOT portable — message-index + EXCLUSIVE "fork before request"): [`extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts`](../../../../../../extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts#L387) +- SDK contract: [`forkSession`](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L685) / [`ForkSessionOptions`](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L690) / [`ForkSessionResult`](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L700) +- E2E skills: [launch](../../../../../../.agents/skills/launch/SKILL.md), [code-oss-logs](../../../../../../.github/skills/code-oss-logs/SKILL.md) +- Council models consulted: GPT-5.5, Claude Opus 4.6, GPT-5.3-Codex (3 independent plans synthesized) + +## Implementation Notes + +Implemented via super-implementer + TDD. All unit / integration suites green (170 tests across `claudeAgent.test.ts`, `claudeReplayMapper.test.ts`, `claudeSubagentResolver.test.ts`), `typecheck-client` clean, `valid-layers-check` clean, `eslint` clean on all changed files. + +### Files actually changed + +Production: +- [claudeReplayMapper.ts](./claudeReplayMapper.ts) — added exported `resolveForkAnchorUuid(messages, turnId)` reusing `parseSessionMessage` (single-pass, `seenTarget` + last-assistant accumulator, fallback to the user-text uuid, `undefined` when the turn is absent). +- [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) — added `forkSession` to `IClaudeAgentSdkService` + `IClaudeSdkBindings` + the passthrough impl (validated against the real SDK by the existing `AssertBindingsMatchSdk` compile-time guard). +- [claudeAgent.ts](./claudeAgent.ts) — replaced the `config.fork` throw in `createSession` with a delegation to a new private `_forkSession(config, fork)` method: subagent/provisional rejection, source-session sequencing, transcript fetch, anchor resolution, `forkSession`, inherited-overlay write (`model`/`permissionMode`/`agent`, NOT `customizationDirectory`), then `_resumeSession` for eager materialization. `turnIdMapping` intentionally ignored (documented at the method). + +Tests: +- [claudeReplayMapper.test.ts](../../test/node/claudeReplayMapper.test.ts) — new `resolveForkAnchorUuid` suite (10 fixtures: fork at each of 3 turns, multiple-assistant turn, tool-result non-boundary, system-notification non-boundary, user-only fallback, not-found, empty, CLI-echo skip). +- [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) — added `forkSession` to `FakeClaudeAgentSdkService` (call capture + programmable result/rejection); replaced the Phase 6.5 throw test with 7 fork tests (happy-path snapshot, last-turn anchor, source-permissionMode inheritance, config-model override persistence, turnId-not-found rejection, subagent-source rejection, provisional-source rejection); added a module-scope `forkSourceMessages` helper. + +### Drift from plan + +- **Three extra test fakes needed `forkSession` stubs** (not in the plan's Files table): widening `IClaudeAgentSdkService` broke conformance of `FakeSdkService` in [claudeSubagentResolver.test.ts](../../test/node/claudeSubagentResolver.test.ts) and the two inline `IClaudeSdkBindings` literals in [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts)'s SDK-service forwarding tests, plus the planned [claudeAgent.integrationTest.ts](../../test/node/claudeAgent.integrationTest.ts) stub. All are trivial mechanical conformance stubs (`throw 'not implemented'` / `{ sessionId }`), surfaced here per the no-silent-drift rule. +- **`_forkSession` is a private method**, mirroring the existing `_resumeSession` / `_materializeProvisional` orchestration methods. The genuinely unit-testable pure logic (`resolveForkAnchorUuid`) is already a free exported function, so the avoid-private-methods principle is satisfied without extraction. **avoid-private-methods: clean** (manual audit; the skill is not installed in this workspace). +- **Fork defers the SDK `Query` to the first `sendMessage` (NOT eager `_resumeSession`).** The plan (Approach, Step 3, the "Reuse `_resumeSession`" Decision) called for fork to call `_resumeSession` and materialize eagerly, firing `onDidMaterializeSession` from inside `createSession`. The **live E2E caught two ordering bugs** with that design: `_resumeSession` runs *inside* `provider.createSession`, but `AgentService.createSession` registers the forked session's state only *after* `createSession` returns. So the eager materialize fired (a) `onDidMaterializeSession` → `[AgentService] onDidMaterializeSession for unknown session`, and (b) pipeline progress signals (`session/serverToolsChanged`) → `[AgentHostStateManager] Action for unknown session`, both landing before the state existed. The fix matches **CONTEXT M9** precisely ("`forkSession` only writes the new session file; it does not start a `Query` … the SDK `Query` is still not started until the first `sendMessage`") and Copilot (whose `_resumeSession`/`_doResumeSession` never fires the materialize event): `_forkSession` now resolves the forked `workingDirectory` + `project` from `getSessionInfo(newSessionId)` and returns a **non-provisional** result **without** materializing. The first `sendMessage` finds no in-memory entry and resumes from disk via `_resumeSession` (which fires the materialize event then — by which point `AgentService` has registered the session). Both warnings re-verified at **count 0** in a second live run. The main fork test now drives a follow-up `sendMessage` to assert the resume handoff (`startupResume === 'forked-1'`, materialize fires once after send, none at fork time). + +### Live E2E — ✅ verified 2026-06-24 + +Ran the fork-and-continue scenario against a live, authenticated Agents window +(`launch` skill, short TMPDIR per the macOS socket gotcha) and read the +agent-host log (`code-oss-logs`). + +1. Created a Claude (Local Agent Host) session, sent 3 turns (apple → banana → + cherry); each turn settled with the expected one-word reply. Session + materialized on first send. +2. Clicked the workbench's **"Fork conversation from this point"** affordance on + the banana turn → a new "Forked: …" session appeared, pre-populated with + apple + banana and **not** cherry (correct `[0..N]` INCLUSIVE truncation). + So the fork affordance **is** wired for Claude. +3. Sent a new turn on the fork ("date" / "elderberry") → landed cleanly and the + assistant responded, confirming `Options.resume` brought the forked + transcript live. +4. Quit + relaunched the agent host, re-opened the forked session → transcript + restored (fork ✕ Phase 13 replay), and the fork affordance worked again on + the restored session. +5. **`cwd`-survival Risk: RESOLVED.** `getSessionInfo(forkedId).cwd` returned the + parent's working directory; the forked session's working dir matched the + source. No explicit-`workingDirectory` fallback was needed. +6. **Log: clean.** Zero `[Claude]` warnings/errors across both runs; the two + ordering warnings below are both at count 0 (see drift note). diff --git a/src/vs/platform/agentHost/node/claude/phase6.7-plan.md b/src/vs/platform/agentHost/node/claude/phase6.7-plan.md new file mode 100644 index 0000000000000..e10f42ede0bb2 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase6.7-plan.md @@ -0,0 +1,227 @@ +# Phase 6.7 — Restore Checkpoint via in-place `truncateSession` + +> Generated by super-planner. Source: [roadmap.md](./roadmap.md) (Phase 6.7). +> Last updated: 2026-06-24 after 3-model council synthesis (GPT-5.5, Claude Opus 4.6, GPT-5.3-Codex) + focused grilling (`grill-with-docs` not installed in this workspace; used `grill-me`-style questioning). + +**Status:** ✅ done *(2026-06-26 — unit/integration green (427 Claude tests); gates clean; live E2E verified both flows against real Claude — "Restore Checkpoint" point-restore (BANANA/ELEPHANT) and "Start Over" remove-all, each across two cycles with zero errors. See Implementation Notes.)* + +## Goal + +Implement the optional `IAgent.truncateSession?(session, turnId?)` contract ([agentService.ts:1081](../../common/agentService.ts#L1081)) for the Claude agent so the workbench "Restore Checkpoint" feature works for Claude. Truncation must be **in place** — the protocol session URI and SDK session id stay the same (unlike fork, which mints a new id). The mechanism is the Claude SDK's `Options.resumeSessionAt` ([sdk.d.ts:1356](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L1356)): when a session is (re)started with `Options.resume = ` **plus** `Options.resumeSessionAt = `, the SDK loads only the transcript up to and including the anchor. `turnId` = the last turn to KEEP (inclusive); omitted = remove all turns. + +## Scope + +**In scope** + +- `ClaudeAgent.truncateSession(session, turnId?)` — the `IAgent` implementation: source sequencing, provisional short-circuit, anchor resolution, restart with `resumeSessionAt`, local DB prune. +- Plumb an optional, one-shot `resumeSessionAt` through `buildOptions` → SDK `Options`. +- A pending-anchor mechanism on `ClaudeAgentSession` consumed by the **next** materialize/rebind so the truncated context applies to the next turn (matches the decided option-(a) write-timing). +- Remove-all (`turnId === undefined`) path: SDK `deleteSession` + recreate a fresh session under the **same** id/URI (no `resumeSessionAt`). +- Prune local per-turn DB state (`deleteTurnsAfter` / `deleteAllTurns`) so file-edit / checkpoint rows stay consistent with the truncated transcript. +- Unit + integration tests; extend the test SDK fake to capture `resumeSessionAt`. + +**Out of scope** + +- **Client-side file rollback** — already handled, provider-agnostic, by [`AgentHostSnapshotController.restoreSnapshot`](../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSnapshotController.ts), which writes the captured **before-content back to disk** for the undone requests' edits. It is fed by Claude's `FileEditTracker` / `ClaudeFileEditObserver` records (Phase 8), stored as `session-db:` before/after URIs — the **same DB-backed path Copilot uses**, so file rollback already works for Claude. This phase does **not** use the SDK's `rewindFiles` / `enableFileCheckpointing`: per Phase 8 decisions D2/D3, `rewindFiles` has the wrong granularity (it bulk-rewinds *all* tracked files at once, not per-checkpoint/per-file selective) and would duplicate the cross-provider snapshot path. (`enableFileCheckpointing: true` is already set on `Options` as forward-compat prep, but no production code calls `rewindFiles`. Separately, the SDK docs note `rewindFiles` does not rewind the conversation either — so it could never substitute for `truncateSession`.) +- **Forcing a durable on-disk truncation at truncate time** (rejected "option (b)" — sentinel write / self-compaction). Truncation finalizes when the next turn writes the branch; see Decisions. +- **Fork** (Phase 6.5, `_forkSession`) — truncate uses `resume` + `resumeSessionAt`, never `forkSession`. +- **Workbench-side `ChatTruncated` dispatch / `requestDisablement`** — owned by [`agentHostSessionHandler.ts`](../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts); this phase only implements the agent-side handler. + +## Prerequisites + +- Phase 6.5 (fork) ✅ — provides `resolveForkAnchorUuid` ([claudeReplayMapper.ts:70](./claudeReplayMapper.ts#L70)). +- Phase 13 (transcript reconstruction / `getSessionMessages` SDK binding) ✅. +- Phase 5/6/9/10 lifecycle (`materialize`, `_resumeSession`, rematerializer/`rebindForRestart`, `_sessionSequencer`) ✅. +- SDK exposes `Options.resumeSessionAt` (used with `resume`, anchor = `SDKAssistantMessage.uuid`) ([sdk.d.ts:1351-1356](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L1351)). + +## Approach + +Add `ClaudeAgent.truncateSession`, serialized through the existing `_sessionSequencer` ([claudeAgent.ts:262](./claudeAgent.ts#L262)) on the **same key** as `sendMessage` so the `ChatTruncated` → `ChatTurnStarted` dispatch pair ([agentSideEffects.ts:911](../agentSideEffects.ts#L911)) stays ordered. For `turnId !== undefined`: fetch the transcript with `getSessionMessages(sessionId, { includeSystemMessages: true })`, resolve the anchor uuid with `resolveForkAnchorUuid`, store it as a **pending one-shot anchor** on the `ClaudeAgentSession`, and prune the local DB. The anchor is **not** applied by an eager Query restart; instead it is consumed by the buildOptions of the **next** materialize/rebind (the next `sendMessage`), which matches the decided option-(a) write-timing and avoids a double-rebind dropping it. Remove-all (`turnId === undefined`) takes a separate path: dispose the live `Query`, SDK `deleteSession(sessionId)`, recreate a fresh provisional session under the same id/URI, and prune all turns. + +## Steps + +1. ✓ **Plumb `resumeSessionAt` into `buildOptions`.** + - Files: [claudeSdkOptions.ts](./claudeSdkOptions.ts) + - Add `readonly resumeSessionAt?: string` to `IBuildOptionsInput` (~line 27). In the resume branch (~113-115), project `...(input.isResume && input.resumeSessionAt ? { resumeSessionAt: input.resumeSessionAt } : {})` alongside `{ resume: input.sessionId }`. Never emit it in the non-resume (`sessionId`) branch. + - Depends on: none + - Done when: `buildOptions({ …, isResume: true, resumeSessionAt: 'x' })` yields `{ resume: id, resumeSessionAt: 'x', … }`; omitting it yields no `resumeSessionAt` key (covered by [claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts)). + +2. ✓ **Add a pending one-shot anchor on `ClaudeAgentSession`, consumed by both build paths.** + - Files: [claudeAgentSession.ts](./claudeAgentSession.ts) + - Add `private _pendingResumeSessionAt: string | undefined` plus `setPendingTruncationAnchor(uuid: string)`. Consume-and-clear it inside **both** buildOptions call sites: the rematerializer closure (~405-422, warm rebind) and `materialize` (~315-323, cold `_resumeSession`). Consume via a private helper that reads then clears so it applies exactly once. + - In `send()` pre-flight (~538-542), force a rebind when `_pendingResumeSessionAt` is set even if `toolDiff` / `clientCustomizationsDiff` report no difference, so the truncated context is guaranteed to reach the next turn's `Query`. + - Depends on: step 1 + - Done when: setting the anchor then sending one turn produces exactly one `startup`/rebuild `Options` carrying `resumeSessionAt`; the anchor is cleared afterward and a subsequent normal resume/rebind carries no stale `resumeSessionAt`. + +3. ✓ **Prune local per-turn DB state.** + - Files: [claudeAgentSession.ts](./claudeAgentSession.ts) (expose a `pruneTurnsAfter(turnId)` / `pruneAllTurns()` that reaches the session DB the pipeline/router opened) — confirm the DB access path (see Risks: dbRef lifecycle). + - Call `deleteTurnsAfter(turnId)` ([sessionDatabase.ts:384](../sessionDatabase.ts#L384)) for the `turnId` path, `deleteAllTurns()` (~397) for remove-all. These cascade per-turn file-edit rows ([fileEditTracker.ts](../shared/fileEditTracker.ts) `storeFileEdit({ turnId })`) and checkpoint refs ([agentHostCheckpointService.ts](../agentHostCheckpointService.ts) `setTurnCheckpointRef`). + - Depends on: none (independent of the Query restart) + - Done when: after truncate, DB rows for turns after the kept boundary are gone (assertable via test DB counters). + +4. ✓ **Implement `ClaudeAgent.truncateSession(session, turnId?)` — `turnId` path.** + - Files: [claudeAgent.ts](./claudeAgent.ts) + - Queue on `_sessionSequencer.queue(sessionId, …)`. Short-circuit if the session is provisional / not pipeline-ready (nothing to truncate) — mirror [copilotAgent.ts:1987](../copilot/copilotAgent.ts#L1987). + - Inside the queue: `const messages = await this._sdkService.getSessionMessages(sessionId, { includeSystemMessages: true })`; `const anchor = resolveForkAnchorUuid(messages, turnId)`; throw a clear error if `undefined`. + - Resolve the live session via `_findAnySession(sessionId)`. If **not** loaded, eagerly `await this._resumeSession(sessionId, sessionUri)` first (cold-start the session), then operate on the now-live session — single code path (see Decisions). On the live session: `session.setPendingTruncationAnchor(anchor)` + `session.pruneTurnsAfter(turnId)`. + - Do **not** call `forkSession`; do **not** mint a new URI/id. + - Depends on: steps 2, 3 + - Done when: the agent-level `turnId` integration test passes (URI unchanged; next turn carries `resumeSessionAt`); truncating an unloaded session cold-resumes it first. + +5. ✓ **Add the `deleteSession` SDK binding.** + - Files: [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) + - Add `deleteSession(sessionId: string, options?: SessionMutationOptions): Promise` to `IClaudeAgentSdkService` + `IClaudeSdkBindings` + the passthrough (mirrors the `forkSession` binding added in Phase 6.5). The SDK's `deleteSession` ([sdk.mjs] `g$$`) deletes the on-disk `.jsonl` transcript. + - Depends on: none + - Done when: a stubbed binding compiles and `typecheck-client` is clean. + +6. ✓ **Implement the remove-all (`turnId === undefined`) path — same id, clean transcript.** + - Files: [claudeAgent.ts](./claudeAgent.ts) + - Dispose the existing in-memory session/`Query`, call `this._sdkService.deleteSession(sessionId)` to remove the on-disk transcript, then re-create a fresh **provisional** session under the **same** id/URI; `pruneAllTurns()`. The next `sendMessage` materializes via the non-resume `{ sessionId: sameId }` path — valid because the old transcript file is gone (no duplicate-id error). Keeps the SDK id/URI stable (see Decisions). + - Depends on: steps 3, 5 + - Done when: remove-all does not set `resumeSessionAt`, the SDK id **and** protocol URI are unchanged, `deleteSession` ran, and the next turn starts from an empty transcript. + +7. ✓ **Extend the test fake + add tests.** + - Files: [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts), [claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts), [sessionTestHelpers.ts](../../test/common/sessionTestHelpers.ts) + - `FakeClaudeAgentSdkService` already records every `startup` `Options` (`capturedStartupOptions`, ~line 161/312) — assert `resumeSessionAt` on the truncating restart and its absence on later restarts. Add a `deleteSession` capture to the fake. Make `deleteTurnsAfter` / `deleteAllTurns` observable in the test DB helper. + - Depends on: steps 1-6 + - Done when: the suite is green (see Verification). + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| [claudeSdkOptions.ts](./claudeSdkOptions.ts) | modify | Add `resumeSessionAt?` to `IBuildOptionsInput`; project into `Options` only in the resume branch | +| [claudeAgentSdkService.ts](./claudeAgentSdkService.ts) | modify | Add `deleteSession` binding (interface + bindings + passthrough), mirroring `forkSession` | +| [claudeAgentSession.ts](./claudeAgentSession.ts) | modify | Pending one-shot anchor field + setter; consume in `materialize` + rematerializer; force rebind in `send()` pre-flight when set; `pruneTurnsAfter` / `pruneAllTurns` | +| [claudeAgent.ts](./claudeAgent.ts) | modify | Add `truncateSession(session, turnId?)`: sequencing, provisional short-circuit, cold-resume, anchor resolution, `turnId` + remove-all (`deleteSession`) paths | +| [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | modify | Truncation integration tests; assert captured `resumeSessionAt`, stable URI, provisional no-op, remove-all, no stale anchor | +| [claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts) | modify | Assert `resumeSessionAt` option wiring (resume-only, one-shot) | +| [sessionTestHelpers.ts](../../test/common/sessionTestHelpers.ts) | modify | Make `deleteTurnsAfter` / `deleteAllTurns` observable for assertions | +| [roadmap.md](./roadmap.md) | modify | Mark Phase 6.7 done; correct CONTEXT M10 reference | + +## Decisions + +- **`resumeSessionAt` over `forkSession`** — fork mints a new SDK id; truncate must keep the same URI/id ([claudeAgent.ts:479](./claudeAgent.ts#L479) shows fork's new-id result). `resumeSessionAt` + `resume` keeps the id. (Council unanimous.) +- **Write-timing = option (a) (no durable write at truncate).** Truncation is in-memory/pending until the next turn writes the branch; restore-and-walk-away returns full pre-restore history on reload ("as if it never happened"). Rejected option (b) (sentinel write / self-compaction). Rationale: the `ChatTruncated` dispatch is coupled to the next turn ([agentHostSessionHandler.ts:~1502](../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts)); a truncate with no follow-up is intentionally a no-op. (User decision, 2026-06-24.) +- **Claude DOES maintain per-turn DB state — prune it.** All three council agents confirmed file edits (`fileEditTracker.storeFileEdit({ turnId })`) and checkpoint refs (`setTurnCheckpointRef`) are per-turn rows, not Copilot-only. Prune via `deleteTurnsAfter` / `deleteAllTurns`. +- **Reuse the rematerializer/`rebindForRestart` path for live sessions, not `_resumeSession`.** `_resumeSession` ([claudeAgent.ts:584](./claudeAgent.ts#L584)) is explicitly the cold "no in-memory state" path; the pipeline rebind ([claudeSdkPipeline.ts:448-500](./claudeSdkPipeline.ts#L448)) already handles abort placeholders, warm disposal, queue reset, and config replay. (Codex + Opus.) +- **Cold-session truncate eagerly resumes, then sets the anchor.** If the session isn't materialized when `truncateSession` runs, `_resumeSession` it first, then set the pending anchor — one code path that always operates on a live session. The extra cold start is acceptable (truncate is rare). The cold resume loads full history into a live `Query`; the next send's anchored rebind then truncates it. (User decision, 2026-06-24; resolves grilling Q1. Minor optimization left open: thread the anchor into the cold `materialize` to load truncated directly and skip the double-load.) +- **Remove-all (`turnId` omitted) = `deleteSession` + reuse the same id.** Rather than minting a new id (rejected) or keeping 1 message (rejected), dispose the live `Query`, call SDK `deleteSession(sessionId)` to delete the on-disk transcript, then recreate a fresh provisional session under the **same** id/URI; the next turn materializes non-resume `{ sessionId: sameId }` (valid once the old file is gone). Keeps id/URI stable **and** yields a truly empty transcript. (User insight, 2026-06-24; resolves grilling Q2.) +- **Remove-all is eagerly durable; point-restore is lazy.** `deleteSession` removes the transcript immediately, so remove-all-then-walk-away does **not** restore history (matches its "clear / start over" semantic). The `turnId` point-restore stays lazy per option (a) (full history returns on walk-away). This asymmetry is intentional. (User decision, 2026-06-24.) + +## Risks + +- **A second pre-send rebind drops an eagerly-applied anchor.** `send()` calls `_rebindForSyncedState()` when `toolDiff`/`clientCustomizationsDiff` differ ([claudeAgentSession.ts:538-539](./claudeAgentSession.ts#L538)); if the anchor were consumed by an eager truncate-time rebind, this second rebuild would resume full history. *Mitigation (chosen design):* lazy pending anchor consumed by the next build, **and** `send()` forces a rebind when the anchor is pending (step 2) so exactly one anchored rebuild precedes the prompt. (GPT-5.5 + Opus.) +- **Anchor leaks into a later, unrelated resume.** If not cleared, a future recover/restart would silently truncate. *Mitigation:* consume-and-clear in a single helper; explicit "no stale anchor on subsequent resume" test. (GPT-5.5 + Codex.) +- **DB prune vs dbRef lifecycle.** The session DB ref is owned by the router/pipeline and disposed on rebind ([claudeSdkPipeline.ts:484](./claudeSdkPipeline.ts#L484)). *Mitigation:* prune **before** any rebind, or via a separate ref-counted `openDatabase` handle (ref-counting makes a short-lived handle safe). With the lazy design there is no eager rebind, so the existing ref is stable during prune. (Opus.) +- **No live round-trip verification.** `resume + resumeSessionAt` is verified from CLI source + offline probe but not through the live `ClaudeAgentSession` wrapper ([roadmap.md:880](./roadmap.md#L880)). *Mitigation:* the live smoke is **task 1** of implementation (Verification → Manual), gating the rest. +- **`deleteSession` durability for remove-all.** `deleteSession` deletes the transcript immediately, so a remove-all that is never followed by a turn still clears history (unlike the lazy `turnId` path). This is intended (see Decisions) but must be covered so the workbench history view tolerates an emptied/recreated same-id session. *Mitigation:* remove-all integration test asserts `getSessionMessages` is empty after `deleteSession` and the id/URI are unchanged. + +## Verification + +### Unit / Integration +- Unit (options): `resumeSessionAt` appears only with `resume`, only when provided, and is one-shot — [claudeSdkOptions.test.ts](../../test/node/claudeSdkOptions.test.ts). +- Unit (resolver): existing `resolveForkAnchorUuid` fixtures already cover anchor edge cases ([claudeReplayMapper.test.ts](../../test/node/claudeReplayMapper.test.ts)); add a case only if a new edge appears. +- Integration (agent): in [claudeAgent.test.ts](../../test/node/claudeAgent.test.ts), with `FakeClaudeAgentSdkService`: + - create → 3 turns → `truncateSession(uri, turn1)` → send turn 4 → assert the post-truncate `startup` `Options` carries `resume = sessionId` + `resumeSessionAt = `, the session **URI is unchanged**, and `deleteTurnsAfter('turn1')` ran. + - provisional session → `truncateSession` is a no-op. + - `truncateSession(uri, undefined)` (remove-all) → `deleteSession(sessionId)` ran, next send uses non-resume `{ sessionId: sameId }`, no `resumeSessionAt`, `deleteAllTurns()` ran, **SDK id and URI unchanged**, and `getSessionMessages` is empty. + - after a truncate+send, a subsequent ordinary rebind/resume carries **no** `resumeSessionAt`. +- Gate: `npm run typecheck-client`, `npm run valid-layers-check`, eslint, hygiene all clean. + +### E2E +The agent-host Claude path is node-side; the realistic E2E for the conversation truncation is the **live SDK round-trip** (see Manual). The workbench-side Restore Checkpoint UI can additionally be exercised with the discovered skills: + +- **Launch skill**: `launch` (`.claude/skills/launch`) — boots Code OSS from source into an isolated profile, drivable via Playwright. +- **Log skill**: `code-oss-logs` (`.github/skills/code-oss-logs`) — locates and reads dev-build logs (search for `[Claude:` / `[AgentHost]` lines). +- **Scenario** (optional, UI-level smoke — requires a configured Claude session): + 1. Use `launch` to open Code OSS and open the Agents window on a Claude-backed session with ≥2 turns. + 2. Invoke "Restore Checkpoint" on an earlier request, then send a new message. + 3. Use `code-oss-logs` to confirm a `[Claude:] … resume … resumeSessionAt=` rebuild line and that the next turn's reconstructed history excludes the dropped turns. + +### Manual (task 1 — gates implementation) +Live `ClaudeAgentSession` round-trip (needs Claude auth + a real model turn): +1. Create a Claude session, send N turns. +2. `truncateSession(uri, turnK)`, then send one turn. +3. Assert the agent's next `getSessionMessages` returns `[0..K]` + the new turn, the dropped turns are gone, and the SDK session id is unchanged. +4. **Option-(a) check:** truncate, then reload the session **without** a follow-up turn → assert full pre-restore history returns. +5. **No-stale-anchor check:** after a truncate+turn, trigger an unrelated rebind (e.g. permission-mode change) → assert it does not re-truncate. + +## Open Questions + +None blocking — both grilling decisions resolved (see Decisions: cold-session eager-resume; remove-all via `deleteSession` + same-id reuse). + +Minor, deferrable to implementation: +- **Cold `materialize` anchor threading (optimization).** The cold-session path currently loads full history then truncates on the next send (double-load). Optionally thread the pending anchor into the cold `materialize` so it loads truncated directly. Owner: implementer — only if the double-load shows up as a latency/log concern. + +## References + +- Roadmap: [./roadmap.md](./roadmap.md) (Phase 6.7, ~line 731) +- Sibling plan (house style, shares `resolveForkAnchorUuid`): [./phase6.5-plan.md](./phase6.5-plan.md) +- Context entries: CONTEXT M7 (`getSessionMessages`), M9 (lifecycle: resume vs fresh, provisional/materialize), M10 (steering & truncation — **to be corrected**: `resumeSessionAt` is the in-place primitive it claims doesn't exist) +- Copilot reference: [copilotAgent.ts](../copilot/copilotAgent.ts) `truncateSession` (~1987), `copilotAgentSession.truncateAtEventId` +- SDK: [sdk.d.ts](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts) `Options.resumeSessionAt` (~1356), `Options.resume` +- E2E skills used: `launch`, `code-oss-logs` + +## Implementation Notes + +> Implemented 2026-06-24 via TDD (red→green per behavior). Unit/integration green; live E2E deferred (see below). + +### Files actually changed +Production (all within the plan's file list): +- `claudeSdkOptions.ts` — `resumeSessionAt?` on `IBuildOptionsInput`; projected into `Options` only in the resume branch. +- `claudeAgentSession.ts` — `_pendingResumeSessionAt` field + private `_consumePendingTruncationAnchor` (2 call sites: `materialize` + rematerializer); `send()` pre-flight forces a rebind when an anchor is pending; `truncateToTurn(turnId, anchor)` (prune + stage anchor as one op) / `pruneAllTurns` (remove-all), both over a private `_withDatabase` short-lived ref-counted handle. +- `claudeAgent.ts` — `truncateSession(session, turnId?)`: `_sessionSequencer`-serialized, provisional short-circuit, `turnId` path (resolve anchor → cold-resume if unloaded → set anchor + prune), remove-all path (`deleteSession` + recreate fresh provisional same id/URI preserving workingDirectory + overlay model/agent/permissionMode + `pruneAllTurns`). +- `claudeAgentSdkService.ts` — `deleteSession` binding on `IClaudeAgentSdkService` + `IClaudeSdkBindings` + passthrough. + +Tests: +- `claudeSdkOptions.test.ts` — 3 `resumeSessionAt` projection tests. +- `claudeAgent.test.ts` — 7 new tests (anchor reaches rebuild / consumed once; prune reaches DB; truncate turnId path; cold-resume; turn-not-found throws; provisional no-op; remove-all in place). Extended `FakeClaudeAgentSdkService` with `deleteSession` capture. +- `sessionTestHelpers.ts` — `TestSessionDatabase` now records `deleteTurnsAfterCalls` / `deleteAllTurnsCalls`. + +### Deviations from the plan +- **Drift (test-only, expected):** adding `deleteSession` to `IClaudeAgentSdkService` / `IClaudeSdkBindings` required implementing it in three other test fakes/stubs not in the plan's file list: `claudeAgent.integrationTest.ts` (`ProxyRoundTripSdkService`), `claudeSubagentResolver.test.ts` (`FakeSdkService`), and two inline `IClaudeSdkBindings` stubs in `claudeAgent.test.ts`. Mechanical no-op/throw stubs; surfaced here per the file-list contract. +- **SDK type source corrected:** `deleteSession` is only typed in the repo-root `node_modules/@anthropic-ai/claude-agent-sdk` **0.3.187** (sdk.d.ts:486), not the older vendored `extensions/copilot/node_modules` copy (0.2.112) the plan linked. The dev `import()` and typecheck resolve to root, so the binding is clean. (`resumeSessionAt` is likewise in the root copy.) +- **Remove-all avoids a wasteful cold materialize:** the plan said "dispose the live Query, deleteSession, recreate". For the *unloaded* case the implementation reads `workingDirectory` from `getSessionInfo` instead of cold-resuming-then-deleting, so no transient Query is started just to be torn down. + +### avoid-private-methods audit +The `avoid-private-methods` skill is now available; ran it (+ `improve-codebase-architecture`) against the production diff. Findings: the new private helpers (`_consumePendingTruncationAnchor`, `_withDatabase`) each have **two** call sites and document a contract — not smells. Pre-existing class bloat in `ClaudeAgentSession` (866 lines / 28 public methods) noted but out of this diff's scope. + +### Post-review refactor (code review, 2026-06-25) +- **Combined the two-half truncation op behind one call.** The `turnId` path originally staged the anchor (`setPendingTruncationAnchor`) and pruned the DB (`pruneTurnsAfter`) as two separate public methods always called adjacently — a footgun (a caller could do one and forget the other, leaving the SDK transcript and the DB out of sync). Replaced both with a single public `ClaudeAgentSession.truncateToTurn(turnId, resumeAnchorUuid)` that prunes **first** (the fallible half, so a DB error rejects without leaving an anchor staged) then stages the anchor. `setPendingTruncationAnchor` removed; `pruneTurnsAfter` folded in. `pruneAllTurns` stays separate (remove-all genuinely has no anchor). Extracted a private `_withDatabase(fn)` to de-dup the ref-counted DB-handle dance shared by `truncateToTurn` and `pruneAllTurns`. Agent call site collapses to `await live.truncateToTurn(turnId, anchor)`. + +### Bug fix — post-restore turn hung (rebind consumer-loop handoff, 2026-06-25) +- **Symptom:** restore checkpoint (esp. a non-tail restore) then send → `_processMessages crashed: Error: Claude SDK stream ended without a result message` and no response. Reproduced live (hi/yo/hello → restore → send): logs showed `truncateSession kept […]` → `resume rebuild` → the crash → a fresh `POST /v1/messages` but **no** `result for sdkUuid` → hang. +- **Root cause (pre-existing in `claudeSdkPipeline.ts`, surfaced by truncation's rebind):** `_rebindQuery` swaps in a new `_query` but relies on the next `send()` → `_ensureConsumerLoop()` to start the consumer loop. That no-ops while the **old** loop is still marked running (it unwinds asynchronously after the old warm disposes); once the old loop exits, nothing drains the new query, so the post-rebind turn's result is produced but never read. Racy — a tail restore (fast old-query teardown) won the race; a non-tail restore reliably lost it. +- **Fix:** `_ensureConsumerLoop` now delegates to a `_runConsumerLoop` that, when a pass ends, detects a rebind swapped `_query` (current `_query` differs from the one it bound) and **re-arms** for the new query instead of stopping. Also: `_processMessages` returns quietly (no throw, no spurious `[error]` log) when the stream ends *because* of a rebind swap; only an unexpected end of the *current* query still trips the "stream ended without a result" recovery. +- **Verification:** new regression test `claudeSdkPipeline.test.ts` → *"a rebind hands the consumer loop off to the new query…"* (drives the exact race with a controllable query; **fails without the fix**, passes with it). Re-verified live end-to-end: post-restore turn now produces `result for sdkUuid=…` and the model answers. `claudeSdkPipeline.test` (17), `claudeAgent.test` (148), typecheck, eslint all green. + +### Verification status +- ✅ Unit/integration: `claudeAgent.test` (148), `claudeSdkOptions.test` (9), `claudeReplayMapper.test` (23), `claudeSubagentResolver.test` (14) all pass via `npm run test-node -- --run `. +- ✅ Gates: `typecheck-client`, `valid-layers-check`, `eslint` (changed files) all clean. +- ✅ **Live E2E (plan task 1) — PASSED.** Ran via the `launch` skill against an authenticated Code OSS Agents window with a real **Claude Opus 4.8** session (id `58465918-…`, 2026-06-25): + 1. Turn 1: "Remember secret word BANANA" → "OK". + 2. Turn 2: "Also remember ELEPHANT" → "OK". + 3. Clicked **Restore Checkpoint** on turn 2 → no `truncateSession` yet (confirms the lazy, next-turn-coupled write-timing — option (a)). + 4. Turn 3: "What words have I asked you to remember?" → agenthost.log shows `truncateSession kept [0..request_8ed330be…] (anchor=607440c7…)` (correct turn-1 assistant uuid) followed by `resume rebuild`; the model answered **"Only one: BANANA"** — it had genuinely forgotten the truncated ELEPHANT turn. Session id unchanged throughout. + This confirms end-to-end, against the real SDK: anchor resolution, `resume + resumeSessionAt` truncation, same-id stability, and the lazy write-timing. + +### Bug fix — "Start Over" failed with "Session ID … is already in use" (remove-all teardown race, 2026-06-25) +- **Symptom:** Start Over (remove-all) then send → `Claude Code process exited with code 1`; agenthost.log: `truncateSession removed all turns …` immediately followed (on the next send's spawn) by `Error: Session ID is already in use.` +- **Root cause:** the CLI's "in use" guard is purely an on-disk check — `e9t(id)` does `statSync(/.jsonl)`. Remove-all deletes that transcript via `deleteSession`, but tearing down the live query did **not** await the OS process exit. The SDK's `WarmQuery[Symbol.asyncDispose]()` calls the query's `close()`, and `close(){ this.cleanup() }` only *fires* the cleanup (fire-and-forget); it is `Query.return()`/`throw()` that `await this.cleanup()`, and `cleanup()` is what does `await Promise.race([transport.waitForExit(), timeout(2000)])`. So our teardown returned while the subprocess was still shutting down, and the dying process re-flushed `.jsonl` after our single delete → the next `--session-id ` spawn saw the file again → "already in use". (Confirmed by disassembling the bundled CLI 2.1.191 and SDK `sdk.mjs`: `close(){this.cleanup()}` vs `return(e){return await this.cleanup(),…}`, and the `nMe`/startup WarmQuery literal whose fresh-session `asyncDispose` skips the `waitForExit` it performs for resumed sessions.) +- **Fix (deterministic — no polling):** await the **actual subprocess exit** during teardown via the SDK's own exit primitive. `claudeSdkPipeline.ts#shutdownAndWait` now, after `warm.asyncDispose()`, awaits `this._query?.return(undefined)` — `Query.return()` awaits the (memoized) `cleanup()` which awaits `transport.waitForExit()`. The pipeline's single `_query` field tracks the lifetime of the warm subprocess (the SDK stream is long-lived — the prompt iterable parks between turns rather than ending), and `_needsRebind` is the sole health signal, so a dead-but-not-yet-rebuilt stream is still reachable for teardown. With the process guaranteed exited (and its final flush done), the remove-all path is a single `deleteSession` — the file stays gone and the fresh same-id respawn succeeds. +- **Verification:** new regression test `claudeAgent.test.ts` → *"truncateSession() with no turnId awaits the live query teardown (subprocess exit) before deleteSession"* — a `queryReturnGate` deferred models `waitForExit`; the test asserts `deleteSession` does **not** run while the gate is pending, then runs once after it resolves (deterministic ordering proof; fails if teardown doesn't await exit). Re-verified live end-to-end: two consecutive Start Over → send cycles on the same session each logged `truncateSession removed all turns` followed by a successful `result for sdkUuid`, with **zero** "already in use" errors (vs. a reliable failure before the fix). `claudeAgent.test` (149), `claudeSdkPipeline.test`, `claudeSdkOptions.test` (163 total), typecheck, eslint all green. + +### Refactor — pipeline query/process handles collapsed 3 → 2 (code review, 2026-06-26) +- **Motivation:** the teardown fix above had introduced a third handle (`_liveQuery`) alongside `_query` and `_warm`, and the lifecycle was hard to explain. +- **Root cause of the smell:** `_query` was doing double duty — both "the live SDK stream handle" *and* a *health flag* (it was nulled on abort/crash/aborted-rebind to mean "dead, rebuild before reuse"). But every one of those nulling sites also set `_needsRebind = true`, so the null was redundant — and nulling the handle for "health" reasons is exactly what destroyed the reference teardown needed, forcing `_liveQuery` into existence. +- **Fix:** give each field one meaning. `_warm` = the subprocess; `_query` = the SDK stream bound to it (lifetime mirrors `_warm` — the stream is long-lived because the prompt iterable parks between turns rather than ending; only swapped on rebind, cleared on dispose); `_needsRebind` = the **sole** health signal. `_liveQuery` deleted; teardown uses `_query`. The three `_query = undefined` health-nulls dropped; steering setters now read as "only steer a healthy live query" (`_query && !_needsRebind`). The consumer-loop swap detection is untouched — it keys off `_query` *object identity*, not null-ness, so the rebind-handoff fix still holds. Net: −1 field, −3 scattered nulls, clean 3-state model (never-bound → dead → healthy). +- **Verification:** re-ran the full live E2E (both flows, two cycles each — zero `already in use` / `stream ended` / `exit-1` / `crashed` across an 8-turn session spanning 2 restores + 2 start-overs + many binds/rebinds). Unit/integration + gates green. + +### Test-gap audit (2026-06-26) +Audited the diff for coverage holes; the established cases already cover `resumeSessionAt` projection (3), warm + cold point-restore, turn-not-found, provisional no-op, warm remove-all + teardown ordering, anchor consumed-once / not-leaked, `truncateToTurn`/`pruneAllTurns` → DB, and the rebind consumer-loop handoff. Two genuine gaps found and filled: +- **The refactor's `!_needsRebind` steering guard** (newest, least-covered code) — `claudeSdkPipeline.test.ts` → *"setEffort while awaiting rebind (post-abort) is buffered, not pushed to the dead query, then replayed on rebind"*. Aborts to enter the dead-but-retained-`_query` state, asserts `setEffort` does **not** steer the dead query, then that the buffered value is replayed onto the freshly-bound query after rebind. Fails if the guard is removed. +- **Cold remove-all (`existing === undefined` branch)** — `claudeAgent.test.ts` → *"truncateSession() with no turnId on an UNLOADED session deletes + recreates fresh on the same id, preserving the overlay (cold remove-all)"*. Unloads the session so remove-all must read the cwd from `getSessionInfo`, then asserts `deleteSession` ran, cwd was consulted, all turns pruned, the recreate is fresh (non-resume) on the same id, and the `permissionMode` overlay survives the recreate (folds in overlay-preservation coverage). + +### Completion +Phase 6.7 is complete: `IAgent.truncateSession` is implemented for Claude (both point-restore and remove-all), all gates and 427 Claude unit/integration tests pass, and both flows are live-E2E verified against real Claude. Roadmap Phase 6.7 marked ✅ done. diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index 74eb2474efecc..483b003acbf9b 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -97,7 +97,7 @@ Phase numbers are stable identifiers — code comments, plan files do **not** renumber. The actual landing order diverges from numeric order to unblock self-hosting sooner: -**1 → 1.5 → 2 → 3 → 4 → 5 → 6 → 9 → 13 → 7 → 8 → 10 → 10.5 → 11 → 12 → 6.5 → 14 → 15 → 16** +**1 → 1.5 → 2 → 3 → 4 → 5 → 6 → 9 → 13 → 7 → 8 → 10 → 10.5 → 11 → 12 → 6.5 → 6.7 → 14 → 15 → 16 → 17 → 18 → 19** Phase 13 (session restoration) is pulled forward immediately after Phase 9 because it unlocks two high-leverage capabilities: @@ -465,10 +465,9 @@ are therefore **invisible to other workbench clients** until materialised. pre-materialise persistence (Phase 5's `_provisionalSessions` map carries the in-memory state). The SDK starts lazily on first `sendMessage` (Phase 6). -- **`createSession({ fork })` — deferred.** The fork branch throws - `TODO: Phase 6.5` with no side effects. See "Phase 6.5 — Fork (deferred)" - below for the structural reason, the reverted attempt, and the deferred - plan that lands alongside Phase 13's result-message mapper. +- **`createSession({ fork })` — deferred to Phase 6.5 (now ✅ done).** At + Phase 5 the fork branch threw `TODO: Phase 6.5`; it now forks the SDK + transcript on demand. See "Phase 6.5 — Fork" below. - `disposeSession(session)` — tear down the session's `Query` (if alive), MCP gateway, in-flight aborts. Provisional sessions dispose by removing the in-memory record (no SDK / sidecar to clean up). @@ -605,15 +604,22 @@ canned Anthropic stream → verify the resulting `AgentSignal` sequence. Exit criteria: a workbench client sends "hi" and sees a streamed assistant response in the UI. -### Phase 6.5 — Fork (deferred — depends on Phase 13's result-message mapper) - -> **Status:** attempted, fully reverted, deferred. `createSession({ fork })` -> currently throws `TODO: Phase 6.5` at -> [`claudeAgent.ts:303`](./claudeAgent.ts) with no side effects. +### Phase 6.5 — Fork ✅ **DONE** + +> **Status:** ✅ done. `createSession({ fork })` is implemented and +> live-E2E verified (fork-and-continue + restart restore, 2026-06-24). +> Implementation contract / retrospective: +> [phase6.5-plan.md](./phase6.5-plan.md). The summary below stays +> high-level for roadmap continuity; two design points shifted during +> implementation (see the materialisation note and the plan's Drift +> section): the turn→uuid lookup is a pure on-demand resolver +> (`resolveForkAnchorUuid` in [claudeReplayMapper.ts](./claudeReplayMapper.ts)), +> and fork **defers** the SDK `Query` to the first `sendMessage` rather +> than materialising eagerly. > > **Sequencing note:** numbered 6.5 to stay consistent with the throw -> message and `phase6-plan.md` §8.1, but **executes after Phase 13** because -> the clean fix shares Phase 13's result-message mapper. +> message and `phase6-plan.md` §8.1, but **executed after Phase 13** because +> the clean fix shares Phase 13's transcript reconstruction. **Why deferred — structural mismatch.** Copilot's fork path ([`copilotAgent.ts:660-714`](../copilot/copilotAgent.ts)) calls @@ -692,16 +698,24 @@ from the JSONL transcript, rather than persisting it. **Materialisation note.** Unlike non-fork `createSession` (Phase 5/6's provisional path), `forkSession` writes the forked SDK transcript file -synchronously. Fork therefore *eagerly* fires `onDidMaterializeSession` -from inside `createSession`, before returning — there is no provisional -state to defer. The host's contract for fork is "materialise immediately, -no separate sendMessage edge" (CONTEXT M9). - -**Workbench client behavior in the interim.** The agent-host contract today -is "fork rejects, no side effects" — any client invocation surfaces the -throw as a session-creation error. Whether the workbench should hide or -disable the fork affordance for Claude sessions until this phase lands is -TBD with the workbench owners. +synchronously, so the result is **non-provisional**. But — corrected +during implementation — fork does **not** start the SDK `Query` or fire +`onDidMaterializeSession` from inside `createSession`. Doing so raced +ahead of `AgentService.createSession`'s own (synchronous, non-provisional) +registration and produced `unknown session` warnings for both the +materialize event and pipeline progress signals. Instead fork resolves +the forked `workingDirectory` from `getSessionInfo` and returns; the +`Query` materialises lazily on the first `sendMessage`, which resumes +from disk via `_resumeSession`. This matches CONTEXT M9's "`forkSession` +only writes the new session file; it does not start a `Query` … the SDK +`Query` is still not started until the first `sendMessage`" and Copilot, +whose resume path likewise never fires the materialize event. + +**Workbench client behavior.** The Agents-window "Fork conversation from +this point" affordance **is** wired for Claude and works end-to-end +(verified live 2026-06-24): forking a turn produces a new "Forked: …" +session truncated to that turn INCLUSIVE, which accepts new turns via +`Options.resume` and survives an agent-host restart. Tests (when this phase lands): unit tests for the mapping ingest (turn end → persisted row), unit tests for `createSession({ fork })` looking up the @@ -714,6 +728,222 @@ with restored sessions, and honors the workbench's "keep `[0..N]` INCLUSIVE" semantic. The reverted heuristic is **not** retained behind a flag. +### Phase 6.7 — Restore Checkpoint via in-place `truncateSession` + +> **Status:** ✅ done. In-place `truncateSession` for Claude is implemented +> and live-E2E verified — both "Restore Checkpoint" (point-restore via +> `resumeSessionAt`) and "Start Over" (remove-all via `deleteSession` + +> same-id recreate), 2026-06-26. Implementation contract / retrospective: +> [phase6.7-plan.md](./phase6.7-plan.md). **Superseded the "Do NOT implement +> `IAgent.truncateSession`" decision in Phase 13 and CONTEXT M10's "no +> in-place primitive" claim** — both were written before the SDK's +> `resumeSessionAt` option was examined; it provides in-place, +> same-session-id conversation truncation (see "How — `resumeSessionAt`" +> below, grounded in a 2026-06-24 source + offline-probe investigation). +> +> **Sequencing note:** numbered 6.7 to sit next to fork (6.5), with which it +> shares the transcript-anchor resolver. Executed after Phase 6.5 because it +> reuses `resolveForkAnchorUuid` +> ([claudeReplayMapper.ts](./claudeReplayMapper.ts)) and Phase 13's mapper. + +**Why this is needed — what "Restore Checkpoint" requires.** The workbench's +"Restore Checkpoint" UX is a two-sided operation: + +- **Client side (already generic, provider-agnostic).** The Agents-window + chat editing session + ([`agentHostSnapshotController.ts`](../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSnapshotController.ts)) + keeps one checkpoint per request and rolls the **on-disk files** back to + the chosen request's "before" state via `restoreSnapshot`. This already + works for Claude — it is pure file I/O over the captured tool-call edits. +- **Server side (provider-specific, the gap).** After a restore the handler + ([`agentHostSessionHandler.ts:~1502`](../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts)) + notices the chat model now has fewer requests than the protocol has turns + and dispatches `ActionType.ChatTruncated` on the **existing** session + channel. `AgentSideEffects` + ([`agentSideEffects.ts:~911`](../agentSideEffects.ts)) routes that to the + provider's optional `IAgent.truncateSession(session, turnId)`. Copilot + implements it in place via the SDK RPC `history.truncate({ eventId })` + ([`copilotAgent.ts:~1987`](../copilot/copilotAgent.ts) → + `copilotAgentSession.truncateAtEventId`), keeping the **same** session id + and URI. **Claude omits `truncateSession`**, so the agent's conversation + history is never pruned: the next turn replays the stale tail and the + checkpoint restore is cosmetically right (files) but semantically wrong + (the model still "remembers" the undone turns). + +**Why Phase 13 / CONTEXT M10 said "impossible" — and what changed.** Both +documents concluded the Claude SDK had **no in-place history truncation**: its +only rewind primitive was believed to be `forkSession`, which **always mints a +new session id**, and unlike "Fork conversation" (Phase 6.5) — where the +workbench *follows* the new "Forked: …" URI — Restore Checkpoint dispatches on, +and must keep continuing, the **same** session URI. A new-id primitive can't +satisfy that without a URI→id indirection hack. **That premise was incomplete:** +the SDK exposes `resumeSessionAt`, which truncates in place on the *same* +session id, plus `rewindFiles` / `enableFileCheckpointing` for the file side. + +**How — `resumeSessionAt` (the in-place conversation primitive).** From +[`sdk.d.ts`](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts): +`resumeSessionAt` — *"When resuming, only resume messages up to and including +the message with this UUID. Use with `resume`. … from `SDKAssistantMessage.uuid`."* +The decisive behaviors, verified by reading the bundled CLI/SDK and an offline +transcript probe (2026-06-24): + +1. **Load-time truncation, same id.** The CLI maps `resumeSessionAt` → + `--resume-session-at`, which requires `--resume`. On load it slices the + loaded transcript to `messages.slice(0, indexOf(uuid) + 1)` — up to and + **including** the anchor. Because this is `resume` (not `forkSession`), the + session id — and therefore the protocol URI — is **unchanged**. No alias + layer, no URI swap. +2. **On disk the file branches; it is not physically shrunk.** The non-fork + resume path calls `resetSessionFile()` then **appends** subsequent turns to + the *same* `.jsonl`. The orphaned tail stays on disk; the new turn's + `parentUuid` points at the resume anchor — a branched tree. +3. **`getSessionMessages` returns the truncated history (what matters).** The + host reconstructs history via `getSessionMessages` (Phase 13 / CONTEXT M7). + Its reader picks the **last-written leaf** (highest file-order index) and + walks up `parentUuid` to the root, so orphaned branches are excluded. + **Proven empirically:** appending a branch off the 8th line of a real + 9,517-line transcript made `getSessionMessages` return **2 messages** + instead of 1,750 — the entire orphaned tail disappeared from the + reconstructed history. So from the host's perspective the conversation is + genuinely truncated, even though the JSONL retains dead lines. + +**Approach — mirror Copilot's shape, no alias indirection.** + +1. **Resolve the anchor.** Reuse Phase 6.5's resolver exactly: + `getSessionMessages(sessionId, { includeSystemMessages: true })` → + `resolveForkAnchorUuid(messages, turnId)`. The anchor is an + `SDKAssistantMessage.uuid` — the same axis `resumeSessionAt` wants — and the + same translation Phase 6.5 already encodes (CONTEXT M9: protocol `turnId` = + the user msg that *started* turn T → the uuid of the *last* SessionMessage + of turn T). Protocol semantics: `turnId` = **last turn to KEEP** + (`[0..turnId]` INCLUSIVE), matching the workbench and Copilot. +2. **Restart the `Query` at the anchor, same id.** Tear down the live `Query` + and re-resume with `Options.resume = sessionId` **plus** + `Options.resumeSessionAt = `. The session id / URI is preserved; + the next `sendMessage` continues from the truncated point. (Plumb + `resumeSessionAt` through `IClaudeAgentSdkService` `Options` → + `_resumeSession`, alongside the existing `resume` handling.) +3. **Reconcile local state.** Prune our session-data DB turns past the kept + boundary (`deleteTurnsAfter(turnId)`, mirroring + `copilotAgentSession.truncateAtEventId`) so future `getNextTurnEventId` / + anchor lookups don't reference dropped turns. No metadata id rebinding is + needed — the id is unchanged. +4. **Serialize** the whole operation through `_sessionSequencer` (as Copilot + does) so it can't race an in-flight `sendMessage` / `disposeSession`. + Provisional sessions short-circuit (nothing to truncate). + +**Remove-all case (`turnId` undefined).** `resumeSessionAt` needs an anchor, so +"remove every turn" has no `SDKAssistantMessage` to point at. Handle it +separately: dispose the current `Query` and rebind the **same** protocol URI to +a fresh provisional session (CONTEXT M9 non-fork provisional path), so the next +`sendMessage` materializes a clean transcript under a new id. (This is the one +sub-case where the SDK id changes — acceptable because "remove all" is +semantically a new conversation; revisit if the workbench needs the id stable +even here.) + +**Known caveats (call out in the plan, not blockers):** +- **Write timing — truncation finalizes on the next turn (by design — DECIDED).** + Verified from the CLI source: a `resume + resumeSessionAt` restart truncates + the transcript **only in memory**. At `query()` / resume time the CLI calls + `resetSessionFile()` (sets `sessionFile = null`) and re-appends **metadata + only** (`last-prompt`, `custom-title`, `tag`, `agent-*` — none are + conversation types, so `getSessionMessages` ignores them). The branch line + that actually drops the tail (a message whose `parentUuid` = the anchor) is + written **lazily on the next user message**, via + `insertMessageChain` → `materializeSessionFile()` (re-opens the same + `.jsonl` in **append** mode — the old tail is still present) → + `appendEntry`. (Contrast Copilot's `truncateAtEventId`, which rewrites the + on-disk transcript **immediately**.) + - **Chosen behavior (option (a), decided):** lean into this. If a user + restores a checkpoint and then **restarts / reloads without sending a + message**, the conversation comes back **as if the restore never happened** + — full pre-restore history. This is the desired product behavior: undoing + part of a conversation only to never interact with it again is rare, and + re-showing the full history is the least surprising outcome. We explicitly + do **not** force a durable write at truncate time (the rejected option (b): + sentinel branch entry / self-compaction) — it would add transcript-shape + handling to defend a case we want to behave this way anyway. + - **Why this is robust, not just tolerated.** In the current handler the + `ChatTruncated` dispatch is **coupled to sending the next turn** — it fires + immediately before `ChatTurnStarted` + ([`agentHostSessionHandler.ts:~1502`](../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts)). + So "restore and walk away" never calls `truncateSession` at all, and a + restore that *is* followed by a turn persists the branch as part of that + turn. The in-memory window (truncated context, tail still on disk) exists + only between the `truncateSession` call and the immediately-following + `sendMessage`; a crash in that sub-second gap simply yields the + full-history fallback, which is the chosen behavior. **The plan must keep + this coupling intact** — if the handler is ever changed to dispatch + `ChatTruncated` standalone (decoupled from a turn), revisit whether option + (b) is needed. +- **Monotonic file growth.** Branching leaves orphaned tail lines in the JSONL + forever; repeated restores compound it. `getSessionMessages` ignores them + (proven), so this is a disk-space / cosmetic concern, not correctness. Note + it; a compaction sweep is out of scope (option (b) was rejected, so we are not + building one now). +- **Live round-trip unverified.** Load + read semantics were confirmed from + source and an offline transcript probe, but a live `resume + resumeSessionAt` + through our `ClaudeAgentSession` wrapper (auth + a real model turn) was + **not** exercised. Make that the plan's task 1: after resume-at + a new turn, + assert our wrapper's next `getSessionMessages` shows the truncated history, + confirm the option-(a) fallback (truncate → reload *without* a follow-up turn + → full pre-restore history returns), and confirm a *subsequent* truncate + still resolves its anchor (it should — the reader follows the active leaf). +- **`rewindFiles` is not the conversation primitive.** Per the SDK docs, + `rewindFiles` restores files only and *"does not rewind the conversation."* + Our file rollback is already handled client-side by + `AgentHostSnapshotController`; we do **not** need `rewindFiles`/ + `enableFileCheckpointing` for `truncateSession`. (They remain an option if we + ever want server-side file rollback, but that's out of scope here.) + +**Doc consistency (required when this lands).** CONTEXT M10 must be corrected: +its "Claude: deliberately not implemented" subsection, the "no in-place +transcript-mutation primitive" table (which lists only `forkSession`, +`Query.interrupt()`, compaction), the Copilot-vs-Claude asymmetry row, and the +"rewind must compose with fork at the UI layer" note are all now wrong — +`resumeSessionAt` is the missing primitive. Update them to describe the +`resumeSessionAt` mechanism and point at this phase. + +**Dependencies:** +- **Hard:** Phase 6.5 (`resolveForkAnchorUuid`) and Phase 13 (transcript + reconstruction / mapper). Soft reuse of the `forkSession` binding is **not** + needed — truncate uses `resume` + `resumeSessionAt`, not fork. +- **Touches:** `IAgent.truncateSession` (optional today — declared in + [`agentService.ts`](../../common/agentService.ts), the user-selected + contract), `claudeAgent.ts`, `claudeAgentSession.ts`, + `IClaudeAgentSdkService` `Options` (add `resumeSessionAt` passthrough), and + the session-data DB prune helper. + +**Architectural model:** Copilot's `truncateSession` → +`truncateAtEventId` (CONTEXT M10 §"Copilot: in-place via SDK RPC"). Phase 6.7 +is the Claude-side equivalent: where Copilot rewrites the transcript via its +session-mutation RPC, Claude restarts the `Query` with `resume + +resumeSessionAt` — both keep the same session id / URI. + +Tests: unit test that `truncateSession(session, turnId)` resolves the right +anchor (reuses `resolveForkAnchorUuid`) and restarts the `Query` with +`resumeSessionAt = ` and unchanged id; unit test that +`turnId === undefined` takes the fresh-provisional remove-all path; integration +test parallel to Copilot's truncate path (create → N turns → truncate at turn K +→ **send a new turn** → assert the new turn continues from turn K, turns +`(K, N]` are gone from a fresh `getSessionMessages`, and the session URI is +unchanged — the `getSessionMessages` assertion runs *after* the new turn, per +the write-timing caveat). **Option-(a) fallback test:** truncate at turn K → +**reload / restart the agent host *without* a follow-up turn** → assert the full +pre-restore history (`[0..N]`) returns and the id is unchanged — i.e. the +restore is treated as if it never happened. Task 1 is the live wrapper +round-trip described in "Known caveats". + +Exit criteria: "Restore Checkpoint" is fully functional for Claude — files roll +back (already working client-side) **and** the conversation is truncated in +place via `resumeSessionAt`, with the session URI/id stable across the +operation. Write-timing behavior follows the decided **option (a)**: a restart +*after* the post-truncate turn shows the truncated history; a restart *in the +gap / restore-and-walk-away* restores the full pre-restore history (covered by +the option-(a) fallback test). CONTEXT M10 and the Phase 13 note are corrected +to reference `resumeSessionAt`. No transcript-file shape inference and no +URI→id alias layer. + ### Phase 7 — Tool calls + permission + user input ✅ **DONE** Wire the SDK's tool-use loop through to the agent host's tool infrastructure. @@ -1093,6 +1323,9 @@ Shipped in PR #318113. Two-tier model: single "Discovered in Claude" Open Plugins-conformant on-disk tree under `IAgentPluginManager.basePath`, namespaced by working-directory hash and nonce-stable across repeated bundles of the same SDK snapshot. + **(Superseded by Phase 16: the synthetic-stub bundle was replaced by a + disk scan returning real editable `file:` URIs; `ClaudeSdkCustomizationBundler` + is deleted.)** **Per-session ownership.** All customization state lives on `ClaudeAgentSession`: @@ -1107,6 +1340,7 @@ Shipped in PR #318113. Two-tier model: demand from `getSessionCustomizations`. Repeated calls with the same SDK snapshot skip the rewrite. The tree is intentionally a cross-session warm cache (not deleted on session dispose). + **(Superseded by Phase 16 — this bundler is deleted; see Phase 16.)** Full step-by-step plan: [phase11-plan.md](./phase11-plan.md). @@ -1236,18 +1470,26 @@ Exit criteria: subagent sessions are first-class for clients. per-turn write tax nor the wider mapper return type was worth it. If a second consumer ever appears, `backfillTurnMapping` is a ~30-line add on `ClaudeSessionMetadataStore`. -- **Do NOT implement `IAgent.truncateSession`**. The SDK's `forkSession` - always produces a *new* session ID, which conflicts with the protocol's - expectation that `truncateSession` mutates the existing session URI in - place. `truncateSession?` is optional in `IAgent` - (`agentService.ts:430`), so we omit it and document: - - Clients wanting truncate-like behavior use - `createSession({ fork: { session, turnIndex, turnId, turnIdMapping } })` - (Phase 6.5 — currently deferred; until that lands, the fork branch - throws and the workbench surfaces a session-creation error). - - The workbench should follow the new URI, just like for any other fork. - - Adding in-place truncate later would require a URI→sessionId mapping - layer; we'd revisit when there's user demand. +- **Do NOT implement `IAgent.truncateSession`** _(superseded by Phase 6.7)_. + This reasoning is **outdated**: it assumed `forkSession` (which mints a + *new* session ID) was the only rewind primitive, so in-place truncate would + need a URI→sessionId mapping layer. Phase 6.7 found the SDK's + `resumeSessionAt` option, which truncates **in place on the same session + id** — no mapping layer required. The original (now-superseded) reasoning is + preserved below for history: + - The SDK's `forkSession` always produces a *new* session ID, which conflicts + with the protocol's expectation that `truncateSession` mutates the existing + session URI in place. `truncateSession?` is optional in `IAgent` + (`agentService.ts:430`), so we omit it and document: + - Clients wanting truncate-like behavior use + `createSession({ fork: { session, turnIndex, turnId, turnIdMapping } })` + (Phase 6.5 — currently deferred; until that lands, the fork branch + throws and the workbench surfaces a session-creation error). + - The workbench should follow the new URI, just like for any other fork. + - Adding in-place truncate later would require a URI→sessionId mapping + layer; we'd revisit when there's user demand. **(Resolved differently by + Phase 6.7: `resumeSessionAt` keeps the same id, so no mapping layer is + needed.)** Tests: persist a session, restart the agent host, reload the session, verify turns are intact and a new turn appends correctly. Verify @@ -1276,22 +1518,33 @@ fork end-to-end ships in Phase 6.5. Exit criteria: ready to enable for external preview. -### Phase 15 — SDK distribution via `product.json` + main.vscode-cdn.net - -**Status as of 2026-06-12:** Runtime shape is per-SDK `urlTemplate` + -runtime `{sdkTarget}` substitution (replaced the per-platform -`{url, sha256}` pair so macOS Universal bundles can share one -`product.json`; see -[`phase15-per-platform-plan.md`](./phase15-per-platform-plan.md) and the -follow-up `urlTemplate` PR). The Claude and Codex SDK distributions are -declared in `product.json` and downloaded on demand by +### Phase 15 — SDK distribution via `product.json` + main.vscode-cdn.net ✅ **DONE** + +> **Implementation contract / retrospective: +> [phase15-plan.md](./phase15-plan.md).** That file documents what +> actually shipped — runtime downloader, the `build/agent-sdk/` tarball +> pipeline, the per-platform `produce.ts` step, and the gulpfile +> `product.json` stamping. The summary below stays high-level for roadmap +> continuity. + +**Status:** both the runtime and the build pipeline have landed. Runtime +shape is per-SDK `urlTemplate` + runtime `{sdkTarget}` substitution +(replaced the per-platform `{url, sha256}` pair so macOS Universal bundles +can share one `product.json`). The Claude and Codex SDK distributions are +declared in `product.json` (built by the per-platform +[`produce.ts`](../../../../../../build/agent-sdk/produce.ts) step and +stamped into `product.json` by `packageTask` in +[`gulpfile.vscode.ts`](../../../../../../build/gulpfile.vscode.ts)) and +downloaded on demand by [`agentSdkDownloader.ts`](../agentSdkDownloader.ts) into `/agent-host/sdk-cache////`. The hand-supplied paths (`chat.agentHost.claudeAgent.path` → `AgentHostClaudeSdkRootEnvVar`, see [`claudeAgentSdkService.ts:148`](./claudeAgentSdkService.ts#L148), and the Codex equivalent) survive as a **dev override** only — set them to bypass -the download. +the download. SDK versions are pinned in +[`build/agent-sdk/agents//package.json`](../../../../../../build/agent-sdk/agents/claude/package.json) +(today: Claude `0.3.169`, Codex `0.135.0`). **Shape:** @@ -1328,105 +1581,811 @@ the download. registered and never appears in the agent picker (matches the pre-CDN UX). -**Exit criteria (met for runtime; build is a follow-up PR):** +**Exit criteria (met):** - Fresh insiders install can use Claude/Codex without manually installing the SDK or setting any path. -- SDK version bumps are now build-pipeline changes that re-pin - `product.agentSdks[pkg]` per platform during packaging. +- SDK version bumps are now build-pipeline changes that re-pin the SDK + version in `build/agent-sdk/agents//package.json` (+ lockfile); + the per-platform `produce.ts` step republishes the tarballs and + `packageTask` re-stamps `product.agentSdks[pkg]` during packaging. - Dev override keeps working for SDK development. -**Build pipeline** — see `build/agent-sdk/` for the tarball production -and CDN upload tooling, including the deterministic-tar setup that -makes `verify-determinism.ts` enforceable in CI. The inline integration -into each `packageTask(platform, arch, ...)` so that the gulpfile patches -`product.json` directly (no `AgentSDK` pipeline stage, no -`aggregate.ts`) ships in a follow-up PR per -[`phase15-per-platform-plan.md`](./phase15-per-platform-plan.md) §PR 2. - -### Phase 16 — Eager session materialization at create time - -**Status:** follow-up to Phase 11. Phase 11's -`getProjectedSessionCustomizations` already returns the SDK-resolved -customization tier when the pipeline is bound, but for provisional -sessions it returns only the client-pushed half. The full picture — -SDK-discovered skills (`~/.claude/skills/**`), agents (`.claude/agents/**`), -and `~/.claude/settings.json` MCP servers — only materializes after the -first `sendMessage`. Workbench UX wants the full list available -immediately on `createSession` so a draft session can show its true -capability surface before the user types. - -**Direction:** collapse the provisional/materialize split for the -non-fork `createSession` path. `createSession` synchronously -materializes (spawns the SDK subprocess, opens the proxy refcount, -runs the metadata write, fires `onDidMaterializeSession`) before -returning. - -**Why this is its own phase, not part of Phase 11.** Phase 11's -projector and SDK snapshot work stand on their own — they make -`getSessionCustomizations` correct *whenever* the pipeline is bound. -The eager-materialize change rewrites the M9 lifecycle contract, -touches the `_sessionSequencer`'s first-send branch, changes -disposable semantics for never-used sessions, and updates CONTEXT.md. -Coupling the two would inflate Phase 11's blast radius for no review -benefit; landing them serially keeps each change small. +**Build pipeline** — see [`build/agent-sdk/`](../../../../../../build/agent-sdk/README.md) +for the tarball production and CDN upload tooling, including the +deterministic-tar setup. The per-platform +[`agent-sdk-produce.yml`](../../../../../../build/azure-pipelines/common/agent-sdk-produce.yml) +template runs `produce.ts` before each `gulp vscode---min-ci` +step; `packageTask`'s `jsonEditor` callback then merges the results into +`product.json` via `readAgentSdkResults()` (no separate `AgentSDK` +pipeline stage, no `aggregate.ts`). Full retrospective in +[phase15-plan.md](./phase15-plan.md). + +### Phase 16 — Editable customization resolution via disk scan + +> **Redesigned 2026-06-17.** This phase originally proposed "eager +> session materialization at create time" — warming the SDK inside +> `createSession` so `getSessionCustomizations` could return the +> SDK-resolved tier before the first `sendMessage`. That premise is +> **retired.** Investigation (below) showed the SDK query APIs can't +> deliver what the customization UX actually needs, and that a disk +> scan — not a warm session — is the right resolution path. The +> warm-at-create machinery (and its JSONL-write-timing / cleanup-on- +> discard tail) is no longer part of this phase. + +**Status:** follow-up to Phase 11. Phase 11 bundles the SDK-discovered +customization tier (`Query.supportedCommands()` / `supportedAgents()` / +`mcpServerStatus()`) into a synthetic "Discovered in Claude" plugin +tree. The problem: those SDK APIs return **only names + descriptions — +no file paths and no content** (`SlashCommand` / `AgentInfo` / +`McpServerStatus` in `sdk.d.ts`). So today's bundler synthesizes **stub +files** (frontmatter with name + description, empty body) and ships +URIs pointing at those *stubs*. A user who opens a "Discovered in +Claude" item edits a generated stub, not their real +`~/.claude/agents/foo.md` — and there is no real content anywhere in +the projection. + +**Driver:** the customization surface must give the user the **real, +editable file path** of each customization (agents, skills, slash +commands, MCP servers). Content follows for free — like +`CopilotAgent`, we ship the real `uri` and the client reads content on +demand by opening it; we do **not** need to ship content bytes through +the protocol. + +**Direction:** resolve customizations by **scanning the file system** +(the `CopilotAgent` model), not by trusting the SDK query payloads. +The SDK list becomes a **post-materialize filter**, not the source of +the data. + +- **Pre-materialization (provisional / draft):** a disk scan resolves + the full set with real paths + parsed metadata. No warm SDK session + required, so `createSession` stays **cold and provisional** — no + subprocess, no proxy refcount, no JSONL, fully invisible in the + sidebar until the first `sendMessage`. **The provisional/materialize + split is preserved, not collapsed.** Show *everything* discovered on + disk (optimistic — no session yet to say what's actually active). +- **Post-materialization (live session):** intersect the disk-scan + superset with the SDK's "known" set (`supportedCommands` / + `supportedAgents` / `mcpServerStatus`, matched by name per type). + A customization discovered on disk that the live session did **not** + load (malformed, disabled, wrong `settingSource`, shadowed by + precedence) is **hidden** post-materialize. The warm session already + exists here, so the filter is free. + +**Why a disk scan (and why it's not really duplicating Claude).** +`CopilotAgent` already scans disk for customizations +(`sessionCustomizationDiscovery.ts` — which *already* walks +`.claude/agents` and `.claude/skills`) and parses frontmatter via the +shared `pluginParsers.ts` (`parseAgentFile` / `parseSkillFile` / +`readSkills` / `readMcpServers`). The Claude provider reuses that +infrastructure. The genuinely net-new surface is only: **user-home +`~/.claude/**`**, **`~/.claude/commands`** (slash commands), and +**Claude's `settings.json` / `.mcp.json` MCP format** — i.e. encoding +Claude's directory conventions, not reimplementing Claude. **Scope:** -- `ClaudeAgent.createSession` calls `_materializeProvisional(sessionId)` - synchronously before returning. Return value's `provisional` flag is - either dropped or redefined ("no on-disk transcript yet" rather than - "no SDK" — settle in the plan). -- `_sessionSequencer`'s "first call materializes" branch in - `sendMessage` is removed; every reachable session has a live pipeline. -- `disposeSession` for a never-sent session now tears down a live - subprocess (the existing teardown handles it but is no longer free — - audit cost). -- Fork path (Phase 6.5, when it lands) already materializes synchronously - on `forkSession` return — semantics align naturally. -- CONTEXT.md M9: revise the "Provisional sessions own no SDK - resources" invariant; relax the "two-phase contract is locked" - framing; update the lifecycle tables to reflect "creation is the - materialize trigger". Phase 16 owns the doc update. -- Tests that exercise the provisional → first-send materialize race - (Phase 10.5 regression coverage, Phase 11 mid-turn toggle race) - reworked against the new contract. -- `getSessionCustomizations` for a freshly-created session now returns - the full SDK-resolved + client-pushed projection without waiting on - a send. - -**Trade-offs accepted (documented for posterity):** - -- Drafting is no longer free — every `createSession` pays a subprocess - spawn, plugin sync, proxy refcount, and metadata write. -- A draft the user cancels without sending costs the same as a session - that runs a turn (minus the actual model call). -- The two-phase model (provisional → materialized) collapses into a - single phase for non-fork creation. Fork already materializes - eagerly; this aligns the two paths. - -**Open design points** (settle in the phase plan when scheduled): - -- Does `IAgentCreateSessionResult.provisional` get dropped, or - redefined to mean "no on-disk SDK transcript yet" (true until the - first message lands and the SDK persists)? Workbench callers may - rely on the flag for deferred-notification semantics. -- `_onDidMaterializeSession` fires from inside `createSession`. The - service-layer deferred `sessionAdded` dispatch (`agentService.ts:412`) - must still see the event between the create and the visibility - window — verify ordering. -- Failure modes: if materialization throws (proxy down, SDK install - broken), does `createSession` reject? Probably yes — the user has - no usable session anyway. Today's lazy path lets the failure surface - on first `sendMessage` instead; eager surfaces it earlier, which is - arguably better UX. -- E2E coverage: a workbench scenario that creates a session and - inspects `getSessionCustomizations` *without* sending a message, - verifies the full SDK-resolved list is present. - -Exit criteria: `getSessionCustomizations(freshlyCreatedSession)` -returns the full SDK + client-pushed projection synchronously after -`createSession` resolves; M9 doc updated; Phase 10.5 / 11 race tests -reworked and green. +- New Claude customization **disk-scan resolver** (mirrors + `CopilotAgent`'s discovery) covering, scoped by the session's `cwd` + + user home + `settingSources`: + - agents — `~/.claude/agents/**`, `/.claude/agents/**` + - skills — `~/.claude/skills/**`, `/.claude/skills/**` + - slash commands — `~/.claude/commands/**`, `/.claude/commands/**` + - MCP servers — `~/.claude/settings.json`, `/.claude/settings.json`, + `/.mcp.json` + - Reuse `pluginParsers.ts` + `sessionCustomizationDiscovery.ts` where + possible; add Claude-specific roots + the `settings.json` MCP parser. +- Each resolved item ships a **real `uri`** (editable file path) + parsed + name/description, replacing the synthetic-stub bundler for the + discovered tier. +- Rules (CLAUDE.md + `.claude/rules/**`) are scanned and surfaced too; + they have no SDK counterpart, so they bypass the post-materialize + filter (always kept). +- `getSessionCustomizations`: + - **provisional:** client-pushed ∪ full disk scan (no filter). + - **materialized:** client-pushed ∪ (disk scan ∩ SDK-known set). +- The synthetic-stub `claudeSdkCustomizationBundler` is **deleted**; the + SDK-only non-editable fallback is generated declaratively inline in + `buildDiscoveredCustomizations` (no stub files on disk). +- **Read-only built-in tier** (added during implementation): a curated set + of built-in slash commands (13) + built-in agents (5) is surfaced + read-only pre-materialize for discoverability (on the `agent-builtin:` + scheme), then superseded by the live SDK set post-materialize. Lives in + `claudeBuiltinCommands.ts`. Built-ins are display-only — `contrib/chat` + is untouched, so clicking one does not render content (accepted; a + read-only editor view is a future request). +- `createSession` is **unchanged** in lifecycle terms: stays + provisional, no warm SDK, no `onDidMaterializeSession` at create. + The provisional path simply gains a disk scan for its customization + projection. + +**What this phase explicitly does NOT do (retired from the old design):** + +- No eager / warm materialization inside `createSession`. +- No collapsing of the provisional/materialize split. +- No `IAgentCreateSessionResult.provisional` change, no + `_sessionSequencer` first-send-branch removal, no `onDidMaterialize` + ordering rework, no JSONL-write-timing investigation, no + cleanup-on-discard net. The lifecycle (M9) is untouched. + +**Open design points** (settle in the phase plan): + +- **SDK-knows-it-but-not-found-on-disk** (built-ins, plugin-provided, + or an unscanned dir): show it as a **non-editable name/description + entry** (so the active capability list stays complete) or **drop** + it? Leaning *show as non-editable* — retains the honest active set + while only the locatable items are editable. +- **Skills vs commands mapping** — the SDK surfaces skills via + `reloadSkills()` / `supportedCommands()` as `SlashCommand[]`, but the + disk layout separates `~/.claude/commands` (slash commands) from + `~/.claude/skills` (skills). The name-match filter needs a per-type + mapping so a skill isn't matched against a command. +- **File watching** — do we re-scan on disk change (live updates to the + customization list) or scan once per `getSessionCustomizations` call? + Prefer correlated watchers via `fileService.createWatcher` if live. +- **MCP completeness pre-materialize** — disk-scan reads declared MCP + servers from `settings.json`; their *connection status* is only known + post-materialize via `mcpServerStatus()`. Pre-materialize entries + carry config but no live status. + +Exit criteria: opening a discovered agent / skill / command from a +Claude session's customization list opens the **real on-disk file** +(editable), not a synthetic stub; a provisional (never-sent) session +lists the full disk-scanned set; a materialized session hides on-disk +customizations the live session did not load, and surfaces +SDK-known-but-not-on-disk items as **non-editable** entries; the +synthetic-stub bundler is **deleted** (the non-editable fallback and the +curated built-in agents/skills tier are generated declaratively inline, +no stub files on disk); `createSession` lifecycle (provisional, cold) is +unchanged. **Shipped.** + +### Phase 17 — User/workspace hooks + Claude-native plugins via disk scan ✅ **DONE** + +> **Status:** both parts shipped. Part A (hooks) landed as PR #322637; Part B +> (native plugins) landed as PR #322766. Both are surface-only (no +> `Options.plugins` / `claudeSdkOptions.ts` change) — unit-tested, +> council-reviewed, and live-E2E verified (real `telegram@claude-plugins-official` +> + `github-inbox@vscode-team-kit` plugins surface under the customization modal +> with their real cache roots, and a workspace `settings.local.json` disable +> hides them via the watcher). See [phase17-plan.md](./phase17-plan.md) for the +> full retrospective, including the post-E2E fixes (multi-format manifest +> detection, `source`-based post-materialize match, and PB-10 standalone-fallback +> suppression). + +> **Driver.** Phase 16's disk scan surfaces agents, skills, slash commands, +> MCP servers, and rules — but **hooks** and **Claude-native plugins** that +> the *user or workspace* has configured are still invisible in the +> customization list. Both already *run* (the runtime loads them via +> `settingSources` + `includeHookEvents`), but the user cannot see or edit +> them from the Agents window. This phase closes that **surfacing** gap for +> both, mirroring Phase 16's provisional/materialize semantics and its +> "real editable `file:` URI, no synthetic stub" rule. It does **not** +> change the SDK loading path. +> +> **Scope is user/workspace-configured surfaces only** — hooks and plugins +> the *user* set up in their `~/.claude` / workspace `.claude`, not what the +> agent host injects into the SDK (the proxy env, the in-process +> client-tool MCP server from Phase 10, or the synced client customizations +> from Phase 11). + +**Reference docs** (verified 2026-06-23): + +- [Hooks reference](https://code.claude.com/docs/en/hooks.md) — hook + locations table: `~/.claude/settings.json` (user), `.claude/settings.json` + (project), `.claude/settings.local.json` (local), plus plugin + `hooks/hooks.json` and skill/agent frontmatter. `disableAllHooks` + short-circuits a scope. +- [Plugins reference](https://code.claude.com/docs/en/plugins-reference.md) + — `enabledPlugins` lives in the same `settings.json` scopes; marketplace + plugins are cached under `~/.claude/plugins/cache/...`; `@skills-dir` + plugins live in-place under `~/.claude/skills//.claude-plugin/` + and `/.claude/skills//.claude-plugin/`. +- [SDK plugins](https://code.claude.com/docs/en/agent-sdk/plugins.md) — a + *bare* SDK app must pass plugins as `Options.plugins: [{ type: 'local', + path }]`. **But with `settingSources` enabled (our config), the runtime + auto-loads `.claude` plugins** — so the host must NOT also pass them + (`claudeSkills.ts` skips `.claude` dirs "to avoid duplicates"). + `Options.plugins` stays client-only. +- [SDK hooks](https://code.claude.com/docs/en/agent-sdk/hooks.md) — shell + command hooks from settings files run **only when the matching + `settingSources` entry is enabled** (it is, for user/project/local). + +#### Part A — Hooks (surface only; already loaded) + +Hooks from user/project/local `settings.json` already **fire** at runtime +because `settingSources` loads them and `includeHookEvents: true` streams +their events. The gap is purely **discovery/surfacing** in the customization +list. There is no SDK enumeration API for active hooks (no +`supportedHooks()`), so — unlike agents/skills/MCP — hooks are surfaced from +disk **only** and bypass the post-materialize SDK-intersection filter +(same as rules in Phase 16). + +- New scanner under `node/claude/customizations/scan/` that reads the + `hooks` block from `~/.claude/settings.json`, + `/.claude/settings.json`, and `/.claude/settings.local.json`, + honoring `settingSources`. **Reuse the existing parser** + ([`pluginParsers.ts`](../../../agentPlugins/common/pluginParsers.ts)): + `parseHooksJson` already understands Claude's nested + `{ matcher, hooks: [...] }` shape, the `HOOK_TYPE_MAP` event names, and + `disableAllHooks`, and emits a protocol `HookCustomization` via + `makeHookCustomization`. The net-new work is the Claude settings-scope + roots, not the parser. +- Each surfaced hook group carries the **real `settings.json` URI** + (editable), the event name, and the matcher — opening it from the + customization list opens the settings file, like the read-only `/hooks` + menu but editable. +- A scope whose `disableAllHooks` is `true` contributes no hook entries. +- **Out of scope:** `managed`-policy hooks (matches the existing + `managed`-excluded `settingSources` non-goal); skill/agent-frontmatter + hooks (those ride along with their owning component, already surfaced in + Phase 16). + +#### Part B — Claude-native plugins (surface only) + +Native plugins are **already loaded** by the SDK runtime via +`settingSources: ['user', 'project', 'local']` — the same mechanism that +auto-loads `.claude` agents / skills / hooks. The gap is purely +**surfacing** them in the customization list. They must **not** be added +to `Options.plugins`: that channel is exclusively for client/host-provided +plugins *outside* `.claude` (the Phase 11 `clientCustomizationsDiff` dirs, +[`claudeAgentSession.ts:324`](./claudeAgentSession.ts#L324) / +[`:414`](./claudeAgentSession.ts#L414)), and the production extension +explicitly **skips `.claude` dirs to avoid duplicate loading** +([`claudeSkills.ts`](../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeSkills.ts) +`isClaudeDirectory` filter; `claudeCodeAgent.ts` builds `Options.plugins` +only from `getPluginLocations()`). Plumbing native plugins in would +**double-load** them. + +- **Discover** enabled native plugins by reading `enabledPlugins` from the + user/project/local `settings.json` scopes and resolving each to its + on-disk root: + - marketplace installs → `~/.claude/plugins/cache/<...>/` (the + current version dir; orphaned version dirs skipped); + - `@skills-dir` plugins → in-place under `~/.claude/skills//` + and `/.claude/skills//` when a `.claude-plugin/plugin.json` + is present. + Parse each manifest with the shared `parsePlugin` / `CLAUDE_FORMAT` + ([`pluginParsers.ts`](../../../agentPlugins/common/pluginParsers.ts) + `manifestPath: '.claude-plugin/plugin.json'`, + `hookConfigPath: 'hooks/hooks.json'`) and surface the plugin plus its + bundled components (skills / agents / hooks / MCP servers) with their + **real `file:` URIs**. +- **No `Options.plugins` change, no host-side rebind to load.** The + runtime owns loading; a mid-session `enabledPlugins` edit takes effect on + the runtime's next `settingSources` read / restart (the same path as any + `settings.json` change). The `ClaudeCustomizationWatcher` only refreshes + the **displayed list**. + +#### Provisional / materialize semantics (both parts) + +Same as Phase 16 — **no eager materialization, no lifecycle change**: + +- **Provisional (pre-send):** show the full disk-scanned hook + plugin set + (optimistic; no live session yet). +- **Materialized (live):** hooks stay disk-only (no SDK filter). Native + plugins **are** enumerable post-materialize — the SDK `system/init` + message reports loaded `plugins` and their namespaced `skills` / + `slash_commands` + ([SDK plugins](https://code.claude.com/docs/en/agent-sdk/plugins.md)) — + so a plugin declared in `enabledPlugins` but **not** loaded by the live + session (bad path, manifest error, untrusted workspace) is hidden + post-materialize, matching Phase 16's "hide on-disk items the live + session did not load" rule. +- **Watching:** extend `ClaudeCustomizationWatcher` + ([`claudeSessionCustomizationDiscovery.ts`](./customizations/claudeSessionCustomizationDiscovery.ts)) + so edits to `settings.json` / `settings.local.json` (hook block + + `enabledPlugins`) and to a resolved plugin's manifest re-fire + `onDidCustomizationsChange`. The `.claude` roots are already watched; + the settings files largely are too (Phase 16 watches `/.claude` + + `/.claude` recursively). + +**Tests:** + +- Unit: hook scanner yields `HookCustomization` entries with real + settings-file URIs from a temp `~/.claude/settings.json` + + `/.claude/settings.json`; `disableAllHooks` drops a scope; + `managed` is never read. +- Unit: plugin resolver maps `enabledPlugins` → cache / skills-dir roots, + parses each manifest, and yields the plugin + bundled components with + real URIs; a missing/orphaned dir is skipped. +- Unit: provisional `getSessionCustomizations` returns the full + hook+plugin set; materialized intersects native plugins against the SDK + `init` plugin list while leaving hooks unfiltered. +- Regression: the existing `claudeSdkOptions.test.ts` still passes + **unmodified** — `Options.plugins` is untouched (native plugins are + auto-loaded, not host-plumbed). +- Integration: a temp plugin enabled in `enabledPlugins` is auto-loaded by + the live session (appears in the captured `init.plugins`) and its skill + is invocable — **without** any host-added `Options.plugins` entry. + +**Manual E2E:** + +- Add a `PostToolUse` hook to `~/.claude/settings.json`; it appears in the + Claude session's customization list with an editable URI, and firing a + `Write` triggers it. +- Install a marketplace plugin via the Claude CLI, enable it, open a Claude + session: the plugin and its skills appear in the list and the skill is + invocable from the agent. + +**Exit criteria:** user/workspace-configured hooks and Claude-native +plugins appear in the customization list with **real editable URIs** +(no synthetic stubs); enabled native plugins continue to be auto-loaded by +the runtime (verified via the captured `init.plugins`) with **no** +host-added `Options.plugins` entry; provisional sessions show the full +disk set, materialized sessions hide native plugins the live session did +not load; the `createSession` provisional/cold lifecycle (M9) is +unchanged. Shipped as two PRs: Part A (hooks, #322637) then Part B +(native plugins, #322766). Detailed implementation contract: +[phase17-plan.md](./phase17-plan.md). + +### Phase 18 — Transport-branched model source (SDK discovery workaround) ✅ **DONE** + +> **Status:** ✅ done (revised 2026-06-24). The original "unify the model path on +> `Query.supportedModels()` via the SDK's gateway model-discovery" approach +> is **abandoned** — a confirmed Claude Agent SDK bug makes it unworkable +> (below). Phase 18 is re-scoped to the small structural seam the eventual +> unification slots into: a **single transport-branched model source**, +> with the proxy hardcoded **on** and a `TODO(Phase 19)`. Proxied mode +> keeps using `ICopilotApiService.models()` exactly as today. + +#### Why the original approach died — confirmed SDK bug + +The premise was: set `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` so the +SDK discovers models from `ClaudeProxyService`'s `/v1/models`, and read them +via `Query.supportedModels()` — one model call for both proxied and native +modes. **It does not work on a brand-new query**, proven empirically against +both the pinned SDK (`0.3.169`) and the latest (`0.3.191`, bundled CLI +`2.1.191`): + +- **`initialize()` does not await gateway discovery.** Timing probe (gateway + `/v1/models` deliberately delayed): `startup()` resolves ~1.5s **before** + the `/v1/models` response arrives. Discovery is fire-and-forget; the + discovered models land in `~/.claude/cache/gateway-models.json` *after* + `this.initialization` has already resolved. +- **`supportedModels()` is a one-shot snapshot** — `return (await + this.initialization).models` — and there is **no `models_changed` push** + to refresh it (even though `commands_changed` exists for exactly this + reason: the SDK's own docs admit `supportedCommands()` is "captured once + at initialize"). No refresh control request, no "wait for discovery" flag. +- Net: a cold first query's `supportedModels()` returns only built-in + aliases (`default/sonnet/haiku/...`); discovered models appear only on a + **subsequent warm-cache startup**. Also `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` + (which proxied `buildOptions` sets) suppresses discovery entirely. + +A minimal standalone repro for the upstream team lives outside the repo at +`~/claude-sdk-model-discovery-repro` (fake gateway + cold/warm `startup() → +supportedModels()`; prints "BUG CONFIRMED"). The clean upstream fix is +either (a) await discovery before resolving `initialize`, or (b) emit a +`models_changed` push. **Until Anthropic ships one, we do not route any +model list through `supportedModels()`-via-discovery.** + +#### The workaround (this phase) + +Establish the seam the future unification needs, and nothing more: + +- **One branch point** in `ClaudeAgent`: `_isProxyEnabled()` returns + `true` (hardcoded) with `// TODO(Phase 19): read RootConfigState + ClaudeUseCopilotProxy`. Phase 19 flips this to the real + `IAgentConfigurationService.getRootValue` read. +- **Proxy branch → `ICopilotApiService.models()`** — today's + `_refreshModels` path, essentially unchanged (`isClaudeModel` filter + + `toAgentModelInfo` projection, CAPI-dotted `ModelSelection.id`, the + `is_chat_default` sort, the `tokenAtStart` stale-write guard). No SDK + bug exposure, no behavior change, no picker regression. +- **Native branch → `Query.supportedModels()`** — **not built here**; + throws `TODO: Phase 19`. It is unreachable while `_isProxyEnabled()` is + hardcoded `true`. Phase 19 implements it (built-in catalogue, which + `supportedModels()` *does* return reliably — the bug only affects + gateway-*discovered* models, not the built-ins). +- **Shared `→ IAgentModelInfo` projection (light):** keep + `toAgentModelInfo(CCAModel)` for the proxy branch; Phase 19 adds + `fromSdkModelInfo(ModelInfo)` for native. Both already target the same + `IAgentModelInfo` shape, and the effort-schema construction + (`createClaudeThinkingLevelSchema` / `isClaudeEffortLevel`) is factored + so both can share it. That shared target + the single branch point is + what makes the future unification cheap. + +**The future "couple lines" once Anthropic fixes the SDK:** point the +**proxy** branch at `supportedModels()` too (through the proxy with +discovery, now that `supportedModels()` is reliable) and delete the +direct-CAPI list call — the native projection already exists, so the +proxied path collapses onto it. Captured here so the intent survives. + +#### Scope + +- `ClaudeAgent._isProxyEnabled()` (hardcoded `true` + `TODO(Phase 19)`). +- `_refreshModels` (or a renamed `_resolveModels`) branches on it: proxy → + existing CAPI path; native → `throw new Error('TODO: Phase 19')`. +- Factor the effort/config-schema helpers so a future `ModelInfo` + projection can share them (no native projection built yet). +- **No** gateway-discovery flag, **no** enumeration query, **no** proxy + `/v1/models` change, **no** picker-behavior change. Those are all + retired with the original approach. + +**Out of scope** (moved to Phase 19): the real `RootConfigState` toggle +read, the native `supportedModels()` branch + `ModelInfo → IAgentModelInfo` +projection, and (post-SDK-fix) collapsing proxied onto `supportedModels()`. + +#### Tests + +- `ClaudeAgent` proxied model path unchanged: `models` observable + populated from a stubbed `ICopilotApiService.models()`, filtered to + Claude family, default ordering + multiplier/policy metadata intact, + stale-write guard preserved (the existing + [claudeAgent.test.ts:757-820](../../test/node/claudeAgent.test.ts) suite + should pass essentially unchanged). +- `_isProxyEnabled()` returns `true`; the native branch is unreachable + (a unit test that forces the native branch asserts it throws + `TODO: Phase 19`). + +#### Exit criteria + +The model source is selected at one transport-branch point with the proxy +hardcoded on; proxied mode behaves **identically to today** (direct CAPI, +no SDK bug exposure, no picker regression); the native branch is a typed +`TODO: Phase 19` stub; and the effort-schema helpers are factored for +reuse. Re-routing proxied onto `supportedModels()` is a documented +couple-lines follow-up gated on the upstream SDK fix. + +### Phase 19 — Direct (non-proxied) Claude access (BYO Anthropic) ✅ **DONE** + +> **Status:** ✅ done. Native (BYO-Anthropic) turns authenticate on the user's +> own credentials from the subprocess env — `ANTHROPIC_API_KEY`, or a +> subscription OAuth token in `CLAUDE_CODE_OAUTH_TOKEN` (from `claude +> setup-token`). The interactive `claude login` keychain session is NOT used in +> headless/SDK mode (verified empirically). The Phase 19.1 native-binary-path +> override was explored and removed — it only selects which `claude` build +> runs, never the credentials. This is the first phase that lets the Claude +> provider talk to Anthropic **without** the Copilot proxy in the path — +> using whatever credentials the user already has (an `ANTHROPIC_API_KEY`, +> a `claude login` OAuth session in `~/.claude`, or a Bedrock/Vertex +> configuration). It also adds the agent-host config switch that selects +> between the two transports and quarantines all proxy-only plumbing +> behind it. + +#### Motivation + +Today the Claude provider is *Copilot-routed Claude*: every `/v1/messages` +call is minted against a GitHub Copilot token, translated by +`ClaudeProxyService`, and billed through CAPI. That is the right default +for Copilot subscribers, but it excludes two populations: + +1. Users who have a **direct Anthropic relationship** (API key or Claude + subscription via `claude login`) and want to spend *that* quota, not + their Copilot entitlement. +2. Users on networks / orgs where the Copilot CAPI path is unavailable but + Anthropic (or a Bedrock/Vertex endpoint) is reachable. + +This phase makes the proxy **optional**, selected by an agent-host config +property, and proves the SDK runs end-to-end on its own credentials. + +#### The things that change when the proxy is removed + +The proxy is load-bearing in several places. The **model picker is no +longer one of them** — Phase 18 already unified model acquisition on +`supportedModels()`, so native mode inherits it (see sub-problem 2). The +remaining concerns each need a native analogue or an explicit "not +available in native mode" decision. + +1. **Transport + auth into Anthropic.** + - *Proxied (today):* `buildOptions` sets `settings.env.ANTHROPIC_BASE_URL` + = `proxyHandle.baseUrl`, `settings.env.ANTHROPIC_AUTH_TOKEN` = + `.`, and `buildSubprocessEnv()` **strips** + `ANTHROPIC_API_KEY`. The SDK thinks it's talking to Anthropic; it's + really talking to `127.0.0.1:` → CAPI. + - *Native (this phase):* do **not** set `ANTHROPIC_BASE_URL` / + `ANTHROPIC_AUTH_TOKEN`; do **not** strip `ANTHROPIC_API_KEY`; pass the + user's relevant env through. The SDK uses its own credential + resolution (env key → CLI OAuth in `~/.claude` → cloud-provider env). + The SDK reports which it used via `SDKSystemMessage.apiKeySource` + (`'user' | 'project' | 'org' | 'temporary' | 'oauth'`, sdk.d.ts:116) + on the `system/init` message — surface this for diagnostics. + - **`buildOptions` / `buildSubprocessEnv` must take a transport + descriptor**, not an `IClaudeProxyHandle`. Today the handle is a + required positional arg (`claudeSdkOptions.ts`). Introduce a + discriminated `ClaudeTransport = + { kind: 'proxy'; handle: IClaudeProxyHandle } + | { kind: 'native'; passthroughEnvKeys: readonly string[] }` + and branch the env construction on it. Keep `buildSubprocessEnv`'s + `VSCODE_*` / `ELECTRON_*` / `NODE_OPTIONS` stripping in **both** + modes (those are agent-host hygiene, unrelated to Anthropic auth); + only the `ANTHROPIC_API_KEY` strip is proxy-mode-only. + +2. **The model picker — build the native branch + flip the seam.** + - Phase 18 laid only a **seam**: `ClaudeAgent._isProxyEnabled()` hardcoded + `true`, proxy branch on `ICopilotApiService.models()`, native branch a + `TODO: Phase 19` stub. (The original gateway-discovery unification was + abandoned — confirmed SDK bug; see Phase 18.) Phase 19 finishes it. + - **Flip the flag to the real read:** `_isProxyEnabled()` → + `IAgentConfigurationService.getRootValue(..., ClaudeUseCopilotProxy)` + (defaulting `true`), the same `_transportMode` resolution the config + switch below describes. + - **Build the native branch:** `Query.supportedModels()` → + `ModelInfo → IAgentModelInfo` projection (the `fromSdkModelInfo` + Phase 18 left unbuilt), reusing the factored effort-schema helpers. + With **no** `ANTHROPIC_BASE_URL` and **no** + `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`, `supportedModels()` + returns the SDK's **built-in** catalogue reliably (the SDK bug only + affects gateway-*discovered* models, not built-ins — verified). This + needs the model-enumeration query lifecycle (provider-scoped, + memoized, `startup() → query(neverYield) → supportedModels()` → + dispose, ephemeral `CLAUDE_CONFIG_DIR`) that Phase 18 deferred. + - **No commercial-metadata overlay in native mode** — multiplier / + policy / default ordering are Copilot/CAPI concepts; native + `IAgentModelInfo` entries omit them. + - Native ids are SDK-canonical end to end (`m.value`); `toSdkModelId` + is an identity at the native seam. + - **Post-SDK-fix unification (separate follow-up, not Phase 19):** once + Anthropic fixes the discovery-before-init bug, point the **proxy** + branch at `supportedModels()` through the proxy too and delete the + direct-CAPI list call — a couple of lines, since the native + projection now exists. + +3. **Per-turn cost / "tracking between requests".** + - *Proxied (today):* the proxy is the **only** place that sees CAPI's + real billed credits (`copilot_usage.total_nano_aiu`), because the SDK + `result` strips them. `onDidReportCredits` routes each report (keyed + by the `sessionId` decoded from the Bearer token) to + `ClaudeAgentSession.recordTurnCredits`, accumulated per turn and + injected into the `ChatUsage` signal as `_meta.copilotUsage` by + `_enrichSignalWithCredits`. + - *Native (this phase):* there is no proxy and no `copilot_usage` — + CAPI is not billing. The relevant signal is the SDK's own `result` + message: token `usage` and `total_cost_usd` (an Anthropic-list-price + estimate; M8). Native turns surface **SDK usage / cost** instead of + Copilot credits: + - `recordTurnCredits` / `_enrichSignalWithCredits` are **proxy-only** + and must no-op in native mode (the agent simply never wires the + `onDidReportCredits` subscription when the session's transport is + native). + - The `ChatUsage` signal in native mode carries the SDK `result` + usage/cost the live mapper already extracts (M8 — usage is + live-only; replayed native turns have `usage === undefined`, same + asymmetry as today). + - **No cross-request bearer correlation is needed in native mode.** The + proxy's `.` Bearer existed *only* to attribute + CAPI requests back to a session at the proxy seam. With no proxy, + the SDK subprocess already belongs to exactly one + `ClaudeAgentSession` (one `Query` per session), so turn/usage + attribution is intrinsic — the session reads its own `result` + messages. This is the answer to "how do we track between requests + without the proxy": **we don't have to — the per-session subprocess + is the attribution boundary the proxy Bearer was emulating.** + +4. **Auth gating + protected resources.** + - *Proxied (today):* `authenticate()` requires the + `GITHUB_COPILOT_PROTECTED_RESOURCE` token; without it + `_ensureAuthenticated()` throws `AHP_AUTH_REQUIRED` and the proxy is + never started. `getProtectedResources()` advertises GitHub Copilot + + GitHub repo. + - *Native (this phase):* the GitHub Copilot token is **not** required to + reach Anthropic. The provider must not block session creation behind a + Copilot sign-in it will never use. Decisions: + - `getProtectedResources()` in native mode drops + `GITHUB_COPILOT_PROTECTED_RESOURCE` (keep + `GITHUB_REPO_PROTECTED_RESOURCE`, which is about repo access for + git operations, not model auth). The workbench renders auth prompts + from this list, so dropping it removes the spurious Copilot + sign-in. + - `_ensureAuthenticated()` becomes mode-aware: native mode is + "authenticated" once the agent is constructed (the SDK owns its own + credential check and will surface a clear `result` error if the + user has no usable Anthropic credential — map that to a turn error, + do **not** try to pre-validate the key in the host). + - **Credential-absent UX:** if the SDK's first turn fails because no + credential resolved (`apiKeySource` unavailable / 401 from + Anthropic), surface a targeted error telling the user to set + `ANTHROPIC_API_KEY` or run `claude login`, rather than a generic + stream failure. (Pre-flighting the credential without a turn is out + of scope — the SDK has no cheap "am I authed" probe short of a + warm query.) + +#### The config switch — a RootConfigState property + +The transport is selected by a **`RootConfigState` property on the agent +host**, not by a workbench `chat.agentHost.*` setting or an env var. This +is the Agent Host Protocol's own config surface: `rootState.config` is a +`{ schema, values }` bag the host broadcasts to every connected client +over AHP and persists to `agent-host-config.json`. It is read/written +through `IAgentConfigurationService` (`getRootValue` / `updateRootConfig` +/ `onDidRootConfigChange`) and described by a JSON schema so clients can +render and edit it generically. The existing +`EnableCustomTerminalTool` / `RubberDuck` / `Opus48Prompt` keys are +exactly this mechanism. + +Why RootConfigState rather than the `claudeAgent.enabled` setting+env +pattern: + +- **It's the host's own config plane.** The toggle is a property of *the + agent host* (it changes how the host talks to Anthropic), not a + workbench preference that has to be marshalled across the process + boundary. `ClaudeAgent` already injects `IAgentConfigurationService` + and reads other root values; it reads this one the same way — **no new + VS Code setting, no `VSCODE_AGENT_HOST_*` env var, no + `nodeAgentHostStarter` forwarding.** +- **It works identically for local and remote/headless hosts.** A remote + operator edits `agent-host-config.json` (or any AHP client writes it via + `updateRootConfig`); a workbench client renders the schema-described + property in its host-config UI. One code path, no precedence rules to + reconcile between a setting and a root-config fallback. +- **It's reactive.** `onDidRootConfigChange` already fires on + `RootConfigChanged`, so `ClaudeAgent` can re-resolve its transport mode + live (subject to the restart semantics below) instead of only at + process start. + +Concretely: + +- **New `AgentHostConfigKey`** (e.g. `ClaudeUseCopilotProxy = + 'claudeUseCopilotProxy'`) declared in + [`agentHostCustomizationConfig.ts`](../../common/agentHostCustomizationConfig.ts) + with a `schemaProperty({ ..., default: true })`, **default + `true`** so existing Copilot users are unaffected. It joins the schema + merged into `rootState.config` by the `AgentConfigurationService` + constructor. + - *Naming open question:* the key is Claude-specific today, but the + "use the Copilot proxy vs. bring-your-own credentials" axis may + generalise to Codex later. Decide whether to namespace it + (`claudeUseCopilotProxy`) or model a more general transport key now. + Leaning Claude-specific for v1 to avoid speculative generality. +- **`ClaudeAgent` reads it** via + `this._configurationService.getRootValue(agentHostCustomizationConfigSchema, + AgentHostConfigKey.ClaudeUseCopilotProxy)` (defaulting to `true` when + absent) to resolve `_transportMode`, and subscribes to + `onDidRootConfigChange` to react to edits. +- **No env var, no setting, no starter change.** This deletes the + `VSCODE_AGENT_HOST_CLAUDE_USE_COPILOT_PROXY` / + `chat.agentHost.claudeAgent.useCopilotProxy` plumbing the earlier draft + proposed. +- **Scope: host-level for v1.** All sessions on the host share one + transport. Per-session transport selection (a session config key, so a + user could run one chat on Copilot quota and another on their own key) + is a deliberate **follow-up** — it interacts with restored-session + transport identity (below) and the picker would need to show two model + catalogues at once. + +#### Where the branch lives (don't scatter it) + +The transport choice should resolve **once** into a `ClaudeTransport` +value and flow as data; avoid re-reading the config in five places. + +- `ClaudeAgent` resolves the `ClaudeUseCopilotProxy` root value into a + `_transportMode: 'proxy' | 'native'` and keeps it current by listening to + `IAgentConfigurationService.onDidRootConfigChange`. **Live-flip + semantics:** a mode change applies to *new* sessions immediately; already + materialised sessions keep the transport they started with until they + restart (their subprocess env is fixed at `buildOptions` time). Document + this — don't try to hot-swap a running subprocess between proxy and + native. +- Proxied mode keeps today's behaviour: acquire `IClaudeProxyHandle` in + `authenticate()`, wire `onDidReportCredits`, advertise the Copilot + protected resource, populate models from CAPI. +- Native mode: skip the proxy handle entirely (do **not** call + `IClaudeProxyService.start`), skip the credits subscription, drop the + Copilot protected resource, populate models via Phase 18's + `supportedModels()` path **with the gateway-discovery flag off** (no + CAPI, no commercial-metadata overlay). +- The mode is threaded into the session as part of the materialize inputs + so `buildOptions` receives a `ClaudeTransport` instead of a bare proxy + handle. `ClaudeAgentSession` already owns the abort controller, model, + and permission mode — the transport joins that bag. +- **`IClaudeProxyService` stays a DI singleton** constructed unconditionally + in `agentHostMain.ts` (cheap until `start()` is called). Native mode + simply never calls `start()`, so no proxy server ever binds. Do **not** + gate the service registration on the toggle — that would couple service + wiring to runtime config and complicate the proxied default. + +#### Restored-session transport identity (the subtle one) + +A session's transport is part of its identity for replay/continuation: + +- A session **created** under proxied mode whose transcript is later + continued under native mode (user flipped the toggle) would resume a + conversation billed one way and continue it the other. The SDK doesn't + care (it's the same JSONL), but **model ids may not resolve**: a proxied + session stored CAPI-dotted `ModelSelection.id`s; native resume expects + SDK-canonical. `toSdkModelId` already normalises proxied→SDK, so resume + *should* work, but the **model picker** for a resumed cross-mode session + must show the catalogue matching the *current* transport, and a + stored model id absent from that catalogue must degrade gracefully + (fall back to the current default, don't hard-fail the resume). +- **Decision for v1:** transport is host-level and resolved live, so a + restored session adopts whatever mode the host is in *now*. Persist the + `apiKeySource` / transport used per turn in session metadata for + diagnostics, but do **not** pin a session to its creation-time + transport in v1. Revisit if per-session transport lands. + +#### Scope checklist + +- `ClaudeTransport` discriminated union + `buildOptions` / + `buildSubprocessEnv` refactor to consume it (replace the required + `IClaudeProxyHandle` arg). +- `AgentHostConfigKey.ClaudeUseCopilotProxy` RootConfigState property + (schema + `default: true`) in `agentHostCustomizationConfig.ts`; + `ClaudeAgent` resolves `_transportMode` via `getRootValue` and reacts to + `onDidRootConfigChange`. No setting, env var, or starter change. +- `ClaudeAgent._transportMode`, mode-aware `authenticate`, + `getProtectedResources`, `_ensureAuthenticated`, and the + `onDidReportCredits` subscription gating. +- Native model path: **build the native branch Phase 18 stubbed.** Flip + `_isProxyEnabled()` to the real `RootConfigState` read; implement + `Query.supportedModels()` → `fromSdkModelInfo` projection (built-in + catalogue, no discovery flag, no overlay) + the provider-scoped + memoized enumeration-query lifecycle (ephemeral `CLAUDE_CONFIG_DIR`). +- Native usage path: `recordTurnCredits` / `_enrichSignalWithCredits` + no-op in native; `ChatUsage` carries SDK `result` usage/cost. +- Credential-absent error mapping (no key / 401 → actionable message). +- Surface `apiKeySource` from `system/init` for diagnostics + per-turn + metadata. + +#### Tests + +- `buildOptions` native branch: no `ANTHROPIC_BASE_URL` / + `ANTHROPIC_AUTH_TOKEN`; `ANTHROPIC_API_KEY` **not** stripped; + `VSCODE_*` / `ELECTRON_*` / `NODE_OPTIONS` still stripped. +- `buildOptions` proxy branch unchanged (regression snapshot). +- Transport resolution: `ClaudeUseCopilotProxy` root value `false` → + native; `true` or absent → proxied default; `onDidRootConfigChange` + flipping the value updates `_transportMode` for subsequent sessions. +- `ClaudeAgent` native mode: `authenticate` does not call + `IClaudeProxyService.start`; `getProtectedResources` omits Copilot; + `models` populated from a stubbed `supportedModels()` (dedicated + enumeration query) without any CAPI call; enumeration query is disposed + and never appears in `listSessions`; `onDidReportCredits` never + subscribed. +- Model enumeration failure (SDK load / credential error) → `models` + stays empty and an actionable error surfaces (no static fallback). +- Native `ChatUsage` carries SDK `result` usage and **no** + `_meta.copilotUsage`; proxied `ChatUsage` still carries it. +- Integration: stub `IClaudeAgentSdkService` so a native session runs a + full turn with a faked `system/init` (`apiKeySource: 'oauth'`) + + `result` and asserts the signal sequence + usage shape, with **no** + proxy server bound. +- Credential-absent: SDK first turn fails 401 → actionable error signal. + +#### Manual E2E + +- With the `claudeUseCopilotProxy` RootConfigState value set to `false` + (via `agent-host-config.json` or a client `updateRootConfig`) and a real + `ANTHROPIC_API_KEY` (or `claude login`), the Claude provider lists + models **enumerated from `supportedModels()`** (verify the ids match the + user's plan, not the CAPI catalogue), runs a turn, streams output, and + renders SDK cost — with the proxy port never bound (verify via logs / + `lsof`). +- Flip the root value back to `true` → Copilot-routed behaviour returns + for new sessions, proxy binds, credits render. +- Resume a session created in one mode under the other → picker shows the + current-mode catalogue, resume succeeds, missing model id degrades to + the default. + +#### Exit criteria + +A user can set one config property to run the Claude provider entirely on +their own Anthropic credentials with no Copilot token, CAPI call, or proxy +server in the path; the model picker is populated without CAPI; per-turn +cost surfaces from the SDK `result`; and all proxy-only plumbing +(credits, Bearer correlation, `ANTHROPIC_BASE_URL` injection, Copilot +protected resource) is cleanly quarantined behind the `'proxy'` transport +mode. The proxied path remains the unchanged default. + +#### Open questions (settle in the phase plan) + +- **Model-enumeration query lifecycle** — owned by Phase 18 (start + timing, `cwd`, cache duration, restart persistence, harvest from first + real `Query`). Native inherits whatever Phase 18 lands; the only native + consideration is re-enumeration on credential change. +- **Cloud providers** — do we explicitly support / document + Bedrock (`CLAUDE_CODE_USE_BEDROCK`) and Vertex + (`CLAUDE_CODE_USE_VERTEX`) env passthrough in native mode, or only the + direct-Anthropic key + OAuth paths for v1? +- **Credential discovery for UX** — can the host cheaply tell *whether* + any Anthropic credential exists (to pre-disable the provider or warn) + without spending a turn? Likely no clean probe; confirm. +- **Per-session transport** — defer, but record the metadata now so the + follow-up doesn't require a migration. +- **`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` in native mode** — in + proxied mode it's a Copilot leak-tightness guarantee. In native mode + it's the user's own Anthropic account; do we keep disabling + nonessential traffic (privacy-preserving default) or respect the + user's normal CLI behaviour? Leaning keep-disabled. --- @@ -1461,8 +2420,12 @@ reworked and green. ## Non-goals (explicit) -- **Anthropic-direct authentication** (`x-api-key` against - api.anthropic.com). This is a Copilot-routed Claude, not a BYOK Claude. +- **Anthropic-direct authentication via the proxy** (`x-api-key` against + api.anthropic.com *through `ClaudeProxyService`*). The proxy is + Copilot-routed only and ignores `x-api-key` by design. Direct Anthropic + access is instead delivered by **Phase 19's native transport**, which + removes the proxy from the path entirely rather than teaching it to + forward personal keys. - **Re-implementing the Anthropic SDK ourselves.** We host the official Claude Agent SDK and proxy beneath it; we re-use `@anthropic-ai/sdk` types. @@ -1476,6 +2439,8 @@ reworked and green. Host itself is the isolation boundary. - **Including `managed` in `settingSources`.** Match the reference's `['user', 'project', 'local']`. Revisit if managed-policy users complain. + Phase 17's hook + native-plugin disk scan follows the same exclusion — + `managed`-policy hooks and plugins are not surfaced. - **Tracking Bash-tool file edits** in Phase 8. Documented gap; follow-up if needed. - **Per-session GitHub token** in the proxy. Single-tenant for v1. diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 9b2f5ed3a15aa..f9a40bd30f234 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -15,17 +15,19 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; import { createSchema, platformSessionSchema, schemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; +import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; -import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider } from '../../common/agentService.js'; +import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatResult, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider } from '../../common/agentService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { ActionType, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js'; -import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition } from '../../common/state/protocol/state.js'; +import { ActionType, isChatAction, type SessionAction, type ChatAction } from '../../common/state/sessionActions.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 { type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn } from '../../common/state/sessionState.js'; -import type { ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { buildDefaultChatUri, parseChatUri, type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn } from '../../common/state/sessionState.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; +import { ActiveClientToolSet } from '../activeClientState.js'; import { McpCustomizationController } from '../shared/mcpCustomizationController.js'; import { buildCodexMcpReadResult, codexMcpListToInventory, codexMcpToolsChanged, inventoryToSdkServers, translateCodexMcpStartupState, type ICodexMcpServerEntry } from './codexMcpServers.js'; import { buildElicitationRequest, cancelledElicitationResponse, declinedElicitationResponse, elicitationResponseFromAnswers } from './codexElicitationMapper.js'; @@ -93,6 +95,14 @@ const CLIENT_INFO = { const CODEX_THINKING_LEVEL_KEY = 'thinkingLevel'; +/** + * User-agent prefix applied to the Codex agent's outbound CAPI calls (e.g. the + * model-list fetch) so the traffic is identifiable server-side. Mirrors + * `claudeAgent.ts` and the `vscode_codex` prefix used by `codexProxyService.ts` + * and `oaiLanguageModelServer.ts`. + */ +const USER_AGENT_PREFIX = 'vscode_codex'; + const CODEX_REASONING_EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high']; /** @@ -336,13 +346,12 @@ interface ICodexSession { */ readonly pendingSteeringFlips: Map; /** - * Client-provided tool definitions for this session (the active - * workbench client's tools), registered with codex as `dynamicTools` - * at `thread/start`. `undefined` until the first {@link setClientTools}. + * Client-provided tool definitions for this session, keyed by the + * contributing workbench client. The merged set is registered with codex + * as `dynamicTools` at `thread/start`. Empty until the first active client + * sets its tools. */ - clientTools: readonly ToolDefinition[] | undefined; - /** Workbench client id that owns {@link clientTools}. */ - toolsClientId: string | undefined; + readonly clientToolSet: ActiveClientToolSet; /** * Parked deferreds for in-flight client-tool calls (codex * `item/tool/call`), keyed by the host-side toolCallId. Resolved by @@ -440,6 +449,7 @@ interface IConnectionReady { */ export const CodexSdkPackage: IAgentSdkPackage = { id: 'codex', + displayName: 'Codex', devOverrideEnvVar: AgentHostCodexAgentSdkRootEnvVar, hasSeparateMuslLinuxPackage: false, }; @@ -479,6 +489,37 @@ function toolsSignature(tools: readonly ToolDefinition[] | undefined): string { .join('\u0001'); } +/** + * Codex active-client handle. Writes flow into the owning session's + * {@link ActiveClientToolSet}; the session is resolved lazily so writes that + * arrive before (or after) the session exists are gracefully dropped, matching + * the prior `setClientTools` early-return behavior. Codex has no client + * customization layer, so `customizations` is inert. + */ +class CodexActiveClientHandle implements IActiveClient { + constructor( + private readonly _getSession: () => ICodexSession | undefined, + readonly clientId: string, + readonly displayName: string | undefined, + private readonly _onToolsSet: (tools: readonly ToolDefinition[]) => void, + ) { } + + get tools(): readonly ToolDefinition[] { + return this._getSession()?.clientToolSet.get(this.clientId) ?? []; + } + set tools(tools: readonly ToolDefinition[]) { + this._getSession()?.clientToolSet.set(this.clientId, tools); + this._onToolsSet(tools); + } + + get customizations(): readonly ClientPluginCustomization[] { + return []; + } + set customizations(_customizations: readonly ClientPluginCustomization[]) { + // Codex does not support client-contributed customizations. + } +} + /** * Map a resolved approval decision to the {@link FileChangeApprovalDecision} * subset. The host's boolean response only yields `accept`/`decline`; the @@ -544,6 +585,7 @@ export class CodexAgent extends Disposable implements IAgent { @ICodexProxyService private readonly _codexProxyService: ICodexProxyService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentSdkDownloader private readonly _agentSdkDownloader: IAgentSdkDownloader, + @IProductService private readonly _productService: IProductService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); @@ -719,7 +761,8 @@ export class CodexAgent extends Disposable implements IAgent { private async _refreshModels(token: string): Promise { try { - const all = await this._copilotApiService.models(token); + const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`; + const all = await this._copilotApiService.models(token, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true }); if (this._githubToken !== token) { return; } @@ -740,12 +783,17 @@ export class CodexAgent extends Disposable implements IAgent { id: m.id, name: m.name ?? m.id, maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!m.capabilities?.supports?.vision, configSchema, policyState: m.policy?.state as PolicyState | undefined, - _meta: typeof m.billing?.multiplier === 'number' ? { - multiplierNumeric: m.billing.multiplier, - } : undefined, + _meta: createPricingMetaFromBilling( + normalizeCAPIBilling(m.billing), + typeof m.model_picker_price_category === 'string' + ? m.model_picker_price_category + : undefined, + ), })); this._models.set(models, undefined); } catch (err) { @@ -784,15 +832,45 @@ export class CodexAgent extends Disposable implements IAgent { return promise; } + /** + * Resolve the Codex SDK root — the directory whose + * `node_modules/@openai/codex-/…` holds the native binary. + * + * Mirrors the three-tier resolution in `ClaudeAgentSdkService._loadSdk`: + * 1. dev override / product download, via the downloader, when the SDK + * `isAvailable` (env override || `product.agentSdks.codex`); + * 2. dev fallback to this repo's `node_modules`, where `@openai/codex` + * and its per-host binary package are devDependencies — this is what + * lets running-from-source (and dev smoke tests) spawn Codex without + * an env-var override. + * + * `isAvailable` is already false in dev, so it discriminates the two + * without injecting `INativeEnvironmentService`. When neither path + * resolves we defer to the downloader so callers get its actionable + * "not configured" diagnostic. + */ + private async _resolveSdkRoot(): Promise { + if (this._agentSdkDownloader.isAvailable(CodexSdkPackage)) { + return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + } + const devRoot = await resolveCodexDevSdkRoot(); + if (devRoot) { + this._logService.info(`[Codex] resolving SDK from repo node_modules (dev fallback): ${devRoot}`); + return devRoot; + } + return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + } + private async _startConnection(token: string): Promise { - // Resolve the Codex SDK root via the downloader: dev override → cache → - // download from `product.agentSdks.codex`. We spawn the native codex - // binary inside the platform package directly (the same shape the JS - // shim at `node_modules/@openai/codex/bin/codex.js` would resolve to) - // — going through the shim adds a launcher hop and forces an + // 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 + // platform package directly (the same shape the JS shim at + // `node_modules/@openai/codex/bin/codex.js` would resolve to) — going + // 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._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + const root = await this._resolveSdkRoot(); const codexTarget = codexPackageSuffix(process.platform, process.arch); if (!codexTarget) { throw new Error(`Codex: unsupported platform ${process.platform}-${process.arch}`); @@ -846,6 +924,8 @@ export class CodexAgent extends Disposable implements IAgent { // elicitation handler is reserved for genuine server-to-user // elicitations. `features.tool_call_mcp_elicitation=false`, + // CAPI rejects the hosted `image_generation` tool; disable it so codex does not emit it. + `features.image_generation=false`, ]; // Extra args forwarded as JSON from the workbench setting. @@ -986,7 +1066,7 @@ export class CodexAgent extends Disposable implements IAgent { */ private _buildDynamicTools(session: ICodexSession): DynamicToolSpec[] | undefined { const serverTools = this._serverToolHost?.definitions ?? []; - const clientTools = session.clientTools ?? []; + const clientTools = session.clientToolSet.merged(); // Server tools first; a server tool name shadows a colliding client tool // (the agent host owns those names) and matches the routing order below. const seen = new Set(); @@ -1035,7 +1115,7 @@ export class CodexAgent extends Disposable implements IAgent { if (toolCallId === undefined) { return { result: this._toolFailure(`No pending client tool call for ${params.tool} (callId ${params.callId})`) }; } - if (!session.clientTools || session.toolsClientId === undefined) { + if (session.clientToolSet.size === 0) { return { result: this._toolFailure(`No client available to run ${params.tool}`) }; } try { @@ -1290,7 +1370,7 @@ export class CodexAgent extends Disposable implements IAgent { } private _fireSteeringConsumed(session: ICodexSession, id: string): void { - this._onDidSessionProgress.fire({ kind: 'steering_consumed', session: session.sessionUri, id }); + this._onDidSessionProgress.fire({ kind: 'steering_consumed', chat: URI.parse(buildDefaultChatUri(session.sessionUri)), id }); } private _registerIgnoredNotifications(client: ICodexAppServerClient): void { @@ -1331,7 +1411,7 @@ export class CodexAgent extends Disposable implements IAgent { } const actions = mapFn(session); for (const action of actions) { - this._onDidSessionProgress.fire({ kind: 'action', session: session.sessionUri, action }); + this._fire(session.sessionUri, action); } } @@ -1469,20 +1549,12 @@ export class CodexAgent extends Disposable implements IAgent { session.hostTurnIdByAppTurnId.delete(appTurnId); } if (turnId) { - this._onDidSessionProgress.fire({ - kind: 'action', - session: session.sessionUri, - action: { - type: ActionType.ChatError, - turnId, - error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }, - }, - }); - this._onDidSessionProgress.fire({ - kind: 'action', - session: session.sessionUri, - action: { type: ActionType.ChatTurnComplete, turnId }, + this._fire(session.sessionUri, { + type: ActionType.ChatError, + turnId, + error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }, }); + this._fire(session.sessionUri, { type: ActionType.ChatTurnComplete, turnId }); } } // Release resources. The proxy handle is refcounted and drops @@ -1511,6 +1583,59 @@ export class CodexAgent extends Disposable implements IAgent { }; } + private _sessionUriFromChat(chat: URI): URI { + const parsed = parseChatUri(chat); + return parsed ? URI.parse(parsed.session) : chat; + } + + // ---- Chat surface ------------------------------------------------------ + // + // Chat-addressed adoption of the {@link IAgent} surface introduced + // in gate G-C1. Codex is a SINGLE-CHAT harness: a session owns exactly one + // (default) chat addressed by its default chat channel URI, so the + // chat methods simply route to the existing session-addressed + // implementations. The legacy `(session, chat?)` methods below are kept as a + // compat shim (removed centrally in gate G-C2) and both surfaces coexist. + + /** + * The chat-addressed operation surface for the chats within a session. + * Codex is single-chat: peer-chat operations + * ({@link IAgentChats.createChat}/{@link IAgentChats.fork}) + * are unsupported and throw, mirroring today's behavior where Codex omits + * `createChat` (the orchestrator rejected multi-chat for Codex). The + * remaining methods address the session's single default chat, whose + * URI is the deterministic default chat channel URI. + */ + readonly chats: IAgentChats = { + createChat: (_chat: URI, _options?: IAgentCreateChatOptions): Promise => { + throw new Error('Codex agent does not support multiple chats'); + }, + fork: (_chat: URI, _source: IAgentCreateChatForkSource, _options?: IAgentCreateChatOptions): Promise => { + throw new Error('Codex agent does not support chat forking'); + }, + disposeChat: (_chat: URI): Promise => { + // Codex has no additional (peer) chats to dispose; the + // default chat lives and dies with its session. + return Promise.resolve(); + }, + sendMessage: (chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise => { + return this._sendMessage(chat, prompt, attachments, turnId); + }, + abort: (chat: URI): Promise => { + return this._abort(chat); + }, + changeModel: (chat: URI, model: ModelSelection): Promise => { + return this._changeModel(chat, model); + }, + changeAgent: (_chat: URI, _agent: AgentSelection | undefined): Promise => { + // Codex does not support selecting a custom agent. + return Promise.resolve(); + }, + getMessages: (chat: URI): Promise => { + return this.getSessionMessages(chat); + }, + }; + async createSession(config: IAgentCreateSessionConfig = {}): Promise { this._logService.info(`[Codex DEBUG] createSession session=${config.session?.toString() ?? '(none)'} model=${config.model?.id ?? '(none)'} cwd=${config.workingDirectory?.toString() ?? '(none)'}`); this._ensureAuthenticated(); @@ -1543,17 +1668,17 @@ export class CodexAgent extends Disposable implements IAgent { }; } + const clientToolSet = new ActiveClientToolSet(); const session: ICodexSession = { sessionId, threadId: undefined, sessionUri, workingDirectory: config.workingDirectory, - mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? [])), + mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry(), acceptedForSession: new Set(), pendingSteeringFlips: new Map(), - clientTools: undefined, - toolsClientId: undefined, + clientToolSet, pendingClientToolCalls: new PendingRequestRegistry(), pendingUserInputs: new PendingRequestRegistry(), materializedToolsSig: undefined, @@ -1643,7 +1768,7 @@ export class CodexAgent extends Disposable implements IAgent { return; } session.threadId = threadId; - session.materializedToolsSig = toolsSignature(session.clientTools); + session.materializedToolsSig = toolsSignature(session.clientToolSet.merged()); this._logService.info(`[Codex DEBUG] materialized session=${session.sessionUri.toString()} threadId=${session.threadId}`); this._sessionIdByThreadId.set(session.threadId, session.sessionId); // Advertise the agent host's server tools on this session so clients see @@ -1664,7 +1789,7 @@ export class CodexAgent extends Disposable implements IAgent { private async _restartThreadWithCurrentTools(session: ICodexSession): Promise { const conn = this._connection; const oldThreadId = session.threadId; - this._logService.info(`[Codex:${session.sessionId}] restarting thread ${oldThreadId} to apply client tools [${(session.clientTools ?? []).map(t => t.name).join(', ') || '(none)'}]`); + this._logService.info(`[Codex:${session.sessionId}] restarting thread ${oldThreadId} to apply client tools [${session.clientToolSet.merged().map(t => t.name).join(', ') || '(none)'}]`); if (conn.kind === 'ready' && oldThreadId !== undefined) { this._sessionIdByThreadId.delete(oldThreadId); try { @@ -1697,7 +1822,16 @@ export class CodexAgent extends Disposable implements IAgent { if (!session.workingDirectory) { return; } - void this._materializeIfNeeded(session, false).then(() => { + void (async () => { + // Prewarm is a background latency optimization, not a user action, + // so it must NOT trigger a cold SDK download. When the SDK isn't + // local yet, skip prewarm; the first `sendMessage` materializes the + // thread and fires the (host-level progress-reported) download then. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info(`[Codex] SDK not downloaded yet; skipping prewarm for session=${session.sessionUri.toString()} until a message triggers the download`); + return; + } + await this._materializeIfNeeded(session, false); if (session.prewarmClaimed || session.threadId === undefined) { return; } @@ -1706,7 +1840,7 @@ export class CodexAgent extends Disposable implements IAgent { void this._expirePrewarm(session); }, CodexPrewarmTtlMs); session.prewarmTimer = prewarmTimer; - }).catch(err => { + })().catch(err => { this._logService.warn(`[Codex] prewarm failed session=${session.sessionUri.toString()}: ${err instanceof Error ? err.message : String(err)}`); }); } @@ -1748,7 +1882,8 @@ export class CodexAgent extends Disposable implements IAgent { } } - async sendMessage(sessionUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { + const sessionUri = this._sessionUriFromChat(chat); 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); @@ -1779,7 +1914,7 @@ export class CodexAgent extends Disposable implements IAgent { // was prewarmed (or otherwise started) before the current client tools // were known, restart it now — before any turn commits history, so // nothing is lost — so the tools land in `dynamicTools`. - if (!session.firstTurnSent && !session.needsResume && toolsSignature(session.clientTools) !== session.materializedToolsSig) { + if (!session.firstTurnSent && !session.needsResume && toolsSignature(session.clientToolSet.merged()) !== session.materializedToolsSig) { try { await this._restartThreadWithCurrentTools(session); this._persistMaterializedSession(session); @@ -1913,7 +2048,8 @@ export class CodexAgent extends Disposable implements IAgent { }); } - async abortSession(sessionUri: URI): Promise { + private async _abort(chat: URI): Promise { + const sessionUri = this._sessionUriFromChat(chat); const sessionId = AgentSession.id(sessionUri); const session = this._sessions.get(sessionId); if (!session) { @@ -1976,7 +2112,8 @@ export class CodexAgent extends Disposable implements IAgent { } } - async changeModel(sessionUri: URI, model: ModelSelection): Promise { + private async _changeModel(chat: URI, model: ModelSelection): Promise { + const sessionUri = this._sessionUriFromChat(chat); const session = this._sessions.get(AgentSession.id(sessionUri)); if (session) { const supported = this._supportedModelOrUndefined(model); @@ -2061,6 +2198,11 @@ export class CodexAgent extends Disposable implements IAgent { // resolve the first match. Mirrors the Claude/Copilot agents. for (const session of this._sessions.values()) { if (session.pendingCommandApprovals.respond(requestId, approved ? 'accept' : 'decline')) { + if (!approved) { + // Remember the decline so the tool's `item/completed` (which + // codex reports as a generic failure) maps to `userCancelled`. + session.mapState.declinedToolCalls.add(requestId); + } return; } } @@ -2078,8 +2220,8 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.info(`[Codex] respondToUserInputRequest: unknown requestId=${requestId}`); } - getSessionMessages(session: URI): Promise { - return this._readSession(session).then(read => read ? replayThreadToTurns(read.thread) : []); + getSessionMessages(chat: URI): Promise { + return this._readSession(this._sessionUriFromChat(chat)).then(read => read ? replayThreadToTurns(read.thread) : []); } async getSessionMetadata(session: URI): Promise { @@ -2095,17 +2237,17 @@ export class CodexAgent extends Disposable implements IAgent { if (!this._sessions.has(sessionId)) { const workingDirectory = read.thread.cwd ? URI.file(read.thread.cwd) : undefined; const threadId = read.thread.id; + const clientToolSet = new ActiveClientToolSet(); this._sessions.set(sessionId, { sessionId, threadId, sessionUri: session, workingDirectory, - mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? [])), + mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry(), acceptedForSession: new Set(), pendingSteeringFlips: new Map(), - clientTools: undefined, - toolsClientId: undefined, + clientToolSet, pendingClientToolCalls: new PendingRequestRegistry(), pendingUserInputs: new PendingRequestRegistry(), materializedToolsSig: undefined, @@ -2174,6 +2316,15 @@ export class CodexAgent extends Disposable implements IAgent { if (!this._githubToken) { return []; } + // Don't connect (and trigger a cold SDK download) just to list threads + // at startup. When the SDK isn't local yet, surface an empty list; the + // download fires (with host-level progress) once the user starts a + // session, and the next `listSessions` — driven by the renderer's + // post-turn refresh — returns the full list. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info('[Codex] SDK not downloaded yet; deferring thread/list until a session triggers the download'); + return []; + } try { const conn = await this._ensureConnection(); const response = await conn.client.request<'thread/list', ThreadListResponse>('thread/list', { @@ -2218,19 +2369,22 @@ export class CodexAgent extends Disposable implements IAgent { this._serverToolHost = host; } - setClientTools(session: URI, clientId: string | undefined, tools: ToolDefinition[]): void { + getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient { const sessionId = AgentSession.id(session); - const sess = this._sessions.get(sessionId); - if (!sess) { - return; - } - sess.clientTools = tools.length > 0 ? tools : undefined; - sess.toolsClientId = clientId; - sess.mapState.toolsClientId = sess.clientTools ? clientId : undefined; - this._logService.info(`[Codex:${sessionId}] setClientTools clientId=${clientId ?? '(none)'} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`); + return new CodexActiveClientHandle( + () => this._sessions.get(sessionId), + client.clientId, + client.displayName, + tools => this._logService.info(`[Codex:${sessionId}] active client ${client.clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`), + ); + } + + removeActiveClient(session: URI, clientId: string): void { + const sessionId = AgentSession.id(session); + this._sessions.get(sessionId)?.clientToolSet.delete(clientId); } - onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { + onClientToolCallComplete(session: URI, _chat: URI, toolCallId: string, result: ToolCallResult): void { const sessionId = AgentSession.id(session); const sess = this._sessions.get(sessionId); // `AgentSideEffects` forwards every `ChatToolCallComplete` envelope @@ -2238,10 +2392,6 @@ export class CodexAgent extends Disposable implements IAgent { sess?.pendingClientToolCalls.respondOrBuffer(toolCallId, result); } - setClientCustomizations(_session: URI, _clientId: string, _customizations: ClientPluginCustomization[]): Promise { - return Promise.resolve([]); - } - setCustomizationEnabled(_uri: string, _enabled: boolean): void { // no-op; customizations not yet wired for codex. } @@ -2263,6 +2413,7 @@ export class CodexAgent extends Disposable implements IAgent { } const controller = this._getOrCreateMcpController(session); controller.applyAll(inventoryToSdkServers(this._mcpInventory)); + this._refreshMcpCustomizationIds(session, controller); return controller.topLevelCustomizations(); } @@ -2347,7 +2498,27 @@ export class CodexAgent extends Disposable implements IAgent { if (session.disposed) { continue; } - this._getOrCreateMcpController(session).applyAll(servers); + const controller = this._getOrCreateMcpController(session); + controller.applyAll(servers); + this._refreshMcpCustomizationIds(session, controller); + } + } + + /** + * Refreshes the session's mapper snapshot of server name → customization id + * (read when stamping the MCP contributor on tool calls). Plain data, owned + * here — the mapper never reaches back into the controller. Must run on every + * inventory change because MCP servers are discovered asynchronously, after a + * session (and possibly its first tool call) already exists. + */ + private _refreshMcpCustomizationIds(session: ICodexSession, controller: McpCustomizationController): void { + const ids = session.mapState.mcpCustomizationIds; + ids.clear(); + for (const serverName of this._mcpInventory.keys()) { + const id = controller.customizationIdForServer(serverName); + if (id !== undefined) { + ids.set(serverName, id); + } } } @@ -2516,7 +2687,7 @@ export class CodexAgent extends Disposable implements IAgent { // #endregion private _fire(sessionUri: URI, action: SessionAction | ChatAction): void { - this._onDidSessionProgress.fire({ kind: 'action', session: sessionUri, action }); + this._onDidSessionProgress.fire({ kind: 'action', resource: isChatAction(action) ? URI.parse(buildDefaultChatUri(sessionUri)) : sessionUri, action }); } override dispose(): void { @@ -2583,3 +2754,37 @@ export function codexBinaryTriple(sdkTarget: string): string | undefined { default: return undefined; } } + +/** + * Locate the SDK root for the dev (running-from-source) fallback by resolving + * `@openai/codex` — a devDependency in source checkouts — out of this repo's + * `node_modules`. Returns the directory that *contains* that `node_modules` + * (i.e. the value `_startConnection` joins `node_modules/@openai/codex-` + * onto), or undefined when the package can't be resolved (e.g. a built product + * where it isn't shipped). `@openai/codex` declares no `exports` map, so its + * `package.json` is resolvable. + * + * `resolvePackageJsonPath` is a seam for tests; production resolves the path + * via {@link defaultResolveCodexPackageJsonPath}. + */ +export async function resolveCodexDevSdkRoot( + resolvePackageJsonPath: () => string | Promise = defaultResolveCodexPackageJsonPath, +): Promise { + try { + const pkgJson = await resolvePackageJsonPath(); + // /node_modules/@openai/codex/package.json → + return dirname(dirname(dirname(dirname(pkgJson)))); + } catch { + return undefined; + } +} + +async function defaultResolveCodexPackageJsonPath(): Promise { + // Dynamic import of `node:module` (not a static top-level import): the + // unit-test electron renderer that loads this module for + // `codexPackagePaths.test` cannot fetch a static `node:module` import, so + // the sibling WSL/SSH host services resolve `createRequire` the same way + // for the same reason. + const { createRequire } = await import('node:module'); + return createRequire(import.meta.url).resolve('@openai/codex/package.json'); +} diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index a307d76a1a94e..d7ced094e748b 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -8,6 +8,8 @@ import { toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { ActionType, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js'; import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState } from '../../common/state/sessionState.js'; import { extractForwardedErrorInfo } from '../shared/forwardedChatError.js'; +import { getServerToolDisplay } from '../shared/serverToolGroups.js'; +import { ActiveClientToolSet } from '../activeClientState.js'; import type { AgentMessageDeltaNotification } from './protocol/generated/v2/AgentMessageDeltaNotification.js'; import type { CommandExecutionOutputDeltaNotification } from './protocol/generated/v2/CommandExecutionOutputDeltaNotification.js'; import type { FileChangeOutputDeltaNotification } from './protocol/generated/v2/FileChangeOutputDeltaNotification.js'; @@ -50,12 +52,12 @@ export interface ICodexSessionMapState { /** Current turn id (per `turn/started`). */ currentTurnId: string | undefined; /** - * Workbench client id that owns the session's client-provided - * (`dynamicTools`) tools. Set so `dynamicToolCall` tool-call starts - * carry a `Client` contributor and the workbench routes execution back - * to that client. `undefined` when no client tools are registered. + * Live registry of the session's client-provided (`dynamicTools`) tools, + * keyed by contributing workbench client. A `dynamicToolCall` tool-call + * start is stamped with the owning client (so the workbench routes + * execution back to it) resolved via {@link ActiveClientToolSet.ownerOf}. */ - toolsClientId: string | undefined; + clientToolSet: ActiveClientToolSet; /** * Names of the agent host's server tools (executed in-process). A * `dynamicToolCall` for one of these omits the `Client` contributor so the @@ -63,6 +65,21 @@ export interface ICodexSessionMapState { * answers the `item/tool/call` directly. */ serverToolNames: ReadonlySet; + /** + * Server name → customization id for the session's MCP servers, used to + * stamp the {@link ToolCallContributorKind.MCP} contributor on `mcpToolCall` + * starts so clients can correlate the call with its originating server + * customization. Owned and populated by the agent (mirrors + * {@link clientToolSet}); empty until the agent first applies the inventory. + */ + readonly mcpCustomizationIds: Map; + /** + * Tool call ids the host declined at the approval prompt. Codex reports the + * resulting `item/completed` as a generic failure, so the completion handler + * consults this set to emit a `userCancelled` (`error.code = 'denied'`) + * result instead. Drained on completion and cleared per turn. + */ + readonly declinedToolCalls: Set; } export interface ICodexToolCallEntry { @@ -72,14 +89,16 @@ export interface ICodexToolCallEntry { output: string; } -export function createCodexSessionMapState(serverToolNames: ReadonlySet = new Set()): ICodexSessionMapState { +export function createCodexSessionMapState(serverToolNames: ReadonlySet = new Set(), clientToolSet: ActiveClientToolSet = new ActiveClientToolSet()): ICodexSessionMapState { return { itemToPartId: new Map(), itemToToolCall: new Map(), itemToReasoningPartId: new Map(), currentTurnId: undefined, - toolsClientId: undefined, + clientToolSet, serverToolNames, + mcpCustomizationIds: new Map(), + declinedToolCalls: new Set(), }; } @@ -94,6 +113,7 @@ export function resetCodexTurnMapState(state: ICodexSessionMapState): void { state.itemToPartId.clear(); state.itemToToolCall.clear(); state.itemToReasoningPartId.clear(); + state.declinedToolCalls.clear(); } /** @@ -415,6 +435,7 @@ export function mapItemStarted( const toolCallId = generateUuid(); const toolName = `${params.item.server}.${params.item.tool}`; const toolInput = toolInputText(params.item.arguments); + const customizationId = state.mcpCustomizationIds.get(params.item.server); state.itemToToolCall.set(params.item.id, { toolCallId, turnId: params.turnId, @@ -428,6 +449,7 @@ export function mapItemStarted( toolCallId, toolName, displayName: params.item.tool, + ...(customizationId ? { contributor: { kind: ToolCallContributorKind.MCP, customizationId } } : {}), }, { type: ActionType.ChatToolCallDelta, @@ -454,6 +476,8 @@ export function mapItemStarted( // they carry no `Client` contributor; only client-provided tools route // execution back to the owning workbench client. const isServerTool = params.item.namespace === null && state.serverToolNames.has(params.item.tool); + const ownerClientId = isServerTool ? undefined : state.clientToolSet.ownerOf(params.item.tool); + const serverDisplay = getServerToolDisplay(params.item.tool, params.item.arguments); state.itemToToolCall.set(params.item.id, { toolCallId, turnId: params.turnId, @@ -466,8 +490,8 @@ export function mapItemStarted( turnId: params.turnId, toolCallId, toolName, - displayName: params.item.tool, - ...(state.toolsClientId && !isServerTool ? { contributor: { kind: ToolCallContributorKind.Client, clientId: state.toolsClientId } } : {}), + displayName: serverDisplay?.displayName ?? params.item.tool, + ...(ownerClientId ? { contributor: { kind: ToolCallContributorKind.Client, clientId: ownerClientId } } : {}), }, { type: ActionType.ChatToolCallDelta, @@ -479,7 +503,7 @@ export function mapItemStarted( type: ActionType.ChatToolCallReady, turnId: params.turnId, toolCallId, - invocationMessage: `Calling ${toolName}`, + invocationMessage: serverDisplay?.invocationMessage ?? `Calling ${toolName}`, toolInput, confirmed: ToolCallConfirmationReason.NotNeeded, }, @@ -607,12 +631,17 @@ export function mapItemCompleted( clearReasoningForItem(state, params.item.id); return []; } + // Every remaining item type is a tool call. Resolve the tracked entry and + // drain the host-decline flag here, once, so all completion paths treat a + // declined tool uniformly (reported as `userCancelled` via + // `error.code = 'denied'`) instead of depending on which tool type completed. + const entry = state.itemToToolCall.get(params.item.id); + if (!entry) { + return []; + } + state.itemToToolCall.delete(params.item.id); + const declined = state.declinedToolCalls.delete(entry.toolCallId); if (params.item.type === 'commandExecution') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const success = params.item.status === 'completed' && (params.item.exitCode === 0 || params.item.exitCode === null); const output = params.item.aggregatedOutput ?? entry.output; const command = params.item.command ?? ''; @@ -635,17 +664,13 @@ export function mapItemCompleted( : undefined, error: success ? undefined : { message: exit !== null ? `Exit code ${exit}` : 'Command failed', + ...(declined ? { code: 'denied' } : {}), }, }, }, ]; } if (params.item.type === 'webSearch') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const query = describeWebSearch(params.item.query, params.item.action); return [{ type: ActionType.ChatToolCallComplete, @@ -658,11 +683,6 @@ export function mapItemCompleted( }]; } if (params.item.type === 'fileChange') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const output = fileChangeOutput(params.item.changes) || entry.output; const success = params.item.status === 'completed'; const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; @@ -670,7 +690,7 @@ export function mapItemCompleted( success, pastTenseMessage: success ? 'Applied file changes' : 'Failed to apply file changes', content, - ...(success ? {} : { error: { message: `Patch ${params.item.status}` } }), + ...(success ? {} : { error: { message: `Patch ${params.item.status}`, ...(declined ? { code: 'denied' } : {}) } }), }; return [{ type: ActionType.ChatToolCallComplete, @@ -680,11 +700,6 @@ export function mapItemCompleted( }]; } if (params.item.type === 'mcpToolCall') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const success = params.item.status === 'completed' && !params.item.error; const output = mcpToolOutput(params.item.result, params.item.error?.message) || entry.output; const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; @@ -696,28 +711,24 @@ export function mapItemCompleted( success, pastTenseMessage: success ? `Called ${entry.toolName}` : `Failed to call ${entry.toolName}`, content, - ...(success ? {} : { error: { message: params.item.error?.message ?? `MCP tool ${params.item.status}` } }), + ...(success ? {} : { error: { message: params.item.error?.message ?? `MCP tool ${params.item.status}`, ...(declined ? { code: 'denied' } : {}) } }), }, }]; } if (params.item.type === 'dynamicToolCall') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const success = params.item.success === true || params.item.status === 'completed'; const output = dynamicToolOutput(params.item.contentItems) || entry.output; const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; + const serverPastTense = success ? getServerToolDisplay(entry.toolName, params.item.arguments, { text: output, success })?.pastTenseMessage : undefined; return [{ type: ActionType.ChatToolCallComplete, turnId: entry.turnId, toolCallId: entry.toolCallId, result: { success, - pastTenseMessage: success ? `Called ${entry.toolName}` : `Failed to call ${entry.toolName}`, + pastTenseMessage: serverPastTense ?? (success ? `Called ${entry.toolName}` : `Failed to call ${entry.toolName}`), content, - ...(success ? {} : { error: { message: `Dynamic tool ${params.item.status}` } }), + ...(success ? {} : { error: { message: `Dynamic tool ${params.item.status}`, ...(declined ? { code: 'denied' } : {}) } }), }, }]; } diff --git a/src/vs/platform/agentHost/node/codex/codexProxyService.ts b/src/vs/platform/agentHost/node/codex/codexProxyService.ts index 642719e20691d..502737125ee27 100644 --- a/src/vs/platform/agentHost/node/codex/codexProxyService.ts +++ b/src/vs/platform/agentHost/node/codex/codexProxyService.ts @@ -72,6 +72,14 @@ type ICodexProxyRuntime = ILoopbackProxyRuntime; const PROXY_USER_FACING_NAME = 'CodexProxyService'; +/** + * User-agent prefix applied to outbound CAPI requests so the codex proxy's + * traffic is identifiable server-side. Mirrors `oaiLanguageModelServer.ts` + * in the Copilot Chat extension, which tags Codex requests with the same + * prefix. + */ +const USER_AGENT_PREFIX = 'vscode_codex'; + /** * When set to an absolute directory path, every `/v1/responses` request body * and its full upstream response stream are written to that directory as @@ -274,9 +282,11 @@ export class CodexProxyService extends LoopbackProxyServer `${k}: ${v}`).join(', '); this._logService.info(`[${PROXY_USER_FACING_NAME}] <<< CAPI response: status=${upstream.status}, contentType=${contentType}, headers=[${upstreamHeaders}]`); @@ -346,3 +356,38 @@ export class CodexProxyService extends LoopbackProxyServer { + const out: Record = {}; + const userAgent = inbound['user-agent']; + if (typeof userAgent === 'string' && userAgent.length > 0) { + out['User-Agent'] = transformUserAgent(userAgent); + } + return out; +} + +/** + * Transform an incoming user-agent string by replacing the client name portion + * (before the first `/`) with {@link USER_AGENT_PREFIX}. This mirrors the + * transform in `oaiLanguageModelServer.ts` in the Copilot Chat extension, + * ensuring all Codex requests are tagged with a consistent prefix for + * server-side identification. + * + * Examples: + * - `codex/1.2.3` → `vscode_codex/1.2.3` + * - `OpenAI/Python/1.0` → `vscode_codex/Python/1.0` + * - `unknown` → `vscode_codex/unknown` + */ +function transformUserAgent(userAgent: string): string { + const slashIndex = userAgent.indexOf('/'); + if (slashIndex === -1) { + return `${USER_AGENT_PREFIX}/${userAgent}`; + } + return `${USER_AGENT_PREFIX}${userAgent.substring(slashIndex)}`; +} diff --git a/src/vs/platform/agentHost/node/commandAutoApprover.ts b/src/vs/platform/agentHost/node/commandAutoApprover.ts index ef6170117d8ab..44ab147c63fda 100644 --- a/src/vs/platform/agentHost/node/commandAutoApprover.ts +++ b/src/vs/platform/agentHost/node/commandAutoApprover.ts @@ -10,6 +10,7 @@ import { FileAccess } from '../../../base/common/network.js'; import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; +import type { AgentHostTerminalAutoApproveRuleValue, AgentHostTerminalAutoApproveRules } from '../common/agentHostSchema.js'; /** * Redirect destinations that do not result in a write to an arbitrary file @@ -98,22 +99,36 @@ export interface IShouldAutoApproveOptions { * sinks (e.g. `/dev/null`) downgrades the result to `noMatch`. */ readonly isWriteDestApproved?: (dest: string) => boolean; + /** + * Effective VS Code `chat.tools.terminal.autoApprove` rules forwarded from + * the renderer. When omitted, the agent host falls back to its bundled + * default rules for compatibility with older clients. + */ + readonly autoApproveRules?: AgentHostTerminalAutoApproveRules; } interface IAutoApproveRule { readonly regex: RegExp; } +interface IAutoApproveRules { + readonly allowRules: IAutoApproveRule[]; + readonly denyRules: IAutoApproveRule[]; + readonly allowCommandLineRules: IAutoApproveRule[]; + readonly denyCommandLineRules: IAutoApproveRule[]; +} + const neverMatchRegex = /(?!.*)/; const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i; /** - * Auto-approves or denies shell commands based on default rules. + * Auto-approves or denies shell commands based on terminal auto-approve rules. * * Uses tree-sitter to parse compound commands (`foo && bar`) into * sub-commands that are individually checked against allow/deny lists. - * The default rules mirror the VS Code `chat.tools.terminal.autoApprove` - * setting defaults. + * The rules are normally forwarded from VS Code's + * `chat.tools.terminal.autoApprove` setting. A bundled default table is kept + * as a compatibility fallback for clients that have not forwarded rules yet. * * Tree-sitter is initialized eagerly; call {@link initialize} and await the * result before using {@link shouldAutoApprove} to guarantee synchronous @@ -123,8 +138,9 @@ const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i; */ export class CommandAutoApprover extends Disposable { - private _allowRules: IAutoApproveRule[] | undefined; - private _denyRules: IAutoApproveRule[] | undefined; + private _fallbackRules: IAutoApproveRules | undefined; + private _cachedRuleConfig: AgentHostTerminalAutoApproveRules | undefined; + private _cachedRules: IAutoApproveRules | undefined; private _parser: Parser | undefined; private _bashLanguage: Language | undefined; private _queryClass: typeof Query | undefined; @@ -160,7 +176,7 @@ export class CommandAutoApprover extends Disposable { return 'approved'; } - this._ensureRules(); + const rules = this._compileRules(options?.autoApproveRules); const parsed = this._extractSubCommands(trimmed); if (!parsed) { @@ -168,7 +184,14 @@ export class CommandAutoApprover extends Disposable { return 'noMatch'; } - const result = this._matchSubCommands(parsed.subCommands); + if (this._matchesRule(trimmed, rules.denyCommandLineRules)) { + return 'denied'; + } + + let result = this._matchSubCommands(parsed.subCommands, rules); + if (result !== 'denied' && this._matchesRule(trimmed, rules.allowCommandLineRules)) { + result = 'approved'; + } if (result === 'approved' && parsed.unsafeWriteDests.length > 0) { for (const dest of parsed.unsafeWriteDests) { if (dest === undefined || !options?.isWriteDestApproved?.(dest)) { @@ -180,7 +203,7 @@ export class CommandAutoApprover extends Disposable { return result; } - private _matchSubCommands(subCommands: string[]): CommandApprovalResult { + private _matchSubCommands(subCommands: string[], rules: IAutoApproveRules): CommandApprovalResult { let allApproved = true; for (const subCommand of subCommands) { // Deny transient env var assignments @@ -188,7 +211,7 @@ export class CommandAutoApprover extends Disposable { return 'denied'; } - const result = this._matchSingleCommand(subCommand); + const result = this._matchSingleCommand(subCommand, rules); if (result === 'denied') { return 'denied'; } @@ -199,24 +222,29 @@ export class CommandAutoApprover extends Disposable { return allApproved ? 'approved' : 'noMatch'; } - private _matchSingleCommand(command: string): CommandApprovalResult { + private _matchSingleCommand(command: string, rules: IAutoApproveRules): CommandApprovalResult { // Check deny rules first - for (const rule of this._denyRules!) { - if (rule.regex.test(command)) { - return 'denied'; - } + if (this._matchesRule(command, rules.denyRules)) { + return 'denied'; } // Then check allow rules - for (const rule of this._allowRules!) { - if (rule.regex.test(command)) { - return 'approved'; - } + if (this._matchesRule(command, rules.allowRules)) { + return 'approved'; } return 'noMatch'; } + private _matchesRule(command: string, rules: readonly IAutoApproveRule[]): boolean { + for (const rule of rules) { + if (rule.regex.test(command)) { + return true; + } + } + return false; + } + // ---- Tree-sitter -------------------------------------------------------- private _extractSubCommands(commandLine: string): { subCommands: string[]; unsafeWriteDests: (string | undefined)[] } | undefined { @@ -318,25 +346,53 @@ export class CommandAutoApprover extends Disposable { // ---- Rules -------------------------------------------------------------- - private _ensureRules(): void { - if (this._allowRules && this._denyRules) { - return; + private _compileRules(ruleConfig: AgentHostTerminalAutoApproveRules | undefined): IAutoApproveRules { + if (!ruleConfig) { + if (!this._fallbackRules) { + this._fallbackRules = this._compileRuleEntries(DEFAULT_TERMINAL_AUTO_APPROVE_RULES); + } + return this._fallbackRules; } + if (this._cachedRuleConfig === ruleConfig && this._cachedRules) { + return this._cachedRules; + } + + this._cachedRuleConfig = ruleConfig; + this._cachedRules = this._compileRuleEntries(ruleConfig); + return this._cachedRules; + } + + private _compileRuleEntries(ruleConfig: Readonly>): IAutoApproveRules { const allowRules: IAutoApproveRule[] = []; const denyRules: IAutoApproveRule[] = []; + const allowCommandLineRules: IAutoApproveRule[] = []; + const denyCommandLineRules: IAutoApproveRule[] = []; - for (const [key, value] of Object.entries(DEFAULT_TERMINAL_AUTO_APPROVE_RULES)) { + for (const [key, value] of Object.entries(ruleConfig)) { const regex = convertAutoApproveEntryToRegex(key); if (value === true) { allowRules.push({ regex }); } else if (value === false) { denyRules.push({ regex }); + } else if (value && typeof value === 'object' && typeof value.approve === 'boolean') { + if (value.approve) { + if (value.matchCommandLine === true) { + allowCommandLineRules.push({ regex }); + } else { + allowRules.push({ regex }); + } + } else { + if (value.matchCommandLine === true) { + denyCommandLineRules.push({ regex }); + } else { + denyRules.push({ regex }); + } + } } } - this._allowRules = allowRules; - this._denyRules = denyRules; + return { allowRules, denyRules, allowCommandLineRules, denyCommandLineRules }; } } @@ -388,10 +444,12 @@ function convertAutoApproveEntryToRegex(value: string): RegExp { // ---- Default rules ---------------------------------------------------------- // -// These mirror the VS Code `chat.tools.terminal.autoApprove` setting defaults. -// Kept in sync manually — the actual setting will be wired up later. +// Compatibility fallback for clients that do not forward the VS Code +// `chat.tools.terminal.autoApprove` setting. +// TODO: Remove this fallback once all agent-host clients are guaranteed to +// forward `chat.tools.terminal.autoApprove` before shell approvals run. -const DEFAULT_TERMINAL_AUTO_APPROVE_RULES: Readonly> = { +const DEFAULT_TERMINAL_AUTO_APPROVE_RULES: Readonly> = { // Safe readonly commands cd: true, echo: true, diff --git a/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts new file mode 100644 index 0000000000000..f101cd10c6475 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts @@ -0,0 +1,285 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as http from 'http'; +import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +import { parseProxyBearer } from '../claude/claudeProxyAuth.js'; +import { + ILoopbackProxyHandle, + ILoopbackProxyRuntime, + IProxyInFlight, + LoopbackProxyServer, + readProxyRequestBody, +} from '../shared/loopbackProxyServer.js'; +import { + IOpenAiChatRequest, + OpenAiTranslationError, + bridgeResultToSseFrames, + openAiErrorBody, + openAiRequestToBridge, +} from './byokOpenAiTranslation.js'; + +// #region Public types + +/** + * Handle returned by {@link IByokLmProxyService.start}. Refcounts the shared + * loopback server (see {@link LoopbackProxyServer}): when every handle is + * disposed the listener closes and the nonce is destroyed; the next `start()` + * rebinds with a fresh port and nonce. + * + * **Subprocess ownership invariant.** Callers that hand `baseUrl`/`nonce` to + * the Copilot SDK runtime subprocess MUST kill that subprocess before calling + * `dispose()` — after disposal the proxy may rebind on a different port and the + * subprocess would silently lose its endpoint (same contract as the Claude and + * Codex proxies). + */ +export interface IByokLmProxyHandle extends ILoopbackProxyHandle { + /** e.g. `http://127.0.0.1:54321` — no trailing slash. */ + readonly baseUrl: string; + /** 256-bit hex string. Combine with a session id as `Bearer .`. */ + readonly nonce: string; + /** + * Build the provider `baseUrl` for a given BYOK vendor. The vendor is + * encoded into the path so a single proxy can serve every vendor; the + * runtime appends `/chat/completions` to this URL. + */ + providerBaseUrl(vendor: string): string; +} + +export const IByokLmProxyService = createDecorator('byokLmProxyService'); + +export interface IByokLmProxyService { + readonly _serviceBrand: undefined; + + /** Start the proxy (if not already running) and return a refcounted handle. */ + start(): Promise; + + /** + * Force-close the proxy regardless of refcount and abort in-flight + * requests. Idempotent; subsequent `start()` calls rebind. + */ + dispose(): void; +} + +// #endregion + +const PROXY_USER_FACING_NAME = 'ByokLmProxyService'; +const VENDOR_PATH_PREFIX = '/v/'; +const CHAT_COMPLETIONS_SUFFIX = '/chat/completions'; + +/** + * The BYOK proxy keeps no per-bind mutable state: the active renderer bridge is + * resolved from {@link IByokLmBridgeRegistry} at request time, and the nonce + * lives on the runtime owned by {@link LoopbackProxyServer}. + */ +type ByokLmProxyState = undefined; + +/** + * Local OpenAI-compatible HTTP proxy that lets the Copilot SDK runtime run + * BYOK models provided by VS Code extensions. The runtime is configured with a + * `type: 'openai'`, `wireApi: 'completions'` provider whose `baseUrl` points + * here; inbound `POST /v//chat/completions` requests are authenticated, + * translated, and forwarded to the renderer LM API via + * {@link IByokLmBridgeRegistry}, and the buffered completion is streamed back + * as OpenAI Chat Completions SSE. + * + * The server lifecycle — lazy bind on `127.0.0.1`, nonce minting, refcounted + * handles, in-flight tracking, and teardown — is inherited from + * {@link LoopbackProxyServer}; this subclass only implements request routing. + */ +export class ByokLmProxyService extends LoopbackProxyServer implements IByokLmProxyService { + + declare readonly _serviceBrand: undefined; + + constructor( + @ILogService logService: ILogService, + @IByokLmBridgeRegistry private readonly _bridgeRegistry: IByokLmBridgeRegistry, + ) { + super(PROXY_USER_FACING_NAME, logService); + } + + protected createState(): ByokLmProxyState { + // No per-bind state — the bridge is resolved from the registry per request. + return undefined; + } + + async start(): Promise { + const { runtime, release } = await this.acquire(); + + let disposed = false; + return { + baseUrl: runtime.baseUrl, + nonce: runtime.nonce, + providerBaseUrl: (vendor: string) => `${runtime.baseUrl}${VENDOR_PATH_PREFIX}${encodeURIComponent(vendor)}`, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + release(); + }, + }; + } + + /** Emit the base's fallback failure using the OpenAI error envelope. */ + protected override writeInternalError(res: http.ServerResponse): void { + this._writeJsonError(res, 500, 'Internal proxy error'); + } + + protected override async handleRequest(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime): Promise { + const method = req.method ?? 'GET'; + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + this._logService.trace(`[${PROXY_USER_FACING_NAME}] ${method} ${pathname}`); + + if (method === 'GET' && pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + return; + } + + // Inbound requests carry `Bearer .`; the runtime is + // handed `.` at session launch. + const auth = parseProxyBearer(req.headers, runtime.nonce); + if (!auth.valid || !auth.sessionId) { + this._writeJsonError(res, 401, 'Invalid authentication', 'authentication_error'); + return; + } + + const vendor = this._parseVendorFromChatPath(pathname); + if (method === 'POST' && vendor !== undefined) { + await this._handleChatCompletions(req, res, runtime, vendor); + return; + } + + this._writeJsonError(res, 404, `No route for ${method} ${pathname}`, 'not_found_error'); + } + + /** + * Extract the vendor from a `/v//chat/completions` path, or return + * `undefined` when the path is not a chat-completions route. + */ + private _parseVendorFromChatPath(pathname: string): string | undefined { + if (!pathname.startsWith(VENDOR_PATH_PREFIX) || !pathname.endsWith(CHAT_COMPLETIONS_SUFFIX)) { + return undefined; + } + const vendorSegment = pathname.slice(VENDOR_PATH_PREFIX.length, pathname.length - CHAT_COMPLETIONS_SUFFIX.length); + if (!vendorSegment) { + return undefined; + } + let vendor: string; + try { + vendor = decodeURIComponent(vendorSegment); + } catch { + return undefined; + } + // Re-check for a path separator *after* decoding: a `%2F` survives the + // pre-decode prefix/suffix checks but would decode into a second path + // segment, breaking the single-segment `vendor/id` selection-id convention. + if (!vendor || vendor.includes('/')) { + return undefined; + } + return vendor; + } + + private async _handleChatCompletions(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime, vendor: string): Promise { + let body: IOpenAiChatRequest; + try { + const raw = await readProxyRequestBody(req); + body = JSON.parse(raw) as IOpenAiChatRequest; + } catch (err) { + this._writeJsonError(res, 400, `Invalid request body: ${err instanceof Error ? err.message : String(err)}`, 'invalid_request_error'); + return; + } + + let bridgeRequest; + try { + bridgeRequest = openAiRequestToBridge(vendor, body); + } catch (err) { + const message = err instanceof OpenAiTranslationError ? err.message : String(err); + this._writeJsonError(res, 400, message, 'invalid_request_error'); + return; + } + + const connection = this._bridgeRegistry.getServingConnection(); + if (!connection) { + this._writeJsonError(res, 503, 'No renderer connection available to service BYOK models', 'api_error'); + return; + } + + // Register the request so {@link LoopbackProxyServer} aborts it on + // teardown; a client-side disconnect also flips `clientGone` and aborts. + // Both surface through the shared `AbortController`, which we re-check + // after the async bridge hop before touching the response. + const entry: IProxyInFlight = { ac: new AbortController(), res, clientGone: false }; + runtime.inFlight.add(entry); + const onClose = () => { + entry.clientGone = true; + entry.ac.abort(); + }; + res.on('close', onClose); + + try { + const result = await connection.chat(bridgeRequest); + if (entry.ac.signal.aborted || res.writableEnded) { + return; + } + if (result.error) { + this._writeJsonError(res, 502, result.error, 'api_error'); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + for (const frame of bridgeResultToSseFrames(result, bridgeRequest.modelId)) { + res.write(frame); + } + res.end(); + } catch (err) { + if (entry.ac.signal.aborted || res.writableEnded) { + return; + } + const message = err instanceof Error ? err.message : String(err); + if (!res.headersSent) { + this._writeJsonError(res, 502, message, 'api_error'); + } else { + try { res.end(); } catch { /* ignore */ } + } + } finally { + res.removeListener('close', onClose); + runtime.inFlight.delete(entry); + } + } + + private _writeJsonError(res: http.ServerResponse, status: number, message: string, type = 'api_error'): void { + if (res.headersSent || res.writableEnded) { + return; + } + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(openAiErrorBody(message, type)); + } +} + +/** + * No-op {@link IByokLmProxyService} for agent host entrypoints that do not + * support BYOK — e.g. the remote agent host, where no extension host runs + * alongside the agent host to serve the renderer LM API. + * + */ +export class NullByokLmProxyService implements IByokLmProxyService { + + declare readonly _serviceBrand: undefined; + + start(): Promise { + return Promise.reject(new Error('BYOK is not supported in this agent host')); + } + + dispose(): void { + // No-op: the null proxy never binds a socket, so there is nothing to close. + } +} diff --git a/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts b/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts new file mode 100644 index 0000000000000..d5deb2786ea32 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts @@ -0,0 +1,241 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + IByokLmChatMessage, + IByokLmChatRequest, + IByokLmChatResult, + IByokLmTool, + IByokLmToolCall, +} from '../../common/agentHostByokLm.js'; + +/** + * Minimal subset of the OpenAI Chat Completions wire format the Copilot SDK + * runtime emits for a `type: 'openai'`, `wireApi: 'completions'` provider + * (verified against the runtime's `chat_completion_transport.rs`, which POSTs + * to `{baseUrl}/chat/completions`). Only the fields this proxy understands are + * modeled; unknown fields are ignored. + */ + +interface IOpenAiTextContentPart { + readonly type: 'text'; + readonly text: string; +} + +type IOpenAiContentPart = IOpenAiTextContentPart | { readonly type: string;[k: string]: unknown }; + +interface IOpenAiToolCall { + readonly id?: string; + readonly type?: string; + readonly function?: { + readonly name?: string; + readonly arguments?: string; + }; +} + +interface IOpenAiRequestMessage { + readonly role?: string; + readonly content?: string | IOpenAiContentPart[] | null; + readonly tool_calls?: IOpenAiToolCall[]; + readonly tool_call_id?: string; +} + +interface IOpenAiToolDefinition { + readonly type?: string; + readonly function?: { + readonly name?: string; + readonly description?: string; + readonly parameters?: object; + }; +} + +export interface IOpenAiChatRequest { + readonly model?: string; + readonly messages?: IOpenAiRequestMessage[]; + readonly tools?: IOpenAiToolDefinition[]; + readonly stream?: boolean; + readonly temperature?: number; + readonly top_p?: number; + readonly max_tokens?: number; + readonly [k: string]: unknown; +} + +/** Thrown when the inbound body cannot be mapped to a bridge request. */ +export class OpenAiTranslationError extends Error { } + +function flattenContent(content: string | IOpenAiContentPart[] | null | undefined): string { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + let out = ''; + for (const part of content) { + if (part && part.type === 'text' && typeof (part as IOpenAiTextContentPart).text === 'string') { + out += (part as IOpenAiTextContentPart).text; + } + } + return out; + } + return ''; +} + +function toBridgeRole(role: string | undefined): IByokLmChatMessage['role'] { + switch (role) { + case 'system': + case 'developer': + return 'system'; + case 'assistant': + return 'assistant'; + case 'tool': + case 'function': + return 'tool'; + case 'user': + default: + return 'user'; + } +} + +function toBridgeToolCalls(toolCalls: IOpenAiToolCall[] | undefined): IByokLmToolCall[] | undefined { + if (!toolCalls || toolCalls.length === 0) { + return undefined; + } + const mapped: IByokLmToolCall[] = []; + for (let i = 0; i < toolCalls.length; i++) { + const call = toolCalls[i]; + const name = call.function?.name; + if (!name) { + // A tool call without a function name is malformed: reject at the + // boundary (→ 400) rather than forwarding an invalid `tool_use` part + // that would fail later, deeper in the renderer. + throw new OpenAiTranslationError(`tool_calls[${i}].function.name is required`); + } + mapped.push({ + id: call.id ?? `call_${i}`, + name, + argumentsJson: call.function?.arguments ?? '{}', + }); + } + return mapped; +} + +function toBridgeTools(tools: IOpenAiToolDefinition[] | undefined): IByokLmTool[] | undefined { + if (!tools || tools.length === 0) { + return undefined; + } + const mapped: IByokLmTool[] = []; + for (const tool of tools) { + const fn = tool.function; + if (!fn?.name) { + continue; + } + mapped.push({ + name: fn.name, + description: fn.description, + parametersSchema: fn.parameters, + }); + } + return mapped.length ? mapped : undefined; +} + +/** + * Convert a parsed OpenAI Chat Completions request into the serializable + * bridge request. `vendor` is the synthesized provider name the runtime used + * (it is not present in the OpenAI body); `model` becomes the provider-local + * wire model id resolved on the renderer. + */ +export function openAiRequestToBridge(vendor: string, body: IOpenAiChatRequest): IByokLmChatRequest { + const model = typeof body.model === 'string' ? body.model : ''; + if (!model) { + throw new OpenAiTranslationError('Request is missing the "model" field'); + } + const sourceMessages = Array.isArray(body.messages) ? body.messages : []; + const messages: IByokLmChatMessage[] = sourceMessages.map(message => ({ + role: toBridgeRole(message.role), + content: flattenContent(message.content), + toolCalls: toBridgeToolCalls(message.tool_calls), + toolCallId: message.tool_call_id, + })); + + const modelOptions: Record = {}; + if (typeof body.temperature === 'number') { + modelOptions.temperature = body.temperature; + } + if (typeof body.top_p === 'number') { + modelOptions.top_p = body.top_p; + } + if (typeof body.max_tokens === 'number') { + modelOptions.max_tokens = body.max_tokens; + } + + return { + vendor, + modelId: model, + messages, + tools: toBridgeTools(body.tools), + modelOptions: Object.keys(modelOptions).length ? modelOptions : undefined, + }; +} + +let chunkCounter = 0; + +function nextCompletionId(): string { + chunkCounter = (chunkCounter + 1) % Number.MAX_SAFE_INTEGER; + return `chatcmpl-byok-${Date.now().toString(36)}-${chunkCounter.toString(36)}`; +} + +/** Serialize a single SSE `data:` frame. */ +function sseFrame(payload: unknown): string { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +/** + * Encode a buffered {@link IByokLmChatResult} as a sequence of OpenAI + * `chat.completion.chunk` SSE frames terminated by `data: [DONE]`. + * + * The whole completion is emitted in one content delta (Stage 1 is + * non-streaming end-to-end); the runtime's SSE parser accepts this shape. + */ +export function bridgeResultToSseFrames(result: IByokLmChatResult, model: string): string[] { + const id = nextCompletionId(); + const created = Math.floor(Date.now() / 1000); + const base = { id, object: 'chat.completion.chunk', created, model }; + const frames: string[] = []; + + // Role delta first, matching the OpenAI streaming contract. + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })); + + if (result.content) { + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { content: result.content }, finish_reason: null }] })); + } + + let finishReason: 'stop' | 'tool_calls' = 'stop'; + if (result.toolCalls && result.toolCalls.length > 0) { + finishReason = 'tool_calls'; + const toolCallsDelta = result.toolCalls.map((call, index) => ({ + index, + id: call.id, + type: 'function', + function: { name: call.name, arguments: call.argumentsJson }, + })); + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { tool_calls: toolCallsDelta }, finish_reason: null }] })); + } + + const finalChunk: Record = { ...base, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] }; + if (result.usage) { + finalChunk.usage = { + prompt_tokens: result.usage.promptTokens ?? 0, + completion_tokens: result.usage.completionTokens ?? 0, + total_tokens: (result.usage.promptTokens ?? 0) + (result.usage.completionTokens ?? 0), + }; + } + frames.push(sseFrame(finalChunk)); + frames.push('data: [DONE]\n\n'); + return frames; +} + +/** Build an OpenAI-style error envelope body. */ +export function openAiErrorBody(message: string, type = 'api_error'): string { + return JSON.stringify({ error: { message, type } }); +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 46e50a4697b6a..184bbc299a36d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -6,19 +6,19 @@ import { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; import * as os from 'os'; -import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; +import { CancelablePromise, createCancelablePromise, Delayer, disposableTimeout, Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { type CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; import { formatTokenCount } from '../../../../base/common/numbers.js'; import { equals } from '../../../../base/common/objects.js'; -import { observableValue } from '../../../../base/common/observable.js'; +import { autorun, observableValue, type ISettableObservable } from '../../../../base/common/observable.js'; import { basename, delimiter, dirname, join } from '../../../../base/common/path.js'; -import { basename as resourceBasename } from '../../../../base/common/resources.js'; +import { basename as resourceBasename, isEqual, isEqualOrParent, joinPath as resourceJoinPath, relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { rgDiskPath } from '../../../../base/node/ripgrep.js'; @@ -27,12 +27,16 @@ import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; +import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { workspacelessScratchDir } from '../workspacelessScratchDir.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; -import { createAgentModelPricingMeta } from '../../common/agentModelPricing.js'; +import { createPricingMetaFromBilling, hasLongContextSurcharge, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification } from '../../common/agentService.js'; +import { AgentSessionEntry, decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentPeerChats.js'; +import { AgentSession, AgentSignal, AuthenticateParams, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js'; import { getEffectiveAgents } from '../../common/customAgents.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; @@ -42,23 +46,27 @@ import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDa import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; -import { ActiveClientState } from '../activeClientState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, AH_META_WORKSPACELESS_DB_KEY, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { ActiveClientToolSet } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; -import { findMcpChildId } from '../shared/mcpCustomizationController.js'; -import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; +import { findMcpChildId, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +import { COPILOT_BRANCH_PREFIX, ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; -import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; +import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; +import { ICopilotApiService } from '../shared/copilotApiService.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js'; +const RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300; + /** * Maps a VS Code {@link LogLevel} to the Copilot CLI runtime's `logLevel` * option so the spawned CLI logs (written to `~/.copilot/logs/process-*.log`) @@ -77,6 +85,53 @@ function copilotCliLogLevelFor(level: LogLevel): NonNullable { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function isLinuxMuslRuntime(): boolean { + if (process.platform !== 'linux') { + return false; + } + + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined; + return !report?.header?.glibcVersionRuntime; +} + +function getCopilotPlatformPackageCandidates(): string[] { + const platformArch = `${process.platform}-${process.arch}`; + if (process.platform !== 'linux') { + return [platformArch]; + } + + const linuxCandidates = [`linux-${process.arch}`, `linuxmusl-${process.arch}`]; + return isLinuxMuslRuntime() ? linuxCandidates.reverse() : linuxCandidates; +} + +async function resolveCopilotCliPath(nodeModulesUri: URI): Promise { + const tried: string[] = []; + for (const platformPackage of getCopilotPlatformPackageCandidates()) { + const cliPath = URI.joinPath(nodeModulesUri, '@github', `copilot-${platformPackage}`, 'index.js').fsPath; + tried.push(cliPath); + if (await fileExists(cliPath)) { + return cliPath; + } + } + + const oldTopLevelPath = URI.joinPath(nodeModulesUri, '@github', 'copilot', 'index.js').fsPath; + tried.push(oldTopLevelPath); + if (await fileExists(oldTopLevelPath)) { + return oldTopLevelPath; + } + + throw new Error(`Unable to resolve @github/copilot CLI path. Tried: ${tried.join(', ')}`); +} + interface ICreatedWorktree { readonly repositoryRoot: URI; readonly worktree: URI; @@ -118,6 +173,8 @@ interface IProvisionalSession { agent: AgentSelection | undefined; /** Project info eagerly resolved at create time so the summary renders. */ readonly project: IAgentSessionProjectInfo | undefined; + /** Whether this session is workspace-less (surfaced in the sessions UI as a "Quick Chat"). */ + readonly workspaceless?: boolean; } export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './prompts/systemMessage.js'; @@ -129,43 +186,6 @@ interface ISerializedModelSelection { config?: unknown; } -/** - * A persisted additional (non-default) peer chat. Records the SDK conversation - * id that backs the chat so it can be resumed after a process restart, along - * with any model override chosen at creation time. - */ -interface IPersistedChat { - readonly sdkSessionId: string; - readonly model?: ModelSelection; -} - -/** - * Augments the published `@vscode/copilot-api` `ModelBilling` with the `tokenPrices` field the runtime CAPI `/models` - * payload already carries but the SDK type doesn't yet declare. Mirror of `IClaudeModelSupports` in `claudeAgent.ts`. - */ -interface ICopilotModelBilling { - readonly multiplier?: number; - /** Coarse price bucket surfaced as a tag in the model picker hover. */ - readonly priceCategory?: string; - /** Whole-number percentage discount (0-100) for the synthetic `auto` model; rendered as a "{n}% discount" detail. */ - readonly discountPercent?: number; - readonly tokenPrices?: { - /** Default-tier prices, expressed as credits per 1M tokens. */ - readonly contextMax?: number; - readonly inputPrice?: number; - readonly cachePrice?: number; - readonly cacheWritePrice?: number; - readonly outputPrice?: number; - readonly longContext?: { - readonly contextMax?: number; - readonly inputPrice?: number; - readonly cachePrice?: number; - readonly cacheWritePrice?: number; - readonly outputPrice?: number; - }; - }; -} - /** * Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's private `connection` field to wire plan mode. * See {@link CopilotAgent._enablePlanModeOnClient}. @@ -205,7 +225,42 @@ export function getCopilotWorktreesRoot(repositoryRoot: URI): URI { } export function getCopilotWorktreeName(branchName: string): string { - return branchName.replace(/\//g, '-'); + // Strip the `agents/` branch prefix so the worktree directory name stays + // concise, then flatten any remaining path separators. + const withoutPrefix = branchName.startsWith(COPILOT_BRANCH_PREFIX) + ? branchName.substring(COPILOT_BRANCH_PREFIX.length) + : branchName; + return withoutPrefix.replace(/\//g, '-'); +} + +/** + * Rebases `uri` from under `fromDir` onto `toDir`, preserving the relative path. + * Returns `undefined` when `uri` is not equal to or under `fromDir`. + */ +export function rebaseUnder(uri: URI, fromDir: URI, toDir: URI): URI | undefined { + if (!isEqualOrParent(uri, fromDir)) { + return undefined; + } + const rel = relativePath(fromDir, uri); + if (rel === undefined) { + return undefined; + } + return rel.length === 0 ? toDir : resourceJoinPath(toDir, rel); +} + +/** + * Returns a copy of `enablement` with keys that live under `fromDir` rebased + * onto `toDir`. Keys that aren't rebased are preserved **verbatim** (no + * `URI.parse(...).toString()` round-trip) so a non-URI-shaped or already-relocated + * key can't be mutated and lose its toggle. + */ +export function migrateEnablementKeys(enablement: ReadonlyMap, fromDir: URI, toDir: URI): Map { + const migrated = new Map(); + for (const [uri, enabled] of enablement) { + const rebased = rebaseUnder(URI.parse(uri), fromDir, toDir); + migrated.set(rebased ? rebased.toString() : uri, enabled); + } + return migrated; } /** @@ -255,6 +310,19 @@ function prependAnnouncementToFirstTurn( return result; } +/** + * Per-session container. Owns the session's default (main) chat and any + * additional peer chats, keeping all chats of a session together in a single + * {@link CopilotAgent._sessions} map (no parallel maps). The default chat is + * optional because a Copilot session can exist as a provisional record (in + * {@link CopilotAgent._provisionalSessions}) whose SDK-backed default chat has + * not materialized yet — a peer chat may still be created on it. Disposing the + * entry disposes the default chat and every peer chat. + * + * Exported for tests, which inject fake sessions into the container. + */ +export class CopilotSessionEntry extends AgentSessionEntry { } + /** * Agent provider backed by the Copilot SDK {@link CopilotClient}. */ @@ -264,6 +332,14 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress = this._onDidSessionProgress.event; + /** + * Membership channel for chats the agent spawns itself — sub-agents + * delegated by a tool call (the same fan-out the `subagent_started` / + * `subagent_completed` signals drive). The orchestrator routes these into + * the chat catalog so harness-spawned and user-driven chats share one path. + */ + private readonly _onDidSpawnChat = this._register(new Emitter()); + readonly onDidSpawnChat = this._onDidSpawnChat.event; private readonly _onDidMaterializeSession = this._register(new Emitter()); readonly onDidMaterializeSession = this._onDidMaterializeSession.event; /** @@ -275,6 +351,32 @@ export class CopilotAgent extends Disposable implements IAgent { readonly onMcpNotification = this._onMcpNotification.event; private readonly _models = observableValue(this, []); readonly models = this._models; + /** + * The two sources merged into {@link _models}: CAPI models from the CLI's + * `models.list` and BYOK models from the renderer bridge registry's serving + * window. Tracked separately so each can refresh independently without + * clobbering the other; {@link _publishModels} concatenates them for the + * picker. + */ + private _capiModels: readonly IAgentModelInfo[] = []; + private _byokModels: readonly IAgentModelInfo[] = []; + + /** Model IDs whose long-context tier costs the same as the default tier. */ + private readonly _freeLongContextModels = new Set(); + + /** + * Bounded exponential-backoff retry for {@link _refreshModels}. The SDK's + * `models.list` RPC can fail transiently (e.g. a `429 "too many requests"` + * right after startup). Without a retry the model picker would stay empty + * until the GitHub token next changes — the only other trigger for a + * refresh — so we retry a few times before giving up. Overridable in tests + * to avoid real delays. + */ + protected readonly _modelRefreshMaxAttempts: number = 5; + protected readonly _modelRefreshBaseDelayMs: number = 1_000; + protected readonly _modelRefreshMaxDelayMs: number = 30_000; + /** Pending model-refresh retry timer; cleared on a fresh refresh, shutdown, or dispose. */ + private readonly _modelRefreshRetry = this._register(new MutableDisposable()); private _client: CopilotClient | undefined; private _clientStarting: Promise | undefined; @@ -294,14 +396,24 @@ export class CopilotAgent extends Disposable implements IAgent { return this._restrictedTelemetryEnabled; } - private readonly _sessions = this._register(new DisposableMap()); + private readonly _sessions = this._register(new DisposableMap()); /** - * Additional (non-default) chats within a session, keyed by chat channel - * URI string. Each entry is its own Copilot SDK conversation sharing the - * owning session's working directory/model scope. The default chat is not - * tracked here — it maps to the primary {@link _sessions} entry. + * Live `chatUri → backing` map for additional (non-default) peer chats, + * keyed by chat channel URI string. Records the SDK chat id (and + * optional model override) that backs each peer chat so the agent can + * resume it without consulting on-disk persistence. Populated by + * {@link createChat} on creation and by {@link materializeChat} on + * restore; the orchestrator now owns the durable peer-chat catalog (the + * agent no longer writes `copilot.chats`). */ - private readonly _chatSessions = this._register(new DisposableMap()); + private readonly _chatBackings = new Map(); + /** + * Fires when a peer chat's opaque `providerData` blob changes after + * creation (e.g. a per-chat model switch), so the orchestrator re-persists + * the refreshed token. See {@link IAgent.onDidChangeChatData}. + */ + private readonly _onDidChangeChatData = this._register(new Emitter()); + readonly onDidChangeChatData: Event = this._onDidChangeChatData.event; /** * Per-session MCP-notification subscriptions, keyed by `sessionId`. * Disposed in lockstep with the matching {@link _sessions} entry so @@ -353,16 +465,27 @@ export class CopilotAgent extends Disposable implements IAgent { @ICopilotBranchNameGenerator private readonly _branchNameGenerator: ICopilotBranchNameGenerator, @IAgentHostCompletions completions: IAgentHostCompletions, @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, + @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, + @IByokLmBridgeRegistry private readonly _byokBridgeRegistry: IByokLmBridgeRegistry, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, ) { super(); this._plugins = this._register(this._instantiationService.createInstance(PluginController)); this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); this.onDidCustomizationsChange = this._plugins.onDidChange; - this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, { - hasHistory: (sessionId) => !this._provisionalSessions.has(sessionId) && this._sessions.has(sessionId), - isRubberDuckEnabled: () => this._isRubberDuckEnabled(), - hasRuntimeSlashCommand: async (sessionId, command) => this._sessions.get(sessionId)?.hasRuntimeSlashCommand(command) ?? false, - }))); + // Mirror the sub-agent fan-out signals onto the first-class spawned- + // chat channel so the orchestrator manages sub-agent chats + // through the same membership path as user-driven chats. + this._register(this._onDidSessionProgress.event(signal => this._emitSpawnedChatForSubagentSignal(signal))); + this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, + { + isRubberDuckEnabled: () => this._isRubberDuckEnabled(), + getRuntimeSlashCommands: async (sessionId, options) => this._findAnySession(sessionId)?.getRuntimeSlashCommands(options) ?? [], + getSessionCustomizations: (sessionId) => this.getSessionCustomizations(AgentSession.uri(this.id, sessionId)), + }, + RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS, + ))); // Restart the CLI client when a setting baked into the client/subprocess at // startup changes, disposing any active sessions. Both session sync (a client @@ -373,6 +496,30 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.error('[Copilot] Failed to restart client after config change', err) ); })); + + // Surface renderer BYOK models in the picker: republish them whenever the + // set of connected renderer bridges, or any renderer's models, change. + // The registry is only populated when `chat.agentHost.byokModels.enabled` + // is on, so this stays a no-op (empty list) while the feature is off. + this._register(this._byokBridgeRegistry.onDidChangeModels(() => { + this._logService.info('[Copilot] BYOK bridge changed; refreshing models'); + this._refreshByokModels(); + })); + } + + /** + * Translates the sub-agent fan-out signals into the first-class spawned- + * chat channel: `subagent_started` -> {@link onDidSpawnChat} + * (carrying the spawning tool call as the chat's parent edge). A completed + * subagent chat stays live and subscribable (it is removed only on session + * teardown), so there is no corresponding end event. The signals themselves + * are left untouched so the existing sub-agent behavior is preserved. + */ + private _emitSpawnedChatForSubagentSignal(signal: AgentSignal): void { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + this._onDidSpawnChat.fire(spawn); + } } private _lastSessionSyncEnabled: boolean = this._isSessionSyncEnabled(); @@ -425,7 +572,8 @@ export class CopilotAgent extends Disposable implements IAgent { return { provider: 'copilotcli', displayName: 'Copilot', - description: 'Copilot SDK agent running in a dedicated process', + description: localize('copilotAgent.description', "Copilot SDK agent running in the local agent host process"), + capabilities: { multipleChats: { fork: true } }, }; } @@ -445,7 +593,7 @@ export class CopilotAgent extends Disposable implements IAgent { const activeClient = this._getOrCreateActiveClient(session, directory); const fromPlugins = await activeClient.pluginController.getCustomizationsSettled(); const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); const topLevelMcp = entry?.topLevelMcpCustomizations() ?? []; if (topLevelMcp.length === 0) { return fromPlugins; @@ -455,7 +603,7 @@ export class CopilotAgent extends Disposable implements IAgent { async handleMcpRequest(session: URI, serverName: string, method: string, params: Record | undefined): Promise { const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); if (!entry) { throw new Error(`Method not found: no active session ${sessionId}`); } @@ -468,9 +616,12 @@ export class CopilotAgent extends Disposable implements IAgent { if (provisional) { return provisional.workingDirectory; } - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); const metadata = entry ? undefined : await this._readSessionMetadata(session); - return entry?.customizationDirectory ?? metadata?.customizationDirectory ?? metadata?.workingDirectory; + // For non-provisional sessions the anchor follows the working directory + // (the worktree). Prefer it over a persisted `customizationDirectory`, + // which older sessions stored as the original user-picked folder. + return entry?.customizationDirectory ?? metadata?.workingDirectory ?? metadata?.customizationDirectory; } async authenticate(resource: string, token: string): Promise { @@ -490,39 +641,164 @@ export class CopilotAgent extends Disposable implements IAgent { return true; } - private _updateRestrictedTelemetry(token: string | undefined): void { - const rtEnabled = isRestrictedTelemetryEnabled(token); + async handleAuthenticationToken(params: AuthenticateParams): Promise { + let handled = false; + for (const [, entry] of this._sessions) { + for (const session of entry.allChatSessions()) { + const didHandle = await session.resolveMcpAuthentication(params); + handled ||= didHandle; + } + } + return handled; + } + + private _updateRestrictedTelemetry(githubToken: string | undefined): void { + // Safe default synchronously: keep restricted/enhanced telemetry disabled until the minted + // CAPI Copilot session token confirms the `rt=1` opt-in. The GitHub token here carries no + // `rt`/`tid` claims — those live in the Copilot session token, which the API service mints — + // so the real values are resolved asynchronously below. Mirrors how the Copilot extension + // reads `rt`/`tid` off its `CopilotToken` rather than the GitHub token. + this._applyRestrictedTelemetry(false, undefined, undefined); + if (githubToken) { + void this._resolveRestrictedTelemetry(githubToken); + } + } + + private async _resolveRestrictedTelemetry(githubToken: string): Promise { + try { + const ctx = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken); + if (this._githubToken !== githubToken) { + return; // token changed while resolving; a newer call owns the state + } + this._applyRestrictedTelemetry( + ctx.restrictedTelemetryEnabled, + ctx.trackingId, + ctx.telemetryEndpoint ? `${ctx.telemetryEndpoint}/telemetry` : undefined, + ); + } catch (err) { + this._logService.debug(`[Copilot] Restricted telemetry resolution failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + private _applyRestrictedTelemetry(rtEnabled: boolean, trackingId: string | undefined, telemetryEndpoint: string | undefined): void { if (rtEnabled !== this._restrictedTelemetryEnabled) { this._restrictedTelemetryEnabled = rtEnabled; - this._logService.info(`[Copilot] Restricted telemetry ${rtEnabled ? 'enabled' : 'disabled'}`); + this._logService.info(`[Copilot] Enhanced (restricted) telemetry ${rtEnabled ? 'enabled for this account' : 'disabled'}`); this._onDidChangeRestrictedTelemetry.fire(); } + // Push the token-derived telemetry policy/identity to the restricted sender: `rt` gates + // enhanced GH telemetry (kept off for public users), `tid` becomes `copilot_trackingId`, and + // the endpoint routes at the user's CAPI telemetry host (dotcom, GHE, or proxy). + if (isAgentHostTelemetryService(this._telemetryService)) { + this._telemetryService.setRestrictedTelemetryEnabled(rtEnabled); + this._telemetryService.setCopilotTrackingId(trackingId); + this._telemetryService.setRestrictedTelemetryEndpoint(telemetryEndpoint); + } } - private async _refreshModels(): Promise { + private async _refreshModels(attempt = 0): Promise { + // A fresh refresh (e.g. a token change) supersedes any scheduled retry. + this._modelRefreshRetry.clear(); + + // Once teardown has begun, skip the refresh entirely: a retry timer that + // fires during the shutdown window would otherwise call `_ensureClient()` + // and resurrect the SDK subprocess after `shutdown()` tore it down. + if (this._shutdownPromise) { + return; + } + const tokenAtRefreshStart = this._githubToken; if (!tokenAtRefreshStart) { - this._models.set([], undefined); + this._capiModels = []; + this._publishModels(); return; } try { const models = await this._listModels(tokenAtRefreshStart); if (this._githubToken === tokenAtRefreshStart) { - this._models.set(models, undefined); + this._capiModels = models; + this._publishModels(); } } catch (err) { - this._logService.error(err, '[Copilot] Failed to refresh models'); - if (this._githubToken === tokenAtRefreshStart) { - this._models.set([], undefined); + // Token rotated mid-flight — a newer refresh owns the result — or + // teardown began while the request was in flight, in which case a + // retry would just resurrect the client we are tearing down. + if (this._githubToken !== tokenAtRefreshStart || this._shutdownPromise) { + return; } + if (attempt + 1 < this._modelRefreshMaxAttempts) { + const delay = this._modelRefreshBackoff(attempt); + this._logService.warn(`[Copilot] Failed to refresh models (attempt ${attempt + 1}), retrying in ${delay}ms`, err); + this._modelRefreshRetry.value = disposableTimeout(() => { + void this._refreshModels(attempt + 1); + }, delay); + return; + } + // Retries exhausted: surface the error but keep the last-known CAPI + // list so a transient failure never wipes a previously loaded, good + // model list. Republish so a concurrently-updated BYOK list still + // shows through. + this._logService.error(err, '[Copilot] Failed to refresh models'); + this._publishModels(); } } + /** + * Re-emit the merged CAPI + BYOK model list to the picker. A fresh array is + * allocated each call so the observable always notifies its consumers. + */ + private _publishModels(): void { + this._models.set([...this._capiModels, ...this._byokModels], undefined); + } + + /** + * (Re)publish the renderer BYOK models from the bridge registry's serving + * window. Triggered when any renderer bridge connects, disconnects, or + * reports a model change — the registry owns enumeration (with its own + * connect-time retry) and caches the serving window's models, so this is a + * cheap synchronous read of that cache. + * + * Each model is surfaced under the provider-qualified id `vendor/id` so a + * selection round-trips to the per-session provider config synthesized by + * `resolveByokSessionConfig`. + */ + private _refreshByokModels(): void { + if (this._shutdownPromise) { + return; + } + this._byokModels = this._byokBridgeRegistry.getModels().map((m): IAgentModelInfo => ({ + provider: this.id, + id: `${m.vendor}/${m.id}`, + name: m.name ?? m.id, + maxContextWindow: m.maxContextWindowTokens, + supportsVision: m.supportsVision ?? false, + })); + this._logService.trace(`[Copilot] Found ${this._byokModels.length} BYOK models${this._byokModels.length ? ': ' + this._byokModels.map(m => m.name).join(', ') : ''}`); + this._publishModels(); + } + + /** + * Equal-jitter exponential backoff for model-refresh retries. Doubles the + * base delay per attempt (capped at {@link _modelRefreshMaxDelayMs}) and + * picks a random point in the upper half of that window, so the returned + * delay lands in `[exp/2, exp]`. The jitter avoids synchronized retries + * across windows/agents hitting a shared rate limit, while the `exp/2` + * floor keeps a minimum spacing between attempts. + */ + private _modelRefreshBackoff(attempt: number): number { + const exp = Math.min(this._modelRefreshMaxDelayMs, this._modelRefreshBaseDelayMs * 2 ** attempt); + return Math.round(exp / 2 + Math.random() * (exp / 2)); + } + private async _stopClient(): Promise { const client = this._client; this._client = undefined; this._clientStarting = undefined; await client?.stop(); + // The runtime subprocess is now dead, so it is safe to release the BYOK + // proxy handle: the next session launch mints a fresh nonce. See the + // ownership invariant on `CopilotSessionLauncher.disposeByokProxyHandle`. + await this._sessionLauncher.disposeByokProxyHandle(); } /** @@ -591,6 +867,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (key === 'ELECTRON_RUN_AS_NODE') { continue; } + if (key === 'VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE') { + // used for running the CLI in a test harness against a mock CAPI server + continue; + } if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { delete env[key]; } @@ -633,7 +913,7 @@ export class CopilotAgent extends Disposable implements IAgent { // because @github/copilot's exports map blocks direct subpath access. // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up. const nodeModulesUri = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules'); - const cliPath = URI.joinPath(nodeModulesUri, '@github', 'copilot', 'index.js').fsPath; + const cliPath = await resolveCopilotCliPath(nodeModulesUri); // The SDK's sandbox auto-detection looks for `//wxc-exec.exe` // (and the Linux/macOS equivalents). VS Code core ships the MXC sandbox binaries @@ -700,14 +980,19 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Synthesize a `contextTier` config property when the model exposes a `long_context` pricing tier with a distinct + * Synthesize a `contextSize` config property when the model exposes a `long_context` pricing tier with a distinct * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in - * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. + * `extensions/copilot/src/extension/chat/vscode-node/languageModelAccess.ts`. + * + * The `enum` values are the two context-window sizes (in tokens), smallest first, so the numeric token counts + * flow to the client. The chosen value comes back in the model's `config` bag and is mapped to the SDK's + * two-valued `contextTier` at the SDK boundary by {@link getCopilotContextTier}, using the model's long-context + * window from {@link _longContextWindowFor}. * * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK - * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. + * `ModelBilling` type — narrow through {@link ICAPIModelBilling} until the SDK catches up. */ - private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { + private _createContextSizeConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; @@ -715,58 +1000,69 @@ export class CopilotAgent extends Disposable implements IAgent { return undefined; } - const hasLongContextSurcharge = typeof tokenPrices?.longContext?.inputPrice === 'number' - || typeof tokenPrices?.longContext?.outputPrice === 'number'; + // When both tiers cost the same, show only the long-context option as + // a non-switchable indicator — the user always gets the full window. + if (!hasLongContextSurcharge(billing as ICAPIModelBilling | undefined)) { + return { + type: 'number', + title: localize('copilot.modelContextSize.title', "Context Size"), + description: localize('copilot.modelContextSize.description', "Selects the context window size for this model."), + default: longContextMax, + enum: [longContextMax], + enumLabels: [formatTokenCount(longContextMax)], + enumDescriptions: [ + localize('copilot.modelContextSize.longerSessions', "Longer sessions"), + ], + }; + } return { - type: 'string', - title: localize('copilot.modelContextTier.title', "Context Size"), - description: localize('copilot.modelContextTier.description', "Selects the context window size for this model."), - default: 'default', - enum: ['default', 'long_context'], + type: 'number', + title: localize('copilot.modelContextSize.title', "Context Size"), + description: localize('copilot.modelContextSize.description', "Selects the context window size for this model."), + default: defaultMax, + enum: [defaultMax, longContextMax], enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)], enumDescriptions: [ - localize('copilot.modelContextTier.default', "Default"), - hasLongContextSurcharge - ? localize('copilot.modelContextTier.longerSessions', "Longer sessions") - : localize('copilot.modelContextTier.longerSessionsNoCompaction', "Longer sessions without compaction"), + localize('copilot.modelContextSize.default', "Default"), + localize('copilot.modelContextSize.longerSessions', "Longer sessions"), ], }; } + /** + * The model's long-context window (in tokens): the largest size offered by its "Context Size" picker + * (the max numeric value in the synthesized `contextSize` {@link ConfigPropertySchema.enum}). Used by + * {@link getCopilotContextTier} to decide whether a numeric selection opts into `long_context`. + * Returns `undefined` when the model exposes no such picker (or the model list isn't loaded yet), + * leaving the SDK on its default tier. + */ + private _longContextWindowFor(modelId: string | undefined): number | undefined { + if (!modelId) { + return undefined; + } + const windows = this._models.get().find(m => m.id === modelId)?.configSchema?.properties?.[ContextSizeConfigKey]?.enum; + const numericWindows = windows?.filter((w): w is number => typeof w === 'number'); + return numericWindows && numericWindows.length > 0 ? Math.max(...numericWindows) : undefined; + } + + /** + * Whether the model has a long-context window available at no additional cost. + * When true the model should always run in `long_context` tier without showing + * a context-size picker. + */ + private _isFreeLongContext(modelId: string | undefined): boolean { + return !!modelId && this._freeLongContextModels.has(modelId); + } + /** * Builds the open `_meta` pricing bag for a model from its billing info so the chat model picker can render its - * cost hover. Cost values are credits per 1M tokens. - * - * Long-context costs are only emitted when they differ from the default tier, mirroring `normalizeTokenPrices` in - * `extensions/copilot/src/extension/conversation/common/languageModelAccess.ts`. - * - * `billing.tokenPrices` / `billing.priceCategory` are present on the runtime CAPI `/models` payload but not yet - * declared on the published SDK `ModelBilling` type — narrow through {@link ICopilotModelBilling}. + * cost hover. Delegates to the shared {@link createPricingMetaFromBilling} helper. */ private _createModelPricingMeta(modelInfo: ModelInfo | undefined): Record | undefined { - const billing = modelInfo?.billing; - const tokenPrices = billing?.tokenPrices; - const longContext = tokenPrices?.longContext; - // Narrow through ICopilotModelBilling: discountPercent may lag the installed SDK type. - const discountPercent = (billing as ICopilotModelBilling | undefined)?.discountPercent; - - const differsFromDefault = (longValue: number | undefined, defaultValue: number | undefined): number | undefined => - longValue !== undefined && longValue !== defaultValue ? longValue : undefined; - - return createAgentModelPricingMeta({ - multiplierNumeric: typeof billing?.multiplier === 'number' ? billing.multiplier : undefined, - inputCost: tokenPrices?.inputPrice, - cacheCost: tokenPrices?.cachePrice, - cacheWriteCost: tokenPrices?.cachePrice, - outputCost: tokenPrices?.outputPrice, - longContextInputCost: differsFromDefault(longContext?.inputPrice, tokenPrices?.inputPrice), - longContextCacheCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), - longContextCacheWriteCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), - longContextOutputCost: differsFromDefault(longContext?.outputPrice, tokenPrices?.outputPrice), - priceCategory: typeof modelInfo?.modelPickerPriceCategory === 'string' ? modelInfo.modelPickerPriceCategory : undefined, - discountPercent: typeof discountPercent === 'number' ? discountPercent : undefined, - }); + const billing = modelInfo?.billing as ICAPIModelBilling | undefined; + const priceCategory = typeof modelInfo?.modelPickerPriceCategory === 'string' ? modelInfo.modelPickerPriceCategory : undefined; + return createPricingMetaFromBilling(billing, priceCategory); } private _createModelConfigSchema(m: ModelInfo): ConfigSchema | undefined { @@ -775,9 +1071,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (thinkingLevel) { properties[ThinkingLevelConfigKey] = thinkingLevel; } - const contextTier = this._createContextTierConfigSchemaProperty(m.billing); - if (contextTier) { - properties[ContextTierConfigKey] = contextTier; + const contextSize = this._createContextSizeConfigSchemaProperty(m.billing); + if (contextSize) { + properties[ContextSizeConfigKey] = contextSize; } if (Object.keys(properties).length === 0) { return undefined; @@ -882,8 +1178,6 @@ export class CopilotAgent extends Disposable implements IAgent { modifiedTime: s.modifiedTime.getTime(), project, summary: s.summary, - model: metadata.model, - agent: metadata.agent, workingDirectory, customizationDirectory: metadata.customizationDirectory, }; @@ -921,8 +1215,6 @@ export class CopilotAgent extends Disposable implements IAgent { modifiedTime: sessionMetadata?.modifiedTime.getTime() ?? Date.now(), project, summary: sessionMetadata?.summary, - model: storedMetadata?.model, - agent: storedMetadata?.agent, workingDirectory, customizationDirectory: storedMetadata?.customizationDirectory, }; @@ -932,44 +1224,212 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing models...'); const client = await this._ensureClient(); const { models } = await client.rpc.models.list({ gitHubToken }); - const result = models.map((m): IAgentModelInfo => ({ - provider: this.id, - id: m.id, - name: m.name, - // Synthetic SDK entries like `auto` ship with `capabilities: {}` and - // no fixed context window — surface them with maxContextWindow undefined. - maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, - supportsVision: !!m.capabilities?.supports?.vision, - configSchema: this._createModelConfigSchema(m), - policyState: m.policy?.state as PolicyState | undefined, - _meta: this._createModelPricingMeta(m), - })); + this._freeLongContextModels.clear(); + const result = models.map((m): IAgentModelInfo => { + const configSchema = this._createModelConfigSchema(m); + // A model has free long context when billing shows a larger long-context + // window but there is no surcharge for using it. + const tokenPrices = m.billing?.tokenPrices; + const hasLargerLongContext = !!tokenPrices?.contextMax + && !!tokenPrices.longContext?.contextMax + && tokenPrices.longContext.contextMax > tokenPrices.contextMax; + if (hasLargerLongContext && !hasLongContextSurcharge(m.billing as ICAPIModelBilling | undefined)) { + this._freeLongContextModels.add(m.id); + } + return { + provider: this.id, + id: m.id, + name: m.name, + // Synthetic SDK entries like `auto` ship with `capabilities: {}` and + // no fixed context window — surface them with maxContextWindow undefined. + maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, + supportsVision: !!m.capabilities?.supports?.vision, + configSchema, + policyState: m.policy?.state as PolicyState | undefined, + _meta: this._createModelPricingMeta(m), + }; + }); this._logService.info(`[Copilot] Found ${result.length} models: ${result.map(m => m.name).join(', ')}`); return result; } /** * Resolves the working directory for a {@link createSession} call: the caller-supplied folder, else a - * still-provisional session's folder for an idempotent re-create, else a freshly created empty directory under the - * OS temp dir (used when the editor has no workspace open). + * still-provisional session's folder for an idempotent re-create, else — when the session is workspace-less + * (no `workingDirectory` supplied) — a stable per-session scratch directory. */ - private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string): Promise { + private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string, isWorkspaceless: boolean): Promise { const existing = sessionConfig.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; if (existing) { return existing; } + // A workspace-less session (inferred from an absent input + // `workingDirectory`) gets a STABLE, deterministic per-session scratch + // dir (mirroring the GitHub app's `/chats/`) rather than + // a throwaway `os.tmpdir()` dir, so the cwd survives reloads and isn't + // lost to OS temp reaping. + if (isWorkspaceless) { + const scratchDir = this._workspacelessScratchDir(sessionId); + await fs.mkdir(scratchDir.fsPath, { recursive: true }); + return scratchDir; + } const tmpPath = await fs.mkdtemp(join(os.tmpdir(), 'agent-host-session-')); const workingDirectory = URI.file(tmpPath); this._logService.trace(`[Copilot] No workingDirectory provided, defaulting to temp directory: ${workingDirectory.fsPath}`); return workingDirectory; } + /** + * Stable per-session scratch directory for a workspace-less chat: + * `/.copilot/chats/`. Deterministic, persistent, and + * cleaned up on session delete (see {@link _cleanupWorkspacelessScratchDir}). + */ + private _workspacelessScratchDir(sessionId: string): URI { + return workspacelessScratchDir(this._environmentService.userHome, sessionId); + } + + /** Ensures a workspace-less chat's scratch dir exists (mkdir -p), recreating it if it was reaped. */ + private async _ensureWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise { + try { + await fs.mkdir(scratchDir.fsPath, { recursive: true }); + this._logService.trace(`[Copilot:${sessionId}] Workspace-less scratch directory ready: ${scratchDir.fsPath}`); + } catch (error) { + this._logService.warn(`[Copilot:${sessionId}] Failed to ensure workspace-less scratch directory '${scratchDir.fsPath}': ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** Removes a workspace-less chat's stable scratch dir on session delete/dispose. */ + private async _cleanupWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise { + try { + await fs.rm(scratchDir.fsPath, { recursive: true, force: true }); + this._logService.trace(`[Copilot:${sessionId}] Removed workspace-less scratch directory: ${scratchDir.fsPath}`); + } catch (error) { + this._logService.warn(`[Copilot:${sessionId}] Failed to remove workspace-less scratch directory '${scratchDir.fsPath}': ${error instanceof Error ? error.message : String(error)}`); + } + } + + // ---- Chat surface ------------------------------------------------------ + // + // The chat-addressed operation surface (see + // {@link IAgent.chats}). The orchestrator owns the feature-level + // `(session, chat)` mapping and hands these methods a single, + // concrete chat channel URI: the default chat channel or an additional + // peer chat channel. Each method re-derives the `(session, chat)` pair + // the agent's internal SDK storage is keyed by via + // {@link _resolveChatTarget}. + + /** + * Maps a resolved chat URI to the `(session, chat)` pair the agent's + * internal storage is keyed by. A peer (`ahp-chat`) chat carries its + * owning session in its URI. The default chat is addressed by its + * deterministic chat channel URI. + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Copilot chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat: chat }; + } + + private _getChatContext(chatOrSession: URI): { session: URI; sessionId: string; chatKey: string; target: CopilotAgentSession | undefined; isPeerChat: boolean } { + // Accept either a chat channel URI or a bare session URI: per the AHP + // convention the default chat's URI equals the session URI, so callers + // that address the default chat by the session URI resolve here in one + // place rather than each operational method re-deriving it. + const chat = parseChatUri(chatOrSession) ? chatOrSession : URI.parse(buildDefaultChatUri(chatOrSession)); + const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); + const sessionId = AgentSession.id(session); + const chatKey = chat.toString(); + const resolved = this._sessions.get(sessionId)?.resolveChat(chatKey); + return { + session, + sessionId, + chatKey, + target: resolved?.chatSession, + isPeerChat: resolved ? !resolved.isDefault : chatKey !== buildDefaultChatUri(session), + }; + } + + /** + * Resolve the session's materialized default (main) chat by raw session id, + * or `undefined` when the session is provisional or not in memory. The + * default chat is the primary {@link CopilotAgentSession} of the owning + * {@link CopilotSessionEntry}. + */ + private _findAnySession(sessionId: string): CopilotAgentSession | undefined { + return this._sessions.get(sessionId)?.defaultChat; + } + + /** + * Resolve a live peer (non-default) chat — its own SDK chat — by + * looking it up within the owning session's entry. Returns `undefined` when + * the session (or the peer chat) is not in memory. + */ + private _findPeerChat(session: URI, chat: URI): CopilotAgentSession | undefined { + return this._sessions.get(AgentSession.id(session))?.getPeerChat(chat.toString()); + } + + /** + * Return the owning session's entry, creating an empty one (no default chat + * yet) if needed so a peer chat can be hosted on a still-provisional parent. + */ + private _ensureEntry(sessionId: string): CopilotSessionEntry { + let entry = this._sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + this._sessions.set(sessionId, entry); + } + return entry; + } + + /** + * Chat-addressed surface for the chats within a session. + */ + readonly chats: IAgentChats = { + createChat: (chat: URI, options?: IAgentCreateChatOptions): Promise => { + return this._createChat(chat, options); + }, + fork: (chat: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise => { + return this._createChat(chat, { ...options, fork: source }); + }, + disposeChat: (chatUri: URI): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this._disposeChat(session, chat); + }, + sendMessage: (chatUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise => { + return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId); + }, + abort: (chatUri: URI): Promise => { + return this._abortSession(chatUri); + }, + changeModel: (chatUri: URI, model: ModelSelection): Promise => { + return this._changeModel(chatUri, model); + }, + changeAgent: (chatUri: URI, agent: AgentSelection | undefined): Promise => { + return this._changeAgent(chatUri, agent); + }, + getMessages: (chat: URI): Promise => { + return this.getSessionMessages(chat); + }, + }; + async createSession(config?: IAgentCreateSessionConfig): Promise { const sessionConfig = config ?? {}; this._logService.info(`[Copilot] Creating session... ${sessionConfig.model ? `model=${sessionConfig.model.id}` : ''}`); const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); - const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId); + // Workspace-less is inferred at create from an absent input + // `workingDirectory`: such a session is run in a stable scratch dir. The + // AH service persists the marker centrally (`agentHost.workspaceless`) and + // hands it back on restore; the agent only reads it (never persists it) to + // pick the workspace-less system prompt. Forks always inherit the source + // session's context, so they are never inferred workspace-less even when no + // `workingDirectory` is passed. + const isWorkspaceless = !sessionConfig.fork && !sessionConfig.workingDirectory; + const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId, isWorkspaceless); const client = await this._ensureClient(); // When forking, use the SDK's sessions.fork RPC. Forking from a source // session that has no turns is equivalent to creating a fresh session; @@ -983,7 +1443,7 @@ export class CopilotAgent extends Disposable implements IAgent { return this._sessionSequencer.queue(sourceSessionId, async () => { this._logService.info(`[Copilot] Forking session ${sourceSessionId} at turnId=${sessionConfig.fork!.turnId}`); - const sourceEntry = this._sessions.get(sourceSessionId) ?? await this._resumeSession(sourceSessionId); + const sourceEntry = this._findAnySession(sourceSessionId) ?? await this._resumeSession(sourceSessionId); // Look up the SDK event ID for the turn *after* the fork point. // toEventId is exclusive — events before it are included. @@ -1048,7 +1508,7 @@ export class CopilotAgent extends Disposable implements IAgent { // non-provisional result so the caller doesn't re-fire `SessionAdded`. // This guards against client retries that race a successful first // message. - if (this._sessions.has(sessionId)) { + if (this._findAnySession(sessionId)) { this._logService.info(`[Copilot] createSession is a no-op: session already materialized: ${sessionUri.toString()}`); const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); return { session: sessionUri, workingDirectory, ...(project ? { project } : {}) }; @@ -1069,13 +1529,15 @@ export class CopilotAgent extends Disposable implements IAgent { // of activeClient state isn't engaged until materialization. if (sessionConfig.activeClient) { const ac = this._getOrCreateActiveClient(sessionUri, workingDirectory); - ac.updateTools(sessionConfig.activeClient.clientId, sessionConfig.activeClient.tools); - if (sessionConfig.activeClient.customizations !== undefined) { + const seeded = sessionConfig.activeClient; + ac.toolSet.set(seeded.clientId, seeded.tools); + ac.getOrCreateHandle(seeded.clientId, seeded.displayName); + if (seeded.customizations !== undefined) { // Provisional eager-create: no session-state listener is // hooked up yet, so suppress action events. The session // reads the final view via its initial snapshot once it // materializes. - await ac.pluginController.sync(sessionConfig.activeClient.clientId, sessionConfig.activeClient.customizations, { quiet: true }); + await ac.pluginController.sync(seeded.clientId, seeded.customizations, { quiet: true }); } } @@ -1092,6 +1554,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: sessionConfig.model, agent: sessionConfig.agent, project, + workspaceless: isWorkspaceless, }); } @@ -1136,29 +1599,41 @@ export class CopilotAgent extends Disposable implements IAgent { config: liveSessionConfig, }; - const customizationDirectory = provisional.workingDirectory; + const workingDirectory = await this._resolveSessionWorkingDirectory(materializedConfig, sessionId, prompt); + // The customization anchor follows the working directory: once a worktree + // is created the agent must discover skills/instructions/agents from the + // worktree (not the user-picked folder) so the model reads and edits files + // in the worktree it actually runs in. + const customizationDirectory = workingDirectory ?? provisional.workingDirectory; // Always create an ActiveClient so the snapshot includes host + - // session-discovered customizations, even when no client has called - // `setClientCustomizations` / `setClientTools` yet. + // session-discovered customizations, even when no client has + // registered an active-client handle yet. const activeClient = this._getOrCreateActiveClient(sessionUri, customizationDirectory); + // Re-anchor in case the provisional active client was already bound to the + // user-picked folder before the worktree existed. + activeClient.pluginController.reanchor(customizationDirectory); const snapshot = await activeClient.snapshot(); - const workingDirectory = await this._resolveSessionWorkingDirectory(materializedConfig, sessionId, prompt); const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); let agentSession: CopilotAgentSession | undefined; + let agent: AgentSelection | undefined; try { - const resolvedAgentName = provisional.agent ? await this._resolveAgentName(provisional.sessionUri, snapshot, provisional.agent) : undefined; + const resolvedAgent = await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); + agent = resolvedAgent?.agent; const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client, sessionId, workingDirectory, - resolvedAgentName, + resolvedAgentName: resolvedAgent?.name, snapshot, - activeClientState: activeClient.state, + activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, model: provisional.model, + longContextWindow: this._longContextWindowFor(provisional.model?.id), + freeLongContext: this._isFreeLongContext(provisional.model?.id), + workspaceless: provisional.workspaceless, }; agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); await agentSession.initializeSession(); @@ -1173,8 +1648,8 @@ export class CopilotAgent extends Disposable implements IAgent { this._provisionalSessions.delete(sessionId); await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, customizationDirectory, project, true); - if (provisional.agent !== undefined) { - await this._storeSessionAgentMetadata(sessionUri, provisional.agent); + if (agent !== undefined) { + await this._storeSessionAgentMetadata(sessionUri, agent); } // Capture the per-session baseline (turn/0) git checkpoint so @@ -1192,6 +1667,43 @@ export class CopilotAgent extends Disposable implements IAgent { return agentSession; } + private async _resolveAgentWhenMaterializing(provisional: IProvisionalSession, snapshot: IActiveClientSnapshot, workingDirectory: URI | undefined): Promise<{ agent: AgentSelection; name: string } | undefined> { + const agent = provisional.agent; + if (!agent) { + return undefined; + } + const alternativeAgent = this._getAlternativeAgentForWorktree(provisional, workingDirectory); + + const [originalAgentName, alternativeAgentName] = await Promise.all([ + this._resolveAgentName(provisional.sessionUri, snapshot, agent), + alternativeAgent ? this._resolveAgentName(provisional.sessionUri, snapshot, alternativeAgent) : Promise.resolve(undefined), + ]); + + if (originalAgentName) { + return { agent: agent, name: originalAgentName }; + } + if (alternativeAgentName && alternativeAgent) { + this._logService.info(`[Copilot] Agent file ${agent.uri} is in the original repo; using worktree agent ${alternativeAgent?.uri}`); + return { agent: alternativeAgent, name: alternativeAgentName }; + } + return undefined; + } + private _getAlternativeAgentForWorktree(provisional: IProvisionalSession, workingDirectory: URI | undefined): AgentSelection | undefined { + const agent = provisional.agent; + if (!agent) { + return undefined; + } + if (!provisional.workingDirectory || !workingDirectory) { + return undefined; + } + if (isEqual(provisional.workingDirectory, workingDirectory)) { + return undefined; + } + const agentUri = URI.parse(agent.uri); + const alternativeAgentUri = rebaseUnder(agentUri, provisional.workingDirectory, workingDirectory); + return alternativeAgentUri ? { uri: alternativeAgentUri.toString() } : undefined; + } + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined; @@ -1264,33 +1776,47 @@ export class CopilotAgent extends Disposable implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { - const directory = await this._getSessionCustomizationDirectory(session); - const activeClient = this._getOrCreateActiveClient(session, directory); - return activeClient.pluginController.sync(clientId, customizations); + getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient { + const activeClient = this._getOrCreateActiveClient(session, undefined); + // Anchor the customization directory (best-effort, idempotent) so + // session-discovered customizations surface alongside this client's, + // mirroring the previous eager resolution in `setClientCustomizations`. + if (!activeClient.pluginController.directory) { + this._getSessionCustomizationDirectory(session).then( + directory => activeClient.pluginController.setDirectory(directory), + () => { /* best-effort anchoring */ }, + ); + } + return activeClient.getOrCreateHandle(client.clientId, client.displayName); } - setClientTools(session: URI, clientId: string | undefined, tools: ToolDefinition[]): void { + removeActiveClient(session: URI, clientId: string): void { const sessionId = AgentSession.id(session); - const activeClient = this._getOrCreateActiveClient(session, undefined); - const hasCachedEntry = this._sessions.has(sessionId); - this._logService.info(`[Copilot:${sessionId}] setClientTools: clientId=${clientId ?? '(none)'}, tools=[${tools.map(t => t.name).join(', ') || '(none)'}], hasCachedSdkSession=${hasCachedEntry}`); - activeClient.updateTools(clientId, tools); + this._logService.info(`[Copilot:${sessionId}] removeActiveClient: clientId=${clientId}`); + this._activeClients.get(session)?.removeClient(clientId); } - onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { - // Walk up the subagent chain to reach the root SDK session entry; - // _sessions is keyed by root session IDs only. - let target = session; - let parsed; - while ((parsed = parseSubagentSessionUri(target))) { - target = parsed.parentSession; + onClientToolCallComplete(session: URI, chat: URI, toolCallId: string, result: ToolCallResult): void { + const sessionId = AgentSession.id(session); + // Peer (non-default) chats own their SDK chat within the owning + // session entry, keyed by the chat URI. Mirrors the routing in `sendMessage`. + if (!isDefaultChatUri(chat)) { + const peerChat = this._findPeerChat(session, chat); + if (!peerChat) { + this._logService.warn(`[Copilot:${sessionId}] Dropping client tool completion for missing peer chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + return; + } + this._logService.info(`[Copilot:${sessionId}] Routing client tool completion to peer chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + peerChat.handleClientToolCallComplete(toolCallId, result); + } else { + const entry = this._findAnySession(sessionId); + if (!entry) { + this._logService.warn(`[Copilot:${sessionId}] Dropping client tool completion for missing default chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + return; + } + this._logService.info(`[Copilot:${sessionId}] Routing client tool completion to default chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + entry.handleClientToolCallComplete(toolCallId, result); } - // The completion may belong to a peer chat (tracked in `_chatSessions` - // keyed by chat URI) rather than the default/parent session. - const entry = this._sessions.get(AgentSession.id(target)) - ?? this._chatSessions.get(target.toString()); - entry?.handleClientToolCallComplete(toolCallId, result); } setCustomizationEnabled(uri: string, enabled: boolean): void { @@ -1302,56 +1828,58 @@ export class CopilotAgent extends Disposable implements IAgent { } } - async sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise { + const context = this._getChatContext(chat); // Additional (non-default) chats are backed by their own SDK - // conversation tracked in `_chatSessions`, keyed by the chat URI. - if (chat && !isDefaultChatUri(chat)) { - const entry = await this._ensureChatSession(session, chat); + // chat hosted on the owning session entry, keyed by the chat URI. + if (context.isPeerChat) { + const entry = await this._ensureChatSession(context.session, chat); if (!entry) { throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); } if (turnId) { - entry.resetTurnState(turnId); + entry.resetTurnState(turnId, senderClientId); } - await entry.send(prompt, attachments, turnId, this._resolveSdkMode(session)); + await entry.send(prompt, attachments, turnId, this._resolveSdkMode(context.session), senderClientId); return; } - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - await this._activeClients.get(session)?.pluginController.retryFailedClientSyncIfNeeded(); + await this._sessionSequencer.queue(context.sessionId, async () => { + await this._activeClients.get(context.session)?.pluginController.retryFailedClientSyncIfNeeded(); // First message on a provisional session: materialize the SDK // session, worktree, and on-disk metadata before continuing. The // prompt is forwarded so a worktree-isolated session can derive // its branch-name hint from the user's first message. let entry: CopilotAgentSession | undefined; - if (this._provisionalSessions.has(sessionId)) { - entry = await this._materializeProvisional(sessionId, prompt); + if (this._provisionalSessions.has(context.sessionId)) { + entry = await this._materializeProvisional(context.sessionId, prompt); } else { - entry = this._sessions.get(sessionId); + entry = this._getChatContext(chat).target; } // If the active client's config changed (tools or plugins), // dispose this session so it gets resumed with the updated config. - const activeClient = this._activeClients.get(session); + const activeClient = this._activeClients.get(context.session); const hadCachedEntry = !!entry; - this._logService.info(`[Copilot:${sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); + this._logService.info(`[Copilot:${context.sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); if (entry && activeClient && await activeClient.requiresRestart(entry.appliedSnapshot)) { - this._logService.info(`[Copilot:${sessionId}] Session config changed (requiresRestart=true), refreshing session. clientId=${activeClient.state.clientId ?? '(none)'}`); - this._sessions.deleteAndDispose(sessionId); + this._logService.info(`[Copilot:${context.sessionId}] Session config changed (requiresRestart=true), refreshing session. clients=[${[...activeClient.toolSet.clientIds()].join(', ') || '(none)'}]`); + // Dispose only the default chat so it resumes with the updated + // config; peer chats on the same entry are left intact. + this._sessions.get(context.sessionId)?.clearDefaultChat(); entry = undefined; } if (!entry) { - this._logService.info(`[Copilot:${sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by requiresRestart)' : ''}, calling _resumeSession`); + this._logService.info(`[Copilot:${context.sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by requiresRestart)' : ''}, calling _resumeSession`); } - entry ??= await this._resumeSession(sessionId); + entry ??= await this._resumeSession(context.sessionId); // Reset per-turn streaming state on the session so that the // next text/reasoning chunk (and any host-emitted announcement) // allocates a fresh response part. if (turnId) { - entry.resetTurnState(turnId); + entry.resetTurnState(turnId, senderClientId); } // Emit any pending first-turn announcement (e.g. worktree @@ -1359,19 +1887,19 @@ export class CopilotAgent extends Disposable implements IAgent { // delegating to the SDK. The SDK's subsequent deltas append to // the same markdown part because the session has already // allocated `_currentMarkdownPartId`. - const announcement = this._pendingFirstTurnAnnouncements.get(sessionId); + const announcement = this._pendingFirstTurnAnnouncements.get(context.sessionId); if (announcement !== undefined) { - this._pendingFirstTurnAnnouncements.delete(sessionId); + this._pendingFirstTurnAnnouncements.delete(context.sessionId); entry.emitInitialMarkdown(announcement); } try { - const sdkMode = this._resolveSdkMode(session); - await entry.send(prompt, attachments, turnId, sdkMode); + const sdkMode = this._resolveSdkMode(context.session); + await entry.send(prompt, attachments, turnId, sdkMode, senderClientId); } catch (err) { const errCode = (err as { code?: number })?.code; const errMsg = err instanceof Error ? err.message : String(err); - this._logService.error(`[Copilot:${sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`); + this._logService.error(`[Copilot:${context.sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`); throw err; } }); @@ -1411,7 +1939,7 @@ export class CopilotAgent extends Disposable implements IAgent { setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); if (!entry) { this._logService.warn(`[Copilot:${sessionId}] setPendingMessages: session not found`); return; @@ -1428,15 +1956,6 @@ export class CopilotAgent extends Disposable implements IAgent { } async getSessionMessages(session: URI): Promise { - // An additional (non-default) peer chat is addressed by its `ahp-chat` - // channel URI. Resume its backing SDK conversation and return its turns. - const chatInfo = parseChatUri(session); - if (chatInfo && !isDefaultChatUri(session)) { - const parentSession = URI.parse(chatInfo.session); - const entry = await this._ensureChatSession(parentSession, session); - return entry ? entry.getMessages() : []; - } - // If the URI describes a subagent child session (`/subagent/`), // load the parent's events once and extract the child's filtered turns. const subagentInfo = parseSubagentSessionUri(session); @@ -1449,7 +1968,7 @@ export class CopilotAgent extends Disposable implements IAgent { rootSession = parentParsed.parentSession; } const rootSessionId = AgentSession.id(rootSession); - const parentEntry = this._sessions.get(rootSessionId) ?? await this._resumeSession(rootSessionId).catch(err => { + const parentEntry = this._findAnySession(rootSessionId) ?? await this._resumeSession(rootSessionId).catch(err => { this._logService.warn(`[Copilot:${rootSessionId}] Failed to resume root for subagent restore`, err); return undefined; }); @@ -1459,12 +1978,19 @@ export class CopilotAgent extends Disposable implements IAgent { return parentEntry.getSubagentMessages(subagentInfo.toolCallId); } - const sessionId = AgentSession.id(session); + const chat = parseChatUri(session) ? session : URI.parse(buildDefaultChatUri(session)); + const context = this._getChatContext(chat); + if (context.isPeerChat) { + const entry = await this._ensureChatSession(context.session, chat); + return entry ? entry.getMessages() : []; + } + + const sessionId = context.sessionId; // Provisional sessions have no SDK history yet. if (this._provisionalSessions.has(sessionId)) { return []; } - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { + const entry = context.target ?? await this._resumeSession(sessionId).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for message lookup`, err); return undefined; }); @@ -1480,7 +2006,7 @@ export class CopilotAgent extends Disposable implements IAgent { // (sendMessage) handles the very first turn when the session is fresh; // this path takes over on subsequent loads, where // _pendingFirstTurnAnnouncements is empty. - const worktreeMeta = await this._readWorktreeMetadata(session).catch(err => { + const worktreeMeta = await this._readWorktreeMetadata(context.session).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to read worktree branch metadata`, err); return undefined; }); @@ -1490,9 +2016,38 @@ export class CopilotAgent extends Disposable implements IAgent { return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(worktreeMeta.branchName)); } + async getSubagentSessions(session: URI): Promise { + // Only the root SDK session entry owns the event log; peer-chat and + // subagent URIs are derived from it and have no subagents of their own. + const chatInfo = parseChatUri(session); + if (chatInfo && !isDefaultChatUri(session)) { + return []; + } + if (parseSubagentSessionUri(session)) { + return []; + } + const sessionId = AgentSession.id(session); + // Provisional sessions have no SDK history (and thus no subagents) yet. + if (this._provisionalSessions.has(sessionId)) { + return []; + } + const entry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(err => { + this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for subagent lookup`, err); + return undefined; + }); + return entry ? entry.getSubagentSessions() : []; + } + async disposeSession(session: URI): Promise { const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { + // Resolve the workspace-less scratch dir (if any) before deleting, so we + // can reap it afterwards. A provisional workspace-less chat carries its state + // in memory; a materialized/restored one persists `workspaceless` metadata. + const provisional = this._provisionalSessions.get(sessionId); + const isWorkspaceless = provisional + ? provisional.workspaceless === true + : (await this._readSessionMetadata(session).catch(() => undefined))?.workspaceless === true; // Remove the session from the SDK's on-disk store first so it doesn't reappear in `listSessions()` after a // restart, and so that any final persist triggered by in-memory teardown can't recreate it. Provisional // sessions were never persisted, so there is nothing to delete on the SDK side. @@ -1501,6 +2056,9 @@ export class CopilotAgent extends Disposable implements IAgent { await client.deleteSession(sessionId); } await this._destroyAndDisposeSession(sessionId); + if (isWorkspaceless) { + await this._cleanupWorkspacelessScratchDir(this._workspacelessScratchDir(sessionId), sessionId); + } }); } @@ -1588,107 +2146,202 @@ export class CopilotAgent extends Disposable implements IAgent { } } - async abortSession(session: URI, chat?: URI): Promise { - if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.abort(); + private async _abortSession(chat: URI): Promise { + const context = this._getChatContext(chat); + if (context.isPeerChat) { + await context.target?.abort(); return; } - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const entry = this._sessions.get(sessionId); - if (entry) { - await entry.abort(); - } + await this._sessionSequencer.queue(context.sessionId, async () => { + await this._getChatContext(chat).target?.abort(); }); } - async createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise { + private async _createChat(chat: URI, options?: IAgentCreateChatOptions): Promise { if (isDefaultChatUri(chat)) { return; } + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`[Copilot] createChat: malformed chat URI ${chat.toString()}`); + } + const session = URI.parse(parsed.session); const chatKey = chat.toString(); - if (this._chatSessions.has(chatKey)) { - return; + if (this._sessions.get(AgentSession.id(session))?.hasPeerChat(chatKey)) { + // Already live: hand back the existing backing so the orchestrator + // re-persists a consistent blob for an idempotent create. + const existing = this._chatBackings.get(chatKey); + return existing ? { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) } : undefined; } const sessionId = AgentSession.id(session); + let result: IAgentCreateChatResult | undefined; await this._sessionSequencer.queue(sessionId, async () => { // Re-check inside the per-session sequencer: the outer `has` check // above is only a fast early-out. If two `createChat` calls for the // same chat URI race, both can pass that outer check; the sequencer // serializes them, so the second task must re-check here to avoid - // overwriting (and disposing) the conversation the first one set. - if (this._chatSessions.has(chatKey)) { + // overwriting (and disposing) the chat the first one set. + if (this._sessions.get(sessionId)?.hasPeerChat(chatKey)) { + const existing = this._chatBackings.get(chatKey); + result = existing ? { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) } : undefined; return; } + const model = options?.model; // Resolve the owning session so the new chat inherits its working // directory scope. The parent may be provisional (no SDK session // yet); in that case use its provisional working directory. - const parentEntry = this._sessions.get(sessionId); + const parentEntry = this._findAnySession(sessionId); const workingDirectory = parentEntry?.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; const client = await this._ensureClient(); const chatSdkId = generateUuid(); // Peer chats share the owning session's ActiveClient so that // client tool / customization updates (which are keyed by the - // session URI via `setClientTools` / `setClientCustomizations`) - // reach the additional chat's SDK conversation. Keying it by the - // chat URI instead would snapshot empty/stale tools and never see - // subsequent updates, and would also leak (nothing disposes a - // chat-keyed ActiveClient). + // session URI via the active-client handles) reach the additional + // chat's SDK chat. Keying it by the chat URI instead would + // snapshot empty/stale tools and never see subsequent updates, and + // would also leak (nothing disposes a chat-keyed ActiveClient). const activeClient = this._getOrCreateActiveClient(session, workingDirectory); const snapshot = await activeClient.snapshot(); const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); - const launchPlan: CopilotSessionLaunchPlan = { - kind: 'create', - client, - sessionId: chatSdkId, - workingDirectory, - resolvedAgentName: undefined, - snapshot, - activeClientState: activeClient.state, - shellManager, - githubToken: this._githubToken, - model: options?.model, - }; + + // Forking: mint the new chat's backing chat by forking the + // source chat's SDK session at the requested turn (copying its + // database into the new chat's data dir), then resume it. Otherwise + // spin up a fresh empty chat. + let launchPlan: CopilotSessionLaunchPlan; + let sdkSessionId: string; + if (options?.fork) { + if (!workingDirectory) { + throw new Error(`[Copilot] createChat fork: missing working directory for session ${session.toString()}`); + } + const sourceEntry = await this._resolveChatEntry(session, options.fork.source); + if (!sourceEntry) { + throw new Error(`[Copilot] createChat fork: source chat ${options.fork.source.toString()} not found`); + } + sdkSessionId = await this._forkSdkChat(client, sourceEntry, options.fork.turnId, this._sessionDataService.getSessionDataDir(chat)); + launchPlan = { + kind: 'resume', + client, + sessionId: sdkSessionId, + workingDirectory, + resolvedAgentName: undefined, + snapshot, + activeClientToolSet: activeClient.toolSet, + shellManager, + githubToken: this._githubToken, + fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, + }; + } else { + sdkSessionId = chatSdkId; + launchPlan = { + kind: 'create', + client, + sessionId: chatSdkId, + workingDirectory, + resolvedAgentName: undefined, + snapshot, + activeClientToolSet: activeClient.toolSet, + shellManager, + githubToken: this._githubToken, + model, + longContextWindow: this._longContextWindowFor(model?.id), + freeLongContext: this._isFreeLongContext(model?.id), + }; + } let agentSession: CopilotAgentSession | undefined; try { agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, chat); await agentSession.initializeSession(); - this._chatSessions.set(chatKey, agentSession); - const parsed = parseChatUri(chat); - if (parsed) { - const persisted = await this._readPersistedChats(session); - persisted.set(parsed.chatId, { sdkSessionId: chatSdkId, ...(options?.model ? { model: options.model } : {}) }); - await this._writePersistedChats(session, persisted); + if (options?.fork?.turnIdMapping) { + await agentSession.remapTurnIds(options.fork.turnIdMapping); } - this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}`); + this._ensureEntry(sessionId).registerPeerChat(chatKey, new CopilotSessionEntry(agentSession)); + // Record the live backing and hand the opaque blob back to the + // orchestrator to persist. The agent no longer owns a durable + // peer-chat catalog (`copilot.chats` is no longer written). + const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}) }; + this._chatBackings.set(chatKey, backing); + result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) }; + this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`); } catch (error) { agentSession?.dispose(); throw error; } }); + return result; } - async disposeChat(session: URI, chat: URI): Promise { + /** + * Resolves the {@link CopilotAgentSession} backing a chat URI — the + * session's default chat (keyed by session id) or an additional peer chat + * (keyed by the chat URI) — resuming it from disk if necessary. + */ + private async _resolveChatEntry(session: URI, chatUri: URI): Promise { + const sessionId = AgentSession.id(session); + if (isDefaultChatUri(chatUri) || isEqual(chatUri, session)) { + return this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); + } + return this._ensureChatSession(session, chatUri); + } + + /** + * Forks {@link sourceEntry}'s SDK chat at {@link turnId} via the + * SDK `sessions.fork` RPC and copies its database into {@link targetDbDir} + * so the forked chat inherits turn event IDs and file-edit + * snapshots. Returns the new SDK session id. + */ + private async _forkSdkChat(client: CopilotClient, sourceEntry: CopilotAgentSession, turnId: string, targetDbDir: URI): Promise { + // toEventId is exclusive — events before it are included. If there's no + // next turn, omit it to include all events. + const toEventId = await sourceEntry.getNextTurnEventId(turnId); + const forkResult = await client.rpc.sessions.fork({ + sessionId: sourceEntry.sessionId, + ...(toEventId ? { toEventId } : {}), + }); + const newSessionId = forkResult.sessionId; + + // VACUUM INTO is safe even while the source DB is open. + const targetDbPath = URI.joinPath(targetDbDir, SESSION_DB_FILENAME); + try { + const sourceDbRef = await this._sessionDataService.tryOpenDatabase(sourceEntry.sessionUri); + if (sourceDbRef) { + try { + await fs.mkdir(targetDbDir.fsPath, { recursive: true }); + await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); + } finally { + sourceDbRef.dispose(); + } + } + } catch (err) { + this._logService.warn(`[Copilot] Failed to copy session database for chat fork: ${err instanceof Error ? err.message : String(err)}`); + } + return newSessionId; + } + + private async _disposeChat(session: URI, chat: URI): Promise { if (isDefaultChatUri(chat)) { return; } const chatKey = chat.toString(); - // Resolve the chat's backing SDK conversation id — from the in-memory - // session if present, otherwise from the persisted catalog — so we can - // delete it from the SDK's on-disk store. Without this a fresh process - // could re-resume an orphaned conversation that no longer has a catalog - // entry. Best-effort: a missing id still drops the catalog entry below. - const parsed = parseChatUri(chat); - let sdkSessionId = this._chatSessions.get(chatKey)?.sessionId; - if (parsed) { - const persisted = await this._readPersistedChats(session); - sdkSessionId ??= persisted.get(parsed.chatId)?.sdkSessionId; - if (persisted.delete(parsed.chatId)) { - await this._writePersistedChats(session, persisted); + // Resolve the chat's backing SDK chat id — from the in-memory + // session, the live backing map, or (for legacy sessions) a one-time + // read of the agent's pre-orchestrator catalog — so we can delete it + // from the SDK's on-disk store. Without this a fresh process could + // re-resume an orphaned chat. The durable peer-chat catalog is + // owned by the orchestrator now, so this no longer rewrites + // `copilot.chats`; it only drops the live backing and SDK chat. + let sdkSessionId = this._findPeerChat(session, chat)?.sessionId + ?? this._chatBackings.get(chatKey)?.sdkSessionId; + if (!sdkSessionId) { + const parsed = parseChatUri(chat); + if (parsed) { + const persisted = await this._readPersistedChats(session); + sdkSessionId = persisted.get(parsed.chatId)?.sdkSessionId; } } - this._chatSessions.deleteAndDispose(chatKey); + this._chatBackings.delete(chatKey); + this._sessions.get(AgentSession.id(session))?.disposePeerChat(chatKey); if (sdkSessionId) { try { const client = await this._ensureClient(); @@ -1700,29 +2353,93 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Returns the catalog of additional (non-default) peer chats persisted for a - * session, as `ahp-chat` channel URIs. Used by the agent service to - * re-register peer chats (and seed their history) when a session is restored - * after a process restart. + * Re-attaches the in-memory backing for a peer chat on session restore, + * decoding the opaque `providerData` the orchestrator persisted at creation + * (or the latest {@link onDidChangeChatData}). After this resolves + * the chat's backing SDK chat can be resumed lazily via + * {@link _ensureChatSession}. When `providerData` is `undefined` (a legacy + * session persisted before the orchestrator owned the catalog) the agent + * falls back to a one-time read of its own `copilot.chats` blob. Best-effort + * — a corrupt/unknown blob is logged and dropped rather than thrown. + */ + async materializeChat(chat: URI, providerData: string | undefined): Promise { + if (isDefaultChatUri(chat)) { + return; + } + const chatInfo = parseChatUri(chat); + if (!chatInfo) { + return; + } + const chatKey = chat.toString(); + let backing: IPersistedChat | undefined; + if (providerData !== undefined) { + backing = decodeProviderData(providerData); + if (!backing) { + this._logService.warn(`[Copilot] materializeChat: dropping corrupt providerData for ${chatKey}`); + return; + } + } else { + // Legacy fallback: consult the agent's own pre-orchestrator catalog + // once to recover the backing for sessions persisted before + // `providerData` existed. + const persisted = await this._readPersistedChats(URI.parse(chatInfo.session)); + backing = persisted.get(chatInfo.chatId); + if (!backing) { + return; + } + } + this._chatBackings.set(chatKey, backing); + } + + /** + * Migration-only enumeration of the session's peer chats from the agent's + * legacy `copilot.chats` catalog, mapping each entry to its channel URI and + * the same opaque `providerData` blob {@link materializeChat} + * decodes. The orchestrator calls this once to drain legacy chats into its + * own catalog. */ - async getChats(session: URI): Promise { + async listLegacyChats(session: URI): Promise { const persisted = await this._readPersistedChats(session); - const result: URI[] = []; - for (const chatId of persisted.keys()) { - result.push(URI.parse(buildChatUri(session.toString(), chatId))); + const result: IAgentLegacyChat[] = []; + for (const [chatId, info] of persisted) { + result.push({ uri: URI.parse(buildChatUri(session, chatId)), providerData: encodeProviderData(info) }); } return result; } + /** + * Resolves the live backing for a peer chat from the in-memory + * {@link _chatBackings} map, falling back once to the agent's legacy + * `copilot.chats` catalog (seeding the live map) for sessions that have not + * been materialized via {@link materializeChat}. + */ + private async _resolveChatBacking(session: URI, chat: URI): Promise { + const chatKey = chat.toString(); + const live = this._chatBackings.get(chatKey); + if (live) { + return live; + } + const parsed = parseChatUri(chat); + if (!parsed) { + return undefined; + } + const persisted = await this._readPersistedChats(session); + const info = persisted.get(parsed.chatId); + if (info) { + this._chatBackings.set(chatKey, info); + } + return info; + } + /** * Returns the SDK-backed {@link CopilotAgentSession} for an additional peer - * chat, resuming its persisted SDK conversation if it is not already in + * chat, resuming its backing SDK chat if it is not already in * memory (e.g. after a process restart). Returns `undefined` when the chat - * has no persisted backing conversation. + * has no known backing chat. */ private async _ensureChatSession(session: URI, chat: URI): Promise { const chatKey = chat.toString(); - const existing = this._chatSessions.get(chatKey); + const existing = this._findPeerChat(session, chat); if (existing) { return existing; } @@ -1732,16 +2449,15 @@ export class CopilotAgent extends Disposable implements IAgent { } const sessionId = AgentSession.id(session); return this._sessionSequencer.queue(sessionId, async () => { - const again = this._chatSessions.get(chatKey); + const again = this._findPeerChat(session, chat); if (again) { return again; } - const persisted = await this._readPersistedChats(session); - const info = persisted.get(parsed.chatId); + const info = await this._resolveChatBacking(session, chat); if (!info) { return undefined; } - const parentEntry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); + const parentEntry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); const workingDirectory = parentEntry?.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; if (!workingDirectory) { @@ -1759,16 +2475,16 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, resolvedAgentName: undefined, snapshot, - activeClientState: activeClient.state, + activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model: info.model }, + fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) }, }; let agentSession: CopilotAgentSession | undefined; try { agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, chat); await agentSession.initializeSession(); - this._chatSessions.set(chatKey, agentSession); + this._ensureEntry(sessionId).registerPeerChat(chatKey, new CopilotSessionEntry(agentSession)); this._logService.info(`[Copilot] Resumed additional chat ${chatKey} in session ${session.toString()}`); return agentSession; } catch (error) { @@ -1788,7 +2504,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); // Ensure the session is loaded so we can use the SDK RPC - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); + const entry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId); // Look up the SDK event ID for the truncation boundary. // The protocol semantics: turnId is the last turn to KEEP. @@ -1812,61 +2528,63 @@ export class CopilotAgent extends Disposable implements IAgent { }); } - async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { - // Additional (non-default) chats are backed by their own SDK - // conversation tracked in `_chatSessions`; apply the change there and - // skip the session-level metadata store (peer chats are not persisted - // per-chat). - if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + private async _changeModel(chat: URI, model: ModelSelection): Promise { + const longContextWindow = this._longContextWindowFor(model.id); + const freeLongContext = this._isFreeLongContext(model.id); + const context = this._getChatContext(chat); + if (context.isPeerChat) { + await context.target?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow, freeLongContext)); + const backing = this._chatBackings.get(context.chatKey); + if (backing) { + const updated: IPersistedChat = { sdkSessionId: backing.sdkSessionId, model }; + this._chatBackings.set(context.chatKey, updated); + this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) }); + } return; } - const sessionId = AgentSession.id(session); - const provisional = this._provisionalSessions.get(sessionId); + const provisional = this._provisionalSessions.get(context.sessionId); if (provisional) { provisional.model = model; return; } - const entry = this._sessions.get(sessionId); + const entry = context.target; if (entry) { - await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow, freeLongContext)); } - await this._storeSessionMetadata(session, model, undefined, undefined, undefined); + await this._storeSessionMetadata(context.session, model, undefined, undefined, undefined); } - async changeAgent(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise { - // Additional (non-default) chats own their SDK conversation in - // `_chatSessions`. Apply the agent to that conversation (resolving the - // URI → SDK name against its own applied snapshot) and skip the - // session-level metadata store. - if (chat && !isDefaultChatUri(chat)) { - const chatEntry = this._chatSessions.get(chat.toString()); - if (chatEntry) { - const resolvedAgentName = agent ? await this._resolveAgentName(session, chatEntry.appliedSnapshot, agent) : undefined; - await chatEntry.setAgent(resolvedAgentName); + private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise { + const context = this._getChatContext(chat); + if (context.isPeerChat) { + if (context.target) { + const resolvedAgentName = agent ? await this._resolveAgentName(context.session, context.target.appliedSnapshot, agent) : undefined; + await context.target.setAgent(resolvedAgentName); } return; } - const sessionId = AgentSession.id(session); - const provisional = this._provisionalSessions.get(sessionId); + const provisional = this._provisionalSessions.get(context.sessionId); if (provisional) { provisional.agent = agent; return; } - const entry = this._sessions.get(sessionId); + const entry = context.target; if (entry) { // Resolve the URI → SDK name from the session's currently-applied // plugin snapshot. If the agent is no longer present (plugin // removed, never loaded), pass `undefined` so the SDK clears its // selection rather than silently keeping the previous one. - const resolvedAgentName = agent ? await this._resolveAgentName(session, entry.appliedSnapshot, agent) : undefined; + const resolvedAgentName = agent ? await this._resolveAgentName(context.session, entry.appliedSnapshot, agent) : undefined; await entry.setAgent(resolvedAgentName); } - await this._storeSessionAgentMetadata(session, agent); + await this._storeSessionAgentMetadata(context.session, agent); } async shutdown(): Promise { this._shutdownPromise ??= (async () => { + // Cancel any pending model-refresh retry so its timer cannot fire + // after teardown and resurrect the client. + this._modelRefreshRetry.clear(); this._logService.info('[Copilot] Shutting down...'); const sessionIds = new Set([...this._sessions.keys(), ...this._createdWorktrees.keys()]); for (const sessionId of sessionIds) { @@ -1874,32 +2592,29 @@ export class CopilotAgent extends Disposable implements IAgent { } await this._client?.stop(); this._client = undefined; + // Release the BYOK proxy handle only after the runtime subprocess is + // gone, mirroring `_stopClient` and the proxy ownership invariant. + await this._sessionLauncher.disposeByokProxyHandle(); })(); return this._shutdownPromise; } respondToPermissionRequest(requestId: string, approved: boolean): void { - for (const [, session] of this._sessions) { - if (session.respondToPermissionRequest(requestId, approved)) { - return; - } - } - for (const [, chat] of this._chatSessions) { - if (chat.respondToPermissionRequest(requestId, approved)) { - return; + for (const entry of this._sessions.values()) { + for (const chat of entry.allChatSessions()) { + if (chat.respondToPermissionRequest(requestId, approved)) { + return; + } } } } respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): void { - for (const [, session] of this._sessions) { - if (session.respondToUserInputRequest(requestId, response, answers)) { - return; - } - } - for (const [, chat] of this._chatSessions) { - if (chat.respondToUserInputRequest(requestId, response, answers)) { - return; + for (const entry of this._sessions.values()) { + for (const chat of entry.allChatSessions()) { + if (chat.respondToUserInputRequest(requestId, response, answers)) { + return; + } } } } @@ -1916,15 +2631,21 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- helpers ------------------------------------------------------------ /** - * Disposes every peer chat (tracked in {@link _chatSessions}) whose - * owning session matches `sessionId`. The chat URI encodes its parent - * session, so we recover it via {@link parseChatUri}. + * Disposes every peer chat hosted on the owning session's entry and drops + * their live backings from {@link _chatBackings}. The chat URI encodes its + * parent session, so we recover it via {@link parseChatUri}. */ private _disposeChildChats(sessionId: string): void { - for (const chatKey of [...this._chatSessions.keys()]) { + const entry = this._sessions.get(sessionId); + if (entry) { + for (const chatKey of entry.peerChatKeys()) { + entry.disposePeerChat(chatKey); + } + } + for (const chatKey of [...this._chatBackings.keys()]) { const parsed = parseChatUri(URI.parse(chatKey)); if (parsed && AgentSession.id(parsed.session) === sessionId) { - this._chatSessions.deleteAndDispose(chatKey); + this._chatBackings.delete(chatKey); } } } @@ -1951,11 +2672,13 @@ export class CopilotAgent extends Disposable implements IAgent { */ private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, channelUri?: URI): CopilotAgentSession { const sessionUri = channelUri ?? AgentSession.uri(this.id, launchPlan.sessionId); + const chatChannelUri = channelUri ?? URI.parse(buildDefaultChatUri(sessionUri)); const agentSession = this._instantiationService.createInstance( CopilotAgentSession, { sessionUri, + chatChannelUri, rawSessionId: launchPlan.sessionId, onDidSessionProgress: this._onDidSessionProgress, sessionLauncher: this._sessionLauncher, @@ -1964,13 +2687,16 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory: launchPlan.workingDirectory, customizationDirectory, clientSnapshot: launchPlan.snapshot, - activeClientState: launchPlan.activeClientState, + activeClientToolSet: launchPlan.activeClientToolSet, resolveMcpChildId: name => findMcpChildId(activeClient.pluginController.getCustomizations(), name), serverToolHost: this._serverToolHost, }, ); - this._mcpNotificationSubs.set(launchPlan.sessionId, agentSession.onMcpNotification(n => this._onMcpNotification.fire(n))); + this._mcpNotificationSubs.set(launchPlan.sessionId, combinedDisposable( + agentSession.onMcpNotification(n => this._onMcpNotification.fire(n)), + autorun(r => activeClient.pluginController.mcpServerStates.set(agentSession.mcpServerStates.read(r), undefined)), + )); return agentSession; } @@ -1991,12 +2717,22 @@ export class CopilotAgent extends Disposable implements IAgent { agentSession.dispose(); throw new CancellationError(); } - this._sessions.set(sessionId, agentSession); + // Reuse an existing entry (which may already host peer chats created + // while the default chat was still provisional) rather than replacing + // it, which would dispose those peers. The default chat is seeded into + // the entry's uniform chat map keyed by its default-chat URI. + const defaultChatKey = buildDefaultChatUri(agentSession.sessionUri.toString()); + let entry = this._sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + this._sessions.set(sessionId, entry); + } + entry.setDefaultChat(defaultChatKey, new CopilotSessionEntry(agentSession)); } private async _destroyAndDisposeSession(sessionId: string): Promise { // Tear down any peer chats owned by this session first so their SDK - // conversations don't leak when the parent is deleted/disposed + // chats don't leak when the parent is deleted/disposed // without each chat being individually disposed via `disposeChat`. this._disposeChildChats(sessionId); // Provisional sessions have no SDK session, no worktree, and no @@ -2006,11 +2742,14 @@ export class CopilotAgent extends Disposable implements IAgent { const provisional = this._provisionalSessions.get(sessionId); if (provisional) { this._provisionalSessions.delete(sessionId); + // Drop any peer-host entry created for this still-provisional + // session (its peers were disposed by `_disposeChildChats` above). + this._sessions.deleteAndDispose(sessionId); this._activeClients.get(provisional.sessionUri)?.dispose(); this._activeClients.delete(provisional.sessionUri); return; } - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); const sessionUri = AgentSession.uri(this.id, sessionId); if (entry) { try { @@ -2048,12 +2787,6 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionUri = AgentSession.uri(this.id, sessionId); const storedMetadata = await this._readSessionMetadata(sessionUri); - const customizationDirectory = storedMetadata.customizationDirectory ?? storedMetadata.workingDirectory; - // Always create an ActiveClient so the snapshot includes host + - // session-discovered customizations, even when no client has called - // `setClientCustomizations` / `setClientTools` yet. - const activeClient = this._getOrCreateActiveClient(sessionUri, customizationDirectory); - const snapshot = await activeClient.snapshot(); const sessionMetadata = await client.getSessionMetadata(sessionId).catch(err => { this._logService.warn(`[Copilot:${sessionId}] getSessionMetadata failed`, err); return undefined; @@ -2062,6 +2795,23 @@ export class CopilotAgent extends Disposable implements IAgent { if (!workingDirectory) { throw new Error(`workingDirectory is required to resume Copilot session '${sessionId}'`); } + // A workspace-less chat's working directory is a stable per-session scratch dir + // that may have been reaped (OS temp cleanup, reboot) while the session + // persisted. Recreate it (mkdir -p) so shell/git/scratch ops don't fail. + if (storedMetadata.workspaceless) { + await this._ensureWorkspacelessScratchDir(workingDirectory, sessionId); + } + // Anchor customization discovery to the working directory (the worktree for + // worktree-isolated sessions), matching how the session was materialized. + // Older sessions persisted `customizationDirectory` as the user-picked + // folder; preferring the working directory corrects them on resume. + const customizationDirectory = workingDirectory; + // Always create an ActiveClient so the snapshot includes host + + // session-discovered customizations, even when no client has + // registered an active-client handle yet. + const activeClient = this._getOrCreateActiveClient(sessionUri, customizationDirectory); + activeClient.pluginController.reanchor(customizationDirectory); + const snapshot = await activeClient.snapshot(); const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); const resolvedAgentName = storedMetadata.agent ? await this._resolveAgentName(sessionUri, snapshot, storedMetadata.agent) : undefined; @@ -2072,11 +2822,14 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, resolvedAgentName, snapshot, - activeClientState: activeClient.state, + activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, + workspaceless: storedMetadata.workspaceless, fallback: { model: storedMetadata.model, + longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), + freeLongContext: this._isFreeLongContext(storedMetadata.model?.id), }, }; @@ -2093,12 +2846,19 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string; defaultBranch: string } | undefined> { - if (!await this._gitService.isInsideWorkTree(workingDirectory)) { + const repositoryRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { return undefined; } - const currentBranch = await this._gitService.getCurrentBranch(workingDirectory) ?? 'HEAD'; - const defaultBranch = await this._gitService.getDefaultBranch(workingDirectory) ?? currentBranch; + // Skip worktree isolation for a repo with no commits yet (unborn HEAD); `git worktree add` would fail. + const headCommit = await this._gitService.revParse(repositoryRoot, 'HEAD').catch(() => undefined); + if (!headCommit) { + return undefined; + } + + const currentBranch = await this._gitService.getCurrentBranch(repositoryRoot) ?? 'HEAD'; + const defaultBranch = await this._gitService.getDefaultBranch(repositoryRoot) ?? currentBranch; return { currentBranch, defaultBranch }; } @@ -2117,7 +2877,15 @@ export class CopilotAgent extends Disposable implements IAgent { } const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); - const branchName = await this._branchNameGenerator.generateBranchName({ sessionId, message: prompt, githubToken: this._githubToken }); + const branchName = await this._branchNameGenerator.generateBranchName({ + sessionId, + message: prompt, + githubToken: this._githubToken, + // Treat a failed existence check as a collision so we fall back to a + // suffixed branch name rather than risk `addWorktree` failing because + // the branch already exists. + branchExists: branchName => this._gitService.branchExists(repositoryRoot, branchName).catch(() => true), + }); const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); const baseBranch = typeof config.config[SessionConfigKey.Branch] === 'string' ? config.config[SessionConfigKey.Branch] as string : undefined; @@ -2168,10 +2936,13 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_CHATS = 'copilot.chats'; /** - * Reads the persisted peer-chat catalog for a session. Each entry maps a - * chatId (the `ahp-chat` authority) to the SDK conversation that backs it - * (and its optional model override), so the chat can be resumed after a - * restart even though {@link _chatSessions} is empty in a fresh process. + * Reads the agent's legacy peer-chat catalog (`copilot.chats`) for a + * session. Each entry maps a chatId (the `ahp-chat` authority) to the SDK + * chat that backs it (and its optional model override). The agent + * no longer *writes* this catalog — the orchestrator owns the durable + * peer-chat catalog via `providerData` — but the read is retained for one + * release to drain sessions persisted before that migration (see + * {@link getChats} and {@link materializeChat}). */ private async _readPersistedChats(session: URI): Promise> { const ref = await this._sessionDataService.tryOpenDatabase(session); @@ -2207,23 +2978,6 @@ export class CopilotAgent extends Disposable implements IAgent { } } - /** Writes the persisted peer-chat catalog for a session. */ - private async _writePersistedChats(session: URI, chats: Map): Promise { - const dbRef = this._sessionDataService.openDatabase(session); - try { - // Use a null-prototype object: chatIds derive from a client-chosen - // chat URI authority, so a value like `__proto__` would otherwise - // pollute the prototype / corrupt the serialized payload. - const obj: Record = Object.create(null); - for (const [chatId, info] of chats) { - obj[chatId] = info; - } - await dbRef.object.setMetadata(CopilotAgent._META_CHATS, JSON.stringify(obj)); - } finally { - dbRef.dispose(); - } - } - private async _writeWorktreeMetadata(session: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise { const dbRef = this._sessionDataService.openDatabase(session); @@ -2291,36 +3045,38 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI }> { + private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; workspaceless?: boolean }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; } try { - const [model, agent, cwd, customizationDirectory] = await Promise.all([ + const [model, agent, cwd, customizationDirectory, workspaceless] = await Promise.all([ ref.object.getMetadata(CopilotAgent._META_MODEL), ref.object.getMetadata(CopilotAgent._META_AGENT), ref.object.getMetadata(CopilotAgent._META_CWD), ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), + ref.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY), ]); return { model: this._parseModelSelection(model), agent: this._parseAgentSelection(agent), workingDirectory: cwd ? URI.parse(cwd) : undefined, customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, + workspaceless: workspaceless === 'true', }; } finally { ref.dispose(); } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean; workspaceless?: boolean } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; } try { - const [model, agent, cwd, customizationDirectory, resolved, uri, displayName] = await Promise.all([ + const [model, agent, cwd, customizationDirectory, resolved, uri, displayName, workspaceless] = await Promise.all([ ref.object.getMetadata(CopilotAgent._META_MODEL), ref.object.getMetadata(CopilotAgent._META_AGENT), ref.object.getMetadata(CopilotAgent._META_CWD), @@ -2328,6 +3084,7 @@ export class CopilotAgent extends Disposable implements IAgent { ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED), ref.object.getMetadata(CopilotAgent._META_PROJECT_URI), ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME), + ref.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY), ]); const workingDirectory = cwd ? URI.parse(cwd) : undefined; const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined; @@ -2338,6 +3095,7 @@ export class CopilotAgent extends Disposable implements IAgent { customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, project, resolved: resolved === 'true' || project !== undefined, + workspaceless: workspaceless === 'true', }; } finally { ref.dispose(); @@ -2551,10 +3309,10 @@ export function toDiscoveredDirectoryCustomizations(directories: readonly IDisco type: CustomizationType.Directory, id: customizationId(protocolUri), uri: protocolUri, - name: resourceBasename(directory.uri), + name: directory.name, enabled: true, contents: toDirectoryContentsType(directory.type), - writable: false, + writable: directory.writable, // whether the new customization can be created in this directory load: { kind: CustomizationLoadStatus.Loaded }, children: await Promise.all(directory.files.map(file => toDiscoveredChildCustomization(file.uri, directory.type, fileService))), }; @@ -2732,6 +3490,7 @@ class PluginController extends Disposable { @IFileService public readonly fileService: IFileService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IInstantiationService public readonly instantiationService: IInstantiationService, + @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, ) { super(); @@ -2759,8 +3518,8 @@ class PluginController extends Disposable { return this._hostSync; } - public getUserHome(): string { - return process.env['HOME'] ?? process.env['USERPROFILE'] ?? ''; + public getUserHome(): URI { + return this._environmentService.userHome; } /** @@ -2867,6 +3626,22 @@ class PluginController extends Disposable { } } +/** + * Per-client slice of {@link SessionPluginController} customization state. + * One entry exists per active client that has contributed customizations to + * the session. + */ +interface IClientCustomizationState { + /** Monotonic revision used to detect and ignore stale in-flight syncs for this client. */ + revision: number; + /** This client's resolved customizations (Loading/Loaded/Error per item). */ + customizations: readonly IResolvedCustomization[]; + /** This client's in-flight (or settled) sync promise. */ + sync: Promise; + /** The raw inputs last passed to {@link SessionPluginController.sync} for this client. */ + inputs: readonly ClientPluginCustomization[]; +} + /** * Per-session view over {@link PluginController}. * @@ -2887,11 +3662,24 @@ class SessionPluginController extends Disposable { readonly onDidPublish = this._onDidPublish.event; private readonly _enablement = new Map(); - private _clientCustomizations: readonly IResolvedCustomization[] = []; - private _clientSync: Promise = Promise.resolve([]); - private _clientRevision = 0; - /** Last clientId seen via {@link sync}; used by {@link retryFailedClientSyncIfNeeded}. */ - private _clientId: string | undefined; + /** + * Live runtime state (`state`/`channel`) per MCP server customization id, + * kept up to date by the owning session from its MCP controller. Overlaid + * onto published customizations by {@link _overlayMcpState} so a re-sync + * preserves the live state of otherwise-unchanged MCP servers instead of + * resetting them to the `Starting` default baked into + * `makeMcpServerCustomization`. Exposed (not injected) so the session can + * write to it once it holds this controller. + */ + public readonly mcpServerStates: ISettableObservable> = observableValue(this, new Map()); + /** + * Per-client customization state, keyed by `clientId`. Each active client + * contributing customizations to this session has one entry; the published + * customization list is the union across all entries (deduplicated by URI, + * first-inserted client wins). Insertion order is preserved so the merged + * order stays stable across updates. + */ + private readonly _clients = new Map(); private readonly _sessionDiscovered: MutableDisposable = this._register(new MutableDisposable()); @@ -2919,22 +3707,69 @@ class SessionPluginController extends Disposable { this._directory = directory; } + /** + * Move the session's customization anchor to a new directory (e.g. from the + * user-picked folder to the worktree at materialization). Recreates the + * discovered entry so discovery/watchers re-scan the new directory, and + * rebases per-session enablement overrides whose URI lived under the old + * directory so the user's toggles survive the move. + */ + public reanchor(directory: URI): void { + if (this._directory && isEqual(this._directory, directory)) { + return; + } + const previous = this._directory; + this._directory = directory; + this._sessionDiscovered.clear(); + if (previous) { + this._migrateEnablement(previous, directory); + } + } + + private _migrateEnablement(fromDir: URI, toDir: URI): void { + const migrated = migrateEnablementKeys(this._enablement, fromDir, toDir); + this._enablement.clear(); + for (const [uri, enabled] of migrated) { + this._enablement.set(uri, enabled); + } + } + public getCustomizations(): readonly Customization[] { const result: Customization[] = [ - ...this._parent.hostCustomizations().map(item => this._applyEnablement(item.customization)), - ...this._clientCustomizations.map(item => this._applyEnablement(item.customization)), + ...this._parent.hostCustomizations().map(item => this._projectForPublish(item.customization)), + ...this._flattenClientCustomizations().map(item => this._projectForPublish(item.customization)), ]; const entry = this._discoveredEntry(); const discovered = entry?.currentCustomizations() ?? []; for (const customization of discovered) { - result.push(this._applyEnablement(customization)); + result.push(this._projectForPublish(customization)); + } + return result; + } + + /** + * The union of every active client's resolved customizations, + * deduplicated by URI with the first-inserted client winning. Order + * follows client insertion order, then per-client order. + */ + private _flattenClientCustomizations(): readonly IResolvedCustomization[] { + const seen = new Set(); + const result: IResolvedCustomization[] = []; + for (const client of this._clients.values()) { + for (const item of client.customizations) { + if (seen.has(item.customization.uri)) { + continue; + } + seen.add(item.customization.uri); + result.push(item); + } } return result; } /** * Settled variant of {@link getCustomizations}: awaits the in-flight - * host sync, the in-flight client sync, and the discovered entry's + * host sync, every in-flight client sync, and the discovered entry's * initial scan + parse before snapshotting the list. Callers that * publish customizations into session state at session creation time * MUST use this — the synchronous variant can return an empty list @@ -2945,7 +3780,7 @@ class SessionPluginController extends Disposable { const entry = this._discoveredEntry(); await Promise.all([ this._parent.hostSync().catch(err => this._parent.logService.warn('[Copilot:SessionPluginController] Host customization update failed', err)), - this._clientSync.catch(err => this._parent.logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err)), + ...[...this._clients.values()].map(client => client.sync.catch(err => this._parent.logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err))), entry?.whenSettled(), ]); return this.getCustomizations(); @@ -2954,15 +3789,15 @@ class SessionPluginController extends Disposable { /** Returns the parsed plugins currently enabled for this session, awaiting any pending sync. */ public async getAppliedPlugins(): Promise { const entry = this._discoveredEntry(); - const [host, client] = await Promise.all([ + const [host] = await Promise.all([ this._parent.hostSync().catch(err => { this._parent.logService.warn('[Copilot:SessionPluginController] Host customization update failed', err); return this._parent.hostCustomizations(); }), - this._clientSync.catch(err => { + ...[...this._clients.values()].map(client => client.sync.catch(err => { this._parent.logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err); - return this._clientCustomizations; - }), + return client.customizations; + })), entry?.whenSettled(), ]); @@ -2973,7 +3808,7 @@ class SessionPluginController extends Disposable { return [ ...host.filter(item => !!item.plugin && this._isEnabled(item.customization)) .map(item => ({ ...item.plugin!, pluginDir: item.pluginDir })), - ...client.filter(item => !!item.plugin && this._isEnabled(item.customization)) + ...this._flattenClientCustomizations().filter(item => !!item.plugin && this._isEnabled(item.customization)) .map(item => ({ ...item.plugin!, pluginDir: item.pluginDir })), ...sessionPlugins, ]; @@ -2991,7 +3826,10 @@ class SessionPluginController extends Disposable { } /** - * Sync the published client customizations for this session. + * Sync the published customizations for a single client of this session, + * keyed by `clientId`. Replaces only that client's slice; other clients' + * customizations are untouched. The published session-state list is the + * union across all clients. * * @param quiet when `true`, suppress {@link onDidPublish} events for * this sync. Used during eager-create paths where there is no @@ -3000,9 +3838,25 @@ class SessionPluginController extends Disposable { */ public sync(clientId: string, customizations: ClientPluginCustomization[], options?: { quiet?: boolean }) { const quiet = options?.quiet === true; - this._clientId = clientId; - const revision = ++this._clientRevision; - this._clientCustomizations = customizations.map(customization => ({ + let client = this._clients.get(clientId); + if (!client) { + client = { revision: 0, customizations: [], sync: Promise.resolve([]), inputs: [] }; + this._clients.set(clientId, client); + } else if (equals(client.inputs, customizations)) { + // No-op re-sync: a window re-subscribing (e.g. navigating away from + // and back to a session) re-publishes the same customizations. Skip + // the revision bump, the `SessionCustomizationsChanged` emit, and the + // redundant plugin-manager re-sync (which otherwise re-parses plugins + // from disk on every navigation). Genuine changes still publish, and + // `_projectForPublish` keeps live MCP state intact across those. + return client.sync.then(results => results.map(item => ({ + customization: this._projectForPublish(item.customization), + ...(item.pluginDir ? { pluginDir: item.pluginDir } : {}), + }))); + } + const revision = ++client.revision; + client.inputs = customizations; + client.customizations = customizations.map(customization => ({ customization: { ...customization, clientId, @@ -3017,12 +3871,12 @@ class SessionPluginController extends Disposable { }); } const published = new Map(); - for (const customization of this._clientCustomizations) { - const enabled = this._applyEnablement(customization.customization); + for (const customization of client.customizations) { + const enabled = this._projectForPublish(customization.customization); published.set(enabled.uri, enabled); } const publishUpdate = (item: IResolvedCustomization) => { - const customization = this._applyEnablement(item.customization); + const customization = this._projectForPublish(item.customization); if (equals(published.get(customization.uri), customization)) { return; } @@ -3035,13 +3889,13 @@ class SessionPluginController extends Disposable { } }; - const prev = this._clientSync; - const promise = this._clientSync = prev.catch(err => { + const prev = client.sync; + const promise = client.sync = prev.catch(err => { this._parent.logService.warn('[Copilot:SessionPluginController] Previous customization sync failed', err); }).then(async () => { const inputByUri = new Map(customizations.map(c => [c.uri, c])); const result = await this._parent.pluginManager.syncCustomizations(clientId, customizations, status => { - if (revision !== this._clientRevision) { + if (revision !== client.revision) { return; } publishUpdate({ @@ -3051,8 +3905,8 @@ class SessionPluginController extends Disposable { }); const resolved = await Promise.all(result.map(item => this._parent.resolveSyncedCustomization(item, clientId, inputByUri.get(item.customization.uri)))); - if (revision === this._clientRevision) { - this._clientCustomizations = resolved; + if (revision === client.revision) { + client.customizations = resolved; for (const item of resolved) { publishUpdate(item); } @@ -3061,37 +3915,63 @@ class SessionPluginController extends Disposable { }); return promise.then(results => results.map(item => ({ - customization: this._applyEnablement(item.customization), + customization: this._overlayMcpState(this._applyEnablement(item.customization)), ...(item.pluginDir ? { pluginDir: item.pluginDir } : {}), }))); } /** - * Re-issue the last client sync if any previously-synced customization - * is currently in an error state. Used to recover from transient - * sync failures (e.g. a `vscode-agent-host://` connection drop during - * reconnection) at message boundaries. Re-syncs **only** the errored - * items and always non-quiet so listeners observe recovery. + * Remove a client's customization contribution from this session, + * publishing the updated (union) customization list so the removed + * client's plugins disappear from session state. */ - public async retryFailedClientSyncIfNeeded(): Promise { - await this._clientSync.catch(() => { }); - if (!this._clientId) { - return; - } - const errored = this._clientCustomizations.filter(item => - item.customization.load?.kind === CustomizationLoadStatus.Error - && item.input !== undefined - ); - if (errored.length === 0) { + public removeClient(clientId: string): void { + const client = this._clients.get(clientId); + if (!client) { return; } - const inputs = errored.map(item => item.input!); - this._parent.logService.info(`[Copilot:SessionPluginController] Retrying ${inputs.length} previously-failed client customization(s)`); - await this.sync(this._clientId, inputs).catch(err => { - this._parent.logService.warn('[Copilot:SessionPluginController] Retried client customization sync failed', err); + // Invalidate any in-flight sync for this client by bumping its + // revision so the late continuation's `revision === client.revision` + // guards fail and it does not re-publish the removed client's + // customizations. + client.revision++; + this._clients.delete(clientId); + this._onDidPublish.fire({ + type: ActionType.SessionCustomizationsChanged, + customizations: [...this.getCustomizations()], }); } + /** The raw input customizations last synced for `clientId` (empty when absent). */ + public clientInputs(clientId: string): readonly ClientPluginCustomization[] { + return this._clients.get(clientId)?.inputs ?? []; + } + + /** + * Re-issue each client's last sync if any of its previously-synced + * customizations is currently in an error state. Used to recover from + * transient sync failures (e.g. a `vscode-agent-host://` connection drop + * during reconnection) at message boundaries. Re-syncs **only** the + * errored items and always non-quiet so listeners observe recovery. + */ + public async retryFailedClientSyncIfNeeded(): Promise { + await Promise.all([...this._clients.values()].map(client => client.sync.catch(() => { }))); + for (const [clientId, client] of [...this._clients]) { + const errored = client.customizations.filter(item => + item.customization.load?.kind === CustomizationLoadStatus.Error + && item.input !== undefined + ); + if (errored.length === 0) { + continue; + } + const inputs = errored.map(item => item.input!); + this._parent.logService.info(`[Copilot:SessionPluginController] Retrying ${inputs.length} previously-failed client customization(s) for ${clientId}`); + await this.sync(clientId, inputs).catch(err => { + this._parent.logService.warn('[Copilot:SessionPluginController] Retried client customization sync failed', err); + }); + } + } + private _discoveredEntry(): SessionDiscoveredEntry | undefined { if (!this._directory) { return undefined; @@ -3099,7 +3979,7 @@ class SessionPluginController extends Disposable { if (!this._sessionDiscovered.value) { this._sessionDiscovered.value = new SessionDiscoveredEntry( this._directory, - URI.file(this._parent.getUserHome()), + this._parent.getUserHome(), () => this._onDidPublish.fire({ type: ActionType.SessionCustomizationsChanged, customizations: [...this.getCustomizations()], @@ -3119,26 +3999,109 @@ class SessionPluginController extends Disposable { const enabled = this._isEnabled(customization); return customization.enabled === enabled ? customization : { ...customization, enabled }; } + + /** + * Projects a raw customization into its published form: applies the + * user's per-session enablement override, then overlays the latest + * known MCP runtime `state`/`channel` (see {@link mcpServerStates}). + * Every publish path runs customizations through this so enablement and + * live MCP state stay consistent. Object identity is preserved when + * neither step changes anything, keeping downstream equality checks + * stable. + */ + private _projectForPublish(customization: T): T { + return this._overlayMcpState(this._applyEnablement(customization)); + } + + /** + * Overlays the latest known MCP runtime `state`/`channel` (see + * {@link mcpServerStates}) onto a customization and its children, + * preserving object identity when nothing is overlaid so downstream + * equality checks stay stable. + */ + private _overlayMcpState(customization: T): T { + const overlays = this.mcpServerStates.get(); + if (overlays.size === 0) { + return customization; + } + if (customization.type === CustomizationType.McpServer) { + const overlay = overlays.get(customization.id); + return overlay ? { ...customization, state: overlay.state, channel: overlay.channel } : customization; + } + const children = customization.children; + if (!children || children.length === 0) { + return customization; + } + let changed = false; + const overlaidChildren = children.map(child => { + if (child.type !== CustomizationType.McpServer) { + return child; + } + const overlay = overlays.get(child.id); + if (!overlay) { + return child; + } + changed = true; + return { ...child, state: overlay.state, channel: overlay.channel }; + }); + return changed ? { ...customization, children: overlaidChildren } : customization; + } } /** - * Tracks per-session active client contributions (tools and plugins). - * Owns the session's {@link SessionPluginController}, which is the - * authoritative source for both the plugin snapshot (host + client + - * session-discovered) and per-session action events. Disposing this - * tears down the controller and any disk watchers it created. + * A per-(session, clientId) handle returned by + * {@link CopilotAgent.getOrCreateActiveClient}. Reads/writes flow straight + * through to the owning session's {@link ActiveClient} (the multi-client + * container), so assigning `tools` / `customizations` updates only this + * client's slice. + */ +class CopilotActiveClientHandle implements IActiveClient { + constructor( + private readonly _owner: ActiveClient, + readonly clientId: string, + readonly displayName: string | undefined, + ) { } + + get tools(): readonly ToolDefinition[] { + return this._owner.toolSet.get(this.clientId); + } + set tools(tools: readonly ToolDefinition[]) { + this._owner.toolSet.set(this.clientId, tools); + } + + get customizations(): readonly ClientPluginCustomization[] { + return this._owner.pluginController.clientInputs(this.clientId); + } + set customizations(customizations: readonly ClientPluginCustomization[]) { + // Fire-and-forget: progress and the settled result flow out via the + // controller's `onDidPublish` session actions, not the setter. + this._owner.pluginController.sync(this.clientId, [...customizations]).catch(() => { /* logged inside sync */ }); + } +} + +/** + * Tracks per-session active client contributions (tools and plugins) across + * potentially several active clients. Owns the session's + * {@link SessionPluginController}, which is the authoritative source for both + * the plugin snapshot (host + all clients + session-discovered) and + * per-session action events, and the {@link ActiveClientToolSet} that merges + * every client's tools. Disposing this tears down the controller and any disk + * watchers it created. */ class ActiveClient extends Disposable { /** - * Live holder of the owning `clientId` and contributed tools. Shared by - * reference with the session's {@link CopilotAgentSession} so a window - * reload (new `clientId`, identical tools) is reflected at tool-call - * stamp time without restarting the SDK session. + * Live, multi-client registry of contributed tools. Shared by reference + * with the session's {@link CopilotAgentSession} so a window reload (new + * `clientId`, identical tools) is reflected at tool-call stamp time without + * restarting the SDK session, and so tool calls are attributed to the + * contributing client. */ - readonly state = new ActiveClientState(); + readonly toolSet = new ActiveClientToolSet(); public readonly pluginController: SessionPluginController; + private readonly _handles = new Map(); + constructor( private readonly _sessionUri: URI, pluginController: SessionPluginController, @@ -3150,17 +4113,30 @@ class ActiveClient extends Disposable { // Forward per-session publish events into the agent's progress // stream. This replaces the previous clientId-based routing. this._register(this.pluginController.onDidPublish(action => { - onDidSessionProgress.fire({ kind: 'action', session: this._sessionUri, action }); + onDidSessionProgress.fire({ kind: 'action', resource: this._sessionUri, action }); })); } - updateTools(clientId: string | undefined, tools: readonly ToolDefinition[]): void { - this.state.update(clientId, tools); + /** Get (or lazily create) the stable handle for `clientId`. */ + getOrCreateHandle(clientId: string, displayName: string | undefined): CopilotActiveClientHandle { + let handle = this._handles.get(clientId); + if (!handle) { + handle = new CopilotActiveClientHandle(this, clientId, displayName); + this._handles.set(clientId, handle); + } + return handle; + } + + /** Drop a client's tool and customization contributions from this session. */ + removeClient(clientId: string): void { + this._handles.delete(clientId); + this.toolSet.delete(clientId); + this.pluginController.removeClient(clientId); } async snapshot(): Promise { return { - tools: this.state.tools, + tools: this.toolSet.merged(), plugins: await this.pluginController.getAppliedPlugins(), mcpServers: this._getMcpServers(), }; @@ -3175,9 +4151,9 @@ class ActiveClient extends Disposable { /** * Returns `true` when the SDK session must be disposed and resumed to * pick up a changed config. Compares ONLY plugins and the structural - * tool set (name + description + inputSchema). The `clientId` is - * deliberately excluded — a clientId-only change is reflected live via - * {@link state} and never requires a restart. + * (merged) tool set (name + description + inputSchema). The owning + * `clientId`s are deliberately excluded — a clientId-only change is + * reflected live via {@link toolSet} and never requires a restart. */ async requiresRestart(snap: IActiveClientSnapshot): Promise { const plugins = await this.pluginController.getAppliedPlugins(); @@ -3187,6 +4163,6 @@ class ActiveClient extends Disposable { if (!equals(snap.mcpServers, this._getMcpServers())) { return true; } - return !this.state.structuralEquals({ tools: snap.tools }); + return !this.toolSet.structuralEquals(snap.tools); } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index a0c0a765f18ce..d442676d39184 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -4,13 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import type { CopilotSession, ExitPlanModeRequest, MessageOptions, PermissionRequestResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { DeferredPromise, raceTimeout } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; +import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; +import { safeStringify } from '../../../../base/common/objects.js'; import { isAbsolute, join } from '../../../../base/common/path.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; import { splitLinesIncludeSeparators } from '../../../../base/common/strings.js'; @@ -24,37 +26,39 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { platformSessionSchema } from '../../common/agentHostSchema.js'; -import { AgentSignal, IMcpNotification } from '../../common/agentService.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentSignal, AuthenticateParams, IMcpNotification, IRestoredSubagentSession, subagentChatTitle } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; +import { readToolCallMeta, toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; -import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../../telemetry/common/languageModelToolTelemetry.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; +import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import type { CopilotSessionLaunchPlan, IActiveClientSnapshot, ICopilotSessionLauncher, ICopilotSessionRuntime } from './copilotSessionLauncher.js'; -import { ActiveClientState } from '../activeClientState.js'; +import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +import { ActiveClientToolSet } from '../activeClientState.js'; +import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; -import { isPromptInvokedCopilotSlashCommand, isRuntimeCopilotSlashCommand, parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; +import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; -import { buildSandboxConfigForSdk } from './sandboxConfigForSdk.js'; +import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js'; import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; -import { mapSessionEvents } from './mapSessionEvents.js'; +import { appendSdkToolResultContent, mapSessionEvents } from './mapSessionEvents.js'; import { buildPendingEditContentUri } from './pendingEditContentStore.js'; -import { McpServerStatus, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; +import { McpAuthRequiredReason, McpServerStatus, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; +import type { ProtectedResourceMetadata } from '../../common/state/protocol/common/state.js'; /** * The full set of agent modes the Copilot SDK accepts. AHP now exposes the @@ -65,12 +69,32 @@ import { McpServerStatus, type McpServerState } from '../../common/state/protoco export type CopilotSdkMode = 'interactive' | 'plan' | 'autopilot'; type CopilotSdkAttachment = Required['attachments'][number]; type CopilotCommandInvocationResult = Awaited>; -type RuntimeSlashCommandCache = { readonly expiresAt: number; readonly promise: Promise> }; +type RuntimeSlashCommandInfo = Awaited>['commands'][number]; +type McpAuthHandler = NonNullable; +type McpAuthRequest = Parameters[0]; +type McpAuthResult = Awaited>; +type RuntimeSlashCommandCatalog = { + readonly commands: readonly RuntimeSlashCommandInfo[]; + readonly byName: ReadonlyMap; + readonly byAlias: ReadonlyMap; +}; + +interface IPendingMcpAuthRequest { + readonly serverName: string; + readonly resource: ProtectedResourceMetadata; + readonly requiredScopes: readonly string[]; + readonly deferred: DeferredPromise; +} +type RuntimeSlashCommandCache = { + value?: RuntimeSlashCommandCatalog; + inFlight?: Promise; +}; const COPILOT_HOME_DIRECTORY = '.copilot'; const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); const EMPTY_TOOL_RESULT_TEXT = ''; -const RUNTIME_SLASH_COMMAND_CACHE_TTL_MS = 30_000; + +type IMappedSessionEvents = { turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap }; function getEmptyToolResultText(binaryResults: readonly { readonly type: 'image' | 'resource' }[] | undefined): string { if (!binaryResults?.length) { @@ -293,6 +317,7 @@ function isCopilotSdkToolOutputTempFile(filePath: string, tmpDir: string): boole */ export interface ICopilotAgentSessionOptions { readonly sessionUri: URI; + readonly chatChannelUri: URI; readonly rawSessionId: string; readonly onDidSessionProgress: Emitter; readonly sessionLauncher: ICopilotSessionLauncher; @@ -313,19 +338,28 @@ export interface ICopilotAgentSessionOptions { */ readonly resolveMcpChildId: (serverName: string) => string | undefined; /** - * Live holder of the owning client's identity, shared by reference with - * the agent's per-session {@link ActiveClient}. Read at tool-call stamp - * time so a window reload (new `clientId`, identical tools) stamps with - * the current id. When omitted, a fresh state seeded from - * {@link clientSnapshot} is used (test / standalone path). + * Live registry of every active client's tool contributions, shared by + * reference with the agent's per-session {@link ActiveClient}. Read at + * tool-call stamp time so a window reload (new `clientId`, identical + * tools) stamps with the current owning id, and so each tool call is + * attributed to whichever client contributed it. When omitted, a fresh + * empty registry is used (test / standalone path) and client tool calls + * are left unstamped. */ - readonly activeClientState?: ActiveClientState; + readonly activeClientToolSet?: ActiveClientToolSet; /** * Server-side host for the agent host's server tools. When provided, the * session advertises the server tools (feedback "comments" today, more in * the future) and exposes SDK tool handlers that execute them in-process. */ readonly serverToolHost?: IAgentServerToolHost; + + /** + * Platform used to compute the SDK sandbox policy. Defaults to + * `process.platform`; injectable so tests can exercise the per-OS gating + * (notably that the sandbox is ignored on Windows) deterministically. + */ + readonly platform?: NodeJS.Platform; } /** @@ -358,8 +392,14 @@ class CopilotTurn { private _state: CopilotTurnState = 'pending'; - /** Accumulated Copilot usage for this turn, in nano-AIU. */ - copilotUsageTotalNanoAiu = 0; + /** + * Accumulated Copilot usage for this turn, in nano-AIU, keyed by scope. + * Scope `''` is the parent turn aggregate (parent agent calls plus every + * subagent call), so the parent turn's reported cost is the full turn + * total. Each subagent additionally accumulates under its `parentToolCallId` + * so its own component cost can be reported on the subagent's child session. + */ + readonly copilotUsageTotalNanoAiuByScope = new Map(); /** * Current markdown response part IDs for this turn, keyed by @@ -372,7 +412,7 @@ class CopilotTurn { /** Current reasoning response part IDs for this turn, keyed by `parentToolCallId ?? ''`. */ readonly reasoningPartIds = new Map(); - constructor(readonly id: string) { } + constructor(readonly id: string, readonly senderClientId: string | undefined) { } get state(): CopilotTurnState { return this._state; } get isPending(): boolean { return this._state === 'pending'; } @@ -399,13 +439,13 @@ class CopilotTurn { export class CopilotAgentSession extends Disposable { readonly sessionId: string; readonly sessionUri: URI; + private readonly _chatChannelUri: URI; /** Working directory this session operates in, if any. */ get workingDirectory(): URI | undefined { return this._workingDirectory; } /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; startTimeMs: number; mcpServerName: string | undefined; meta: IToolCallMeta | undefined }>(); + private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; mcpServerName: string | undefined; meta: IToolCallMeta | undefined }>(); /** * Maps a running subagent's `agentId` to its parent tool call id. Session- * scoped rather than per-turn: a subagent's lifetime is bounded by its @@ -499,11 +539,13 @@ export class CopilotAgentSession extends Disposable { * subsequent client tool calls with the current id rather than the one * frozen into {@link _appliedSnapshot}. */ - private readonly _activeClientState: ActiveClientState; + private readonly _activeClientToolSet: ActiveClientToolSet; /** Tool names that are client-provided, derived from snapshot. */ private readonly _clientToolNames: ReadonlySet; /** Deferred promises for pending client tool calls, keyed by toolCallId. */ private readonly _pendingClientToolCalls = new PendingRequestRegistry(); + /** Pending SDK MCP auth handler promises, keyed by SDK auth request id. */ + private readonly _pendingMcpAuthRequests = new Map(); /** `pending-edit-content:` URIs written during permission requests, keyed * by toolCallId. Cleaned up when the permission resolves or the session * is disposed. */ @@ -538,7 +580,17 @@ export class CopilotAgentSession extends Disposable { private readonly _pendingMcpSamplings = new Set(); /** Tracks whether a non-empty activity has been published, so we only emit a clear when needed. */ - private _hasReportedActivity = false; + private _hasActivity = false; + + /** Platform used to compute the SDK sandbox policy (injectable for tests). */ + private readonly _platform: NodeJS.Platform; + + get mcpServerStates() { + return this._mcpCustomizations.runtimeStates; + } + + /** Stateless reporter used to emit restricted GH/MSFT telemetry for this session's model calls. */ + private readonly _telemetryReporter: AgentHostTelemetryReporter; constructor( options: ICopilotAgentSessionOptions, @@ -553,6 +605,7 @@ export class CopilotAgentSession extends Disposable { super(); this.sessionId = options.rawSessionId; this.sessionUri = options.sessionUri; + this._chatChannelUri = options.chatChannelUri; this._onDidSessionProgress = options.onDidSessionProgress; this._sessionLauncher = options.sessionLauncher; this._launchPlan = options.launchPlan; @@ -560,18 +613,16 @@ export class CopilotAgentSession extends Disposable { this._workingDirectory = options.workingDirectory; this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; + this._platform = options.platform ?? process.platform; + this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; - this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); - // Share the agent's live ActiveClientState when provided so clientId - // changes are observed at stamp time. Standalone / test construction - // seeds a private instance with the applied tools and no owning client. - if (options.activeClientState) { - this._activeClientState = options.activeClientState; - } else { - this._activeClientState = new ActiveClientState(); - this._activeClientState.update(undefined, this._appliedSnapshot.tools); - } + this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot); + // Share the agent's live ActiveClientToolSet when provided so client + // contributions (and owner identity) are observed at stamp time. + // Standalone / test construction uses a fresh empty registry, which + // leaves client tool calls unstamped (no owning client). + this._activeClientToolSet = options.activeClientToolSet ?? new ActiveClientToolSet(); this._databaseRef = sessionDataService.openDatabase(options.sessionUri); this._register(toDisposable(() => this._databaseRef.dispose())); @@ -593,6 +644,7 @@ export class CopilotAgentSession extends Disposable { this._register(toDisposable(() => this._cancelPendingPlanReviews())); this._register(toDisposable(() => this._drainPendingSteeringFlips())); this._register(toDisposable(() => this._cancelPendingMcpSamplings())); + this._register(toDisposable(() => this._cancelPendingMcpAuthRequests())); // When a shell tool associates a terminal with a tool call, fire a // tool_content_changed event so the UI can connect to the terminal @@ -624,10 +676,11 @@ export class CopilotAgentSession extends Disposable { // ---- AgentSignal helpers ------------------------------------------------ /** Wraps a {@link SessionAction} in an {@link AgentSignal} envelope and emits it. */ + /** todo@connor4312: AHP is missing a chat activity update action which is needed to drop `SessionAction` here */ private _emitAction(action: SessionAction | ChatAction, parentToolCallId?: string): void { this._onDidSessionProgress.fire({ kind: 'action', - session: this.sessionUri, + resource: isChatAction(action) ? this._chatChannelUri : this.sessionUri, action, parentToolCallId, }); @@ -692,7 +745,7 @@ export class CopilotAgentSession extends Disposable { for (const id of ids) { this._onDidSessionProgress.fire({ kind: 'steering_consumed', - session: this.sessionUri, + chat: this._chatChannelUri, id, }); } @@ -738,8 +791,8 @@ export class CopilotAgentSession extends Disposable { * from a previous turn so the next text/reasoning chunk allocates a new * response part. The turn becomes `running` on the first SDK event. */ - resetTurnState(turnId: string): void { - this._currentTurn = new CopilotTurn(turnId); + resetTurnState(turnId: string, senderClientId?: string): void { + this._currentTurn = new CopilotTurn(turnId, senderClientId); } private _completeActiveTurn(): void { @@ -766,30 +819,6 @@ export class CopilotAgentSession extends Disposable { return join(this._workingDirectory.fsPath, path); } - private _sendToolInvokedTelemetry(success: boolean, errorCode: string | undefined, toolCall: { readonly toolName: string; readonly startTimeMs: number; readonly mcpServerName: string | undefined }): void { - let result: LanguageModelToolInvokedEvent['result']; - if (success) { - result = 'success'; - } else if (errorCode === 'rejected' || errorCode === 'denied' || errorCode === 'cancelled') { - result = 'userCancelled'; - } else { - result = 'error'; - } - - const isClientTool = this._clientToolNames.has(toolCall.toolName); - const toolSourceKind = toolCall.mcpServerName ? 'mcp' : isClientTool ? 'client' : 'agentHost'; - const invocationTimeMs = Date.now() - toolCall.startTimeMs; - - this._telemetryService.publicLog2('languageModelToolInvoked', { - result, - chatSessionId: this.sessionUri.toString(), - toolId: toolCall.toolName, - toolExtensionId: undefined, - toolSourceKind, - invocationTimeMs, - }); - } - /** * Emits a synthetic markdown content block for the active turn and * makes it the current markdown response part so that subsequent SDK @@ -917,14 +946,13 @@ export class CopilotAgentSession extends Disposable { if (!host) { return []; } - const sessionUri = this.sessionUri.toString(); return host.definitions.map(def => ({ name: def.name, description: def.description ?? '', parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, handler: async (args: Record): Promise => { try { - const text = host.executeTool(sessionUri, def.name, args); + const text = host.executeTool(this._chatChannelUri.toString(), def.name, args); return { textResultForLlm: text, resultType: 'success' }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -965,6 +993,10 @@ export class CopilotAgentSession extends Disposable { binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, }); } + + // Still pending permission, so this call may have errored while getting permission. + // Go ahead and allow the call which will immediately see the buffered value. + this.respondToPermissionRequest(toolCallId, true); } /** @@ -984,6 +1016,7 @@ export class CopilotAgentSession extends Disposable { this._wrapper = this._register(wrapper); this._subscribeToEvents(); this._subscribeForLogging(); + this._subscribeForMemoInvalidation(); // Advertise the agent host's server tools for this session so clients // see them as server-provided. Execution happens in-process via the SDK @@ -997,6 +1030,7 @@ export class CopilotAgentSession extends Disposable { handleExitPlanModeRequest: (request, invocation) => this._handleExitPlanModeRequest(request, invocation), handleUserInputRequest: (request, invocation) => this._handleUserInputRequest(request, invocation), handleElicitationRequest: context => this._handleElicitationRequest(context), + handleMcpAuthRequest: request => this._handleMcpAuthRequest(request), requestUnsandboxedCommandConfirmation: request => this._requestUnsandboxedCommandConfirmation(request), createClientSdkTools: () => this._createClientSdkTools(), createServerSdkTools: () => this._createServerSdkTools(), @@ -1005,21 +1039,126 @@ export class CopilotAgentSession extends Disposable { }; } + async resolveMcpAuthentication(params: AuthenticateParams): Promise { + let resolved = false; + for (const [requestId, pending] of this._pendingMcpAuthRequests) { + if (pending.resource.resource !== params.resource || !this._scopesSatisfy(params.scopes, pending.requiredScopes)) { + continue; + } + this._pendingMcpAuthRequests.delete(requestId); + pending.deferred.complete({ kind: 'token', accessToken: params.token }); + resolved = true; + } + return resolved; + } + + private async _handleMcpAuthRequest(request: McpAuthRequest): Promise { + const resource = this._protectedResourceFromMcpAuthRequest(request); + const requiredScopes = this._requiredScopesFromMcpAuthRequest(request, resource); + const deferred = new DeferredPromise(); + this._pendingMcpAuthRequests.set(request.requestId, { + serverName: request.serverName, + resource, + requiredScopes, + deferred, + }); + this._mcpCustomizations.applyOne({ + name: request.serverName, + state: { + kind: McpServerStatus.AuthRequired, + reason: this._mcpAuthRequiredReason(request.reason), + resource, + requiredScopes: requiredScopes.length ? [...requiredScopes] : undefined, + description: request.wwwAuthenticateParams?.error, + }, + }); + this._logService.info(`[Copilot:${this.sessionId}] MCP server '${request.serverName}' requires authentication for ${resource.resource}`); + return deferred.p.finally(() => this._pendingMcpAuthRequests.delete(request.requestId)); + } + + private _protectedResourceFromMcpAuthRequest(request: McpAuthRequest): ProtectedResourceMetadata { + if (request.resourceMetadata) { + try { + const parsed = JSON.parse(request.resourceMetadata); + if (isAuthorizationProtectedResourceMetadata(parsed)) { + return parsed; + } + this._logService.warn(`[Copilot:${this.sessionId}] Ignoring invalid MCP protected-resource metadata for '${request.serverName}'`); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to parse MCP protected-resource metadata for '${request.serverName}'`, err); + } + } + const scopes = this._scopesFromChallenge(request.wwwAuthenticateParams?.scope); + return { + resource: request.serverUrl, + resource_name: request.serverName, + scopes_supported: scopes.length ? scopes.slice() : undefined, + }; + } + + private _requiredScopesFromMcpAuthRequest(request: McpAuthRequest, resource: ProtectedResourceMetadata): readonly string[] { + const challengeScopes = this._scopesFromChallenge(request.wwwAuthenticateParams?.scope); + return challengeScopes.length ? challengeScopes : resource.scopes_supported ?? []; + } + + private _scopesFromChallenge(scope: string | undefined): readonly string[] { + return scope?.split(/\s+/).map(s => s.trim()).filter(s => s.length > 0) ?? []; + } + + private _mcpAuthRequiredReason(reason: McpAuthRequest['reason']): McpAuthRequiredReason { + switch (reason) { + case 'refresh': + case 'reauth': + return McpAuthRequiredReason.Expired; + case 'upscope': + return McpAuthRequiredReason.InsufficientScope; + case 'initial': + default: + return McpAuthRequiredReason.Required; + } + } + + private _scopesSatisfy(provided: readonly string[] | undefined, required: readonly string[]): boolean { + if (required.length === 0 || provided === undefined) { + return true; + } + const providedSet = new Set(provided); + return required.every(scope => providedSet.has(scope)); + } + + private _cancelPendingMcpAuthRequests(): void { + for (const pending of this._pendingMcpAuthRequests.values()) { + pending.deferred.complete({ kind: 'cancelled' }); + } + this._pendingMcpAuthRequests.clear(); + } + // ---- session operations ------------------------------------------------- - async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode): Promise { + async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string): Promise { if (turnId && this._currentTurn?.id !== turnId) { // Establish the `pending` turn for this message. Callers normally // call `resetTurnState` just before `send()`; this covers the // direct-send path and is a no-op when the turn already exists. - this.resetTurnState(turnId); + this.resetTurnState(turnId, senderClientId); } this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); const slashCommand = parseLeadingSlashCommand(prompt); if (slashCommand?.command === 'compact') { try { - await this._wrapper.session.rpc.history.compact(); + const result = await this._wrapper.session.rpc.history.compact(); + // Compaction reduces the number of tokens currently occupying the context window. Report the + // new occupancy so the context-usage widget refreshes immediately. Emitted before + // `_completeActiveTurn` since the reducer drops usage for a non-active turn. + const usedTokens = result.contextWindow?.currentTokens; + if (typeof usedTokens === 'number') { + this._emitAction({ + type: ActionType.ChatUsage, + turnId: this._turnId, + usage: { inputTokens: usedTokens, outputTokens: 0, model: this._lastSeenModelId }, + }); + } this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed")); } catch (err) { if (getErrorMessage(err).toLowerCase().includes('nothing to compact')) { @@ -1037,51 +1176,10 @@ export class CopilotAgentSession extends Disposable { this._completeActiveTurn(); return; } - if (slashCommand && isRuntimeCopilotSlashCommand(slashCommand.command) && await this.hasRuntimeSlashCommand(slashCommand.command)) { - let result: CopilotCommandInvocationResult; - try { - result = await this._wrapper.session.rpc.commands.invoke({ - name: slashCommand.command, - }); - } catch (err) { - this._logService.error(err, `[Copilot:${this.sessionId}] rpc.commands.invoke(${slashCommand.command}) failed`); - throw err; - } - switch (result.kind) { - case 'text': - this._emitMarkdownDelta(result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)); - break; - case 'completed': - if (result.message) { - this._emitMarkdownDelta(result.message); - } - break; - case 'agent-prompt': { - const runtimeMode = toCopilotSdkMode(result.mode); - if (runtimeMode) { - mode = runtimeMode; - } - prompt = result.prompt; - break; - } - default: - this._logService.warn(`[Copilot:${this.sessionId}] Unexpected /${slashCommand.command} command result kind: ${result.kind}`); - this._emitMarkdownDelta(localize('copilotSlashCommand.unsupportedRuntimeResult', "The /{0} command returned an unsupported result.", slashCommand.command)); - break; - } - if (result.kind !== 'agent-prompt') { - this._completeActiveTurn(); - return; - } - } - if (slashCommand && isPromptInvokedCopilotSlashCommand(slashCommand.command)) { - prompt = slashCommand.rest ? `/${slashCommand.command} ${slashCommand.rest}` : `/${slashCommand.command}`; - } if (slashCommand?.command === 'plan') { mode = 'plan'; prompt = slashCommand.rest; - } - if (slashCommand?.command === 'rubber-duck') { + } else if (slashCommand?.command === 'rubber-duck') { if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.RubberDuck) !== true) { // Feature not enabled — pass the remaining text through as a plain // message rather than injecting agent instructions for an unavailable agent. @@ -1092,6 +1190,60 @@ export class CopilotAgentSession extends Disposable { ? `The user has requested a rubber duck review via the /rubber-duck command. Use the task tool with agent_type: "rubber-duck" to get an independent critique of your current approach, plan, or recent work. Summarize the relevant context for the rubber duck agent so it has what it needs to evaluate it.\n\nAdditional instructions: ${userPrompt}` : 'The user has requested a rubber duck review via the /rubber-duck command. Use the task tool with agent_type: "rubber-duck" to get an independent critique of your current approach, plan, or recent work. Summarize the relevant context for the rubber duck agent so it has what it needs to evaluate it.'; } + } else if (slashCommand) { + const runtimeSlashCommand = await this._resolveRuntimeSlashCommand(slashCommand.command); + // Skills can be passed as is to the runtime. + if (runtimeSlashCommand && runtimeSlashCommand.kind !== 'skill') { + let result: CopilotCommandInvocationResult; + try { + result = await this._wrapper.session.rpc.commands.invoke({ + name: runtimeSlashCommand.name, + ...(slashCommand.rawRest.length > 0 ? { input: slashCommand.rawRest } : {}), + }); + } catch (err) { + this._logService.error(err, `[Copilot:${this.sessionId}] rpc.commands.invoke(${slashCommand.command}) failed`); + throw err; + } + switch (result.kind) { + case 'text': + this._emitMarkdownDelta(result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)); + break; + case 'completed': + if (result.message) { + this._emitMarkdownDelta(result.message); + } + break; + case 'agent-prompt': { + const runtimeMode = toCopilotSdkMode(result.mode); + if (runtimeMode) { + mode = runtimeMode; + } + prompt = result.prompt; + break; + } + case 'select-subcommand': + this._emitMarkdownDelta(localize( + 'copilotSlashCommand.selectSubcommandResult', + "The /{0} command requires selecting a subcommand. Available options: {1}", + result.command, + result.options.map(option => option.name).join(', '), + )); + break; + default: + // The runtime can be newer than these compiled SDK types, so an + // unknown kind must be logged rather than silently swallowed (the + // turn would otherwise complete with no user-facing output). + this._logService.warn(`[Copilot:${this.sessionId}] Unhandled slash command result kind: ${(result as { kind: string }).kind}`); + break; + } + if (result.runtimeSettingsChanged === true) { + this._invalidateRuntimeSlashCommandCache(); + } + if (result.kind !== 'agent-prompt') { + this._completeActiveTurn(); + return; + } + } } const sdkAttachments = attachments?.length @@ -1102,53 +1254,144 @@ export class CopilotAgentSession extends Disposable { } await this.applyMode(mode); + await this._applyEffectiveSandboxConfig(); await this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined }); this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } async hasRuntimeSlashCommand(command: string): Promise { try { - return (await this._getRuntimeSlashCommands()).has(command); + return !!(await this._resolveRuntimeSlashCommand(command)); } catch (err) { this._logService.warn(`[Copilot:${this.sessionId}] rpc.commands.list failed`, err); return false; } } - private _getRuntimeSlashCommands(): Promise> { - const now = Date.now(); - if (this._runtimeSlashCommandCache && this._runtimeSlashCommandCache.expiresAt > now) { - return this._runtimeSlashCommandCache.promise; + async getRuntimeSlashCommands(options?: { readonly maxWaitMs?: number }): Promise { + try { + const maxWaitMs = options?.maxWaitMs; + const catalog = await this._getRuntimeSlashCommandCatalog(maxWaitMs === undefined ? undefined : Math.max(0, maxWaitMs)); + return catalog.commands; + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] rpc.commands.list failed`, err); + return []; + } + } + + private async _resolveRuntimeSlashCommand(command: string, maxWaitMs: number | undefined = undefined): Promise { + const key = this._normalizeSlashCommandKey(command); + if (!key) { + return undefined; } - const promise = this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: false, includeClientCommands: false }) - .then(result => new Set(result.commands.filter(command => command.kind === 'builtin').map(command => command.name))); - this._runtimeSlashCommandCache = { expiresAt: now + RUNTIME_SLASH_COMMAND_CACHE_TTL_MS, promise }; - promise.catch(() => { - if (this._runtimeSlashCommandCache?.promise === promise) { - this._runtimeSlashCommandCache = undefined; + const catalog = await this._getRuntimeSlashCommandCatalog(maxWaitMs); + return catalog.byName.get(key) ?? catalog.byAlias.get(key); + } + + private async _getRuntimeSlashCommandCatalog(maxWaitMs: number | undefined = undefined): Promise { + const cache = this._runtimeSlashCommandCache ??= {}; + if (cache.value) { + return cache.value; + } + + const inFlight = this._refreshRuntimeSlashCommandCatalog(cache); + if (maxWaitMs === undefined) { + return inFlight; + } + const settled = await raceTimeout(inFlight, maxWaitMs); + if (settled) { + return settled; + } + if (cache.value) { + return cache.value; + } + return { + commands: [], + byName: new Map(), + byAlias: new Map(), + }; + } + + private _refreshRuntimeSlashCommandCatalog(cache: RuntimeSlashCommandCache): Promise { + if (cache.inFlight) { + return cache.inFlight; + } + + const inFlight = this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: true, includeClientCommands: true }) + .then(result => this._toRuntimeSlashCommandCatalog(result.commands)); + cache.inFlight = inFlight; + inFlight.then(catalog => { + if (this._runtimeSlashCommandCache === cache) { + cache.value = catalog; + cache.inFlight = undefined; + } + }, () => { + if (this._runtimeSlashCommandCache === cache) { + cache.inFlight = undefined; + if (!cache.value) { + this._runtimeSlashCommandCache = undefined; + } } }); - return promise; + return inFlight; + } + + private _toRuntimeSlashCommandCatalog(commands: readonly RuntimeSlashCommandInfo[]): RuntimeSlashCommandCatalog { + const byName = new Map(); + const byAlias = new Map(); + const deduped: RuntimeSlashCommandInfo[] = []; + for (const command of commands) { + const nameKey = this._normalizeSlashCommandKey(command.name); + if (!nameKey) { + continue; + } + let canonical = byName.get(nameKey); + if (!canonical) { + canonical = command; + byName.set(nameKey, canonical); + deduped.push(canonical); + } + for (const alias of command.aliases ?? []) { + const aliasKey = this._normalizeSlashCommandKey(alias); + if (!aliasKey || byAlias.has(aliasKey)) { + continue; + } + byAlias.set(aliasKey, canonical); + } + } + return { commands: deduped, byName, byAlias }; + } + + private _normalizeSlashCommandKey(command: string): string | undefined { + const trimmed = command.trim(); + if (!trimmed) { + return undefined; + } + const slashStripped = trimmed.charCodeAt(0) === 0x2f /* / */ ? trimmed.slice(1) : trimmed; + return slashStripped.toLowerCase(); + } + + private _invalidateRuntimeSlashCommandCache(): void { + if (this._runtimeSlashCommandCache) { + // Keep in-flight promises isolated from fresh lookups after invalidation. + this._runtimeSlashCommandCache = undefined; + } } /** - * Translate a protocol {@link MessageAttachment} into the Copilot CLI - * SDK's `attachments` payload shape. Resource attachments map to the - * SDK's reference-style `file`/`directory`/`selection` variants (the - * {@link MessageAttachmentBase.displayKind} advisory hint controls - * which one). Embedded resources (e.g. inline image bytes) map to the - * SDK's `blob` variant. - * Simple attachments with a model representation map to `text/plain` - * blob attachments. + * Translate a protocol {@link MessageAttachment} into the Copilot CLI SDK's `attachments` payload shape. Resource + * attachments map to the SDK's reference-style `file`/`directory`/`selection` variants (the + * {@link MessageAttachmentBase.displayKind} advisory hint controls which one). Embedded resources (e.g. inline + * image bytes, or unsaved editor content) map to the SDK's `blob` variant, and simple attachments with a model + * representation map to `text/plain` blob attachments. * * Any Resource attachment carrying a {@link TextSelection} (e.g. `displayKind === 'selection'` or `'symbol'`) is * mapped to the SDK's `selection` variant so the range survives the round-trip — keying off the `selection` field - * rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). - * - * For selections we read the resource content from disk and slice it - * by the carried range (the protocol's {@link TextSelection} only - * carries the range, not the inline text). On read failure the - * selection downgrades to a plain file reference. + * rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). For those + * we read the resource content from disk and slice it by the carried range (the protocol's {@link TextSelection} + * only carries the range, not the inline text); on read failure the selection downgrades to a plain file reference. + * A textual embedded resource already carries the exact inline text to send (the whole live buffer for a document, + * or just the selected text for a selection), so it is forwarded as-is without further slicing. */ private async _toSdkAttachment(attachment: MessageAttachment): Promise { if (isAgentFeedbackAnnotationsAttachment(attachment)) { @@ -1247,6 +1490,13 @@ export class CopilotAgentSession extends Disposable { return this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.Mode) === 'autopilot'; } + /** + * Whether VS Code's auto-reply setting is enabled in the root config. + */ + private _isAutoReplyEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostAutoReplyEnabledConfigKey) === true; + } + async sendSteering(steeringMessage: PendingMessage): Promise { if (this._steeringMessagesInFlight.has(steeringMessage.id) || this._pendingSteeringFlips.has(steeringMessage.id)) { return; @@ -1267,18 +1517,85 @@ export class CopilotAgentSession extends Disposable { } async getMessages(): Promise { - const events = await this._wrapper.session.getEvents(); - let db: ISessionDatabase | undefined; - try { - db = this._databaseRef.object; - } catch { - // Database may not exist yet — that's fine - } - const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); + const result = await this._getMappedEvents(); return result.turns; } async getSubagentMessages(parentToolCallId: string): Promise { + const result = await this._getMappedEvents(); + const turns = result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; + return turns; + } + + /** + * Returns the subagent child sessions discoverable in this session's event + * log, derived from the same {@link mapSessionEvents} reconstruction used + * for {@link getMessages}/{@link getSubagentMessages}. Lets a parent + * restore register every child up-front instead of each child re-fetching + * and re-reconstructing the full parent event log. + */ + async getSubagentSessions(): Promise { + const result = await this._getMappedEvents(); + if (result.subagentTurnsByToolCallId.size === 0) { + return []; + } + const parentSessionStr = this.sessionUri.toString(); + const out: IRestoredSubagentSession[] = []; + for (const turn of result.turns) { + for (const rp of turn.responseParts) { + if (rp.kind !== ResponsePartKind.ToolCall) { + continue; + } + const tc = rp.toolCall; + const childTurns = result.subagentTurnsByToolCallId.get(tc.toolCallId); + if (!childTurns || childTurns.length === 0) { + continue; + } + const content = (tc as { content?: readonly ToolResultContent[] }).content; + const subagentContent = content ? getToolSubagentContent({ content }) : undefined; + // Prefer the spawning Task tool's short `description` (captured on + // the parent tool call's `_meta`) so restored peer tabs match the + // live path's concise, per-task naming; fall back to the agent + // type's display name. + const taskDescription = readToolCallMeta(tc).subagentDescription; + out.push({ + resource: URI.parse(buildSubagentSessionUri(parentSessionStr, tc.toolCallId)), + toolCallId: tc.toolCallId, + title: subagentChatTitle(taskDescription, subagentContent?.title), + turns: childTurns, + }); + } + } + return out; + } + + /** + * Memoized `getEvents()` + {@link mapSessionEvents} result, shared by + * {@link getMessages}, {@link getSubagentMessages} and + * {@link getSubagentSessions}. A single session open reads and + * reconstructs the full parent event log once instead of once per + * subagent. The memo is scoped to the resume/restore wave: it is dropped + * whenever the persisted event log could change (see + * {@link _invalidateMappedEvents}) and on dispose, so it never serves + * stale turns for an actively-running session. + */ + private _mappedEventsMemo: Promise | undefined; + + private _getMappedEvents(): Promise { + if (!this._mappedEventsMemo) { + const pending = this._computeMappedEvents(); + this._mappedEventsMemo = pending; + // Don't cache a rejected reconstruction — let the next caller retry. + pending.catch(() => { + if (this._mappedEventsMemo === pending) { + this._mappedEventsMemo = undefined; + } + }); + } + return this._mappedEventsMemo; + } + + private async _computeMappedEvents(): Promise { const events = await this._wrapper.session.getEvents(); let db: ISessionDatabase | undefined; try { @@ -1286,8 +1603,18 @@ export class CopilotAgentSession extends Disposable { } catch { // Database may not exist yet — that's fine } - const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); - return result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; + const result = await mapSessionEvents(this.sessionUri, db, events, { + workingDirectory: this._workingDirectory, + model: this._launchPlan.kind === 'create' + ? this._launchPlan.model + : this._launchPlan.fallback.model, + }); + return result; + } + + /** Drop the memoized event reconstruction; the next read rebuilds it. */ + private _invalidateMappedEvents(): void { + this._mappedEventsMemo = undefined; } async abort(): Promise { @@ -1384,10 +1711,11 @@ export class CopilotAgentSession extends Disposable { const mcpRequestId = generateUuid(); this._pendingMcpSamplings.add(requestId); try { + type McpExecuteSamplingParams = Parameters[0]; const result = await this._wrapper.session.rpc.mcp.executeSampling({ requestId, serverName, - mcpRequestId, + mcpRequestId: mcpRequestId as unknown as McpExecuteSamplingParams['mcpRequestId'], request: params, }); if (result.action === 'success') { @@ -1496,7 +1824,13 @@ export class CopilotAgentSession extends Disposable { const deferred = new DeferredPromise(); this._pendingPermissions.set(toolCallId, deferred); - if (isShellRequest && await this._isShellSandboxedByDefault()) { + // Auto-approve shell commands that run sandboxed by default, since the + // sandbox already contains them. Commands that opted OUT of the sandbox + // (`requestSandboxBypass`) are an elevation of privilege and must + // fall through to the normal confirmation flow — otherwise enabling + // `sandbox.allowBypass` would let the model escape the sandbox with no + // prompt at all. + if (isShellRequest && !request.requestSandboxBypass && await this._isShellSandboxedByDefault()) { // Session may have been disposed while we awaited the engine // check; if so the deferred has already been settled and // removed, so leave it alone. @@ -1535,7 +1869,7 @@ export class CopilotAgentSession extends Disposable { const parentToolCallId = this._activeToolCalls.get(toolCallId)?.parentToolCallId; this._onDidSessionProgress.fire({ kind: 'pending_confirmation', - session: this.sessionUri, + chat: this._chatChannelUri, state: { status: ToolCallStatus.PendingConfirmation, toolCallId, @@ -1548,6 +1882,7 @@ export class CopilotAgentSession extends Disposable { }, permissionKind, permissionPath, + requestSandboxBypass: request.requestSandboxBypass, parentToolCallId, }); @@ -1601,16 +1936,16 @@ export class CopilotAgentSession extends Disposable { * terminal tool is enabled) or through the SDK's built-in shell tool wrapped * by the `sandboxConfig` we pushed via `session.options.update`. * - * In both cases the shell tool prompts on its own when escalating to - * unsandboxed execution, so the SDK's pre-call `shell` permission prompt - * is redundant and we auto-approve it. + * Callers use this to auto-approve shell permission prompts that the sandbox + * already contains. Commands that explicitly opt out of the sandbox + * (`requestSandboxBypass`) are excluded by the caller, since the + * sandbox no longer contains them. * * Returns false when neither sandbox path is configured, so the standard * confirmation flow is preserved. */ private async _isShellSandboxedByDefault(): Promise { - const customTerminalToolEnabled = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; - if (customTerminalToolEnabled) { + if (this._isCustomTerminalToolEnabled()) { if (!this._shellManager) { return false; } @@ -1619,8 +1954,74 @@ export class CopilotAgentSession extends Disposable { // SDK-managed shell path: gate on the same host config that // `CopilotSessionLauncher` reads when forwarding `sandboxConfig` to // the SDK, so the two stay in lock-step. + return this._computeSdkSandboxConfig() !== undefined; + } + + /** + * `true` when the AgentHost's own shell tools (wrapped by + * {@link TerminalSandboxEngine}) replace the SDK's built-in shell. In that + * mode the SDK sandbox config is unused, so we neither forward nor toggle it. + */ + private _isCustomTerminalToolEnabled(): boolean { + return this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; + } + + /** + * The SDK-shaped sandbox policy for this session, mirroring + * {@link CopilotSessionLauncher}'s computation: `undefined` when the custom + * terminal tool is enabled (the host's own terminal sandbox engine handles + * containment) or when the host sandbox config evaluates to disabled + * (including on Windows, where the sandbox is not supported). + */ + private _computeSdkSandboxConfig(): ISdkSandboxConfig | undefined { + if (this._isCustomTerminalToolEnabled()) { + return undefined; + } + const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); + return buildSandboxConfigForSdk(this._platform, sandbox); + } + + /** + * `true` when the session runs with bypass approvals — the global + * auto-approve setting, the session's `autoApprove` ("Bypass Approvals") + * level, or `autopilot` mode (which runs autonomously with no user to + * confirm and therefore implies a disabled sandbox). The sandbox enable + * setting only applies under default approvals, so the sandbox is disabled + * for the request when this is `true`. + */ + private _isBypassApprovals(): boolean { + if (this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true) { + return true; + } + if (this._isAutopilotMode()) { + return true; + } + return this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autoApprove'; + } + + /** + * Apply the SDK sandbox policy for the request that is about to be sent. + * + * Skips the SDK sandbox entirely when the custom terminal tool is enabled + * (the host's own terminal sandbox engine handles containment and the SDK's + * built-in shell is unused). Otherwise it always pushes the effective state + * so the SDK never retains a stale or auto-discovered sandbox: the + * configured policy under default approvals, or an explicitly disabled + * sandbox when the request runs with bypass approvals or no sandbox is + * configured (setting off, or Windows). + */ + private async _applyEffectiveSandboxConfig(): Promise { + if (this._isCustomTerminalToolEnabled()) { + return; + } const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); - return buildSandboxConfigForSdk(process.platform, sandbox) !== undefined; + const base = buildSandboxConfigForSdk(this._platform, sandbox); + const sandboxConfig: ISdkSandboxConfig | { enabled: false } = (base && !this._isBypassApprovals()) ? base : { enabled: false }; + try { + await this._wrapper.session.rpc.options.update({ sandboxConfig }); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to update sandbox config for request`, err); + } } /** @@ -1711,9 +2112,10 @@ export class CopilotAgentSession extends Disposable { ? localize('agentHost.unsandboxedCommandConfirmation.blockedDomains', "This command needs to access blocked network domain(s): {0}.", blockedDomains) : localize('agentHost.unsandboxedCommandConfirmation.generic', "This command needs to run outside the sandbox."); + const parentToolCallId = this._activeToolCalls.get(request.toolCallId)?.parentToolCallId; this._onDidSessionProgress.fire({ kind: 'pending_confirmation', - session: this.sessionUri, + chat: this._chatChannelUri, state: { status: ToolCallStatus.PendingConfirmation, toolCallId: request.toolCallId, @@ -1729,7 +2131,7 @@ export class CopilotAgentSession extends Disposable { // Mirrors the workbench's sandbox-aware analyzer, which forces // `isAutoApproveAllowed: false` whenever `requiresUnsandboxConfirmation` // is set. - parentToolCallId: this._activeToolCalls.get(request.toolCallId)?.parentToolCallId, + parentToolCallId, }); return deferred.p; @@ -1738,16 +2140,14 @@ export class CopilotAgentSession extends Disposable { // ---- user input handling ------------------------------------------------ /** - * Handles a user input request from the SDK (ask_user tool) by firing a - * `user_input_request` progress event and waiting for the renderer to - * respond via {@link respondToUserInputRequest}. + * Handles a user input request from the SDK (ask_user tool). Auto-answers when the user is unavailable; otherwise waits for the renderer to respond via {@link respondToUserInputRequest}. */ private async _handleUserInputRequest( request: UserInputRequest, _invocation: { sessionId: string }, ): Promise { const isAutopilot = this._isAutopilotMode(); - if (isAutopilot) { + if (isAutopilot || this._isAutoReplyEnabled()) { return { answer: 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.', wasFreeform: true, @@ -2078,6 +2478,10 @@ export class CopilotAgentSession extends Disposable { }); return; } + if (!notification.startsTurn) { + this._logService.trace(`[Copilot:${sessionId}] Ignoring passive system.notification kind=${e.data.kind.type} without an active turn`); + return; + } const turnId = generateUuid(); this.resetTurnState(turnId); @@ -2135,6 +2539,14 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); + // Report the enhanced GH `request.options.tools` event for this model call — parity with + // the Copilot extension, which emits it per LLM request. `assistant.message` is the + // agent-host's per-model-call boundary; we correlate on its `x-copilot-service-request-id`. + // Main agent only: `_appliedSnapshot.tools` is the session's tool set, which does not + // describe a subagent's model call, so subagent messages (mapped or dropped) are skipped. + if (!e.agentId) { + this._telemetryReporter.assistantMessageReceived(this.sessionUri.toString(), e.data.serviceRequestId, this._appliedSnapshot.tools); + } // The SDK fires a `message` event with the full assembled content after // streaming deltas. If deltas already created a markdown part for this // turn, the live state is up to date and we skip. Only emit a fresh @@ -2167,20 +2579,6 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onToolStart(e => { if (isHiddenTool(e.data.toolName)) { this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`); - // The CLI uses the `report_intent` tool to signal what the - // agent is currently doing. Surface this as session activity - // so the UI can show a live "what is the agent doing now?" - // hint while the turn is in progress. - if (e.data.toolName === 'report_intent') { - const intent = (e.data.arguments as { intent?: unknown } | undefined)?.intent; - if (typeof intent === 'string' && intent.length > 0) { - this._hasReportedActivity = true; - this._emitAction({ - type: ActionType.SessionActivityChanged, - activity: intent, - }); - } - } return; } this._logService.info(`[Copilot:${sessionId}] Tool started: ${e.data.toolName}`); @@ -2200,7 +2598,7 @@ export class CopilotAgentSession extends Disposable { return; } const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, startTimeMs: Date.now(), mcpServerName: e.data.mcpServerName, meta: undefined }); + this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, mcpServerName: e.data.mcpServerName, meta: undefined }); if (isTaskCompleteTool(e.data.toolName)) { const scope = parentToolCallId ?? ''; this._currentTurn?.markdownPartIds.delete(scope); @@ -2212,8 +2610,9 @@ export class CopilotAgentSession extends Disposable { let contributor: { readonly kind: ToolCallContributorKind.Client; readonly clientId: string } | { readonly kind: ToolCallContributorKind.MCP; readonly customizationId: string } | undefined; const isClientTool = this._clientToolNames.has(e.data.toolName); - if (isClientTool && this._activeClientState.clientId) { - contributor = { kind: ToolCallContributorKind.Client, clientId: this._activeClientState.clientId }; + const ownerClientId = isClientTool ? this._activeClientToolSet.ownerOf(e.data.toolName, this._currentTurn?.senderClientId) : undefined; + if (ownerClientId) { + contributor = { kind: ToolCallContributorKind.Client, clientId: ownerClientId }; } else if (e.data.mcpServerName) { const customizationId = this._mcpCustomizations.customizationIdForServer(e.data.mcpServerName); if (customizationId !== undefined) { @@ -2339,7 +2738,6 @@ export class CopilotAgentSession extends Disposable { const toolOutput = e.data.error?.message ?? e.data.result?.content; if (isTaskCompleteTool(tracked.toolName)) { - this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); const summary = getTaskCompleteMarkdown(tracked.parameters, toolOutput); if (summary) { this._emitAction({ @@ -2355,6 +2753,7 @@ export class CopilotAgentSession extends Disposable { if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } + appendSdkToolResultContent(content, e.data.result?.contents); const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; @@ -2382,7 +2781,6 @@ export class CopilotAgentSession extends Disposable { } } - this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); // eslint-disable-next-line local/code-no-untyped-meta-access -- Copilot SDK's own typed `_meta`, not the AHP protocol bag. const resourceUri = e.data.toolDescription?._meta?.ui?.resourceUri; let completeMeta: IToolCallMeta | undefined = tracked.meta; @@ -2406,7 +2804,7 @@ export class CopilotAgentSession extends Disposable { toolCallId: e.data.toolCallId, result: { success: e.data.success, - pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success), + pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success, e.data.success ? toolOutput : undefined), content: content.length > 0 ? content : undefined, error: e.data.error, }, @@ -2416,11 +2814,8 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onIdle(e => { this._logService.info(`[Copilot:${sessionId}] Session idle`); - // Clear any in-progress activity description set during the - // turn (e.g. via the `report_intent` tool) — the agent is no - // longer doing anything once the loop settles. - if (this._hasReportedActivity) { - this._hasReportedActivity = false; + if (this._hasActivity) { + this._hasActivity = false; this._emitAction({ type: ActionType.SessionActivityChanged, activity: undefined, @@ -2496,13 +2891,25 @@ export class CopilotAgentSession extends Disposable { this._parentToolCallIdsByAgentId.set(e.agentId, e.data.toolCallId); } this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); + const tracked = this._activeToolCalls.get(e.data.toolCallId); this._onDidSessionProgress.fire({ kind: 'subagent_started', - session: this.sessionUri, + chat: this._chatChannelUri, toolCallId: e.data.toolCallId, agentName: e.data.agentName, agentDisplayName: e.data.agentDisplayName, agentDescription: e.data.agentDescription, + // The spawning Task tool's short `description` input (captured on + // tool start) is the concise per-task tab title for the subagent's + // read-only peer chat — distinct even for same-type subagents. + taskDescription: tracked?.meta?.subagentDescription, + // When the spawning tool call is itself an inner tool of + // another subagent, its recorded parent is the tool call one + // level up — the tool call in whose (subagent) chat this + // spawning tool lives. The host uses it to route the + // discovery content block to that immediate parent chat, at + // any nesting depth. + parentToolCallId: tracked?.parentToolCallId, }); })); @@ -2524,43 +2931,73 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onUsage(e => { - const metadata: UsageInfoMeta = {}; - if (typeof e.data.cost === 'number') { - metadata.cost = e.data.cost; - } + // Usage events for a subagent's model calls carry the subagent's + // `agentId`. Such an event is reported twice: + // 1. Folded into the parent turn (scope `''`) so the parent turn's + // reported cost stays the full turn aggregate (parent + every + // subagent), and + // 2. Emitted to the subagent's own child session (via + // `parentToolCallId`) carrying just that subagent's running + // component total, so the subagent tool can show its own cost. + // Main-agent (or unmapped subagent) events only contribute to the + // parent aggregate. + const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); // TODO: `copilotUsage` is marked `asInternal` in the SDK schema so it is not exposed on the generated // `AssistantUsageData` type, but it is present at runtime. Read it dynamically. const copilotUsage = (e.data as unknown as Record).copilotUsage as { totalNanoAiu?: number } | undefined; - const turn = this._currentTurn; - if (turn && typeof copilotUsage?.totalNanoAiu === 'number') { - turn.copilotUsageTotalNanoAiu += copilotUsage.totalNanoAiu; - metadata.copilotUsage = { - ...copilotUsage, - totalNanoAiu: turn.copilotUsageTotalNanoAiu, - }; - } // `quotaSnapshots` is likewise `asInternal` in the SDK schema (not on the generated type) but is // present at runtime. Forward the per-category snapshots on `_meta` so the client can keep the // account quota UI current. Mirrors the extension-host CLI path, which feeds these into its quota service. const quotaSnapshots = normalizeQuotaSnapshots((e.data as unknown as Record).quotaSnapshots); - if (quotaSnapshots) { - metadata.quotaSnapshots = quotaSnapshots; - } + const turn = this._currentTurn; + if (typeof e.data.model === 'string' && e.data.model) { this._lastSeenModelId = e.data.model; } - const usage: UsageInfo = { - inputTokens: e.data.inputTokens, - outputTokens: e.data.outputTokens, - model: e.data.model, - cacheReadTokens: e.data.cacheReadTokens, - ...(Object.keys(metadata).length > 0 ? { _meta: metadata } : {}), + + // Builds a usage object carrying this event's tokens/model and the + // running credit total for the given scope. + const buildUsage = (scope: string): UsageInfo => { + const metadata: UsageInfoMeta = {}; + if (typeof e.data.cost === 'number') { + metadata.cost = e.data.cost; + } + if (turn && typeof copilotUsage?.totalNanoAiu === 'number') { + const scopedTotal = (turn.copilotUsageTotalNanoAiuByScope.get(scope) ?? 0) + copilotUsage.totalNanoAiu; + turn.copilotUsageTotalNanoAiuByScope.set(scope, scopedTotal); + metadata.copilotUsage = { + ...copilotUsage, + totalNanoAiu: scopedTotal, + }; + } + if (quotaSnapshots) { + metadata.quotaSnapshots = quotaSnapshots; + } + return { + inputTokens: e.data.inputTokens, + outputTokens: e.data.outputTokens, + model: e.data.model, + cacheReadTokens: e.data.cacheReadTokens, + ...(Object.keys(metadata).length > 0 ? { _meta: metadata } : {}), + }; }; + + // Parent turn aggregate (scope `''`): every model call contributes. this._emitAction({ type: ActionType.ChatUsage, turnId: this._turnId, - usage, + usage: buildUsage(''), }); + + // Subagent component: additionally report the subagent's own running + // total to its child session. + if (parentToolCallId) { + this._emitAction({ + type: ActionType.ChatUsage, + turnId: this._turnId, + usage: buildUsage(parentToolCallId), + }, parentToolCallId); + } })); this._register(wrapper.onReasoningDelta(e => { @@ -2576,6 +3013,13 @@ export class CopilotAgentSession extends Disposable { // before sending). The SDK and AHP share the same three modes // (`interactive` / `plan` / `autopilot`), so we map directly. this._register(wrapper.onSessionModeChanged(e => { + // Sub-agents (e.g. a `task` tool sub-agent running in plan mode) + // emit their own `session.mode_changed` events carrying an + // `agentId`. + if (e.agentId) { + this._logService.trace(`[Copilot:${sessionId}] Ignoring subagent session.mode_changed: agentId=${e.agentId}, ${e.data.previousMode} -> ${e.data.newMode}`); + return; + } this._logService.info(`[Copilot:${sessionId}] session.mode_changed: ${e.data.previousMode} -> ${e.data.newMode}`); const newMode = e.data.newMode; if (newMode !== 'interactive' && newMode !== 'plan' && newMode !== 'autopilot') { @@ -2602,8 +3046,12 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onToolsUpdated(() => { + this._invalidateRuntimeSlashCommandCache(); this._fireMcpToolsListChanged(); })); + this._register(wrapper.onCommandsChanged(() => { + this._invalidateRuntimeSlashCommandCache(); + })); // Seed the inventory with any servers the SDK has already loaded by // the time we attach. The `session.mcp_servers_loaded` event may @@ -2753,8 +3201,6 @@ export class CopilotAgentSession extends Disposable { const questionId = generateUuid(); this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request: rpcId=${requestId}, actions=[${data.actions.join(',')}], recommended=${data.recommendedAction}`); - - // Resolve the plan file path so we can embed a markdown link. let planPath: string | null = null; try { const planRead = await this._wrapper.session.rpc.plan.read(); @@ -2763,15 +3209,6 @@ export class CopilotAgentSession extends Disposable { this._logService.warn(`[Copilot:${this.sessionId}] rpc.plan.read failed for exit_plan_mode: ${err instanceof Error ? err.message : String(err)}`); } - // Build the input-request markdown: summary + link to the plan file. - let message = data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review."); - if (planPath) { - const planUri = URI.file(planPath); - message += `\n\n[${localize('agentHost.planReview.viewPlanLink', "View full plan")}](${planUri.toString()})`; - } - - this._emitMarkdownDelta(message); - const options = data.actions.map(actionId => { const desc = getPlanActionDescription(actionId); return { @@ -2782,8 +3219,24 @@ export class CopilotAgentSession extends Disposable { }; }); - const inputRequest: ChatInputRequest = { + const actions: IAgentHostPlanReviewAction[] = options.map(option => ({ + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.recommended ? { default: true } : {}), + ...(option.id === 'autopilot' || option.id === 'autopilot_fleet' ? { permissionLevel: 'autopilot' } : {}), + })); + + const inputRequest: ChatInputRequestWithPlanReview = { id: requestId, + planReview: { + title: localize('agentHost.planReview.title', "Review Plan"), + content: data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review."), + actions, + canProvideFeedback: true, + answerQuestionId: questionId, + ...(planPath ? { planUri: URI.file(planPath).toString() } : {}), + }, questions: [{ kind: ChatInputQuestionKind.SingleSelect, id: questionId, @@ -2805,7 +3258,7 @@ export class CopilotAgentSession extends Disposable { this._onDidSessionProgress.fire({ kind: 'action', - session: this.sessionUri, + resource: this._chatChannelUri, action: { type: ActionType.ChatInputRequested, request: inputRequest, @@ -2820,10 +3273,40 @@ export class CopilotAgentSession extends Disposable { } } + /** + * Drop the memoized event reconstruction whenever the persisted event log + * could have changed, so {@link _getMappedEvents} never serves stale turns + * once the session resumes activity. While the session is idle (e.g. during + * a historical session open) none of these fire, so the whole restore wave + * coalesces to a single reconstruction. + */ + private _subscribeForMemoInvalidation(): void { + const wrapper = this._wrapper; + const invalidate = () => this._invalidateMappedEvents(); + // New content appended to the log. + this._register(wrapper.onUserMessage(invalidate)); + this._register(wrapper.onTurnStart(invalidate)); + this._register(wrapper.onMessage(invalidate)); + this._register(wrapper.onToolStart(invalidate)); + this._register(wrapper.onToolComplete(invalidate)); + this._register(wrapper.onSubagentStarted(invalidate)); + this._register(wrapper.onSubagentCompleted(invalidate)); + this._register(wrapper.onSubagentFailed(invalidate)); + this._register(wrapper.onTurnEnd(invalidate)); + // In-place rewrites of the persisted log. + this._register(wrapper.onSessionCompactionComplete(invalidate)); + this._register(wrapper.onSessionTruncation(invalidate)); + this._register(wrapper.onSessionSnapshotRewind(invalidate)); + } + private _subscribeForLogging(): void { const wrapper = this._wrapper; const sessionId = this.sessionId; + this._register(wrapper.onUnhandledEvent(e => { + this._logService.trace(`[Copilot:${sessionId}] Unhandled SDK event: ${safeStringify(e)}`); + })); + this._register(wrapper.onSessionStart(e => { this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); })); @@ -2897,6 +3380,15 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onIntent(e => { this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); + const activity = e.data.intent || undefined; + if (activity === undefined && !this._hasActivity) { + return; + } + this._hasActivity = activity !== undefined; + this._emitAction({ + type: ActionType.SessionActivityChanged, + activity, + }); })); this._register(wrapper.onReasoning(e => { @@ -2938,7 +3430,7 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); this._onDidSessionProgress.fire({ kind: 'subagent_completed', - session: this.sessionUri, + chat: this._chatChannelUri, toolCallId: e.data.toolCallId, }); })); @@ -2950,7 +3442,7 @@ export class CopilotAgentSession extends Disposable { this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); this._onDidSessionProgress.fire({ kind: 'subagent_completed', - session: this.sessionUri, + chat: this._chatChannelUri, toolCallId: e.data.toolCallId, }); })); diff --git a/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts b/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts index 8392e90d83dfe..1d1960c372684 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts @@ -7,7 +7,7 @@ import { ILogService } from '../../../log/common/log.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ICopilotApiService, type ICopilotUtilityChatMessage } from '../shared/copilotApiService.js'; -const COPILOT_BRANCH_PREFIX = 'agents/'; +export const COPILOT_BRANCH_PREFIX = 'agents/'; const COPILOT_BRANCH_SESSION_ID_SUFFIX_LENGTH = 8; const MAX_BRANCH_NAME_HINT_LENGTH = 48; const MIN_GENERATED_BRANCH_NAME_LENGTH = 8; @@ -17,6 +17,12 @@ export interface ICopilotBranchNameGeneratorRequest { readonly message?: string; readonly githubToken?: string; readonly signal?: AbortSignal; + /** + * Optional predicate used to check whether a candidate branch name already + * exists. When it reports a collision, a short suffix derived from the + * session id is appended so the generated branch name stays unique. + */ + readonly branchExists?: (branchName: string) => Promise; } export const ICopilotBranchNameGenerator = createDecorator('copilotBranchNameGenerator'); @@ -36,7 +42,7 @@ export class CopilotBranchNameGenerator implements ICopilotBranchNameGenerator { async generateBranchName(request: ICopilotBranchNameGeneratorRequest): Promise { const branchNameHint = (await this._generateBranchNameHint(request)) ?? getCopilotBranchNameHintFromMessage(request.message ?? ''); - return this._buildBranchName(request.sessionId, branchNameHint); + return this._buildBranchName(request, branchNameHint); } private async _generateBranchNameHint(request: ICopilotBranchNameGeneratorRequest): Promise { @@ -98,8 +104,20 @@ export class CopilotBranchNameGenerator implements ICopilotBranchNameGenerator { ]; } - private _buildBranchName(sessionId: string, branchNameHint: string | undefined): string { - return `${COPILOT_BRANCH_PREFIX}${branchNameHint ? `${branchNameHint}-${sessionId.substring(0, COPILOT_BRANCH_SESSION_ID_SUFFIX_LENGTH)}` : sessionId}`; + private async _buildBranchName(request: ICopilotBranchNameGeneratorRequest, branchNameHint: string | undefined): Promise { + if (!branchNameHint) { + // No usable hint - fall back to the (already unique) session id. + return `${COPILOT_BRANCH_PREFIX}${request.sessionId}`; + } + + // Prefer the bare hint and only append a short session-id suffix when + // the branch name would collide with an existing branch. + const branchName = `${COPILOT_BRANCH_PREFIX}${branchNameHint}`; + if (request.branchExists && await request.branchExists(branchName)) { + return `${branchName}-${request.sessionId.substring(0, COPILOT_BRANCH_SESSION_ID_SUFFIX_LENGTH)}`; + } + + return branchName; } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts index bb75ce3fbc23f..5f08a3098779a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts @@ -20,12 +20,12 @@ export async function resolveGitProject(workingDirectory: URI | undefined, gitSe return undefined; } - if (!await gitService.isInsideWorkTree(workingDirectory)) { + const repositoryRoot = await gitService.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { return undefined; } - const uri = (await gitService.getWorktreeRoots(workingDirectory))[0] - ?? await gitService.getRepositoryRoot(workingDirectory); + const uri = (await gitService.getWorktreeRoots(workingDirectory))[0] ?? repositoryRoot; if (!uri) { return undefined; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 97470ca6b711d..00e04a0b6ee44 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -3,19 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, ExitPlanModeRequest, ExitPlanModeResult, PermissionRequestResult, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; +import type { CopilotClient, ExitPlanModeRequest, ExitPlanModeResult, NamedProviderConfig, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; -import { ILogService } from '../../../log/common/log.js'; +import { ILogService, LogLevel } from '../../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyService.js'; +import type { IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; -import type { ActiveClientState } from '../activeClientState.js'; +import type { ActiveClientToolSet } from '../activeClientState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; @@ -23,9 +26,21 @@ import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfi import type { ITypedPermissionRequest } from './copilotToolDisplay.js'; import type { ICopilotPluginInfo } from './copilotAgent.js'; import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; +import { describeSystemMessageConfig } from './prompts/systemMessage.js'; import './prompts/allPrompts.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; +/** + * Config key for the numeric "Context Size" selection (a context-window token count). Mapped to the + * SDK's two-valued {@link SessionConfig.contextTier} by {@link getCopilotContextTier}. + */ +export const ContextSizeConfigKey = 'contextSize'; +/** + * @deprecated Legacy config key that stored the resolved tier string (`'default'` / `'long_context'`) + * directly. Replaced by the numeric {@link ContextSizeConfigKey}; still read from persisted sessions + * for backward compatibility. + */ export const ContextTierConfigKey = 'contextTier'; const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; @@ -41,6 +56,10 @@ type UserInputResponse = Awaited>; type ElicitationHandler = NonNullable; type ElicitationContext = Parameters[0]; type ElicitationResult = Awaited>; +type McpAuthHandler = NonNullable; +type McpAuthRequest = Parameters[0]; +type McpAuthContext = Parameters[1]; +type McpAuthResponse = Awaited>; type SessionHooks = NonNullable; type PreToolUseHookInput = Parameters>[0]; type PostToolUseHookInput = Parameters>[0]; @@ -53,9 +72,9 @@ type CopilotSessionLaunchConfig = ResumeSessionConfig & { * Immutable snapshot of the active client's structural contributions at * session creation time. Used to detect when the session needs to be * refreshed. Root MCP servers participate in restart detection because they - * are merged into the SDK session config. The owning `clientId` is + * are merged into the SDK session config. The owning `clientId`s are * deliberately NOT part of this snapshot: client identity is tracked live via - * {@link ActiveClientState} so a window + * {@link ActiveClientToolSet} so a window * reload (new `clientId`, identical tools/plugins) does not force a restart. */ export interface IActiveClientSnapshot { @@ -64,11 +83,22 @@ export interface IActiveClientSnapshot { readonly mcpServers: AgentHostMcpServers; } +/** + * The set of client-tool names the agent sees for a snapshot — each tool's + * `ToolDefinition.name` (the camelCase `toolReferenceName`). Used both to gate + * tool-specific prompt sections at launch and to route client tool calls during + * the session, so the two stay derived from one definition. + */ +export function clientToolNamesFromSnapshot(snapshot: IActiveClientSnapshot): ReadonlySet { + return new Set(snapshot.tools.map(tool => tool.name)); +} + export interface ICopilotSessionRuntime { handlePermissionRequest(request: ITypedPermissionRequest): Promise; handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise; handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise; handleElicitationRequest(context: ElicitationContext): Promise; + handleMcpAuthRequest(request: McpAuthRequest, context: McpAuthContext): Promise; requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise; handlePreToolUse(input: PreToolUseHookInput): Promise; handlePostToolUse(input: PostToolUseHookInput): Promise; @@ -95,19 +125,30 @@ interface ICopilotSessionLaunchBase { readonly resolvedAgentName: string | undefined; readonly snapshot: IActiveClientSnapshot; /** - * Live, long-lived holder of the owning client's identity. Read at - * tool-call stamp time so a window reload (new `clientId`, identical - * tools) stamps subsequent client tool calls with the current id - * rather than the one frozen into {@link snapshot} at creation. + * Live, long-lived registry of every active client's tool contributions. + * Read at tool-call stamp time so a window reload (new `clientId`, + * identical tools) stamps subsequent client tool calls with the current + * owning id rather than the one frozen into {@link snapshot} at creation, + * and so a tool call is attributed to whichever client contributed it. */ - readonly activeClientState: ActiveClientState; + readonly activeClientToolSet: ActiveClientToolSet; readonly shellManager: ShellManager | undefined; readonly githubToken: string | undefined; + + /** + * Whether this is a workspace-less session. Threaded into the + * prompt context so the resolved system message gets the scratch/repoless + * variant. Named to match the `workspaceless` marker used throughout the AH + * layer (session `_meta`, stored metadata) that this value flows from. + */ + readonly workspaceless?: boolean; } export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { readonly kind: 'create'; readonly model: ModelSelection | undefined; + readonly longContextWindow?: number; + readonly freeLongContext?: boolean; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -115,16 +156,18 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly workingDirectory: URI; readonly fallback: { readonly model: ModelSelection | undefined; + readonly longContextWindow?: number; + readonly freeLongContext?: boolean; }; } export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; -function isReasoningEffort(value: string | undefined): value is ReasoningEffort { +function isReasoningEffort(value: unknown): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } -function isContextTier(value: string | undefined): value is ContextTier { +function isContextTier(value: unknown): value is ContextTier { return ContextTiers.some(contextTier => contextTier === value); } @@ -177,18 +220,116 @@ export function getCopilotReasoningEffort(model: ModelSelection | undefined): Se return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } -export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { - const contextTier = model?.config?.[ContextTierConfigKey]; - return isContextTier(contextTier) ? contextTier : undefined; +export function getCopilotContextTier(model: ModelSelection | undefined, longContextWindow?: number, freeLongContext?: boolean): SessionConfig['contextTier'] { + // Legacy persisted selections stored the resolved tier string directly under the deprecated key. + const legacyTier = model?.config?.[ContextTierConfigKey]; + if (isContextTier(legacyTier)) { + return legacyTier; + } + // The "Context Size" picker exposes numeric token-count enum values, so a current selection arrives + // under `contextSize` as a token count. Map it to the SDK's two-valued tier using the model's + // long-context window: only a selection that reaches that window opts into `long_context`. Without + // the window (model exposes no picker, or the model list isn't loaded) leave the SDK on its default + // tier. + const contextSize = model?.config?.[ContextSizeConfigKey]; + if (contextSize === undefined) { + // When the model's long-context tier costs the same as the default tier, + // always opt into long_context — no picker is shown and the user gets the + // larger window for free. + return freeLongContext ? 'long_context' : undefined; + } + const selectedWindow = Number(contextSize); + if (!Number.isFinite(selectedWindow) || typeof longContextWindow !== 'number') { + return undefined; + } + return selectedWindow >= longContextWindow ? 'long_context' : 'default'; +} + +/** + * Resolve the BYOK provider/model session config for `sessionId` from the + * renderer's active bridge. Returns empty — the session launches without BYOK + * models — when BYOK is gated off (no active bridge), when the renderer reports + * no BYOK models, or when enumeration fails; `startProxy` is invoked only once + * at least one model is present. + * + * Each vendor maps to one `type: 'openai'` / `wireApi: 'completions'` provider + * whose `baseUrl` points at the proxy and authenticates with the session-scoped + * `Bearer .`; each model is surfaced under the + * provider-qualified selection id `vendor/id`, matching what the renderer's + * `AgentHostByokLmHandler` resolves. + * + * Extracted from {@link CopilotSessionLauncher} so the synthesis and gating are + * unit-testable without instantiating the launcher; the launcher passes a + * `startProxy` thunk that memoizes the single shared proxy handle. + */ +export async function resolveByokSessionConfig( + sessionId: string, + bridgeRegistry: IByokLmBridgeRegistry, + startProxy: () => Promise, + logService: ILogService, +): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { + // Surface the serving window's BYOK models. The registry tracks every + // connected renderer but does not union their model sets — a window's BYOK + // models come from its installed extensions, so all serving windows expose + // the same set and the registry picks one serving window (see + // `IByokLmBridgeRegistry`). Inbound inference is routed to that same serving + // connection by the proxy (`getServingConnection`). + let byokModels: IByokLmModelInfo[]; + try { + byokModels = await bridgeRegistry.listModels(); + } catch (err) { + logService.warn(`[Copilot:${sessionId}] Failed to enumerate BYOK models from renderer bridges`, err); + return {}; + } + if (byokModels.length === 0) { + return {}; + } + // `startProxy` binds a local loopback listener — unlikely to fail, but it + // must never break session materialization (which fires the cross-window + // `sessionAdded` broadcast). Degrade to no BYOK config on failure. + let handle: IByokLmProxyHandle; + try { + handle = await startProxy(); + } catch (err) { + logService.warn(`[Copilot:${sessionId}] Failed to start BYOK loopback proxy`, err); + return {}; + } + const providers: NamedProviderConfig[] = [...new Set(byokModels.map(m => m.vendor))].map(vendor => ({ + name: vendor, + type: 'openai', + wireApi: 'completions', + baseUrl: handle.providerBaseUrl(vendor), + bearerToken: `${handle.nonce}.${sessionId}`, + })); + const models: ProviderModelConfig[] = byokModels.map(m => ({ + id: m.id, + provider: m.vendor, + ...(m.name !== undefined ? { name: m.name } : {}), + ...(m.maxContextWindowTokens !== undefined ? { maxContextWindowTokens: m.maxContextWindowTokens } : {}), + })); + logService.info(`[Copilot:${sessionId}] Wired ${models.length} BYOK model(s) across ${providers.length} provider(s) via loopback proxy ${handle.baseUrl}`); + return { providers, models }; } export class CopilotSessionLauncher implements ICopilotSessionLauncher { + /** + * Memoized handle for the single shared BYOK loopback proxy, started lazily + * on the first session launch that surfaces BYOK models (see + * {@link _resolveByokSessionConfig}). Held as a promise so concurrent + * launches share one bind. Released and cleared by + * {@link disposeByokProxyHandle} when the owning Copilot client/runtime is + * stopped, so the next start mints a fresh nonce. + */ + private _byokProxyHandle: Promise | undefined; + constructor( @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, @ILogService private readonly _logService: ILogService, @IFileService private readonly _fileService: IFileService, + @IByokLmProxyService private readonly _byokLmProxyService: IByokLmProxyService, + @IByokLmBridgeRegistry private readonly _byokLmBridgeRegistry: IByokLmBridgeRegistry, ) { } async launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { @@ -199,13 +340,14 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } try { - this._logService.info(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); + const stopWatch = new StopWatch(); + this._logService.trace(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); const raw = await plan.client.resumeSession(plan.sessionId, { ...config, workingDirectory: plan.workingDirectory.fsPath, ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), }); - this._logService.info(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded`); + this._logService.trace(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded after ${stopWatch.elapsed()}ms`); await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); return new CopilotSessionWrapper(raw); } catch (err) { @@ -224,6 +366,8 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...plan, kind: 'create', model: plan.fallback.model, + longContextWindow: plan.fallback.longContextWindow, + freeLongContext: plan.fallback.freeLongContext, }, config, sandboxConfig); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -237,7 +381,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { streaming: true, model: plan.model?.id, reasoningEffort: getCopilotReasoningEffort(plan.model), - contextTier: getCopilotContextTier(plan.model), + contextTier: getCopilotContextTier(plan.model, plan.longContextWindow, plan.freeLongContext), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, }); @@ -287,8 +431,50 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } } + /** + * Launcher-bound wrapper over {@link resolveByokSessionConfig}: supplies the + * active bridge registry and a `startProxy` thunk that memoizes the single + * shared proxy handle for this launcher (started lazily on first use). + */ + private _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { + return resolveByokSessionConfig(sessionId, this._byokLmBridgeRegistry, () => { + if (!this._byokProxyHandle) { + this._byokProxyHandle = this._byokLmProxyService.start(); + } + return this._byokProxyHandle; + }, this._logService); + } + + /** + * Release the memoized BYOK loopback proxy handle (if any) and clear it so + * the next session launch mints a fresh nonce. Idempotent. + * + * **Ownership invariant.** The caller MUST stop the Copilot client/runtime + * subprocess before invoking this: disposing the handle drops the proxy's + * refcount and may rebind it on a different port/nonce, so a still-running + * subprocess would silently lose its endpoint — see {@link IByokLmProxyHandle}. + * Invoked from `CopilotAgent._stopClient` / `CopilotAgent.shutdown` after the + * client has stopped. + */ + async disposeByokProxyHandle(): Promise { + const handle = this._byokProxyHandle; + this._byokProxyHandle = undefined; + if (!handle) { + return; + } + try { + (await handle).dispose(); + } catch { + // The lazy `start()` rejected; there is nothing to release. + } + } + private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { const plugins = plan.snapshot.plugins; + // Synthesize BYOK provider/model config (empty when BYOK is gated off or the + // renderer reports no BYOK models). Merged into the returned config so both + // createSession and resumeSession advertise the models to the runtime. + const byok = await this._resolveByokSessionConfig(plan.sessionId); const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited> = []; if (enableCustomTerminalTool) { @@ -306,15 +492,34 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const skillDirectories = toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)); const instructionDirectories = toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)); const model = plan.kind === 'create' ? plan.model : plan.fallback.model; + // Client tools (browser tools, tasks, etc.) are addressed by the name the + // agent sees them under; used to gate tool-specific prompt sections. + const clientToolNames = clientToolNamesFromSnapshot(plan.snapshot); const promptContext: IAgentHostPromptContext = { getSetting: key => this._configurationService.getRootValue(agentHostCustomizationConfigSchema, key), + hasClientTool: name => clientToolNames.has(name), + workspaceless: plan.workspaceless === true, }; + // Resolved once per (re)launch — the SDK has no mid-session system-message + // update, so this reflects the model/tools/settings at launch time. Log a + // summary at info for prompt observability; the full config at trace. + const systemMessage = agentHostPromptRegistry.resolveSystemMessageConfig(model, promptContext); + this._logService.info(`[Copilot:${plan.sessionId}] Resolved system message: ${describeSystemMessageConfig(systemMessage)}`); + if (this._logService.getLevel() <= LogLevel.Trace) { + // Guarded: a `replace`-mode prompt's content can be multiple KB, so only + // serialize it when trace output is actually emitted. + this._logService.trace(`[Copilot:${plan.sessionId}] System message config: ${JSON.stringify(systemMessage, (_key, value) => typeof value === 'function' ? '[transform fn]' : value)}`); + } return { + ...byok, clientName: 'vscode', enableMcpApps: true, + enableFileHooks: true, + enableConfigDiscovery: true, onPermissionRequest: request => runtime.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), onElicitationRequest: context => runtime.handleElicitationRequest(context), + onMcpAuthRequest: (request, context) => runtime.handleMcpAuthRequest(request, context), hooks: toSdkHooks(pluginsWithoutDirs.flatMap(p => p.hooks), { onPreToolUse: input => runtime.handlePreToolUse(input), onPostToolUse: input => runtime.handlePostToolUse(input), @@ -325,7 +530,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { customAgents, skillDirectories, instructionDirectories, - systemMessage: agentHostPromptRegistry.resolveSystemMessageConfig(model, promptContext), + systemMessage, pluginDirectories: coalesce(plugins.map(p => p.pluginDir)) .filter(d => d.scheme === Schemas.file).map(d => d.fsPath), tools: [...shellTools, ...runtime.createClientSdkTools(), ...runtime.createServerSdkTools()], diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index 04eba456317a7..a9240fb247057 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotSession, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; +import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -14,8 +14,18 @@ import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; */ export class CopilotSessionWrapper extends Disposable { + private readonly _handledEventTypes = new Set(); + private readonly _onUnhandledEvent = this._register(new Emitter()); + readonly onUnhandledEvent = this._onUnhandledEvent.event; + constructor(readonly session: CopilotSession) { super(); + const unsubscribeAll = session.on(event => { + if (!this._handledEventTypes.has(event.type)) { + this._onUnhandledEvent.fire(event); + } + }); + this._register(toDisposable(unsubscribeAll)); this._register(toDisposable(() => { session.disconnect().catch(() => { /* best-effort */ }); })); @@ -238,8 +248,16 @@ export class CopilotSessionWrapper extends Disposable { return this._onToolsUpdated ??= this._sdkEvent('session.tools_updated'); } + private _onCommandsChanged: Event> | undefined; + get onCommandsChanged(): Event> { + return this._onCommandsChanged ??= this._sdkEvent('commands.changed'); + } + private _sdkEvent(eventType: K): Event> { - const emitter = this._register(new Emitter>()); + const emitter = this._register(new Emitter>({ + onDidAddFirstListener: () => this._handledEventTypes.add(eventType), + onDidRemoveLastListener: () => this._handledEventTypes.delete(eventType), + })); const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => emitter.fire(data)); this._register(toDisposable(unsubscribe)); return emitter.event; diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index d0db4dea996a7..94e40f56b4650 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -360,21 +360,27 @@ function shouldUseBracketedPasteMode(command: string): boolean { function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } { const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`; - const idx = content.indexOf(marker); - if (idx === -1) { - return { found: false, exitCode: -1, outputBeforeSentinel: content }; + let markerIndex = content.lastIndexOf(marker); + while (markerIndex !== -1) { + const outputBeforeSentinel = content.substring(0, markerIndex); + const afterMarker = content.substring(markerIndex + marker.length); + const endIdx = afterMarker.indexOf('>>>'); + if (endIdx !== -1) { + const exitCodeStr = afterMarker.substring(0, endIdx).trim(); + if (/^-?\d+$/.test(exitCodeStr)) { + return { + found: true, + exitCode: parseInt(exitCodeStr, 10), + outputBeforeSentinel, + }; + } + } + // Ignore echoed sentinel command text (for example `$?`) and continue + // scanning for the latest complete numeric sentinel marker. + markerIndex = content.lastIndexOf(marker, markerIndex - 1); } - const outputBeforeSentinel = content.substring(0, idx); - const afterMarker = content.substring(idx + marker.length); - const endIdx = afterMarker.indexOf('>>>'); - const exitCodeStr = endIdx >= 0 ? afterMarker.substring(0, endIdx) : afterMarker.trim(); - const exitCode = parseInt(exitCodeStr, 10); - return { - found: true, - exitCode: isNaN(exitCode) ? -1 : exitCode, - outputBeforeSentinel, - }; + return { found: false, exitCode: -1, outputBeforeSentinel: content }; } function prepareOutputForModel(rawOutput: string): string { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 2584959885b20..b60800e3c05ef 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -4,89 +4,91 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { localize } from '../../../../nls.js'; import { AgentSession } from '../../common/agentService.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../../common/state/protocol/commands.js'; -import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; +import { Customization, CustomizationType, DirectoryCustomization, MessageAttachmentKind, PluginCustomization, SkillCustomization } from '../../common/state/protocol/state.js'; import { toCommandCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from '../agentHostCompletions.js'; -import { extractLeadingSlashToken } from '../agentHostSlashCompletion.js'; +import { extractLeadingSlashToken, extractWhitespaceDelimitedSlashToken } from '../agentHostSlashCompletion.js'; +import { localize } from '../../../../nls.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; -/** - * Slash-command name and the token we surface to the user / round-trip on - * the {@link MessageAttachmentKind.Simple} attachment's `_meta`. - */ -export type CopilotSlashCommandName = 'plan' | 'compact' | 'research' | 'rubber-duck' | 'env' | 'review' | 'security-review'; - -const COMMANDS: readonly CopilotSlashCommandName[] = ['plan', 'compact', 'research', 'rubber-duck', 'env', 'review', 'security-review']; -const RUNTIME_RPC_INVOKED_COMMANDS = new Set(['env']); -const PROMPT_INVOKED_COMMANDS = new Set(['research', 'review', 'security-review']); -function getCommandDescription(command: CopilotSlashCommandName): string { - switch (command) { - case 'plan': return localize('copilotSlashCommand.plan.description', "Create an implementation plan before coding"); - case 'compact': return localize('copilotSlashCommand.compact.description', "Free up context by compacting the conversation history"); - case 'research': return localize('copilotSlashCommand.research.description', "Run deep research on a topic using search and web sources"); - case 'rubber-duck': return localize('copilotSlashCommand.rubberDuck.description', "Get an independent critique of the current approach"); - case 'env': return localize('copilotSlashCommand.env.description', "Show loaded environment details"); - case 'review': return localize('copilotSlashCommand.review.description', "Run code review agent to analyze changes"); - case 'security-review': return localize('copilotSlashCommand.securityReview.description', "Analyze staged and unstaged changes for security vulnerabilities"); - } -} +const HIDDEN_RUNTIME_COMMANDS = new Set(['agent', 'app', 'changelog', 'context', 'copy', 'cwd', 'exit', 'extensions', 'feedback', 'help', 'ide', 'instructions', 'login', 'logout', 'mcp', 'model', 'new', 'plugin', 'rename', 'restart', 'resume', 'sandbox', 'session', 'settings', 'skills', 'statusline', 'streamer-mode', 'subagents', 'tasks', 'terminal-setup', 'theme', 'undo', 'update', 'user', 'voice', 'worktree', 'autopilot', 'yolo']); -export function isRuntimeCopilotSlashCommand(command: CopilotSlashCommandName): boolean { - return RUNTIME_RPC_INVOKED_COMMANDS.has(command); -} +export const DEFAULT_RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300; -export function isPromptInvokedCopilotSlashCommand(command: CopilotSlashCommandName): boolean { - return PROMPT_INVOKED_COMMANDS.has(command); -} +const CommandOptionDescriptions: Record = { + 'chronicle:cost-tips': localize('copilot.command.chronicle.cost.tips', "Get personalized tips to reduce token usage and Copilot cost"), + 'chronicle:improve': localize('copilot.command.chronicle.improve', "Get personalized tips to improve your chat session usage"), + 'chronicle:reindex': localize('copilot.command.chronicle.reindex', "Rebuild the local session index and sync to cloud"), + 'chronicle:search': localize('copilot.command.chronicle.search', "Search recent chat sessions by keyword, file path, or PR/issue ref"), + 'chronicle:standup': localize('copilot.command.chronicle.standup', "Generate a standup report from recent chat sessions"), + 'chronicle:tips': localize('copilot.command.chronicle.tips', "Get personalized tips based on your chat session usage patterns"), +}; + +// Some hints like `prompt` or `directory` are not useful to show in the completion list, so we ignore them. +// They are not useful as completion items. +const CommandOptionsToIgnore = new Set([ + 'add-dir:directory', + 'after: ', + 'compact:focus instructions', + 'directory', + 'every: ', + 'fleet:prompt', + 'loop: ', + 'plan:prompt', + 'research:topic', + 'review:additional instructions', + 'security-review:additional instructions' +]); -function commandExpectsInput(command: CopilotSlashCommandName): boolean { - return command !== 'compact' && command !== 'env'; -} /** - * Lookup hook used by {@link CopilotSlashCommandCompletionProvider} to - * decide whether history-dependent commands (e.g. `/compact`) make sense - * for a given session. Sessions that haven't been materialized yet — i.e. - * the user hasn't sent a first message — have no history. + * Lookup hooks used by {@link CopilotSlashCommandCompletionProvider} to + * retrieve runtime slash command metadata and apply feature gating. */ export interface ICopilotSlashCommandSessionInfo { - /** `sessionId` is the raw id (URI path without the leading slash). */ - hasHistory(sessionId: string): boolean; /** * Whether the experimental rubber duck critic subagent is enabled via - * the agent host config. When absent or `false`, `/rubber-duck` is hidden. + * the agent host config. When provided and `false`, `/rubber-duck` is hidden. */ isRubberDuckEnabled?(): boolean; - /** Whether the runtime reports the given slash command as available. */ - hasRuntimeSlashCommand?(sessionId: string, command: string): Promise; + /** Runtime slash commands discovered from the SDK session. */ + getRuntimeSlashCommands?(sessionId: string, options?: ICopilotRuntimeSlashCommandQueryOptions): Promise; + getSessionCustomizations: (session: string) => Promise; +} + +export interface ICopilotRuntimeSlashCommandQueryOptions { + readonly maxWaitMs?: number; } /** * Result of {@link parseLeadingSlashCommand}. */ export interface IParsedLeadingSlashCommand { - readonly command: CopilotSlashCommandName; + readonly command: string; /** Trimmed text following the command (empty if none). */ readonly rest: string; + /** Raw text after the command delimiter (preserves multiline text). */ + readonly rawRest: string; } /** * Parses a Copilot CLI slash command at the very start of `prompt`. * - * The command must be `/plan`, `/compact`, `/research`, `/rubber-duck`, `/env`, `/review`, or `/security-review`, - * followed either by end-of-input or by at least one whitespace character. - * `/compact-hello`, `/plans`, or a leading-space `/compact` all return - * `undefined`. Match is case-sensitive. + * Accepts any `/command` token where `command` is a single non-whitespace + * segment (no leading/trailing spaces, no embedded slash), followed either + * by end-of-input or by at least one whitespace character. */ export function parseLeadingSlashCommand(prompt: string): IParsedLeadingSlashCommand | undefined { - const match = /^\/(plan|compact|research|rubber-duck|env|review|security-review)(?:$|\s+([\s\S]*))/.exec(prompt); + const match = /^\/([^\s/]+)(?:$|\s+([\s\S]*))/.exec(prompt); if (!match) { return undefined; } + const rawRest = match[2] ?? ''; return { - command: match[1] as CopilotSlashCommandName, - rest: (match[2] ?? '').trim(), + command: match[1], + rest: rawRest.trim(), + rawRest, }; } @@ -105,51 +107,157 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti readonly kinds: ReadonlySet = new Set([CompletionItemKind.UserMessage]); readonly triggerCharacters = [CompletionTriggerCharacter.Slash] as const; - constructor(private readonly copilotcliId: string, private readonly _sessionInfo?: ICopilotSlashCommandSessionInfo) { } + constructor( + private readonly copilotcliId: string, + private readonly _sessionInfo: ICopilotSlashCommandSessionInfo, + private readonly _runtimeSlashCommandCompletionWaitMs: number = DEFAULT_RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS, + ) { } async provideCompletionItems(params: CompletionsParams, _token: CancellationToken): Promise { if (AgentSession.provider(params.channel) !== this.copilotcliId) { return []; } - const leading = extractLeadingSlashToken(params.text, params.offset); + const leadingTokenForSkills = extractWhitespaceDelimitedSlashToken(params.text, params.offset); + const leadingTokenForCommands = extractLeadingSlashToken(params.text, params.offset); + const leading = leadingTokenForCommands ?? leadingTokenForSkills; + const returnJustSkills = !leadingTokenForCommands && !!leadingTokenForSkills; if (!leading) { return []; } // Raw session id is the URI path without the leading slash. const sessionId = AgentSession.id(params.channel); - const hasHistory = this._sessionInfo?.hasHistory(sessionId) ?? true; - // `/abc` → typed = 'abc'; empty after just '/' → typed = ''. const typed = leading.typed; - const rubberDuckEnabled = this._sessionInfo?.isRubberDuckEnabled?.() ?? false; - const items: CompletionItem[] = []; - for (const command of COMMANDS) { - if (typed.length > 0 && !command.startsWith(typed)) { + return await this._getRuntimeSlashCommandCompletionInfo(sessionId, typed, leading, returnJustSkills); + } + + private async _getKnownSkills(sessionId: string) { + const knownCommands = new Set(); + const customizations = await this._sessionInfo.getSessionCustomizations(sessionId) ?? []; + for (const c of customizations) { + if (c.type === CustomizationType.McpServer || !c.enabled || !c.children) { + continue; + } + for (const child of c.children) { + if (child.type === CustomizationType.Skill) { + knownCommands.add(this._toSlashCommandCandidate(c, child)); + } + } + } + return knownCommands; + } + + private _toSlashCommandCandidate(container: PluginCustomization | DirectoryCustomization, skill: SkillCustomization): string { + // see getCanonicalPluginCommandId + let slashCommandName = skill.name; + if (container.type === CustomizationType.Plugin && !isSyncedCustomization(container) && skill.name !== container.name) { + slashCommandName = `${container.name}:${skill.name}`; + } + return slashCommandName; + } + + private async _getRuntimeSlashCommandCompletionInfo(sessionId: string, typed: string, { rangeStart, rangeEnd }: { rangeStart: number; rangeEnd: number }, returnJustSkills: boolean): Promise { + const [runtimeCommands, knownSkills] = await Promise.all([ + this._sessionInfo.getRuntimeSlashCommands?.(sessionId, { maxWaitMs: this._runtimeSlashCommandCompletionWaitMs }) ?? [], + this._getKnownSkills(sessionId) + ]); + const typedLower = typed.toLowerCase(); + const rubberDuckEnabled = this._sessionInfo?.isRubberDuckEnabled?.() ?? true; + const completionItems: CompletionItem[] = []; + const addedAliases = new Set(); + + for (const command of runtimeCommands) { + if (!command.name) { continue; } - // `/compact` only makes sense once the session has prior turns to compact. - if (command === 'compact' && !hasHistory) { + if (returnJustSkills && command.kind !== 'skill') { continue; } - // `/rubber-duck` is only available when the feature is enabled. - if (command === 'rubber-duck' && !rubberDuckEnabled) { + if (command.kind === 'skill' && knownSkills.has(command.name)) { + // This is a known skill, so we don't want to show it in the runtime command completion list. continue; } - if (isRuntimeCopilotSlashCommand(command) && !(await this._sessionInfo?.hasRuntimeSlashCommand?.(sessionId, command))) { + if (HIDDEN_RUNTIME_COMMANDS.has(command.name) || command.aliases?.some(alias => HIDDEN_RUNTIME_COMMANDS.has(alias))) { continue; } - items.push({ - insertText: commandExpectsInput(command) ? '/' + command + ' ' : '/' + command, - rangeStart: 0, - rangeEnd: leading.rangeEnd, - attachment: { - type: MessageAttachmentKind.Simple, - label: '/' + command, - _meta: toCommandCompletionAttachmentMeta({ command, description: getCommandDescription(command) }), - }, - }); + if (!rubberDuckEnabled && command.name === 'rubber-duck') { + continue; + } + if (typed.length > 0 && !command.name.toLowerCase().startsWith(typedLower) && !command.aliases?.some(alias => alias.toLowerCase().startsWith(typedLower))) { + continue; + } + // Hints contain sub commands like [on|off] or `on|off` + // First remove the brackets and then split by pipe to get the options + // If its a skill, then do not use the hint to construct the options. + const options: string[] = []; + let skillHint = ''; + if (command.kind === 'skill') { + // do nothing, skills do not have options + skillHint = command.input?.hint ? ` \n(Prompt: ${command.input.hint})` : ''; + } else { + options.push(...(command.input?.hint ?? '').replace(/[\[\]]/g, '').split('|')); + if (options.length && !command.input?.required) { + // If we have options but they are optional, + // then make sure we add an empty option so that the user can select just the command without any options. + options.unshift(''); + } + } + + + // Generate completion items for each alias and option combination. + // If there are no options, generate a single completion item for the alias. + const aliases = Array.from(new Set([command.name].concat(command.aliases ?? []))); + aliases + .filter(alias => !addedAliases.has(alias)) + .forEach(alias => { + (Array.from(new Set(options.length ? options : ['']))) + .filter(option => !CommandOptionsToIgnore.has(`${command.name}:${option}`)) + .forEach(option => { + // Add a trailing space after the command (and sub command/option if present). + // This is so user can continue to type additional arguments after the command and option. + const insertText = `/${alias}${option ? ' ' + option : ''} `; + const optionDescription = option ? CommandOptionDescriptions[`${command.name}:${option}`] : command.description; + const description = `${optionDescription ?? command.description}${skillHint}`; + + addedAliases.add(alias); + + completionItems.push({ + insertText, + rangeStart: rangeStart, + rangeEnd: rangeEnd, + attachment: { + type: MessageAttachmentKind.Simple, + label: insertText, + _meta: toCommandCompletionAttachmentMeta({ + command: command.name, + ...(description !== undefined ? { description } : {}) + }), + }, + }); + }); + }); } - return items; + + return completionItems.sort((a, b) => a.insertText.localeCompare(b.insertText)); } } + +export interface ICopilotRuntimeSlashCommandInfo { + readonly name: string; + readonly aliases?: readonly string[]; + readonly description: string; + readonly kind: 'builtin' | 'skill' | 'client'; + readonly input?: { + readonly hint: string; + readonly required?: boolean; + readonly preserveMultilineInput?: boolean; + }; + readonly allowDuringAgentExecution: boolean; + readonly experimental?: boolean; + readonly schedulable?: boolean; +} + +function isSyncedCustomization(container: PluginCustomization): boolean { + return container.uri.startsWith(SYNCED_CUSTOMIZATION_SCHEME + ':'); +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts index a5d5c7836eff0..98e74e42da25c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { SessionEventPayload } from '@github/copilot-sdk'; +import { softAssertNever } from '../../../../base/common/assert.js'; import { localize } from '../../../../nls.js'; export interface ICopilotSystemNotification { @@ -11,6 +12,8 @@ export interface ICopilotSystemNotification { readonly content: string; /** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */ readonly messageText: string; + /** Whether the runtime notification wakes the agent loop when it arrives while idle. */ + readonly startsTurn: boolean; } export function buildCopilotSystemNotification(event: SessionEventPayload<'system.notification'>): ICopilotSystemNotification | undefined { @@ -33,14 +36,37 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste : shellId ? localize('agentHost.copilot.systemNotification.shellIdCompleted', "Shell `{0}` completed", shellId) : localize('agentHost.copilot.systemNotification.shellCompleted', "Shell completed"), + startsTurn: true, }; } case 'agent_completed': return { content, - messageText: localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent completed"), + messageText: kind.status === 'failed' + ? localize('agentHost.copilot.systemNotification.agentFailed', "Background agent {0} failed", kind.agentId) + : localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent {0} completed", kind.agentId), + startsTurn: true, + }; + case 'agent_idle': + return { + content, + messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is complete", kind.agentId), + startsTurn: true, + }; + case 'new_inbox_message': + return { + content, + messageText: localize('agentHost.copilot.systemNotification.newInboxMessage', "New inbox message from {0}", kind.senderName), + startsTurn: false, + }; + case 'instruction_discovered': + return { + content, + messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath), + startsTurn: false, }; default: + softAssertNever(kind); return undefined; } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 3a4c2d713536c..fbc6cdb19e487 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -13,6 +13,7 @@ import type { IAgentToolPendingConfirmationSignal } from '../../common/agentServ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { StringOrMarkdown } from '../../common/state/protocol/state.js'; import { basename } from '../../../../base/common/resources.js'; +import { getServerToolDisplay } from '../shared/serverToolGroups.js'; // ============================================================================= // Copilot CLI built-in tool interfaces @@ -475,6 +476,10 @@ function md(value: string): StringOrMarkdown { } export function getToolDisplayName(toolName: string): string { + const serverDisplay = getServerToolDisplay(toolName, undefined)?.displayName; + if (serverDisplay !== undefined) { + return serverDisplay; + } switch (toolName) { case CopilotToolName.StrReplaceEditor: case CopilotToolName.Edit: @@ -534,6 +539,11 @@ export function getToolDisplayName(toolName: string): string { } export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined): StringOrMarkdown { + const serverDisplay = getServerToolDisplay(toolName, parameters)?.invocationMessage; + if (serverDisplay !== undefined) { + return serverDisplay; + } + if (SHELL_TOOL_NAMES.has(toolName)) { const args = parameters as ICopilotShellToolArgs | undefined; if (args?.command) { @@ -639,11 +649,16 @@ export function getInvocationMessage(toolName: string, displayName: string, para } } -export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean): StringOrMarkdown { +export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean, resultText?: string): StringOrMarkdown { if (!success) { return localize('toolComplete.failed', "\"{0}\" failed", displayName); } + const serverDisplay = getServerToolDisplay(toolName, parameters, { text: resultText, success })?.pastTenseMessage; + if (serverDisplay !== undefined) { + return serverDisplay; + } + if (SHELL_TOOL_NAMES.has(toolName)) { const args = parameters as ICopilotShellToolArgs | undefined; if (args?.command) { @@ -971,6 +986,12 @@ export interface ITypedPermissionRequest { fileName?: string; /** Full shell command text — set for `shell` permission requests. */ fullCommandText?: string; + /** + * True when the model requested this `shell` command run outside the + * sandbox (via `requestSandboxBypass`) and the host opted in via + * `sandbox.allowBypass`. + */ + requestSandboxBypass?: boolean; /** Human-readable intention describing the operation. */ intention?: string; /** MCP server name — set for `mcp` permission requests. */ @@ -1010,6 +1031,10 @@ export function getPermissionDisplay(request: ITypedPermissionRequest, workingDi const serverName = str(request.serverName); const toolName = str(request.toolName); + const shellConfirmationTitle = request.requestSandboxBypass + ? localize('copilot.permission.shell.bypass.title', "Run in terminal outside the sandbox?") + : localize('copilot.permission.shell.title', "Run in terminal?"); + switch (request.kind) { case 'shell': { // Strip a redundant `cd && …` prefix so the @@ -1018,7 +1043,7 @@ export function getPermissionDisplay(request: ITypedPermissionRequest, workingDi stripRedundantCdPrefix(CopilotToolName.Bash, shellParams, workingDirectory); const cleanedCommand = typeof shellParams?.command === 'string' ? shellParams.command : fullCommandText; return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), + confirmationTitle: shellConfirmationTitle, invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), cleanedCommand ? { command: cleanedCommand } : undefined), toolInput: cleanedCommand, permissionKind: 'shell', @@ -1034,7 +1059,7 @@ export function getPermissionDisplay(request: ITypedPermissionRequest, workingDi stripRedundantCdPrefix(sdkToolName, args, workingDirectory); const command = args.command as string; return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), + confirmationTitle: shellConfirmationTitle, invocationMessage: getInvocationMessage(sdkToolName, getToolDisplayName(sdkToolName), { command }), toolInput: command, permissionKind: 'shell', diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 393db223833f6..01326d7b64109 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteData } from '@github/copilot-sdk'; +import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteContent, ToolExecutionCompleteData } from '@github/copilot-sdk'; import { decodeBase64 } from '../../../../base/common/buffer.js'; import { basename } from '../../../../base/common/path.js'; import { isString } from '../../../../base/common/types.js'; @@ -13,7 +13,7 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type Message, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../shared/fileEditTracker.js'; import { getMediaMime } from '../../../../base/common/mime.js'; @@ -42,6 +42,23 @@ function isSyntheticUserMessage(event: SessionEvent): boolean { return !!source && source.toLowerCase() !== 'user'; } +export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined): void { + for (const sdkContent of sdkContents ?? []) { + switch (sdkContent.type) { + case 'shell_exit': + content.push({ + type: ToolResultContentType.ShellExit, + shellId: sdkContent.shellId, + exitCode: sdkContent.exitCode, + ...(sdkContent.cwd !== undefined ? { cwd: sdkContent.cwd } : {}), + ...(sdkContent.outputPreview !== undefined ? { outputPreview: sdkContent.outputPreview } : {}), + ...(sdkContent.outputTruncated !== undefined ? { outputTruncated: sdkContent.outputTruncated } : {}), + }); + break; + } + } +} + // ============================================================================= // Single-pass turn builder // ============================================================================= @@ -80,10 +97,20 @@ interface ITurnBuilder { readonly pendingTools: Map; } -function newTurnBuilder(id: string, text: string, attachments?: MessageAttachment[]): ITurnBuilder { - const message: Message = attachments?.length - ? { text, origin: { kind: MessageKind.User }, attachments } - : { text, origin: { kind: MessageKind.User } }; +export interface IMapSessionEventsOptions { + readonly workingDirectory?: URI; + readonly model?: ModelSelection; + readonly agent?: AgentSelection; +} + +function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection }): ITurnBuilder { + const message: Message = { + text, + origin: { kind: MessageKind.User }, + ...(options?.attachments?.length ? { attachments: options.attachments } : {}), + ...(options?.model ? { model: options.model } : {}), + ...(options?.agent ? { agent: options.agent } : {}), + }; return { id, message, responseParts: [], pendingTools: new Map() }; } @@ -147,8 +174,11 @@ export async function mapSessionEvents( session: URI, db: ISessionDatabase | undefined, events: readonly SessionEvent[], - workingDirectory?: URI, + options: URI | IMapSessionEventsOptions | undefined = undefined, ): Promise<{ turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap }> { + const workingDirectory = options instanceof URI ? options : options?.workingDirectory; + let currentModel = options instanceof URI ? undefined : options?.model; + let currentAgent = options instanceof URI ? undefined : options?.agent; // First pass: collect tool-arg info and identify edit tool calls so we // can batch-load their stored file edits before the second pass needs // them at `tool.execution_complete` time. We also build the @@ -221,22 +251,38 @@ export async function mapSessionEvents( // the most recent turn per subagent is built (subagents currently emit // at most one turn per invocation). const subagentBuilders = new Map(); + const subagentTurnStates = new Map(); const subagentTurns = new Map(); const subagentInfoByToolCallId = new Map(); let parentBuilder: ITurnBuilder | undefined; + let parentTurnState = TurnState.Cancelled; + let parentTurnAborted = false; + + const flushParent = (): void => { + if (!parentBuilder) { + return; + } + turns.push(finalizeTurn(parentBuilder, parentTurnState)); + parentBuilder = undefined; + parentTurnState = TurnState.Cancelled; + parentTurnAborted = false; + }; const flushSubagent = (parentToolCallId: string): void => { const builder = subagentBuilders.get(parentToolCallId); if (!builder) { + subagentTurnStates.delete(parentToolCallId); return; } subagentBuilders.delete(parentToolCallId); + const state = subagentTurnStates.get(parentToolCallId) ?? TurnState.Complete; + subagentTurnStates.delete(parentToolCallId); if (builder.responseParts.length === 0) { return; } const list = subagentTurns.get(parentToolCallId) ?? []; - list.push(finalizeTurn(builder, TurnState.Complete)); + list.push(finalizeTurn(builder, state)); subagentTurns.set(parentToolCallId, list); }; @@ -245,6 +291,9 @@ export async function mapSessionEvents( if (!builder) { builder = newTurnBuilder(generateUuid(), ''); subagentBuilders.set(parentToolCallId, builder); + if (!subagentTurnStates.has(parentToolCallId)) { + subagentTurnStates.set(parentToolCallId, TurnState.Complete); + } } return builder; }; @@ -258,6 +307,16 @@ export async function mapSessionEvents( for (const e of events) { switch (e.type) { + case 'session.model_change': { + currentModel = { id: e.data.newModel }; + break; + } + case 'subagent.deselected': { + if (!e.agentId) { + currentAgent = undefined; + } + break; + } case 'user.message': { if (isSyntheticUserMessage(e)) { continue; @@ -291,11 +350,9 @@ export async function mapSessionEvents( // `setTurnEventId` records as `event_id`) so the restored // turn id round-trips back to the SDK boundary id that // fork / truncate RPCs operate on. - if (parentBuilder) { - turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); - } + flushParent(); const turnId = e.id ?? messageId; - parentBuilder = newTurnBuilder(turnId, content, attachments); + parentBuilder = newTurnBuilder(turnId, content, { attachments, model: currentModel, agent: currentAgent }); } break; } @@ -306,6 +363,12 @@ export async function mapSessionEvents( const reasoningText = d.reasoningText; const hasToolRequests = !!d.toolRequests && d.toolRequests.length > 0; const parentToolCallId = resolveParentToolCallId(e.agentId, d.parentToolCallId); + if (!content && !reasoningText && !hasToolRequests) { + if (!parentToolCallId && parentBuilder && !parentTurnAborted) { + parentTurnState = TurnState.Complete; + } + break; + } // When this is the first event in a turn (no parent builder // yet), seed the builder with the SDK envelope id so the // turn id matches `turns.event_id` for fork/truncate @@ -328,17 +391,12 @@ export async function mapSessionEvents( content, }); } + if (!parentToolCallId && builder === parentBuilder && !parentTurnAborted) { + parentTurnState = hasToolRequests ? TurnState.Cancelled : TurnState.Complete; + } if (d.toolRequests?.length) { appendFallbackToolRequests(builder, d.toolRequests, parentToolCallId); } - // A parent assistant message without further tool requests - // terminates the current parent turn (no more responses - // expected). Subagent turns are flushed at the parent's - // `tool.execution_complete` instead. - if (!parentToolCallId && !hasToolRequests && builder === parentBuilder) { - turns.push(finalizeTurn(parentBuilder, TurnState.Complete)); - parentBuilder = undefined; - } break; } case 'subagent.started': { @@ -351,8 +409,10 @@ export async function mapSessionEvents( break; } case 'tool.execution_start': { - // Already collected in the first pass; no per-event work - // needed here. Hidden tools are filtered above. + const parentToolCallId = resolveParentToolCallId(e.agentId, e.data.parentToolCallId); + if (!parentToolCallId && parentBuilder) { + parentTurnState = TurnState.Cancelled; + } break; } case 'tool.execution_complete': { @@ -377,9 +437,8 @@ export async function mapSessionEvents( content: summary, }); } - if (!parentToolCallId && d.success && builder === parentBuilder) { - turns.push(finalizeTurn(parentBuilder, TurnState.Complete)); - parentBuilder = undefined; + if (!parentToolCallId && d.success && builder === parentBuilder && !parentTurnAborted) { + parentTurnState = TurnState.Complete; } continue; } @@ -399,7 +458,12 @@ export async function mapSessionEvents( } case 'skill.invoked': { const synth = synthesizeSkillToolCall(e.data, e.id); - const builder = parentBuilder ?? (parentBuilder = newTurnBuilder(generateUuid(), '')); + const parentToolCallId = resolveParentToolCallId(e.agentId, undefined); + const builder = targetBuilderFor(parentToolCallId) + ?? (parentBuilder = newTurnBuilder(generateUuid(), '')); + if (!parentToolCallId && builder === parentBuilder) { + parentTurnState = TurnState.Cancelled; + } builder.responseParts.push({ kind: ResponsePartKind.ToolCall, toolCall: { @@ -415,16 +479,22 @@ export async function mapSessionEvents( }); break; } + case 'abort': { + const parentToolCallId = resolveParentToolCallId(e.agentId, undefined); + if (parentToolCallId) { + subagentTurnStates.set(parentToolCallId, TurnState.Cancelled); + } else if (parentBuilder) { + parentTurnState = TurnState.Cancelled; + parentTurnAborted = true; + } + break; + } default: break; } } - // Drain any unfinished turns. - if (parentBuilder) { - turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); - parentBuilder = undefined; - } + flushParent(); for (const parentToolCallId of [...subagentBuilders.keys()]) { flushSubagent(parentToolCallId); } @@ -451,6 +521,9 @@ export async function mapSessionEvents( content: summary, }); } + if (!parentToolCallId && completion?.success && builder === parentBuilder && !parentTurnAborted) { + parentTurnState = TurnState.Complete; + } continue; } builder.responseParts.push(makeCompletedToolCallPart( @@ -521,6 +594,9 @@ function sdkAttachmentToProtocol( }; } case 'blob': { + if (typeof attachment.data !== 'string') { + return undefined; + } if (attachment.mimeType.startsWith('text/plain')) { return { type: MessageAttachmentKind.Simple, @@ -560,6 +636,7 @@ function makeCompletedToolCallPart( if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } + appendSdkToolResultContent(content, d.result?.contents); // Restore file edit content references from the database. const edits = storedEdits?.get(d.toolCallId); @@ -606,7 +683,7 @@ function makeCompletedToolCallPart( invocationMessage: info.invocationMessage, toolInput: info.toolInput, success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success), + pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success, d.success ? toolOutput : undefined), content: content.length > 0 ? content : undefined, error: d.error, confirmed: ToolCallConfirmationReason.NotNeeded, diff --git a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md new file mode 100644 index 0000000000000..d503c7922a5cd --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md @@ -0,0 +1,139 @@ +# Agent host system-prompt customization + +This directory customizes the system prompt for Copilot CLI **agent host** +(ahp+cli) sessions. Read this before changing how the system message is built or +adding per-model / per-tool guidance. It mirrors the Copilot extension's +`extensions/copilot/.../prompts/node/agent/` (agentPrompts), but the agent host +runs in its own process and cannot use prompt-tsx, so contributors return plain +data the SDK accepts directly. + +## Files + +- `promptRegistry.ts` — `AgentHostPromptRegistry`: resolves the final + `SystemMessageConfig` for a session's model. Defines the `IAgentHostPrompt` + contributor interface and the `IAgentHostPromptContext` read-time context. +- `systemMessage.ts` — the default message (`COPILOT_AGENT_HOST_SYSTEM_MESSAGE`), + shared identity text, the `fullSystemPrompt` / `sectionOverrides` builders, and + `describeSystemMessageConfig` (the one-line log summary). +- `toolInstructions.ts` — the model-agnostic `tool_instructions` layer: gated + one-line nudges (`TOOL_INSTRUCTION_LINES`) composed into the SDK's + `tool_instructions` section. The browser line is the one registered today. +- `anthropicPrompt.ts` — example per-model contributor (Claude Opus 4.8). +- `allPrompts.ts` — side-effect import hub; importing it registers every + contributor into the shared `agentHostPromptRegistry`. + +## How the system message is built + +`resolveSystemMessageConfig(model, context)` runs two steps: + +1. **`_resolveModelConfig`** — picks the per-model (or default) config. Falls + back to `COPILOT_AGENT_HOST_SYSTEM_MESSAGE` when there's no model, no matching + contributor, or the contributor opts out for this `context`. +2. **`_withUniversalSections`** — layers the model-agnostic sections (currently + just `tool_instructions`) on top, **composing** with — never clobbering — any + per-model override for that section. + +> **Launch-time freeze.** The SDK accepts a system message only at session +> create/resume; there is no mid-session update. The prompt is resolved once per +> (re)launch and any tool-gated content reflects the tool set at that moment. A +> change to the session's tools/plugins is part of the launcher's restart +> snapshot, so it re-launches and recomputes; an in-flight turn keeps the prompt +> it launched with. + +There are two ways to customize, and a model can use both at once. + +## Lever 1 — universal, all models (`toolInstructions.ts`) + +Guidance for a tool that should apply to **every** model whenever that tool is in +the session. This is what the browser line does. + +1. Write a `ToolInstructionLine` — a function `(hasTool) => string | undefined` + that returns one sentence (no surrounding newlines) when its tool is present, + or `undefined` to contribute nothing. +2. Add it to `TOOL_INSTRUCTION_LINES`. + +```ts +const exampleToolInstructions: ToolInstructionLine = hasTool => + hasTool('someClientToolReferenceName') + ? 'One sentence of guidance, shown only when that tool is present.' + : undefined; + +const TOOL_INSTRUCTION_LINES: readonly ToolInstructionLine[] = [browserToolInstructions, exampleToolInstructions]; +``` + +**Caveat — `hasTool` sees CLIENT tools only.** It is `context.hasClientTool`, +which knows only the forwarded workbench tools, addressed by their **camelCase +`toolReferenceName`** (e.g. `openBrowserPage`, `runTask`, `getTaskOutput`) — NOT +the extension's snake_case ids, and NOT shell / server-SDK / MCP tools (MCP is +discovered dynamically and isn't in the launch snapshot). A +line gated on a name that is never a client tool silently never renders. The +default client-tool allowlist is `chat.agentHost.clientTools` (see +`chat.shared.contribution.ts`). Broadening this context is a known follow-up. + +These lines compose with a per-model `tool_instructions` override (see +`composeToolInstructions`), so Lever 1 and Lever 2 stack. + +## Lever 2 — per-model contributor (`promptRegistry.ts` + `allPrompts.ts`) + +Guidance scoped to a model or family. Implement `IAgentHostPrompt` and register +it. Use `anthropicPrompt.ts` as the template. + +A contributor provides EITHER: + +- `resolveSectionOverrides` → `{ mode: 'customize' }` — overrides named sections, + keeps the SDK foundation prompt and its guardrails. **Prefer this.** +- `resolveFullSystemPrompt` → `{ mode: 'replace' }` — owns the entire prompt and + **drops all SDK guardrails (including safety)**. Only for callers that truly + own the whole prompt. A replace contributor bypasses Lever 1, so it must inline + any universal guidance itself (`universalToolInstructions(hasTool)` renders the + same gated lines; add a small replace-mode helper alongside it when the first + such contributor lands). + +```ts +class MyModelPrompt implements IAgentHostPrompt { + static readonly familyPrefixes = ['my-model']; // or implement static matchesModel(model) + resolveSectionOverrides(model: ModelSelection, context: IAgentHostPromptContext) { + // Gate on host settings; return undefined to fall back to the default message. + return context.getSetting(AgentHostConfigKey.SomeFlag) === true + ? { tool_instructions: { action: 'append', content: '\nFor this model, batch independent tool calls.' } } + : undefined; + } +} +agentHostPromptRegistry.registerPrompt(MyModelPrompt); // then add `import './myModelPrompt.js'` to allPrompts.ts +``` + +Matching: a contributor matches a model by `static matchesModel(model)` (takes +precedence) or by `familyPrefixes` (model-id `startsWith`). The registry resolves +**exactly one** contributor per model (first match wins) — base + version +layering is a known follow-up. + +## Reference + +- **Modes** (`SystemMessageConfig.mode`): `append` (foundation + text, default), + `customize` (override named sections), `replace` (own the whole prompt, no + guardrails). +- **Sections** (`SystemMessageSection`): `identity`, `tone`, `tool_efficiency`, + `environment_context`, `code_change_rules`, `guidelines`, `safety`, + `tool_instructions`, `custom_instructions`, `runtime_instructions`, + `last_instructions`. +- **Override actions** (`SectionOverride.action`): `replace`, `append`, + `prepend`, `remove`, or a `(content: string) => string` transform. + +## Gotchas + +- **Empty overrides = no override.** `resolveSectionOverrides` returning `{}` + (or `undefined`) falls back to the default message rather than emitting an + empty customize config that would drop the default identity. +- **Don't mutate the shared default.** `COPILOT_AGENT_HOST_SYSTEM_MESSAGE` is a + shared constant; layering spreads into a fresh object, preserving any other + customize-mode fields (e.g. `content`). Keep it that way. +- **Spacing is relative to the foundation.** `composeToolInstructions` pads by + action (`append` leads with `\n`, `prepend` trails with `\n`, `replace` owns + the section). When writing a section's `content` by hand, a leading `\n` keeps + appended text off the foundation's last line. +- **Observability.** The launcher logs `describeSystemMessageConfig(...)` at + `info` (mode + overridden sections) and the full config at `trace`. Keep new + config shapes summarizable there. +- **Tests.** `../../../test/node/agentHostPromptRegistry.test.ts` covers the + registry/wiring; `../../../test/node/toolInstructions.test.ts` covers the + composition/gating. Add cases there, not new harnesses. diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts index 793cf5e11b181..068f36131ddc8 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts @@ -7,7 +7,8 @@ import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from import { agentHostCustomizationConfigSchema } from '../../../common/agentHostCustomizationConfig.js'; import type { SchemaValue } from '../../../common/agentHostSchema.js'; import type { ModelSelection } from '../../../common/state/protocol/state.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +import { COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +import { resolveToolInstructionsOverride } from './toolInstructions.js'; type CustomizationConfigDefinition = typeof agentHostCustomizationConfigSchema.definition; @@ -27,6 +28,30 @@ export interface IAgentHostPromptContext { * {@link agentHostCustomizationConfigSchema}. */ getSetting(key: K): SchemaValue | undefined; + + /** + * Returns whether a *client* tool is available in the session, addressed by + * the camelCase `toolReferenceName` the agent sees it under (e.g. + * `openBrowserPage`). Used to gate tool-specific instructions on the tool + * being present, the agent-host equivalent of the Copilot extension + * inspecting its tool set. + * + * Scope: client tools only (the forwarded workbench tools). It does NOT see + * shell tools, server-SDK tools, or MCP-provided tools — those aren't in the + * session snapshot at launch (MCP is discovered dynamically). A line that + * gates on one of those names silently resolves to `false`; broadening this + * is the context-enrichment follow-up. + */ + hasClientTool(name: string): boolean; + + /** + * Whether this is a workspace-less session. When `true`, the + * resolved system message gets a scratch/repoless section (see + * {@link COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}) telling the agent its + * working directory is a scratch dir, not a code repo. Set by the launcher + * from the session's `workspaceless` marker. + */ + workspaceless: boolean; } /** @@ -109,7 +134,25 @@ export class AgentHostPromptRegistry { } /** - * Resolves the {@link SystemMessageConfig} for a session's model. + * Resolves the {@link SystemMessageConfig} for a session's model: the + * per-model (or default) config from {@link _resolveModelConfig}, with the + * model-agnostic section overrides from {@link _withUniversalSections} + * layered on top. + * + * Lifetime: the SDK accepts a system message only at session create/resume + * (there is no mid-session update), so this is resolved once per (re)launch + * and any tool-gated content reflects the tool set at that moment. A change + * to the session's tools/plugins is part of the launcher's restart-detection + * snapshot, so it re-launches the session and recomputes this; an in-flight + * turn keeps the prompt it launched with. + */ + resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { + const config = this._withUniversalSections(this._resolveModelConfig(model, context), context); + return this._withWorkspacelessScratch(config, context); + } + + /** + * Resolves the per-model config, before universal sections are layered on. * * Falls back to {@link COPILOT_AGENT_HOST_SYSTEM_MESSAGE} when the model is * unknown (e.g. server-side "Auto" selection where no model is chosen at @@ -117,7 +160,7 @@ export class AgentHostPromptRegistry { * contributor opts out for the current {@link context} (e.g. a setting that * gates it is disabled). */ - resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { + private _resolveModelConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { if (!model) { return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; } @@ -139,6 +182,52 @@ export class AgentHostPromptRegistry { } return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; } + + /** + * Layers section overrides that apply to EVERY model on top of the per-model + * (or default) config. Currently this is only the `tool_instructions` section + * (see {@link resolveToolInstructionsOverride}), which the agent host wants + * for all models rather than gating per-model like the Opus prompt. + * + * Only `customize`-mode configs carry section overrides, so this is a no-op + * for a contributor's full `replace` prompt (which owns the entire system + * message and intentionally drops the SDK foundation) and for `append` mode. + * A `replace` contributor that wants the universal guidance re-includes it + * itself by rendering `universalToolInstructions` (in `toolInstructions.ts`) + * from its `resolveFullSystemPrompt`, mirroring how the extension's full-prompt + * models inline the same lines. + * + * A per-model `tool_instructions` override is composed with — not overwritten + * by — the universal lines (see {@link resolveToolInstructionsOverride}). + */ + private _withUniversalSections(config: SystemMessageConfig, context: IAgentHostPromptContext): SystemMessageConfig { + if (config.mode !== 'customize') { + return config; + } + const toolInstructions = resolveToolInstructionsOverride(name => context.hasClientTool(name), config.sections?.tool_instructions); + if (!toolInstructions) { + return config; + } + return { ...config, sections: { ...config.sections, tool_instructions: toolInstructions } }; + } + + /** + * Appends the scratch/repoless workspace-less guidance (see + * {@link COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}) as customize-mode + * `content` when {@link IAgentHostPromptContext.workspaceless} is set, so it + * composes on top of whatever sections the per-model (or default) config + * carries while keeping the SDK foundation intact. + * + * No-op for workspace-bound sessions and for a full `replace` prompt (which + * owns the entire system message and intentionally drops the SDK foundation). + */ + private _withWorkspacelessScratch(config: SystemMessageConfig, context: IAgentHostPromptContext): SystemMessageConfig { + if (!context.workspaceless || config.mode !== 'customize') { + return config; + } + const content = config.content ? `${config.content}\n\n${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}` : COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS; + return { ...config, content }; + } } /** diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index 1b52c8cc6388f..d313daa35a925 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -30,6 +30,23 @@ export const COPILOT_AGENT_HOST_SYSTEM_MESSAGE = { }, } satisfies SystemMessageConfig; +/** + * Scratch/repoless guidance appended to a workspace-less chat's system message. + * A workspace-less chat's working directory is a throwaway SCRATCH dir, not a + * code repository — so this tells the agent not to treat it like a project, to + * stay read-only on real repos, and to delegate code changes to a dedicated + * session. Modeled on the GitHub app's `build_general_chat_system_message`. + */ +export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ + '', + 'This is a lightweight workspace-less chat, not tied to any project or workspace. The user opens it for quick questions, navigation, and triage.', + '', + '- Your working directory is a SCRATCH directory for running commands and saving throwaway artifacts — it is NOT a code repository. Do not treat it as a project to build, test, or commit.', + '- If the user points you at a real repository, prefer read-only operations: read files, search code, and inspect git metadata (branch, log, diff, status) to answer questions. Avoid modifying files or running builds, tests, linters, or installs in their working copies.', + '- When the user wants code changes, test runs, or any work that modifies or executes against a real project, delegate it to a dedicated session rather than doing it here.', + '', +].join('\n'); + /** * Builds a {@link SystemMessageConfig} that fully replaces the CLI/SDK system * prompt with `content`. @@ -49,3 +66,28 @@ export function fullSystemPrompt(content: string): SystemMessageConfig { export function sectionOverrides(sections: Partial>): SystemMessageConfig { return { mode: 'customize', sections }; } + +/** + * One-line, log-friendly summary of a resolved {@link SystemMessageConfig} — + * the mode plus, for `customize`, which sections are overridden and with what + * action (e.g. `mode=customize sections=[identity:replace, tool_instructions:append]`). + * + * Keeps prompt observability cheap at `info` level without dumping full prompt + * text on every session launch (log the whole config at `trace` for that). + */ +export function describeSystemMessageConfig(config: SystemMessageConfig): string { + if (config.mode === 'replace') { + return `mode=replace (content length ${config.content.length})`; + } + if (config.mode === 'customize') { + const parts = Object.entries(config.sections ?? {}).map(([name, override]) => { + const action = override?.action; + return `${name}:${typeof action === 'function' ? 'transform' : action}`; + }); + // The customize convenience `content` is appended after all sections; note + // it so the summary doesn't understate what was sent. + const content = config.content ? ` +content(length ${config.content.length})` : ''; + return `mode=customize sections=[${parts.join(', ')}]${content}`; + } + return `mode=append (content length ${config.content?.length ?? 0})`; +} diff --git a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts new file mode 100644 index 0000000000000..c32495471cea5 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SectionOverride } from '@github/copilot-sdk'; +import { coalesce } from '../../../../../base/common/arrays.js'; +import { BrowserChatToolReferenceName, browserChatToolReferenceNames } from '../../../../browserView/common/browserChatToolReferenceNames.js'; + +/** + * Model-agnostic guidance for the `tool_instructions` system-prompt section. + * + * This is the agent-host home for the Copilot extension's `toolUseInstructions` + * pattern (`defaultAgentInstructions.tsx` and the per-model agent prompts): a + * sequence of one-line nudges, each gated on the relevant tool being present in + * the session, composed into the single SDK `tool_instructions` section. The + * agent host sees client tools under their camelCase `toolReferenceName`, so a + * line's gate and any tool name it mentions use that form (NOT the extension's + * snake_case ids). + * + * To add guidance for a tool, write a {@link ToolInstructionLine} and add it to + * {@link TOOL_INSTRUCTION_LINES}. The browser guidance ({@link browserToolInstructions}) + * is the first such hookup: it gates on `openBrowserPage` (plus an agentic browser + * tool) being present and returns the extension's "Use the browser tools (...)" + * sentence. + */ + +/** + * A single gated tool-instructions line. Returns its content (a single + * sentence, no surrounding newlines) when the session exposes the tools it + * applies to, or `undefined` to contribute nothing. Mirrors one gated `<>…` + * fragment in the extension's `toolUseInstructions` block. + * + * @param hasTool predicate for whether a tool name is available in the session. + */ +type ToolInstructionLine = (hasTool: (name: string) => boolean) => string | undefined; + +/** + * Browser tools other than `openBrowserPage` — the agent-host equivalent of the + * Copilot extension's `agenticBrowserTools`. Derived from the full reference-name + * list so it stays in sync as browser tools are added or removed. + */ +const agenticBrowserToolNames = browserChatToolReferenceNames.filter(name => name !== BrowserChatToolReferenceName.OpenBrowserPage); + +/** + * Front-end guidance for the integrated browser tools, ported from the Copilot + * extension's `defaultAgentInstructions`/per-model prompts. Emitted only when the + * page-opening tool AND at least one agentic browser tool are available, naming + * the first available agentic tool as the example (the rest are covered by "etc."). + */ +const browserToolInstructions: ToolInstructionLine = hasTool => { + if (!hasTool(BrowserChatToolReferenceName.OpenBrowserPage)) { + return undefined; + } + const companion = agenticBrowserToolNames.find(hasTool); + if (!companion) { + return undefined; + } + return `Use the browser tools (${BrowserChatToolReferenceName.OpenBrowserPage}, ${companion}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.`; +}; + +/** + * The registered tool-instruction lines, in render order. Add new per-tool + * guidance here. + */ +const TOOL_INSTRUCTION_LINES: readonly ToolInstructionLine[] = [browserToolInstructions]; + +/** + * Composes the applicable `lines` into a single block (one line each), or + * `undefined` when none apply to the session. + * + * @param lines defaults to the registered {@link TOOL_INSTRUCTION_LINES}; + * overridable so the composition can be exercised in isolation. + */ +export function universalToolInstructions(hasTool: (name: string) => boolean, lines: readonly ToolInstructionLine[] = TOOL_INSTRUCTION_LINES): string | undefined { + const rendered = coalesce(lines.map(line => line(hasTool))); + return rendered.length > 0 ? rendered.join('\n') : undefined; +} + +/** + * Folds universal tool-instructions `content` into a per-model contributor's + * `existing` `tool_instructions` override (if any), so a contributor's section + * is preserved rather than clobbered. + * + * @param existing the per-model contributor's `tool_instructions` override, if any. + */ +function composeToolInstructions(existing: SectionOverride | undefined, content: string): SectionOverride { + // No per-model override: append after the SDK foundation section, led by a + // newline so it doesn't run on from the foundation content. + if (!existing) { + return { action: 'append', content: `\n${content}` }; + } + // A `remove` or transform-function override is a deliberate, non-composable + // choice by the contributor; preserve it untouched rather than fight it. + if (existing.action === 'remove' || typeof existing.action === 'function') { + return existing; + } + // Fold our lines into the contributor's content (preserve it, don't clobber), + // then pad relative to the foundation by where this action places the content: + // `append` sits after it (lead with a newline), `prepend` sits before it (trail + // with a newline), `replace` owns the section (no foundation adjacency, so no + // padding — and no leading newline even when the contributor's content is empty). + const base = existing.content ?? ''; + const merged = base ? `${base}\n${content}` : content; + switch (existing.action) { + case 'append': return { action: 'append', content: `\n${merged}` }; + case 'prepend': return { action: 'prepend', content: `${merged}\n` }; + default: return { action: existing.action, content: merged }; + } +} + +/** + * Resolves the `tool_instructions` {@link SectionOverride} for a session, + * composing the universal lines with any override a per-model contributor + * already set for that section. + * + * Returns `undefined` when no universal lines apply — the caller then keeps the + * contributor's `existing` override (if any) untouched. + * + * @param existing the per-model contributor's `tool_instructions` override, if any. + * @param lines defaults to the registered {@link TOOL_INSTRUCTION_LINES}. + */ +export function resolveToolInstructionsOverride(hasTool: (name: string) => boolean, existing: SectionOverride | undefined, lines: readonly ToolInstructionLine[] = TOOL_INSTRUCTION_LINES): SectionOverride | undefined { + const content = universalToolInstructions(hasTool, lines); + return content === undefined ? undefined : composeToolInstructions(existing, content); +} diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts index 5d3d6ce344064..12246665f72a0 100644 --- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +++ b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts @@ -6,6 +6,15 @@ import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { AgentHostSandboxKey, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; +/** + * Whether the SDK sandbox is supported on Windows. Not enabled yet, so the + * builders bail out early on `win32`; the Windows handling is kept so support + * can be turned on by flipping this flag once the runtime is ready. Typed as + * `boolean` (not the `false` literal) so the Windows branches are not flagged + * as unreachable by control-flow narrowing. + */ +const WINDOWS_SANDBOX_SUPPORTED: boolean = false; + /** * Per-platform filesystem rule bundle accepted under each `fileSystem.` * sub-key (`AgentHostSandboxKey.LinuxFileSystem` etc.) in the AgentHost root @@ -30,6 +39,7 @@ export interface IAgentSandboxFileSystemSetting { */ export interface ISdkSandboxConfig { enabled: true; + allowBypass?: boolean; userPolicy: { filesystem: { readwritePaths?: string[]; @@ -63,9 +73,14 @@ export interface ISdkSandboxConfig { * `readwritePaths`. * - Network: `allowNetwork` opens outbound to everything and drops the * allow/deny lists. Otherwise the allow/deny lists open outbound when - * set so they're actually enforced; macOS fails closed because the - * runtime has no per-host filter (Seatbelt would silently degrade to - * "allow all outbound"). + * set so they're actually enforced; host lists are currently disabled on + * all platforms (fail closed) because the runtime does not yet enforce + * them reliably everywhere. + * + * Windows is not supported yet, so this bails out early and returns `undefined` + * there. The Windows handling below is intentionally kept (and exercised when + * {@link WINDOWS_SANDBOX_SUPPORTED} is flipped) so support can be turned on once + * the runtime is ready. */ export function buildSandboxConfigForSdk( platform: NodeJS.Platform, @@ -75,6 +90,12 @@ export function buildSandboxConfigForSdk( return undefined; } + // Typed as `boolean` (not the `false` literal) so the Windows branches below + // are not flagged as unreachable by control-flow narrowing. + if (platform === 'win32' && !WINDOWS_SANDBOX_SUPPORTED) { + return undefined; + } + const enabledRaw = platform === 'win32' && sandbox[AgentHostSandboxKey.WindowsEnabled] !== undefined ? sandbox[AgentHostSandboxKey.WindowsEnabled] : sandbox[AgentHostSandboxKey.Enabled]; @@ -108,8 +129,9 @@ export function buildSandboxConfigForSdk( } } - const allowAllNetwork = enabledRaw === AgentSandboxEnabledValue.AllowNetwork; - const hostListsEnforceable = platform !== 'darwin'; + const legacyAllowAllNetwork = enabledRaw === AgentSandboxEnabledValue.AllowNetwork; + const allowAllNetwork = legacyAllowAllNetwork || (enabledRaw === AgentSandboxEnabledValue.On && sandbox[AgentHostSandboxKey.AllowNetwork] === true); + const hostListsEnforceable = false; const rawAllow = sandbox[AgentHostSandboxKey.AllowedNetworkDomains]; const rawBlock = sandbox[AgentHostSandboxKey.DeniedNetworkDomains]; const allowedHosts = !allowAllNetwork && hostListsEnforceable && rawAllow?.length ? [...rawAllow] : undefined; @@ -117,6 +139,7 @@ export function buildSandboxConfigForSdk( const allowOutbound = allowAllNetwork || !!allowedHosts || !!blockedHosts; return { enabled: true, + allowBypass: true, userPolicy: { filesystem: { ...(readwrite.size ? { readwritePaths: [...readwrite] } : {}), diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts index 9331c71b39252..3a72bd50d71fc 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts @@ -19,6 +19,7 @@ import type { AgentsDiscoverRequest } from './copilotRCP.js'; import { AgentCustomization, ChildCustomization, CustomizationLoadStatus, CustomizationType, DirectoryCustomization, RuleCustomization, SkillCustomization, customizationId } from '../../common/state/sessionState.js'; import { ChildCustomizationType } from '../../common/state/protocol/state.js'; import { toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; +import { raceCancellationError } from '../../../../base/common/async.js'; /** * The kinds of customizations the agent host discovers from disk. @@ -37,6 +38,8 @@ export const enum DiscoveredType { export interface IDiscoveredDirectory { readonly uri: URI; readonly type: DiscoveredType; + readonly name: string; + readonly writable: boolean; readonly files: readonly IDiscoveredFile[]; } @@ -114,6 +117,7 @@ interface ISearchRoot { readonly path: readonly string[]; readonly type: DiscoveredType; readonly recursive?: boolean; // whether to watch recursively for changes (defaults to false) + readonly name: string; } interface IFixedDiscoveryFile { @@ -129,20 +133,22 @@ interface IFixedDiscoveryFile { */ const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = { workspace: [ - { path: ['.github', 'agents'], type: DiscoveredType.Agent }, - { path: ['.agents', 'agents'], type: DiscoveredType.Agent }, - { path: ['.claude', 'agents'], type: DiscoveredType.Agent }, - { path: ['.github', 'skills'], recursive: true, type: DiscoveredType.Skill }, - { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill }, - { path: ['.claude', 'skills'], recursive: true, type: DiscoveredType.Skill }, - { path: ['.github', 'instructions'], recursive: true, type: DiscoveredType.Instruction }, - { path: ['.github', 'hooks'], recursive: true, type: DiscoveredType.Hook }, + { path: ['.github', 'agents'], type: DiscoveredType.Agent, name: '.github' }, + { path: ['.agents', 'agents'], type: DiscoveredType.Agent, name: '.agents' }, + { path: ['.claude', 'agents'], type: DiscoveredType.Agent, name: '.claude' }, + { path: ['.github', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.github' }, + { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.agents' }, + { path: ['.claude', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.claude' }, + { path: ['.github', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '.github' }, + { path: ['.github', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '.github' }, + ], user: [ - { path: ['.copilot', 'agents'], type: DiscoveredType.Agent }, - { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill }, - { path: ['.copilot', 'instructions'], recursive: true, type: DiscoveredType.Instruction }, - { path: ['.copilot', 'hooks'], recursive: true, type: DiscoveredType.Hook }, + { path: ['.copilot', 'agents'], type: DiscoveredType.Agent, name: '~/.copilot' }, + { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '~/.agents' }, + { path: ['.copilot', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '~/.copilot' }, + { path: ['.copilot', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '~/.copilot' }, + { path: ['.copilot', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '~/.copilot' }, ], }; @@ -244,38 +250,12 @@ export class SessionCustomizationDiscovery extends Disposable { const p: AgentsDiscoverRequest = { projectPaths: [this._workingDirectory.fsPath] }; try { - const agents: AgentCustomization[] = []; - - const agentDiscovery = await client.rpc.agents.discover(p); - for (const agent of agentDiscovery.agents) { - if (agent.path) { - const uri = URI.file(agent.path); - agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); - } - } - - const rules: RuleCustomization[] = []; - - const instructionDiscovery = await client.rpc.instructions.discover(p); - for (const instruction of instructionDiscovery.sources) { - let uri: URI; - if (isAbsolute(instruction.sourcePath)) { - uri = URI.file(instruction.sourcePath); - } else { - uri = joinPath(this._workingDirectory, instruction.sourcePath); - } - rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); - } - - const skills: SkillCustomization[] = []; - - const skillDiscovery = await client.rpc.skills.discover(p); - for (const skill of skillDiscovery.skills) { - if (skill.path) { - const uri = URI.file(skill.path); - skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); - } - } + const [agents, rules, skills] = await Promise.all([ + this.discoverAgents(p, client, token), + this.discoverRules(p, client, token), + this.discoverSkills(p, client, token) + ]); + throwIfCancelled(token); const result: DirectoryCustomization[] = []; this.toDirectoryCustomizations(CustomizationType.Agent, agents, result); @@ -288,6 +268,48 @@ export class SessionCustomizationDiscovery extends Disposable { } } + private async discoverAgents(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const agents: AgentCustomization[] = []; + + const agentDiscovery = await raceCancellationError(client.rpc.agents.discover(discoveryRequest), token); + for (const agent of agentDiscovery.agents) { + if (agent.path) { + const uri = URI.file(agent.path); + agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); + } + } + return agents; + } + + private async discoverRules(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const rules: RuleCustomization[] = []; + + const instructionDiscovery = await raceCancellationError(client.rpc.instructions.discover(discoveryRequest), token); + for (const instruction of instructionDiscovery.sources) { + let uri: URI; + if (isAbsolute(instruction.sourcePath)) { + uri = URI.file(instruction.sourcePath); + } else { + uri = joinPath(this._workingDirectory, instruction.sourcePath); + } + rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); + } + return rules; + } + + private async discoverSkills(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const skills: SkillCustomization[] = []; + + const skillDiscovery = await raceCancellationError(client.rpc.skills.discover(discoveryRequest), token); + for (const skill of skillDiscovery.skills) { + if (skill.path) { + const uri = URI.file(skill.path); + skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); + } + } + return skills; + } + private toDirectoryCustomizations(type: ChildCustomizationType, customizations: readonly ChildCustomization[], result: DirectoryCustomization[]): void { const byParent = new ResourceMap<{ readonly uri: URI; readonly children: ChildCustomization[] }>(); for (const customization of customizations) { @@ -422,11 +444,11 @@ export class SessionCustomizationDiscovery extends Disposable { */ private async _scanFixedDiscoveryFiles(base: URI, roots: IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap, token: CancellationToken): Promise { const filesByType = new Map(); - for (const root of roots) { + await Promise.all(roots.map(async root => { throwIfCancelled(token); if (!await this._watchAncestors(base, root.path, watchRootUris, token)) { - continue; + return; } const rootUri = joinPath(base, ...root.path); @@ -435,10 +457,10 @@ export class SessionCustomizationDiscovery extends Disposable { stat = await this._fileService.resolve(rootUri, { resolveMetadata: true }); } catch { // Root does not exist (or is unreadable) — nothing to discover or watch. - continue; + return; } if (!stat.isDirectory || !stat.children) { - continue; + return; } // Trigger refresh only for the specific filenames this root cares about @@ -459,42 +481,36 @@ export class SessionCustomizationDiscovery extends Disposable { } } } - } + })); for (const [type, files] of filesByType.entries()) { - if (files.length === 0) { - continue; + if (files.length > 0) { + result.push({ uri: base, type, files: files.sort(compareDiscoveredFile), name: '', writable: false }); } - result.push({ uri: base, type, files: files.sort(compareDiscoveredFile) }); } } private async _scanRoot(base: URI, root: ISearchRoot, seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap, token: CancellationToken): Promise { throwIfCancelled(token); - if (!await this._watchAncestors(base, root.path, watchRootUris, token)) { - return; - } - const rootUri = joinPath(base, ...root.path); - let stat: IFileStatWithMetadata; + let stat: IFileStatWithMetadata | undefined = undefined; + let children: IFileStatWithMetadata[] = []; try { stat = await this._fileService.resolve(rootUri, { resolveMetadata: true }); + children = stat.children ?? []; } catch { - // Root does not exist (or is unreadable) — nothing to discover or watch. - return; - } - if (!stat.isDirectory || !stat.children) { - return; + // Root does not exist (or is unreadable) — still discover it as a possible source folder. } // Filenames are dynamic for these roots, so we watch the whole directory. // `addWatch` upgrades to recursive if any root requests it. + await this._watchAncestors(base, root.path, watchRootUris, token); addWatch(watchRootUris, rootUri, root.recursive ?? false, rootUri); if (root.type === DiscoveredType.Skill) { const files: IDiscoveredFile[] = []; - for (const child of stat.children) { + await Promise.all(children.map(async child => { throwIfCancelled(token); if (child.isDirectory) { @@ -509,13 +525,13 @@ export class SessionCustomizationDiscovery extends Disposable { // SKILL.md missing — skip this skill directory. } } - } - result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) }); + })); + result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); } else if (root.type === DiscoveredType.Agent) { const files: IDiscoveredFile[] = []; // agents are markdown files directly under the root (no subdirectory scanning), // excluding only exact-case README.md. - for (const child of stat.children) { + for (const child of children) { throwIfCancelled(token); if (child.isFile) { @@ -526,7 +542,7 @@ export class SessionCustomizationDiscovery extends Disposable { } } } - result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) }); + result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); } else if (root.type === DiscoveredType.Instruction) { const files: IDiscoveredFile[] = []; @@ -556,8 +572,10 @@ export class SessionCustomizationDiscovery extends Disposable { } } }; - await findInstructions(stat, 0); - result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) }); + if (stat) { + await findInstructions(stat, 0); + } + result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); } else if (root.type === DiscoveredType.Hook) { const files: IDiscoveredFile[] = []; // hooks are recursively discovered as `*.json` under the root. @@ -586,9 +604,11 @@ export class SessionCustomizationDiscovery extends Disposable { } } }; + if (stat) { + await findHooks(stat, 0); + } + result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); - await findHooks(stat, 0); - result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) }); } else { this._logService.warn(`[SessionCustomizationDiscovery] Unrecognized root type '${root.type}' for root '${rootUri.toString()}'`); } diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index 8016d0f293cbc..1df8ea128a2dc 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,7 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; import '../common/agentHostStarter.config.contribution.js'; /** @@ -83,12 +83,15 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte codexBinaryArgs: this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId), codexAgentEnabled: this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId), + byokModelsEnabled: this._configurationService.getValue(AgentHostByokModelsEnabledSettingId), }, process.env); Object.assign(env, sdkEnv); // Translate `chat.agentHost.otel.*` settings into the env vars consumed by // the agent host process. Any value already present on `process.env` wins - // (developer override) — see `buildAgentHostOTelEnv`. + // for user settings, while enterprise policy values win over inherited env — + // see `buildAgentHostOTelEnv`. + const policyValue = (key: string): T | undefined => this._configurationService.inspect(key).policyValue; const otelEnv = buildAgentHostOTelEnv({ enabled: this._configurationService.getValue(AgentHostOTelEnabledSettingId), exporterType: this._configurationService.getValue(AgentHostOTelExporterTypeSettingId), @@ -96,7 +99,16 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte captureContent: this._configurationService.getValue(AgentHostOTelCaptureContentSettingId), outfile: this._configurationService.getValue(AgentHostOTelOutfileSettingId), dbSpanExporterEnabled: this._configurationService.getValue(AgentHostOTelDbSpanExporterEnabledSettingId), - }, process.env); + }, process.env, { + enabled: policyValue(AgentHostOTelEnabledSettingId), + exporterType: policyValue(AgentHostOTelExporterTypeSettingId), + otlpProtocol: policyValue(AgentHostOTelOtlpProtocolSettingId), + otlpEndpoint: policyValue(AgentHostOTelOtlpEndpointSettingId), + captureContent: policyValue(AgentHostOTelCaptureContentSettingId), + outfile: policyValue(AgentHostOTelOutfileSettingId), + serviceName: policyValue(AgentHostOTelServiceNameSettingId), + resourceAttributes: policyValue>(AgentHostOTelResourceAttributesSettingId), + }); Object.assign(env, otelEnv); // Forward WebSocket server configuration to the child process via env vars diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 02e4b92a89932..b79b6b362e117 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -12,6 +12,7 @@ import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { AgentSession, type IAgentService, type IMcpNotification } from '../common/agentService.js'; +import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import type { CommandMap } from '../common/state/protocol/messages.js'; import { ActionEnvelope, ActionType, INotification, isChatAction, isSessionAction, isTerminalAction, type ChatAction, type SessionAction, type TerminalAction, type IRootConfigChangedAction } from '../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; @@ -37,7 +38,7 @@ import { type IStateSnapshot, type SubscribeResult, } from '../common/state/sessionProtocol.js'; -import { isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseDefaultChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; +import { isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { @@ -198,27 +199,47 @@ interface IConnectedClient { * at the end. When the last transport disconnects, the record is retained * (until pruned) so the tool-call disconnect-grace machinery can compute the * remaining window and hold any armed timeouts. + * + * A client is in exactly one of two states, which makes the core invariant + * unrepresentable in the wrong shape: a client either has one or more live + * transports ({@link IActiveClientRecord}, never any disconnect-grace timers) + * or has no transport and is within its disconnect-grace window + * ({@link IGraceClientRecord}, never any connections). Transitions happen only + * in {@link ProtocolServerHandler._attachConnection} (→ active, which disposes + * any grace timers) and the transport `onClose` handler (→ grace, once the last + * transport is gone). */ -interface IClientRecord { +type IClientRecord = IActiveClientRecord | IGraceClientRecord; + +interface IActiveClientRecord { + readonly state: 'active'; /** - * All live transports for this client, oldest first. The active connection - * is the last entry (most recent wins). Older entries are kept so that if a + * Live transports for this client, oldest first. The active connection is + * the last entry (most recent wins). Older entries are kept so that if a * reconnecting client registers `A`, then `B`, then `B` closes first, we can - * fall back to `A` instead of treating the client as disconnected. + * fall back to `A` instead of treating the client as disconnected. Never + * empty: removing the last transport promotes the record to a grace record. */ readonly connections: IConnectedClient[]; +} + +interface IGraceClientRecord { + readonly state: 'grace'; /** - * Epoch ms when the client last had no live transports. `undefined` while at - * least one connection is active, or when the client has never connected. - * Drives the disconnect-timeout grace window for disconnected records only. + * Epoch ms when the client last had a live transport, or when this record + * was created for a never-connected orphan tool-call stamp. Pins the grace + * clock so re-arms triggered by later orphaned tool calls shrink the + * remaining window instead of resetting it. Drives the disconnect-timeout + * delay (residual window from this instant). */ - lastSeenAt: number | undefined; + lastSeenAt: number; /** * Pending tool-call disconnect timeouts owned by this client, keyed by * session URI. Armed when the client owns a pending client tool call but is * not connected; fires a failing completion if it does not (re)connect - * within the grace window. Disposing an entry (or the whole map) clears the - * underlying timer. + * within the grace window. Reconnecting promotes the record to active and + * disposes these timers (the grace window no longer applies once a transport + * is live). Disposing an entry (or the whole map) clears the timer. */ readonly disconnectTimeouts: DisposableMap; } @@ -323,11 +344,10 @@ export class ProtocolServerHandler extends Disposable { // stamped while no client is connected are failed immediately by // the provider, so they never reach this path. if (envelope.action.type === ActionType.ChatToolCallStart || envelope.action.type === ActionType.ChatToolCallReady) { - // Chat-action envelopes are emitted on the chat channel URI; - // the disconnect-grace machinery keys by session URI, so - // resolve back to the owning session before checking. - const session = isAhpChatChannel(envelope.channel) ? (parseDefaultChatUri(envelope.channel) ?? envelope.channel) : envelope.channel; - this._checkOrphanedClientToolCalls(session); + if (!isAhpChatChannel(envelope.channel)) { + throw new Error(`[ProtocolServer] Chat tool-call action emitted on non-chat channel: ${envelope.channel}`); + } + this._checkOrphanedClientToolCalls(parseRequiredSessionUriFromChatUri(envelope.channel), envelope.channel); } })); @@ -442,7 +462,7 @@ export class ProtocolServerHandler extends Disposable { disposables.add(transport.onClose(() => { const record = client ? this._clients.get(client.clientId) : undefined; - if (client && record) { + if (client && record?.state === 'active') { const connectionIndex = record.connections.indexOf(client); if (connectionIndex !== -1) { const subscriptionCount = client.subscriptions.size; @@ -451,7 +471,7 @@ export class ProtocolServerHandler extends Disposable { this._rejectPendingReverseRequestsForConnection(client); if (record.connections.length === 0) { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${subscriptionCount}`); - record.lastSeenAt = Date.now(); + this._clients.set(client.clientId, { state: 'grace', lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap() }); this._handleClientDisconnected(client.clientId); this._onDidChangeConnectionCount.fire(this._connectedClientCount); } @@ -500,11 +520,7 @@ export class ProtocolServerHandler extends Disposable { subscriptions: new Map(), disposables, }; - const record = this._ensureClientRecord(params.clientId); - record.connections.push(client); - record.lastSeenAt = undefined; - this._pruneClientRecords(); - this._onDidChangeConnectionCount.fire(this._connectedClientCount); + this._attachConnection(params.clientId, client); this._registerClientFileSystemAuthority(params.clientId, disposables); @@ -617,11 +633,7 @@ export class ProtocolServerHandler extends Disposable { subscriptions: new Map(), disposables, }; - const record = this._ensureClientRecord(params.clientId); - record.connections.push(client); - record.lastSeenAt = undefined; - this._pruneClientRecords(); - this._onDidChangeConnectionCount.fire(this._connectedClientCount); + this._attachConnection(params.clientId, client); // Re-establish the reverse-RPC filesystem authority for this client. // The prior transport's `onClose` disposed the previous registration, @@ -714,6 +726,8 @@ export class ProtocolServerHandler extends Disposable { } })); + this._reconcileActiveClientsAfterReconnect(client); + if (canReplay) { const actions: ActionEnvelope[] = []; for (const envelope of this._replayBuffer) { @@ -728,22 +742,85 @@ export class ProtocolServerHandler extends Disposable { return { type: 'snapshot', snapshots: snapshots.filter((s): s is IStateSnapshot => s !== undefined) }; } + /** + * Release a client from every session where it is still an active client + * but did not resubscribe during a reconnect. The set of resubscribed + * sessions is gathered from every live connection the client currently + * holds (not just the reconnecting one) so an overlapping connection that + * still subscribes to a session keeps the client active there. + */ + private _reconcileActiveClientsAfterReconnect(client: IConnectedClient): void { + const record = this._clients.get(client.clientId); + const resubscribed = new Set(); + for (const connection of record?.state === 'active' ? record.connections : [client]) { + for (const sub of connection.subscriptions.values()) { + if (sub.kind === ChannelKind.State) { + resubscribed.add(sub.uri); + } + } + } + for (const session of this._stateManager.getSessionUris()) { + const state = this._stateManager.getSessionState(session); + if (state && this._isActiveClient(state, client.clientId)) { + for (const chat of state.chats) { + if (!resubscribed.has(session) && !resubscribed.has(chat.resource)) { + this._releaseActiveClientForSession(session, client.clientId, chat.resource); + } + } + } + } + } + private _handleClientDisconnected(clientId: string): void { for (const session of this._stateManager.getSessionUris()) { const state = this._stateManager.getSessionState(session); + const isActive = state ? this._isActiveClient(state, clientId) : false; const ownsPendingToolCall = state ? this._hasPendingClientToolCall(state, clientId) : false; - if (state?.activeClient?.clientId === clientId) { - this._stateManager.dispatchServerAction(session, { - type: ActionType.SessionActiveClientChanged, - activeClient: null, - }); - } - if (state?.activeClient?.clientId === clientId || ownsPendingToolCall) { - this._startClientToolCallDisconnectTimeout(clientId, session); + // Keep the client marked active during the grace window so a quick + // reconnect that resubscribes can retain its slot. The disconnect + // timeout removes the active client (and fails its pending tool + // calls) if it never returns; an explicit unsubscribe or a + // reconnect without resubscription removes it sooner. + if (isActive || ownsPendingToolCall) { + for (const chat of state?.chats ?? []) { + this._startClientToolCallDisconnectTimeout(clientId, session, chat.resource); + } } } } + /** Whether `clientId` is one of the session's active clients. */ + private _isActiveClient(state: SessionState, clientId: string): boolean { + return state.activeClients.some(c => c.clientId === clientId); + } + + /** + * Remove `clientId` from a session's active clients, if present. Dispatched + * as a server action so the removal is reflected in state and broadcast to + * the remaining subscribers. + */ + private _removeActiveClient(session: string, clientId: string): void { + const state = this._stateManager.getSessionState(session); + if (state && this._isActiveClient(state, clientId)) { + this._stateManager.dispatchServerAction(session, { + type: ActionType.SessionActiveClientRemoved, + clientId, + }); + } + } + + /** + * Release a client from a session: clear its pending disconnect timeout, + * fail any client tool calls it still owns, and remove it from the active + * clients. Used by the explicit-unsubscribe and reconnect-reconciliation + * paths to drop a client that has left a session. + */ + private _releaseActiveClientForSession(session: string, clientId: string, chatChannel: string): void { + this._clearClientToolCallDisconnectTimeout(clientId, chatChannel); + this._completeDisconnectedClientToolCalls(clientId, session, chatChannel); + this._removeActiveClient(session, clientId); + } + /** * Yields every still-pending client-contributed tool call in `state`'s * active turn, paired with its owning `clientId`. Single source of truth @@ -779,34 +856,35 @@ export class ProtocolServerHandler extends Disposable { } private _hasReplacementActiveClientTool(state: SessionState, clientId: string, toolName: string): boolean { - const activeClient = state.activeClient; - return activeClient !== undefined - && activeClient.clientId !== clientId - && activeClient.tools.some(tool => tool.name === toolName); + return state.activeClients.some(client => + client.clientId !== clientId + && client.tools.some(tool => tool.name === toolName)); } /** * Arm (or re-arm) the per-(clientId, session) timeout that fails pending - * client tool calls owned by `clientId` if it does not reconnect and - * resubscribe. The delay is the remaining grace measured from when the - * client disconnected — so a client that disconnected a while before the - * call was issued gets the residual window rather than a fresh one, and a - * stamp from a long-dead client fails promptly. A client never seen at all - * has its grace clock pinned to the first arm, so re-arms triggered by later - * orphaned tool calls in the same session shrink the remaining window - * instead of resetting it. + * client tool calls owned by `clientId` if it does not reconnect before the + * grace window elapses. Only meaningful for a client with no live transport: + * a connected client is handled by {@link _attachConnection}, which disposes + * any armed timers, so this is a no-op when the client is active. The delay + * is the remaining grace measured from when the client disconnected — so a + * client that disconnected a while before the call was issued gets the + * residual window rather than a fresh one, and a stamp from a long-dead + * client fails promptly. A never-connected client has its grace clock pinned + * to the first arm, so re-arms triggered by later orphaned tool calls in the + * same session shrink the remaining window instead of resetting it. */ - private _startClientToolCallDisconnectTimeout(clientId: string, session: string): void { - this._clearClientToolCallDisconnectTimeout(clientId, session); - const record = this._ensureClientRecord(clientId); - if (record.lastSeenAt === undefined) { - record.lastSeenAt = Date.now(); + private _startClientToolCallDisconnectTimeout(clientId: string, session: string, chatChannel: string): void { + const record = this._ensureGraceRecord(clientId); + if (!record) { + // Client is connected; the grace machinery does not apply. + return; } + record.disconnectTimeouts.deleteAndDispose(chatChannel); const elapsed = Date.now() - record.lastSeenAt; const delay = Math.max(0, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT - elapsed); - record.disconnectTimeouts.set(session, disposableTimeout(() => { - record.disconnectTimeouts.deleteAndDispose(session); - this._completeDisconnectedClientToolCalls(clientId, session); + record.disconnectTimeouts.set(chatChannel, disposableTimeout(() => { + this._releaseActiveClientForSession(session, clientId, chatChannel); }, delay)); } @@ -819,43 +897,71 @@ export class ProtocolServerHandler extends Disposable { * connected at stamp time) are failed immediately by the provider, so they * never reach a pending state here. */ - private _checkOrphanedClientToolCalls(session: string): void { - const state = this._stateManager.getSessionState(session); + private _checkOrphanedClientToolCalls(session: string, chatChannel: string): void { + const state = this._stateManager.getSessionState(chatChannel); const orphanOwners = new Set(); for (const { clientId } of this._pendingClientToolCalls(state)) { const ownerRecord = this._clients.get(clientId); - if (!ownerRecord || ownerRecord.connections.length === 0) { + if (ownerRecord?.state !== 'active') { orphanOwners.add(clientId); } } for (const ownerId of orphanOwners) { - this._startClientToolCallDisconnectTimeout(ownerId, session); + this._startClientToolCallDisconnectTimeout(ownerId, session, chatChannel); } } /** - * Get the existing per-client record or create an empty one. A freshly - * created record has no connections and `lastSeenAt === undefined`. + * Register a freshly connected (or reconnected) transport for `clientId`, + * promoting the record to {@link IActiveClientRecord}. Promoting a grace + * record back to active disposes its pending disconnect timers: the + * disconnect-grace window only applies while the client has no live + * transport. This is the single place that maintains the "active records + * hold no grace timers" invariant. */ - private _ensureClientRecord(clientId: string): IClientRecord { - let record = this._clients.get(clientId); - if (!record) { - record = { connections: [], lastSeenAt: undefined, disconnectTimeouts: new DisposableMap() }; - this._clients.set(clientId, record); + private _attachConnection(clientId: string, client: IConnectedClient): void { + const existing = this._clients.get(clientId); + if (existing?.state === 'active') { + existing.connections.push(client); + } else { + existing?.disconnectTimeouts.dispose(); + this._clients.set(clientId, { state: 'active', connections: [client] }); } - return record; + this._pruneClientRecords(); + this._onDidChangeConnectionCount.fire(this._connectedClientCount); + } + + /** + * Return the existing grace record for `clientId`, creating one for a + * never-connected client (an orphan tool-call stamp). Returns `undefined` + * when the client is currently active — the grace machinery does not apply + * to a connected client. A newly created record pins its grace clock to now. + */ + private _ensureGraceRecord(clientId: string): IGraceClientRecord | undefined { + const record = this._clients.get(clientId); + if (record?.state === 'active') { + return undefined; + } + if (record) { + return record; + } + const created: IGraceClientRecord = { state: 'grace', lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap() }; + this._clients.set(clientId, created); + return created; } private _getActiveClient(clientId: string): IConnectedClient | undefined { - const connections = this._clients.get(clientId)?.connections; - return connections?.[connections.length - 1]; + return this._getActiveClientFromRecord(this._clients.get(clientId)); } - private _getActiveClientFromRecord(record: IClientRecord): IConnectedClient | undefined { + private _getActiveClientFromRecord(record: IClientRecord | undefined): IConnectedClient | undefined { + if (record?.state !== 'active') { + return undefined; + } return record.connections[record.connections.length - 1]; } - private _releaseClientSubscriptions(client: IConnectedClient, record: IClientRecord): void { + private _releaseClientSubscriptions(client: IConnectedClient, record: IActiveClientRecord): void { for (const sub of client.subscriptions.values()) { if (sub.kind === ChannelKind.State) { if (this._hasSubscriptionInOtherConnection(record, client, sub.uri)) { @@ -870,6 +976,9 @@ export class ProtocolServerHandler extends Disposable { } private _hasSubscriptionInOtherConnection(record: IClientRecord, client: IConnectedClient, uri: string): boolean { + if (record.state !== 'active') { + return false; + } for (const other of record.connections) { if (other !== client && other.subscriptions.has(uri)) { return true; @@ -878,11 +987,11 @@ export class ProtocolServerHandler extends Disposable { return false; } - /** Number of records that currently hold a live connection. */ + /** Number of clients that currently have a live connection. */ private get _connectedClientCount(): number { let count = 0; for (const record of this._clients.values()) { - if (record.connections.length > 0) { + if (record.state === 'active') { count++; } } @@ -890,30 +999,34 @@ export class ProtocolServerHandler extends Disposable { } /** - * Drop disconnected, timer-less client records whose last-seen time is - * stale beyond the retention window (10× the disconnect timeout), plus - * never-connected placeholder records (e.g. a stamp from a long-dead - * client) once their timeouts have fired. Bounds {@link _clients} without - * tracking liveness precisely — a pruned-then-resurfacing stamp simply - * falls back to the full grace window. + * Drop grace records whose timers have all fired and whose last-seen time is + * stale beyond the retention window (10× the disconnect timeout). This + * covers both genuinely-disconnected clients and never-connected orphan + * stamps. Bounds {@link _clients} without tracking liveness precisely — a + * pruned-then-resurfacing stamp simply falls back to the full grace window. + * Active records are never pruned; they persist until their last transport + * closes. */ private _pruneClientRecords(): void { const cutoff = Date.now() - CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT * 10; for (const [clientId, record] of this._clients) { - if (record.connections.length === 0 + if (record.state === 'grace' && record.disconnectTimeouts.size === 0 - && (record.lastSeenAt === undefined || record.lastSeenAt < cutoff)) { + && record.lastSeenAt < cutoff) { this._clients.delete(clientId); } } } - private _clearClientToolCallDisconnectTimeout(clientId: string, session: string): void { - this._clients.get(clientId)?.disconnectTimeouts.deleteAndDispose(session); + private _clearClientToolCallDisconnectTimeout(clientId: string, channel: string): void { + const record = this._clients.get(clientId); + if (record?.state === 'grace') { + record.disconnectTimeouts.deleteAndDispose(channel); + } } - private _completeDisconnectedClientToolCalls(clientId: string, session: string): void { - const state = this._stateManager.getSessionState(session); + private _completeDisconnectedClientToolCalls(clientId: string, session: string, chatChannel: string): void { + const state = this._stateManager.getSessionState(chatChannel); const activeTurn = state?.activeTurn; if (!state || !activeTurn) { return; @@ -924,7 +1037,7 @@ export class ProtocolServerHandler extends Disposable { } const mayRetryWithReplacementClient = this._hasReplacementActiveClientTool(state, clientId, toolCall.toolName); if (toolCall.status === ToolCallStatus.Streaming) { - this._stateManager.dispatchServerAction(session, { + this._stateManager.dispatchServerAction(chatChannel, { type: ActionType.ChatToolCallReady, turnId: activeTurn.id, toolCallId: toolCall.toolCallId, @@ -932,7 +1045,7 @@ export class ProtocolServerHandler extends Disposable { confirmed: ToolCallConfirmationReason.NotNeeded, }); } - this._stateManager.dispatchServerAction(session, { + this._stateManager.dispatchServerAction(chatChannel, { type: ActionType.ChatToolCallComplete, turnId: activeTurn.id, toolCallId: toolCall.toolCallId, @@ -1022,13 +1135,12 @@ export class ProtocolServerHandler extends Disposable { try { createdSession = await this._agentService.createSession({ provider: params.provider, - model: params.model, - agent: params.agent, workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, session: URI.parse(params.channel), fork, config: params.config, activeClient: params.activeClient, + progressToken: params.progressToken, }); } catch (err) { if (err instanceof ProtocolError) { @@ -1061,7 +1173,7 @@ export class ProtocolServerHandler extends Disposable { URI.parse(params.channel), URI.parse(params.chat), { - ...(params.model ? { model: params.model } : {}), + ...(params.source ? { fork: { source: URI.parse(params.source.chat), turnId: params.source.turnId } } : {}), }, ); return null; @@ -1099,10 +1211,9 @@ export class ProtocolServerHandler extends Disposable { title: s.summary ?? 'Session', status, activity: s.activity, - createdAt: s.startTime, - modifiedAt: s.modifiedTime, + createdAt: new Date(s.startTime).toISOString(), + modifiedAt: new Date(s.modifiedTime).toISOString(), ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), - model: s.model, workingDirectory: s.workingDirectory?.toString(), changes: s.changes, }; @@ -1372,6 +1483,14 @@ export class ProtocolServerHandler extends Disposable { return; } this._agentService.unsubscribe(URI.parse(sub.uri), client.clientId); + if (isAhpChatChannel(sub.uri)) { + this._releaseActiveClientForSession(parseRequiredSessionUriFromChatUri(sub.uri), client.clientId, sub.uri); + } else { + const state = this._stateManager.getSessionState(sub.uri); + for (const chat of state?.chats ?? []) { + this._releaseActiveClientForSession(sub.uri, client.clientId, chat.resource); + } + } } else if (sub.kind === ChannelKind.ResourceWatch) { this._agentService.onResourceWatchUnsubscribed(sub.uri); } @@ -1409,29 +1528,33 @@ export class ProtocolServerHandler extends Disposable { } private _isRelevantToClient(client: IConnectedClient, envelope: ActionEnvelope): boolean { - // The root channel has two equivalent string forms (`ahp-root://` and - // the URI-roundtripped `ahp-root:`). Treat them interchangeably so a - // client that subscribed with either form receives root broadcasts - // regardless of which form the envelope carries. See - // {@link isAhpRootChannel}. - if (isAhpRootChannel(envelope.channel)) { - for (const sub of client.subscriptions.values()) { - if (sub.kind === ChannelKind.State && isAhpRootChannel(sub.uri)) { - return true; - } - } + const sub = client.subscriptions.get(envelope.channel); + if (sub?.kind === ChannelKind.State || sub?.kind === ChannelKind.ResourceWatch) { + return true; + } + if (!isAhpRootChannel(envelope.channel)) { return false; } - const sub = client.subscriptions.get(envelope.channel); - return sub?.kind === ChannelKind.State || sub?.kind === ChannelKind.ResourceWatch; + return isActionEnvelopeRelevantToSubscriptionUris(envelope, this._stateAndResourceWatchUris(client)); + } + + private *_stateAndResourceWatchUris(client: IConnectedClient): Iterable { + for (const sub of client.subscriptions.values()) { + if (sub.kind === ChannelKind.State || sub.kind === ChannelKind.ResourceWatch) { + yield sub.uri; + } + } } override dispose(): void { for (const record of this._clients.values()) { - for (const connection of [...record.connections]) { - connection.disposables.dispose(); + if (record.state === 'active') { + for (const connection of [...record.connections]) { + connection.disposables.dispose(); + } + } else { + record.disconnectTimeouts.dispose(); } - record.disconnectTimeouts.dispose(); } this._clients.clear(); for (const [, pending] of this._pendingReverseRequests) { diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 91b2b84b702ab..5b54d5caf8004 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -6,8 +6,10 @@ import * as fs from 'fs'; import { SequencerByKey } from '../../../base/common/async.js'; import type { Database, RunResult } from '@vscode/sqlite3'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, IReviewedFileRecord, ISessionDatabase } from '../common/sessionDataService.js'; import { dirname } from '../../../base/common/path.js'; +import { URI } from '../../../base/common/uri.js'; +import type { Message } from '../common/state/sessionState.js'; /** * A single numbered migration. Migrations are applied in order of @@ -85,6 +87,21 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ version: 5, sql: `ALTER TABLE turns ADD COLUMN checkpoint_ref TEXT`, }, + { + version: 6, + sql: `CREATE TABLE IF NOT EXISTS chat_drafts ( + chat_uri TEXT PRIMARY KEY NOT NULL, + draft TEXT NOT NULL + )`, + }, + { + version: 7, + sql: `CREATE TABLE IF NOT EXISTS reviewed_files ( + uri TEXT NOT NULL, + nonce TEXT NOT NULL, + PRIMARY KEY (uri, nonce) + )`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -549,6 +566,65 @@ export class SessionDatabase implements ISessionDatabase { })); } + setChatDraft(chat: URI, draft: Message | undefined): Promise { + const chatUri = chat.toString(); + return this._track(async () => { + const db = await this._ensureDb(); + if (!draft) { + await dbRun(db, 'DELETE FROM chat_drafts WHERE chat_uri = ?', [chatUri]); + return; + } + await dbRun(db, 'INSERT OR REPLACE INTO chat_drafts (chat_uri, draft) VALUES (?, ?)', [chatUri, JSON.stringify(draft)]); + }); + } + + async getChatDraft(chat: URI): Promise { + const db = await this._ensureDb(); + const row = await dbGet(db, 'SELECT draft FROM chat_drafts WHERE chat_uri = ?', [chat.toString()]); + if (typeof row?.draft !== 'string') { + return undefined; + } + try { + return JSON.parse(row.draft) as Message; + } catch { + return undefined; + } + } + + // ---- Reviewed files ------------------------------------------------- + + markFileReviewed(uri: URI, nonce: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO reviewed_files (uri, nonce) VALUES (?, ?)', [uri.toString(), nonce]); + }); + } + + unmarkFileReviewed(uri: URI, nonce: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'DELETE FROM reviewed_files WHERE uri = ? AND nonce = ?', [uri.toString(), nonce]); + }); + } + + async getReviewedFiles(): Promise { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files ORDER BY rowid', []); + return rows.map(toReviewedFileRecord); + } + + async getReviewedFilesForUri(uri: URI): Promise { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files WHERE uri = ? ORDER BY rowid', [uri.toString()]); + return rows.map(toReviewedFileRecord); + } + + async isFileReviewed(uri: URI, nonce: string): Promise { + const db = await this._ensureDb(); + const row = await dbGet(db, 'SELECT 1 FROM reviewed_files WHERE uri = ? AND nonce = ? LIMIT 1', [uri.toString(), nonce]); + return !!row; + } + remapTurnIds(mapping: ReadonlyMap): Promise { return this._track(async () => { const db = await this._ensureDb(); @@ -624,6 +700,13 @@ export class SessionDatabase implements ISessionDatabase { } } +function toReviewedFileRecord(row: Record): IReviewedFileRecord { + return { + uri: URI.parse(row.uri as string), + nonce: row.nonce as string, + }; +} + function toUint8Array(value: unknown): Uint8Array { if (value instanceof Buffer) { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); diff --git a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts index 60c8a379eb565..a83c1ec9ef343 100644 --- a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts +++ b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts @@ -13,20 +13,20 @@ function getFileEditUri(diff: ISessionFileDiff): string | undefined { return diff.after?.uri ?? diff.before?.uri; } -function createSessionFileDiff(sessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { +function createSessionFileDiff(beforeSessionUri: string, afterSessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { const hasBefore = identity.firstKind !== FileEditKind.Create; const hasAfter = identity.lastKind !== FileEditKind.Delete; return { ...(hasBefore ? { before: { uri: URI.file(identity.firstFilePath).toString(), - content: { uri: buildSessionDbUri(sessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') }, + content: { uri: buildSessionDbUri(beforeSessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') }, }, } : {}), ...(hasAfter ? { after: { uri: URI.file(identity.terminalPath).toString(), - content: { uri: buildSessionDbUri(sessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') }, + content: { uri: buildSessionDbUri(afterSessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') }, }, } : {}), diff: { added, removed }, @@ -46,12 +46,33 @@ interface IFileIdentity { firstFilePath: string; /** The kind of the first edit (Create means no "before" content). */ firstKind: FileEditKind; + /** Index into the sources array of the DB that owns the first edit. */ + firstSourceIdx: number; /** Tool call ID of the last edit (for fetching "after" content). */ lastToolCallId: string; /** File path used in the last edit's database record. */ lastFilePath: string; /** The kind of the last edit (Delete means no "after" content). */ lastKind: FileEditKind; + /** Index into the sources array of the DB that owns the last edit. */ + lastSourceIdx: number; +} + +/** + * A single database whose file edits contribute to a session's aggregated + * diff. For single-chat sessions there is one source (the session DB); for + * multi-chat sessions each peer chat records edits into its own DB, so the + * session changeset unions the session DB with every peer chat DB. + */ +export interface ISessionDiffSource { + /** + * The session / peer-chat URI that owns {@link db}. Encoded into the + * `session-db:` content URIs so the resource resolver opens the correct + * database when fetching before/after blobs. + */ + sessionUri: string; + /** The database holding this source's file edits. */ + db: ISessionDatabase; } /** @@ -86,32 +107,35 @@ export async function computeSessionDiffs( diffService: IDiffComputeService, incremental?: IIncrementalDiffOptions, ): Promise { - // In incremental mode, try to fetch only the current turn's edits. - // When the turn only introduces new files (no renames, no re-edits of - // previously changed files), the full edit history is not needed. + // Full mode (no incremental) is the single-source case of the unioned + // computation — delegate so the identity-graph + diff logic lives in one + // place and multi-chat sessions reuse the exact same code path. + if (!incremental) { + return computeUnionedDiffs([{ sessionUri, db }], diffService); + } + + // Incremental mode (single source): try to fetch only the current turn's + // edits. When the turn only introduces new files (no renames, no re-edits + // of previously changed files), the full edit history is not needed. let edits: IFileEditRecord[]; let fastPath = false; - if (incremental) { - const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId); - if (turnEdits.length === 0) { - return [...incremental.previousDiffs]; - } + const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId); + if (turnEdits.length === 0) { + return [...incremental.previousDiffs]; + } - const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri)); - const needsFullHistory = turnEdits.some(e => - e.kind === FileEditKind.Rename || - previousDiffsUris.has(URI.file(e.filePath).toString()) - ); + const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri)); + const needsFullHistory = turnEdits.some(e => + e.kind === FileEditKind.Rename || + previousDiffsUris.has(URI.file(e.filePath).toString()) + ); - if (needsFullHistory) { - edits = await db.getAllFileEdits(); - } else { - edits = turnEdits; - fastPath = true; - } - } else { + if (needsFullHistory) { edits = await db.getAllFileEdits(); + } else { + edits = turnEdits; + fastPath = true; } if (edits.length === 0) { @@ -128,7 +152,7 @@ export async function computeSessionDiffs( const identities = new Map(); // Track which identity keys were touched by the incremental turn. // In fast-path mode all identities are from the current turn, so no tracking needed. - const touchedIdentityKeys = (incremental && !fastPath) ? new Set() : undefined; + const touchedIdentityKeys = !fastPath ? new Set() : undefined; for (const edit of edits) { let identityKey: string; @@ -146,7 +170,7 @@ export async function computeSessionDiffs( pathToIdentityKey.set(edit.filePath, identityKey); } - if (touchedIdentityKeys && edit.turnId === incremental!.changedTurnId) { + if (touchedIdentityKeys && edit.turnId === incremental.changedTurnId) { touchedIdentityKeys.add(identityKey); } @@ -158,9 +182,11 @@ export async function computeSessionDiffs( firstToolCallId: edit.toolCallId, firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath, firstKind: edit.kind, + firstSourceIdx: 0, lastToolCallId: edit.toolCallId, lastFilePath: edit.filePath, lastKind: edit.kind, + lastSourceIdx: 0, }); } else { // Update last snapshot info and terminal path @@ -173,7 +199,7 @@ export async function computeSessionDiffs( // In incremental slow-path mode, build a lookup map from URI string → // previous diff so untouched identities can carry over their previous results. - const previousDiffsMap = (incremental && !fastPath) + const previousDiffsMap = !fastPath ? new Map(incremental.previousDiffs.map(d => [getFileEditUri(d), d])) : undefined; @@ -218,7 +244,7 @@ export async function computeSessionDiffs( } const counts = await diffService.computeDiffCounts(beforeText, afterText); - results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed)); + results.push(createSessionFileDiff(sessionUri, sessionUri, identity, counts.added, counts.removed)); })()); } @@ -227,9 +253,126 @@ export async function computeSessionDiffs( // In fast-path mode, carry over previous diffs for untouched files // (they were not in the identity graph since we only loaded the current turn) if (fastPath) { - results.push(...incremental!.previousDiffs); + results.push(...incremental.previousDiffs); + } + + return results; +} + +/** + * Computes aggregated diff statistics across one or more {@link ISessionDiffSource} + * databases by unioning their file edits and comparing each file's first + * snapshot to its last snapshot, tracking renames across the chain. + * + * Single-chat sessions pass one source (the session DB). Multi-chat sessions + * pass the session DB plus every peer chat DB so peer-chat edits (recorded into + * their own databases) roll up into the session-level changes. Each file + * identity remembers which source owns its first and last snapshots so the + * before/after content is read from — and its `session-db:` content URI encodes — + * the correct database. + * + * Sources are unioned in array order (session first, peers next); within a + * source, edits keep their insertion order. When a file is touched by more than + * one source the "before" comes from the earliest source that touched it and the + * "after" from the latest, which matches the shared working tree the chats edit. + * + * TODO (debt): this always does a full recompute — it ignores the + * {@link IIncrementalDiffOptions} fast/slow paths that {@link computeSessionDiffs} + * uses for single-source sessions. An incremental union is a safe follow-up: + * the per-identity `firstSourceIdx`/`lastSourceIdx` already carry the provenance + * needed to recompute only the turn's owning source plus cross-source files and + * carry over the rest. Requires plumbing the owning source of `changedTurnId` + * through `onTurnComplete` → `_doComputeStaticChangeset`. See tracking issue. + */ +export async function computeUnionedDiffs( + sources: readonly ISessionDiffSource[], + diffService: IDiffComputeService, +): Promise { + // Load every source's edits in parallel, then concatenate in source order so + // the identity graph sees a deterministic session-first ordering while each + // source keeps its own insertion order. + const perSourceEdits = await Promise.all(sources.map(source => source.db.getAllFileEdits())); + + const pathToIdentityKey = new Map(); + const identities = new Map(); + let totalEdits = 0; + + for (let sourceIdx = 0; sourceIdx < perSourceEdits.length; sourceIdx++) { + for (const edit of perSourceEdits[sourceIdx]) { + totalEdits++; + let identityKey: string; + + if (edit.kind === FileEditKind.Rename && edit.originalPath) { + identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath; + pathToIdentityKey.set(edit.filePath, identityKey); + pathToIdentityKey.delete(edit.originalPath); + } else { + identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath; + pathToIdentityKey.set(edit.filePath, identityKey); + } + + const existing = identities.get(identityKey); + if (!existing) { + identities.set(identityKey, { + terminalPath: edit.filePath, + firstToolCallId: edit.toolCallId, + firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath, + firstKind: edit.kind, + firstSourceIdx: sourceIdx, + lastToolCallId: edit.toolCallId, + lastFilePath: edit.filePath, + lastKind: edit.kind, + lastSourceIdx: sourceIdx, + }); + } else { + existing.terminalPath = edit.filePath; + existing.lastToolCallId = edit.toolCallId; + existing.lastFilePath = edit.filePath; + existing.lastKind = edit.kind; + existing.lastSourceIdx = sourceIdx; + } + } + } + + if (totalEdits === 0) { + return []; + } + + const results: ISessionFileDiff[] = []; + const diffPromises: Promise[] = []; + + for (const identity of identities.values()) { + diffPromises.push((async () => { + const firstSource = sources[identity.firstSourceIdx]; + const lastSource = sources[identity.lastSourceIdx]; + + let beforeText: string; + if (identity.firstKind === FileEditKind.Create) { + beforeText = ''; + } else { + const content = await firstSource.db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath); + beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : ''; + } + + let afterText: string; + if (identity.lastKind === FileEditKind.Delete) { + afterText = ''; + } else { + const content = await lastSource.db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath); + afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : ''; + } + + if (beforeText === afterText) { + return; + } + + const counts = await diffService.computeDiffCounts(beforeText, afterText); + results.push(createSessionFileDiff(firstSource.sessionUri, lastSource.sessionUri, identity, counts.added, counts.removed)); + })()); } + await Promise.allSettled(diffPromises); + return results; } @@ -273,9 +416,11 @@ export async function computeTurnDiffs( firstToolCallId: edit.toolCallId, firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath, firstKind: edit.kind, + firstSourceIdx: 0, lastToolCallId: edit.toolCallId, lastFilePath: edit.filePath, lastKind: edit.kind, + lastSourceIdx: 0, }); } else { existing.terminalPath = edit.filePath; @@ -307,7 +452,7 @@ export async function computeTurnDiffs( return; } const counts = await diffService.computeDiffCounts(beforeText, afterText); - results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed)); + results.push(createSessionFileDiff(sessionUri, sessionUri, identity, counts.added, counts.removed)); })()); } await Promise.allSettled(diffPromises); diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 75f0e00dd5f10..06935134a02a3 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -15,12 +15,14 @@ import { URI } from '../../../base/common/uri.js'; import { Promises } from '../../../base/node/pfs.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; -import { platformSessionSchema } from '../common/agentHostSchema.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformRootSchema, platformSessionSchema } from '../common/agentHostSchema.js'; import type { IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { + isAhpChatChannel, + parseRequiredSessionUriFromChatUri, ResponsePartKind, ToolCallConfirmationReason, type URI as ProtocolURI, @@ -40,6 +42,7 @@ export interface IToolApprovalEvent { readonly permissionKind?: IAgentToolPendingConfirmationSignal['permissionKind']; readonly permissionPath?: string; readonly toolInput?: string; + readonly requestSandboxBypass?: boolean; } /** Standard per-tool confirmation options presented to the user. */ @@ -233,26 +236,39 @@ export class SessionPermissionManager extends Disposable { * required. * * Checks are evaluated in order: - * 1. Session-level bypass (`autoApprove` config) - * 2. Per-tool session permissions (`permissions.allow`) - * 3. Read path rules (within working directory) - * 4. Write path rules (within working directory + glob patterns) - * 5. Shell command rules (tree-sitter parsed, default allow/deny) + * 1. Global auto-approve setting (`chat.tools.global.autoApprove`) + * 2. Session-level bypass (`autoApprove` config) + * 3. Per-tool session permissions (`permissions.allow`) + * 4. Read path rules (within working directory) + * 5. Write path rules (within working directory + glob patterns) + * 6. Shell command rules (tree-sitter parsed, default allow/deny) */ async getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): Promise { const workDir = this._configService.getEffectiveWorkingDirectory(sessionKey); - // 1. Session-level auto-approve + // 0. Sandbox bypass: a shell command that opted out of the + // sandbox (`requestSandboxBypass`) escapes the sandbox's + // containment. + if (e.requestSandboxBypass) { + return undefined; + } + + // 1. Global auto-approve setting + if (this.isGlobalAutoApproveEnabled()) { + return ToolCallConfirmationReason.Setting; + } + + // 2. Session-level auto-approve if (this.isSessionAutoApproveEnabled(sessionKey)) { return ToolCallConfirmationReason.Setting; } - // 2. Per-tool session permissions + // 3. Per-tool session permissions if (this._isToolAllowedByPermissions(sessionKey, e.toolCallId)) { return ToolCallConfirmationReason.Setting; } - // 3. Read auto-approval + // 4. Read auto-approval if (e.permissionKind === 'read' && e.permissionPath) { if (this._isPathInWorkingDirectory(e.permissionPath, workDir)) { this._logService.trace(`[SessionPermissionManager] Auto-approving read of ${e.permissionPath}`); @@ -261,7 +277,7 @@ export class SessionPermissionManager extends Disposable { return undefined; } - // 4. Write auto-approval + // 5. Write auto-approval if (e.permissionKind === 'write' && e.permissionPath) { if (await this._isEditAutoApproved(e.permissionPath, workDir)) { this._logService.trace(`[SessionPermissionManager] Auto-approving write to ${e.permissionPath}`); @@ -270,9 +286,13 @@ export class SessionPermissionManager extends Disposable { return undefined; } - // 5. Shell auto-approval + // 6. Shell auto-approval if (e.permissionKind === 'shell' && e.toolInput) { + if (this._configService.getRootValue(platformRootSchema, AgentHostTerminalAutoApproveEnabledConfigKey) === false) { + return undefined; + } const result = this._commandAutoApprover.shouldAutoApprove(e.toolInput, { + autoApproveRules: this._configService.getRootValue(platformRootSchema, AgentHostTerminalAutoApproveRulesConfigKey), isWriteDestApproved: (dest) => this._isShellWriteDestApproved(dest, workDir), }); if (result === 'approved') { @@ -288,6 +308,14 @@ export class SessionPermissionManager extends Disposable { return undefined; } + /** + * Returns whether VS Code's global auto-approve setting (`chat.tools.global.autoApprove`) is enabled. + * When enabled, every tool call is auto-approved without changing the session's approval level in the permissions picker. + */ + isGlobalAutoApproveEnabled(): boolean { + return this._configService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true; + } + isSessionAutoApproveEnabled(sessionKey: ProtocolURI): boolean { // `autoApprove` (Bypass Approvals) auto-approves every tool call. return this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove) === 'autoApprove'; @@ -338,9 +366,13 @@ export class SessionPermissionManager extends Disposable { * user selected "Allow in this Session". Adds the tool to the session's * permission allow list so future calls are auto-approved. */ - handleToolCallConfirmed(sessionKey: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { + handleToolCallConfirmed(chatChannel: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { + if (!isAhpChatChannel(chatChannel)) { + throw new Error(`Tool call confirmations must be handled on an AHP chat channel: ${chatChannel}`); + } + const sessionKey = parseRequiredSessionUriFromChatUri(chatChannel); if (selectedOptionId === ALLOW_SESSION_OPTION_ID) { - const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); + const toolName = this._getToolNameForToolCall(chatChannel, toolCallId); if (toolName) { this._addToolToSessionPermissions(sessionKey, toolName); } diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 8ff1f6d1dcd11..8d7580386a1df 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { generateUuid } from '../../../../base/common/uuid.js'; -import { FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, VIEW_UNREVIEWED_COMMENTS_TOOL_NAME, type IFeedbackAnnotationMeta } from '../../common/meta/agentFeedbackAnnotations.js'; +import { localize } from '../../../../nls.js'; +import { FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, VIEW_UNREVIEWED_COMMENTS_TOOL_NAME, ADD_COMMENT_TOOL_NAME, type IFeedbackAnnotationMeta } from '../../common/meta/agentFeedbackAnnotations.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import type { AnnotationsAction } from '../../common/state/sessionActions.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { parseChatUri, type Annotation, type AnnotationsState, type StringOrMarkdown, type TextRange, type ToolDefinition } from '../../common/state/sessionState.js'; -import type { IServerToolGroup } from './agentServerToolHost.js'; +import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; /** * Server-side implementation of the agent feedback ("comments") tools. @@ -26,7 +27,7 @@ import type { IServerToolGroup } from './agentServerToolHost.js'; * the actions) lives in the caller. */ -export const addCommentToolName = 'addComment'; +export const addCommentToolName = ADD_COMMENT_TOOL_NAME; export const listCommentsToolName = 'listComments'; export const deleteCommentsToolName = 'deleteComments'; export const resolveCommentsToolName = 'resolveComments'; @@ -501,6 +502,81 @@ export function applyFeedbackTool(state: AnnotationsState, sessionResource: stri } } +/** + * Parses the number of comments returned by the {@link listCommentsToolName} + * tool from its JSON result (`{ comments: [...] }`). Returns `undefined` when + * the result is missing or not in the expected shape, so the caller can fall + * back to a count-less message. + */ +function parseListedCommentCount(resultText: string | undefined): number | undefined { + if (!resultText) { + return undefined; + } + try { + const parsed = JSON.parse(resultText) as { comments?: unknown }; + return Array.isArray(parsed.comments) ? parsed.comments.length : undefined; + } catch { + return undefined; + } +} + +/** + * Display strings for the feedback ("comments") tools, authored here so every + * provider (Copilot, Claude, Codex, …) renders them identically instead of + * each provider's display layer re-deriving the strings from the tool name. + * Returns `undefined` for tools this group does not own, so the caller falls + * back to its generic display. + * + * {@link toolName} is the bare tool name (any transport prefix such as Claude's + * `mcp____` has already been stripped by the dispatcher). + */ +function getFeedbackToolDisplay(toolName: string, _args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined { + switch (toolName) { + case addCommentToolName: + return { + displayName: localize('toolName.addComment', "Add Comment"), + invocationMessage: localize('toolInvoke.addComment', "Adding comment"), + pastTenseMessage: localize('toolComplete.addComment', "Added comment"), + }; + case listCommentsToolName: { + let pastTenseMessage: StringOrMarkdown; + const count = result ? parseListedCommentCount(result.text) : undefined; + if (count === undefined) { + pastTenseMessage = localize('toolComplete.listComments', "Checked comments"); + } else if (count === 1) { + pastTenseMessage = localize('toolComplete.listComments.one', "Checked 1 comment"); + } else { + pastTenseMessage = localize('toolComplete.listComments.many', "Checked {0} comments", count); + } + return { + displayName: localize('toolName.listComments', "List Comments"), + invocationMessage: localize('toolInvoke.listComments', "Checking comments"), + pastTenseMessage, + }; + } + case deleteCommentsToolName: + return { + displayName: localize('toolName.deleteComments', "Delete Comments"), + invocationMessage: localize('toolInvoke.deleteComments', "Deleting comments"), + pastTenseMessage: localize('toolComplete.deleteComments', "Deleted comments"), + }; + case resolveCommentsToolName: + return { + displayName: localize('toolName.resolveComments', "Resolve Comments"), + invocationMessage: localize('toolInvoke.resolveComments', "Resolving comments"), + pastTenseMessage: localize('toolComplete.resolveComments', "Resolved comments"), + }; + case viewUnreviewedCommentsToolName: + return { + displayName: localize('toolName.viewUnreviewedComments', "View Comments"), + invocationMessage: localize('toolInvoke.viewUnreviewedComments', "Viewing comments"), + pastTenseMessage: localize('toolComplete.viewUnreviewedComments', "Viewed comments"), + }; + default: + return undefined; + } +} + /** * The feedback ("comments") server-tool group, contributed to the * {@link AgentServerToolHost} at startup (see `node/agentService.ts`). Wraps @@ -514,12 +590,15 @@ export const feedbackServerToolGroup: IServerToolGroup = { requiresConfirmation(toolName): boolean { return feedbackToolRequiresConfirmation(toolName); }, - execute(stateManager, sessionUri, toolName, rawArgs): string { + getDisplay(toolName, args, result): IServerToolDisplay | undefined { + return getFeedbackToolDisplay(toolName, args, result); + }, + execute(stateManager, chatUri, toolName, rawArgs): string { // A session can contain multiple chats, each addressed by its own // `ahp-chat` URI but sharing the same context/workspace. Comments belong // to the session as a whole, so always resolve a chat URI back to its // owning session and operate on the main session's annotations channel. - const mainSessionUri = parseChatUri(sessionUri)?.session ?? sessionUri; + const mainSessionUri = parseChatUri(chatUri)?.session ?? chatUri; const annotationsUri = buildAnnotationsUri(mainSessionUri); const snapshot = stateManager.getSnapshot(annotationsUri); const state: AnnotationsState = (snapshot?.state as AnnotationsState | undefined) ?? { annotations: [] }; diff --git a/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts index 61a535012217c..abd91b26733ab 100644 --- a/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts +++ b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { LRUCache } from '../../../../base/common/map.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; @@ -18,11 +19,25 @@ export type FetchFunction = typeof globalThis.fetch; export interface CreatedPullRequest { readonly url: string; readonly number: number; + readonly nodeId?: string; } +/** + * Merge strategy used when enabling auto-merge on a pull request. + * Mirrors the GitHub GraphQL `PullRequestMergeMethod` enum. + */ +export type AutoMergeMethod = 'MERGE' | 'SQUASH' | 'REBASE'; + interface GitHubPullRequestResponseItem { - readonly html_url?: unknown; readonly number?: unknown; + readonly html_url?: unknown; + readonly node_id?: unknown; +} + +export interface IGitHubApiResponse { + readonly data: T | undefined; + readonly statusCode: number; + readonly etag?: string; } /** @@ -64,6 +79,18 @@ export interface IAgentHostOctoKitService { /** Finds the most recently updated pull request for `owner:branch`, if any. */ findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise; + + /** + * Enables auto-merge on a pull request so GitHub merges it automatically + * once all required reviews and status checks pass. + * + * Issues the GraphQL `enablePullRequestAutoMerge` mutation. `pullRequestId` + * is the pull request's GraphQL global node id (see + * {@link CreatedPullRequest.nodeId}). Throws on GraphQL or transport errors, + * including when the repository does not allow the requested merge method or + * auto-merge is not enabled for the repository. + */ + enablePullRequestAutoMerge(pullRequestId: string, mergeMethod: AutoMergeMethod, token: string, signal: AbortSignal): Promise; } export const IAgentHostOctoKitService = createDecorator('agentHostOctoKitService'); @@ -72,12 +99,23 @@ const GITHUB_API_HOST = 'https://api.github.com'; const GITHUB_API_VERSION = '2022-11-28'; const MAX_ERROR_RESPONSE_BODY_LENGTH = 500; +const ENABLE_AUTO_MERGE_MUTATION = `mutation EnableAutoMerge($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) { + enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: $mergeMethod }) { + pullRequest { id } + } +}`; + export class AgentHostOctoKitService implements IAgentHostOctoKitService { declare readonly _serviceBrand: undefined; private readonly _fetch: FetchFunction; + /** + * A cache of ETags for pull request search results. + */ + private readonly pullRequestSearchEtags = new LRUCache(100); + constructor( fetchFn: FetchFunction | undefined, @ILogService private readonly _logService: ILogService, @@ -96,7 +134,7 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { token: string, signal: AbortSignal, ): Promise { - const response = await this._makeGHAPIRequest( + const response = await this._makeGHAPIRequest( `repos/${owner}/${repo}/pulls`, 'POST', token, @@ -104,46 +142,70 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { { title, body, head, base, draft }, ); - const html_url = (response as { html_url?: unknown } | undefined)?.html_url; - const number = (response as { number?: unknown } | undefined)?.number; + const number = response.data?.number; + const html_url = response.data?.html_url; if (typeof html_url !== 'string' || typeof number !== 'number') { throw new Error(`Failed to create pull request for ${owner}/${repo}`); } - return { url: html_url, number }; + const node_id = response.data?.node_id; + return { url: html_url, number, nodeId: typeof node_id === 'string' ? node_id : undefined }; } async findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise { - const response = await this._makeGHAPIRequest( - `repos/${owner}/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`, - 'GET', - token, - signal, - ); - if (!Array.isArray(response) || response.length === 0) { + const routeSlug = `repos/${owner}/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`; + + const etag = this.pullRequestSearchEtags.get(routeSlug); + const response = await this._makeGHAPIRequest(routeSlug, 'GET', token, signal, undefined, etag); + + if (response.etag) { + this.pullRequestSearchEtags.set(routeSlug, response.etag); + } + + if ( + response.statusCode === 304 || + !Array.isArray(response.data) || + response.data.length === 0 + ) { return undefined; } - const first = response[0] as GitHubPullRequestResponseItem | undefined; + + const first = response.data[0]; const html_url = first?.html_url; const number = first?.number; + const node_id = first?.node_id; return typeof html_url === 'string' && typeof number === 'number' - ? { url: html_url, number } + ? { + number, + url: html_url, + nodeId: typeof node_id === 'string' + ? node_id + : undefined + } : undefined; } - private async _makeGHAPIRequest( + async enablePullRequestAutoMerge(pullRequestId: string, mergeMethod: AutoMergeMethod, token: string, signal: AbortSignal): Promise { + await this._makeGraphQLRequest(ENABLE_AUTO_MERGE_MUTATION, { pullRequestId, mergeMethod }, token, signal); + } + + private async _makeGHAPIRequest( routeSlug: string, method: 'GET' | 'POST', token: string, signal: AbortSignal, body?: Record, - ): Promise { + etag?: string + ): Promise> { const url = `${GITHUB_API_HOST}/${routeSlug}`; const headers: Record = { 'Accept': 'application/vnd.github+json', 'Authorization': `Bearer ${token}`, 'X-GitHub-Api-Version': GITHUB_API_VERSION, }; + if (etag) { + headers['If-None-Match'] = etag; + } if (body) { headers['Content-Type'] = 'application/json'; } @@ -164,6 +226,25 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { throw err; } + // Inspect rate limit header + const rateLimitHeader = response.headers.get('x-ratelimit-remaining'); + if (rateLimitHeader) { + const rateLimitRemaining = parseRateLimitHeader(rateLimitHeader); + if (rateLimitRemaining !== undefined && rateLimitRemaining < 100) { + this._logService.warn(`[AgentHostOctoKitService] ${method} ${url} - GitHub API rate limit low: ${rateLimitRemaining} remaining`); + } + } + + const statusCode = response.status ?? 0; + const responseETag = response.headers.get('etag') ?? undefined; + + if ( + statusCode === 204 /* No Content */ || + statusCode === 304 /* Not Modified */ + ) { + return { data: undefined, statusCode, etag: responseETag }; + } + if (!response.ok) { const errorText = await response.text().catch(() => undefined); const errorDetail = this._formatErrorResponseBody(errorText); @@ -172,13 +253,73 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { } try { - return await response.json(); + const data = await response.json(); + return { data, statusCode, etag: responseETag }; } catch (err) { this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Failed to parse JSON`, err); throw err; } } + private async _makeGraphQLRequest( + query: string, + variables: Record, + token: string, + signal: AbortSignal, + ): Promise { + const url = `${GITHUB_API_HOST}/graphql`; + const headers: Record = { + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }; + + let response: Response; + try { + response = await this._fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + signal, + }); + } catch (err) { + if (signal.aborted) { + throw err; + } + this._logService.error(`[AgentHostOctoKit] POST ${url} - Network error`, err); + throw err; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => undefined); + const errorDetail = this._formatErrorResponseBody(errorText); + this._logService.error(`[AgentHostOctoKit] POST ${url} - Status: ${response.status}${errorDetail ? ` - ${errorDetail}` : ''}`); + throw new Error(`GitHub GraphQL request failed: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); + } + + let json: { data?: unknown; errors?: ReadonlyArray<{ message?: unknown }> }; + try { + json = await response.json(); + } catch (err) { + this._logService.error(`[AgentHostOctoKit] POST ${url} - Failed to parse JSON`, err); + throw err; + } + + // GraphQL reports failures with a 200 status code and an `errors` array. + if (Array.isArray(json.errors) && json.errors.length > 0) { + const message = json.errors.map(error => { + return typeof error?.message === 'string' + ? error.message + : JSON.stringify(error); + }).join('; '); + this._logService.error(`[AgentHostOctoKit] POST ${url} - GraphQL error: ${message}`); + throw new Error(`GitHub GraphQL request failed: ${message}`); + } + + return json.data; + } + private _formatErrorResponseBody(errorText: string | undefined): string | undefined { const normalized = errorText?.replace(/\s+/g, ' ').trim(); if (!normalized) { @@ -189,3 +330,12 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { : normalized; } } + +function parseRateLimitHeader(value: string | string[] | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const str = Array.isArray(value) ? value[0] : value; + const parsed = parseInt(str, 10); + return isNaN(parsed) ? undefined : parsed; +} diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 8c25cb4d6d2b6..4d0575f1b8fcd 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -5,9 +5,38 @@ import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import type { ToolDefinition, URI } from '../../common/state/sessionState.js'; +import type { StringOrMarkdown, ToolDefinition, URI } from '../../common/state/sessionState.js'; import type { AgentHostStateManager } from '../agentHostStateManager.js'; +/** + * Result of a server tool, passed to {@link IServerToolGroup.getDisplay} so the + * owning group can tailor its past-tense message to what the tool returned + * (for example a count parsed from the textual result). Absent while the tool + * is still running. + */ +export interface IServerToolDisplayResult { + /** The textual tool result (the string the group's `execute` returned). */ + readonly text?: string; + /** Whether the tool completed successfully. */ + readonly success: boolean; +} + +/** + * Display strings for a server tool, authored by the group that owns the tool + * so every provider renders it identically (instead of each provider's display + * layer re-deriving the strings from the tool name). Each field is optional: a + * provider uses the returned value where present and falls back to its own + * generic display otherwise. + */ +export interface IServerToolDisplay { + /** Human-readable tool name (e.g. "List Comments"). */ + readonly displayName?: string; + /** Present-tense message shown while the tool runs (e.g. "Checking comments"). */ + readonly invocationMessage?: StringOrMarkdown; + /** Past-tense message shown once the tool completes (e.g. "Checked 3 comments"). */ + readonly pastTenseMessage?: StringOrMarkdown; +} + /** * A group of related server tools owned and executed by the agent host. Each * group bundles the {@link ToolDefinition}s it advertises with an executor @@ -43,6 +72,20 @@ export interface IServerToolGroup { * are invalid. */ execute(stateManager: AgentHostStateManager, sessionUri: URI, toolName: string, rawArgs: unknown): string; + + /** + * Display strings for {@link toolName} (one of this group's + * {@link definitions}), authored here so every provider renders this tool + * identically rather than re-deriving the strings from the tool name. The + * caller passes the parsed tool arguments and, once the tool has completed, + * its {@link IServerToolDisplayResult result}. Returns `undefined` (or + * individually-absent fields) to let the provider fall back to its generic + * display. Optional: a group without bespoke display omits this. + * + * `toolName` is the bare tool name (the provider strips any transport + * prefix such as Claude's `mcp____` before calling). + */ + getDisplay?(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined; } /** diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index a405bdc995b9f..0891b9d430270 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -11,6 +11,7 @@ import { createDecorator } from '../../../instantiation/common/instantiation.js' import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; +import { parseCopilotTokenFields } from '../copilot/copilotTokenFields.js'; // #region Types @@ -28,6 +29,19 @@ import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgree export interface ICopilotApiServiceRequestOptions { readonly headers?: Readonly>; readonly signal?: AbortSignal; + + /** + * Suppress the `Copilot-Integration-Id` header on this request. + * + * When unset, `@vscode/copilot-api` derives the integration id from the + * discovered Copilot SKU: a `no_auth_limited_copilot` SKU maps to + * `vscode-nl`, which the CAPI backend treats as the limited/no-auth + * integration and refuses premium models such as `claude-opus-4.7`. + * Setting this to `true` omits the header so CAPI authorizes against the + * token's real entitlement. Mirrors the Copilot Chat extension's + * `ClaudeStreamingPassThroughEndpoint.getEndpointFetchOptions()`. + */ + readonly suppressIntegrationId?: boolean; } /** @@ -79,6 +93,8 @@ interface ICopilotUserResponse { interface ICachedClient { readonly capiClient: CAPIClient; readonly expiresAt: number; + /** The CAPI `endpoints.telemetry` base URL discovered for this token, if any. */ + readonly telemetryEndpoint?: string; } /** @@ -367,6 +383,20 @@ export const ICopilotApiService = createDecorator('copilotAp * and is not part of the Anthropic-shaped CAPI surface. * - Malformed JSON in an SSE `data:` line is logged and skipped, not thrown. */ +/** + * Restricted/enhanced telemetry context derived from a user's minted CAPI Copilot session token, + * mirroring what the Copilot extension reads off its `CopilotToken` (`rt` opt-in, `tid` tracking id) + * plus the CAPI `endpoints.telemetry` host. + */ +export interface IRestrictedTelemetryContext { + /** Whether the token opts into enhanced/restricted telemetry (the `rt=1` claim). */ + readonly restrictedTelemetryEnabled: boolean; + /** The Copilot user tracking id (`tid` claim), or `undefined` when absent. */ + readonly trackingId: string | undefined; + /** The CAPI `endpoints.telemetry` base URL, resolved only when enabled; `undefined` otherwise. */ + readonly telemetryEndpoint: string | undefined; +} + export interface ICopilotApiService { readonly _serviceBrand: undefined; @@ -460,6 +490,15 @@ export interface ICopilotApiService { request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions, ): Promise; + + /** + * Resolve this user's restricted-telemetry context from the minted CAPI Copilot session token — + * the `rt` opt-in and `tid` tracking id — plus the CAPI `endpoints.telemetry` host. The GitHub + * token itself carries none of these claims; they live in the Copilot session token (minted via + * `RequestType.CopilotToken`), exactly as the Copilot extension reads them off its `CopilotToken`. + * The telemetry endpoint is resolved only when enabled, so public users incur no extra discovery. + */ + resolveRestrictedTelemetryContext(githubToken: string): Promise; } export class CopilotApiService implements ICopilotApiService { @@ -522,6 +561,9 @@ export class CopilotApiService implements ICopilotApiService { ...options?.headers, 'Authorization': `Bearer ${githubToken}`, }, + // Opt-in per request — see + // `ICopilotApiServiceRequestOptions.suppressIntegrationId`. + suppressIntegrationId: options?.suppressIntegrationId, signal: options?.signal, }, { type: RequestType.Models }, @@ -566,6 +608,9 @@ export class CopilotApiService implements ICopilotApiService { 'X-Request-Id': requestId, 'OpenAI-Intent': 'conversation', }, + // Opt-in per request — see + // `ICopilotApiServiceRequestOptions.suppressIntegrationId`. + suppressIntegrationId: options?.suppressIntegrationId, body, signal: options?.signal, }, @@ -750,6 +795,7 @@ export class CopilotApiService implements ICopilotApiService { // paths already omit it). Thread a real per-turn initiator here if // that signal ever becomes available at the proxy boundary. }, + suppressIntegrationId: options?.suppressIntegrationId, body, signal: options?.signal, }, @@ -779,16 +825,36 @@ export class CopilotApiService implements ICopilotApiService { * dispatched for token B. */ private _getClientForToken(githubToken: string): Promise { + return this._getEntryForToken(githubToken).then(entry => entry.capiClient); + } + + /** + * Resolve this user's restricted-telemetry context. Reads the `rt`/`tid` claims from the minted + * CAPI Copilot session token (the GitHub token has neither), and resolves the CAPI + * `endpoints.telemetry` host from the cached `/copilot_internal/user` discovery only when the + * user is opted in, so public users pay no extra discovery call. + */ + async resolveRestrictedTelemetryContext(githubToken: string): Promise { + const fields = parseCopilotTokenFields(await this._getCopilotToken(githubToken)); + const restrictedTelemetryEnabled = fields.get('rt') === '1'; + const trackingId = fields.get('tid'); + const telemetryEndpoint = restrictedTelemetryEnabled + ? (await this._getEntryForToken(githubToken)).telemetryEndpoint + : undefined; + return { restrictedTelemetryEnabled, trackingId, telemetryEndpoint }; + } + + private _getEntryForToken(githubToken: string): Promise { const nowSeconds = Date.now() / 1000; const existing = this._clientsByToken.get(githubToken); if (existing) { return existing.then(entry => { if (entry.expiresAt - nowSeconds > CAPI_CONTEXT_REFRESH_BUFFER_SECONDS) { - return entry.capiClient; + return entry; } // Stale — evict and recurse to build a fresh entry. this._clientsByToken.delete(githubToken); - return this._getClientForToken(githubToken); + return this._getEntryForToken(githubToken); }).catch(err => { // A previous failed build leaked into the cache; evict and rebuild. this._clientsByToken.delete(githubToken); @@ -804,7 +870,7 @@ export class CopilotApiService implements ICopilotApiService { throw err; }); this._clientsByToken.set(githubToken, pending); - return pending.then(entry => entry.capiClient); + return pending; } private _invalidateClientForToken(githubToken: string): void { @@ -870,6 +936,7 @@ export class CopilotApiService implements ICopilotApiService { return { capiClient, expiresAt: Date.now() / 1000 + CAPI_CONTEXT_TTL_SECONDS, + telemetryEndpoint: envelope.endpoints?.telemetry, }; } diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index 9de8759cb4187..4143775a2f631 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, observableValue, transaction, type IObservable, type ITransaction } from '../../../../base/common/observable.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; @@ -22,6 +23,16 @@ export interface ISdkMcpServer { readonly state: McpServerState; } +/** + * Runtime fields of an MCP server customization that this controller + * owns — the high-frequency `state`/`channel` pair. Consumers overlay + * these onto their published customizations (keyed by customization id) + * so a wholesale customization republish preserves live MCP status + * rather than resetting it to the `Starting` default baked into + * `makeMcpServerCustomization`. + */ +export type IMcpServerRuntimeState = Pick; + /** * Re-export so existing imports of `DEFAULT_MCP_APP_CAPABILITIES` from * the controller keep working — the canonical home is now @@ -89,24 +100,47 @@ interface ILiveEntry { * * The controller is SDK-agnostic: providers translate their own events * into {@link ISdkMcpServer} and call {@link applyAll} / {@link applyOne}. - * - * V1 scope: auth states are intentionally not surfaced as - * {@link McpServerStatus.AuthRequired} — they are folded back into - * {@link McpServerStatus.Starting} by the caller's translation layer. + * If a provider reports a coarse {@link McpServerStatus.Starting} update + * after a richer {@link McpServerStatus.AuthRequired} state, the controller + * preserves the auth-required state until a definitive + * {@link McpServerStatus.Ready}, {@link McpServerStatus.Error}, or + * {@link McpServerStatus.Stopped} update arrives. */ export class McpCustomizationController extends Disposable { /** Per-server live entries, keyed by server name. */ - private readonly _live = new Map(); + private readonly _live = observableValue>(this, new Map()); + + /** + * Snapshot of every live server's runtime {@link IMcpServerRuntimeState}, + * keyed by the customization id under which it is published (the + * minted top-level id, or the plugin-derived child id resolved via + * {@link IMcpChildIdResolver}). Derived from {@link _live}. Callers mirror + * this into their own published customizations so a wholesale republish + * preserves live MCP status. Servers whose child id cannot currently be + * resolved are omitted. + */ + readonly runtimeStates: IObservable>; constructor(private readonly _options: IMcpCustomizationControllerOptions) { super(); + this.runtimeStates = derived(this, reader => { + const out = new Map(); + for (const entry of this._live.read(reader).values()) { + const id = entry.topLevelId ?? this._options.resolveChildId(entry.serverName); + if (id === undefined) { + continue; + } + out.set(id, { state: entry.state, channel: this._buildChannel(entry.serverName, entry.state) }); + } + return out; + }); } /** Snapshot for inclusion in `getSessionCustomizations()` results. */ topLevelCustomizations(): readonly McpServerCustomization[] { const out: McpServerCustomization[] = []; - for (const entry of this._live.values()) { + for (const entry of this._live.get().values()) { if (entry.topLevelId === undefined) { continue; } @@ -124,7 +158,7 @@ export class McpCustomizationController extends Disposable { */ readyChannels(): readonly { readonly serverName: string; readonly channel: string }[] { const out: { serverName: string; channel: string }[] = []; - for (const entry of this._live.values()) { + for (const entry of this._live.get().values()) { if (entry.state.kind !== McpServerStatus.Ready) { continue; } @@ -147,7 +181,7 @@ export class McpCustomizationController extends Disposable { * server customization. */ customizationIdForServer(serverName: string): string | undefined { - const live = this._live.get(serverName); + const live = this._live.get().get(serverName); if (live?.topLevelId !== undefined) { return live.topLevelId; } @@ -163,7 +197,7 @@ export class McpCustomizationController extends Disposable { * back through {@link IAgentHostService.handleMcpRequest}. */ channelForServer(serverName: string): string | undefined { - const live = this._live.get(serverName); + const live = this._live.get().get(serverName); if (!live || live.state.kind !== McpServerStatus.Ready) { return undefined; } @@ -173,23 +207,32 @@ export class McpCustomizationController extends Disposable { /** * Replaces the live inventory with `servers`. Servers no longer * present are removed; new servers and changed servers are upserted. + * Batched in a single transaction so {@link runtimeStates} observers + * see one coalesced update. */ applyAll(servers: readonly ISdkMcpServer[]): void { - const seen = new Set(); - for (const server of servers) { - seen.add(server.name); - this.applyOne(server); - } - for (const name of [...this._live.keys()]) { - if (!seen.has(name)) { - this.remove(name); + transaction(tx => { + const seen = new Set(); + for (const server of servers) { + seen.add(server.name); + this._applyOne(server, tx); } - } + for (const name of [...this._live.get().keys()]) { + if (!seen.has(name)) { + this._remove(name, tx); + } + } + }); } /** Upserts a single server. */ applyOne(server: ISdkMcpServer): void { - const previous = this._live.get(server.name); + transaction(tx => this._applyOne(server, tx)); + } + + private _applyOne(server: ISdkMcpServer, tx: ITransaction): void { + const previous = this._live.get().get(server.name); + const state = this._stateForUpdate(previous?.state, server.state); // Once promoted to a top-level entry, stay top-level for the // session — flipping back to a child mid-stream would orphan the // previously-published top-level id. @@ -197,21 +240,21 @@ export class McpCustomizationController extends Disposable { if (topLevelId === undefined) { const childId = this._options.resolveChildId(server.name); if (childId !== undefined) { - this._live.set(server.name, { serverName: server.name, state: server.state, topLevelId: undefined }); + this._setLiveEntry(server.name, { serverName: server.name, state, topLevelId: undefined }, tx); this._options.emit({ type: ActionType.SessionMcpServerStateChanged, id: childId, - state: server.state, - channel: this._buildChannel(server.name, server.state), + state, + channel: this._buildChannel(server.name, state), }); return; } topLevelId = this._mintTopLevelId(server.name); } - this._live.set(server.name, { serverName: server.name, state: server.state, topLevelId }); + this._setLiveEntry(server.name, { serverName: server.name, state, topLevelId }, tx); this._options.emit({ type: ActionType.SessionCustomizationUpdated, - customization: this._buildTopLevel(topLevelId, server.name, server.state), + customization: this._buildTopLevel(topLevelId, server.name, state), }); } @@ -228,11 +271,15 @@ export class McpCustomizationController extends Disposable { * actual removal of the child container. */ remove(serverName: string): void { - const entry = this._live.get(serverName); + transaction(tx => this._remove(serverName, tx)); + } + + private _remove(serverName: string, tx: ITransaction): void { + const entry = this._live.get().get(serverName); if (!entry) { return; } - this._live.delete(serverName); + this._deleteLiveEntry(serverName, tx); if (entry.topLevelId !== undefined) { this._options.emit({ type: ActionType.SessionCustomizationRemoved, @@ -253,6 +300,31 @@ export class McpCustomizationController extends Disposable { // ---- internals --------------------------------------------------------- + /** Immutable upsert into the {@link _live} observable. */ + private _setLiveEntry(serverName: string, entry: ILiveEntry, tx: ITransaction): void { + const next = new Map(this._live.get()); + next.set(serverName, entry); + this._live.set(next, tx); + } + + /** Immutable delete from the {@link _live} observable. */ + private _deleteLiveEntry(serverName: string, tx: ITransaction): void { + const current = this._live.get(); + if (!current.has(serverName)) { + return; + } + const next = new Map(current); + next.delete(serverName); + this._live.set(next, tx); + } + + private _stateForUpdate(previous: McpServerState | undefined, next: McpServerState): McpServerState { + if (previous?.kind === McpServerStatus.AuthRequired && next.kind === McpServerStatus.Starting) { + return previous; + } + return next; + } + private _mintTopLevelId(serverName: string): string { return `mcp-top-level:${this._options.providerId}:${this._options.sessionId}:${serverName}`; } diff --git a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts new file mode 100644 index 0000000000000..9ba7ae2d59adc --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/serverToolGroups.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 { feedbackServerToolGroup } from './agentFeedbackServerTools.js'; +import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; + +/** + * The server-tool groups contributed to every agent host session, in priority + * order. This is the single source of truth wired into the + * {@link AgentServerToolHost} at startup (see `agentService.ts`) and consulted + * by each provider's display layer via {@link getServerToolDisplay}. + * + * Adding a group here makes its tools available to all providers (Copilot, + * Claude, Codex, …) and — if the group implements + * {@link IServerToolGroup.getDisplay} — gives them nice display everywhere for + * free. + */ +export const serverToolGroups: readonly IServerToolGroup[] = [feedbackServerToolGroup]; + +/** + * Whether {@link toolName} (a tool name as seen on a tool call) refers to the + * server tool {@link bareName}. Accepts both the bare name and a transport + * prefix such as Claude's `mcp____` (matched as a `__`-delimited + * suffix), mirroring the convention in `agentFeedbackAnnotations.ts`. + */ +function matchesServerToolName(toolName: string, bareName: string): boolean { + return toolName === bareName || toolName.endsWith(`__${bareName}`); +} + +/** + * Resolves the {@link IServerToolDisplay} for a server tool call, authored by + * the group that owns the tool. Returns `undefined` when no contributed group + * owns {@link toolName} or the owning group has no bespoke display, so each + * provider's display layer can fall back to its generic behavior. + * + * Pure over {@link serverToolGroups} (it does not need the constructed + * {@link AgentServerToolHost}) so the providers' history-replay paths — which + * build display from pure functions without a host instance — can call it too. + * + * @param toolName The tool name as seen on the call (bare or transport-prefixed). + * @param args The parsed tool arguments. + * @param result The tool result, once it has completed; absent while running. + */ +export function getServerToolDisplay(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined { + for (const group of serverToolGroups) { + if (!group.getDisplay) { + continue; + } + for (const def of group.definitions) { + if (matchesServerToolName(toolName, def.name)) { + return group.getDisplay(def.name, args, result); + } + } + } + return undefined; +} diff --git a/src/vs/platform/agentHost/node/workspacelessScratchDir.ts b/src/vs/platform/agentHost/node/workspacelessScratchDir.ts new file mode 100644 index 0000000000000..f0f677deab3ba --- /dev/null +++ b/src/vs/platform/agentHost/node/workspacelessScratchDir.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs/promises'; +import { joinPath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; + +/** + * Stable, deterministic per-session scratch directory for a workspace-less + * (workspace-less chat) session: `/.copilot/chats/`. Shared by the + * Copilot and Claude agents so both resolve the same cwd for a session that was + * created with no `workingDirectory`. + */ +export function workspacelessScratchDir(userHome: URI, sessionId: string): URI { + return joinPath(userHome, '.copilot', 'chats', sessionId); +} + +/** Ensures the workspace-less scratch dir exists (mkdir -p), returning it. */ +export async function ensureWorkspacelessScratchDir(userHome: URI, sessionId: string): Promise { + const dir = workspacelessScratchDir(userHome, sessionId); + await fs.mkdir(dir.fsPath, { recursive: true }); + return dir; +} diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index ecd90015f697e..6502b849d5273 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { createSchema, migrateLegacyAutopilotConfig, platformSessionSchema, schemaProperty, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; +import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; +import { createSchema, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -345,4 +346,56 @@ suite('agentHostSchema', () => { assert.strictEqual(migrateLegacyAutopilotConfig(undefined), undefined); }); }); + + // ---- terminal auto-approve rule forwarding ----------------------------- + + suite('normalizeAgentHostTerminalAutoApproveRulesConfig', () => { + + test('keeps null entries and object rules', () => { + const inspectValue: IConfigurationValue> = {}; + const result = normalizeAgentHostTerminalAutoApproveRulesConfig({ + echo: null, + python: true, + '/^npm run build$/': { approve: true, matchCommandLine: true }, + }, inspectValue, false); + + assert.deepStrictEqual(result, { + echo: null, + python: true, + '/^npm run build$/': { approve: true, matchCommandLine: true }, + }); + }); + + test('removes default-only entries when default rules are ignored', () => { + const inspectValue: IConfigurationValue> = { + default: { value: { echo: true, ls: true, python: false } }, + user: { value: { echo: null } }, + }; + const result = normalizeAgentHostTerminalAutoApproveRulesConfig({ + echo: null, + ls: true, + python: true, + }, inspectValue, true); + + assert.deepStrictEqual(result, { + echo: null, + python: true, + }); + }); + + test('keeps entries that match defaults when they come from a non-default target', () => { + const inspectValue: IConfigurationValue> = { + default: { value: { echo: true, ls: true } }, + userValue: { ls: true }, + }; + const result = normalizeAgentHostTerminalAutoApproveRulesConfig({ + echo: true, + ls: true, + }, inspectValue, true); + + assert.deepStrictEqual(result, { + ls: true, + }); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts index cd29d5fdfc9f9..6c253e021a3ad 100644 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -6,7 +6,9 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentSession, isAgentEnabled } from '../../common/agentService.js'; +import { IConfigurationService } from '../../../configuration/common/configuration.js'; +import { AgentHostByokModelsEnabledEnvVar, AgentSession, AgentHostOTelEnvVars, buildAgentHostOTelEnv, buildAgentSdkEnv, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings } from '../../common/agentService.js'; +import { buildChatUri, buildDefaultChatUri, resolveChatUri } from '../../common/state/sessionState.js'; suite('AgentSession namespace', () => { @@ -66,3 +68,218 @@ suite('isAgentEnabled', () => { }); } }); + +suite('buildAgentHostOTelEnv', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('enterprise policy wins over inherited env', () => { + const env = buildAgentHostOTelEnv( + { enabled: false }, + { [AgentHostOTelEnvVars.OtlpEndpoint]: 'http://user:4318' }, + { enabled: true, otlpEndpoint: 'http://enterprise:4318' }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.Enabled], 'true'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpEndpoint], 'http://enterprise:4318'); + }); + + test('managed protocol sets the generic and per-signal protocol env vars', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.OtlpProtocol]: 'http/json' }, + { otlpProtocol: 'http/protobuf' }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpProtocol], 'http/protobuf'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpTracesProtocol], 'http/protobuf'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpMetricsProtocol], 'http/protobuf'); + }); + + test('policy-disabled blanks endpoint and file export', () => { + const env = buildAgentHostOTelEnv( + { enabled: true, otlpEndpoint: 'http://user:4318' }, + {}, + { enabled: false }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.Enabled], 'false'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpEndpoint], ''); + assert.strictEqual(env[AgentHostOTelEnvVars.FilePath], ''); + }); + + test('managed service name wins over inherited env', () => { + const env = buildAgentHostOTelEnv( + { serviceName: 'user-service' }, + { [AgentHostOTelEnvVars.ServiceName]: 'env-service' }, + { serviceName: 'enterprise-service' }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.ServiceName], 'enterprise-service'); + }); + + test('empty managed service name emits no override', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.ServiceName]: 'env-service' }, + { serviceName: '' }, + ); + // The builder returns only overrides; leaving the key out preserves the inherited env value. + assert.strictEqual(env[AgentHostOTelEnvVars.ServiceName], undefined); + }); + + test('managed resource attributes serialize into OTEL_RESOURCE_ATTRIBUTES', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.ResourceAttributes]: 'service.namespace=env' }, + { resourceAttributes: { 'deployment.environment': 'prod', 'service.namespace': 'acme' } }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.ResourceAttributes], 'deployment.environment=prod,service.namespace=acme'); + }); + + test('empty managed resource attributes emit no override', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.ResourceAttributes]: 'service.namespace=env' }, + { resourceAttributes: {} }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.ResourceAttributes], undefined); + }); +}); + +suite('readAgentHostOTelPolicySettings', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function fakeConfig(policy: Record): IConfigurationService { + return { + inspect: (key: string) => ({ policyValue: policy[key] as T | undefined }), + } as unknown as IConfigurationService; + } + + test('maps the policy value of every otel key', () => { + const cfg = fakeConfig({ + 'chat.agentHost.otel.enabled': true, + 'chat.agentHost.otel.exporterType': 'otlp-http', + 'chat.agentHost.otel.otlpProtocol': 'http/protobuf', + 'chat.agentHost.otel.otlpEndpoint': 'http://localhost:4318', + 'chat.agentHost.otel.captureContent': false, + 'chat.agentHost.otel.outfile': '/tmp/o.jsonl', + 'chat.agentHost.otel.serviceName': 'my-service', + 'chat.agentHost.otel.resourceAttributes': { 'service.namespace': 'acme' }, + }); + assert.deepStrictEqual(readAgentHostOTelPolicySettings(cfg), { + enabled: true, + exporterType: 'otlp-http', + otlpProtocol: 'http/protobuf', + otlpEndpoint: 'http://localhost:4318', + captureContent: false, + outfile: '/tmp/o.jsonl', + serviceName: 'my-service', + resourceAttributes: { 'service.namespace': 'acme' }, + }); + }); + + test('absent policy yields an all-undefined snapshot', () => { + assert.deepStrictEqual(readAgentHostOTelPolicySettings(fakeConfig({})), { + enabled: undefined, + exporterType: undefined, + otlpProtocol: undefined, + otlpEndpoint: undefined, + captureContent: undefined, + outfile: undefined, + serviceName: undefined, + resourceAttributes: undefined, + }); + }); +}); + +suite('sanitizeAgentHostOTelPolicySettings', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps well-typed fields and drops unknown/mistyped ones', () => { + assert.deepStrictEqual( + sanitizeAgentHostOTelPolicySettings({ + enabled: true, + exporterType: 'otlp-http', + otlpProtocol: 'http/protobuf', + otlpEndpoint: 'http://localhost:4318', + captureContent: false, + outfile: '/tmp/o.jsonl', + serviceName: 'my-service', + resourceAttributes: { 'service.namespace': 'acme', dropped: 7 }, + bogus: 123, + }), + { + enabled: true, + exporterType: 'otlp-http', + otlpProtocol: 'http/protobuf', + otlpEndpoint: 'http://localhost:4318', + captureContent: false, + outfile: '/tmp/o.jsonl', + serviceName: 'my-service', + resourceAttributes: { 'service.namespace': 'acme' }, + }, + ); + }); + + test('mistyped fields are dropped to undefined', () => { + assert.deepStrictEqual( + sanitizeAgentHostOTelPolicySettings({ enabled: 'yes', otlpEndpoint: 42, captureContent: 1 }), + { enabled: undefined, exporterType: undefined, otlpProtocol: undefined, otlpEndpoint: undefined, captureContent: undefined, outfile: undefined, serviceName: undefined, resourceAttributes: undefined }, + ); + }); + + test('non-object input yields an empty policy', () => { + assert.deepStrictEqual(sanitizeAgentHostOTelPolicySettings(null), {}); + assert.deepStrictEqual(sanitizeAgentHostOTelPolicySettings('x'), {}); + }); + + test('resourceAttributes drop prototype-pollution keys', () => { + // JSON.parse yields an OWN enumerable `__proto__` data property; the sanitizer must not + // copy it onto the result (which would trigger the prototype setter). + const raw = JSON.parse('{"resourceAttributes":{"__proto__":"polluted","constructor":"x","service.namespace":"acme"}}'); + const result = sanitizeAgentHostOTelPolicySettings(raw); + assert.deepStrictEqual(result.resourceAttributes, { 'service.namespace': 'acme' }); + assert.strictEqual(({} as Record).polluted, undefined); + }); +}); + +suite('resolveChatUri', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = AgentSession.uri('copilot', 'sess-1'); + + test('default chat collapses onto the scope (session) URI', () => { + const defaultChat = URI.parse(buildDefaultChatUri(session)); + assert.strictEqual(resolveChatUri(session, defaultChat).toString(), session.toString()); + }); + + test('peer chat is addressed by its own URI', () => { + const peer = URI.parse(buildChatUri(session, 'peer-42')); + assert.strictEqual(resolveChatUri(session, peer).toString(), peer.toString()); + }); +}); + +suite('buildAgentSdkEnv (BYOK gate forwarding)', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('forwards byokModelsEnabled=true as the enable env var', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: true }, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], 'true'); + }); + + test('forwards byokModelsEnabled=false as the disable env var', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: false }, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], 'false'); + }); + + test('omits the env var when byokModelsEnabled is undefined', () => { + const env = buildAgentSdkEnv({}, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], undefined); + }); + + test('lets an inherited env var win over the setting (developer override)', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: true }, { [AgentHostByokModelsEnabledEnvVar]: 'false' }); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 0274bd41abf8a..ebe65392b2310 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -8,9 +8,9 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ActionType, type ActionEnvelope } from '../../common/state/sessionActions.js'; -import { MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TurnState, type RootState, type SessionState, type TerminalState } from '../../common/state/protocol/state.js'; +import { MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TurnState, 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 { AgentSubscriptionManager, ChatStateSubscription, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; +import { AgentSubscriptionManager, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; // Helpers @@ -23,26 +23,34 @@ function makeRootState(overrides?: Partial): RootState { }; } +function makeSessionSummary(sessionUri: string): SessionSummary { + return { + resource: sessionUri, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, + }; +} + function makeSessionState(sessionUri: string, overrides?: Partial): SessionState { return { - summary: { - resource: sessionUri, - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: 1, - modifiedAt: 1, - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + project: { uri: 'file:///test-project', displayName: 'Test Project' }, lifecycle: SessionLifecycle.Ready, + activeClients: [], chats: [], ...overrides, }; } -function makeChatState(chatUri: string, sessionState: SessionState = makeSessionState(sessionUri), overrides?: Partial): ChatState { +function makeChatState(chatUri: string, sessionSummary: SessionSummary = makeSessionSummary(sessionUri), overrides?: Partial): ChatState { return { - ...createChatState(createDefaultChatSummary(sessionState.summary, chatUri)), + ...createChatState(createDefaultChatSummary(sessionSummary, chatUri)), ...overrides, }; } @@ -174,10 +182,18 @@ suite('RootStateSubscription', () => { const sub = disposables.add(new RootStateSubscription('c1', noop)); sub.handleSnapshot(makeRootState(), 0); const err = new Error('failed'); + const errors: Error[] = []; + disposables.add(sub.onDidError(error => errors.push(error))); sub.setError(err); - assert.strictEqual(sub.value, err); - // verifiedValue should still be the state - assert.ok(sub.verifiedValue); + assert.deepStrictEqual({ + value: sub.value, + verifiedValueExists: !!sub.verifiedValue, + errors, + }, { + value: err, + verifiedValueExists: true, + errors: [err], + }); }); }); @@ -227,9 +243,9 @@ suite('SessionStateSubscription', () => { }); assert.strictEqual(clientSeq, 1); - assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic'); + assert.strictEqual((sub.value as SessionState).title, 'Optimistic'); // verifiedValue should remain unchanged - assert.strictEqual(sub.verifiedValue!.summary.title, 'Test'); + assert.strictEqual(sub.verifiedValue!.title, 'Test'); }); test('confirmed own action removes pending and updates confirmed', () => { @@ -249,9 +265,9 @@ suite('SessionStateSubscription', () => { )); // After confirmation, verifiedValue should match - assert.strictEqual(sub.verifiedValue!.summary.title, 'Optimistic'); + assert.strictEqual(sub.verifiedValue!.title, 'Optimistic'); // No pending, value falls through to confirmed - assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic'); + assert.strictEqual((sub.value as SessionState).title, 'Optimistic'); }); test('rejected own action removes pending without updating confirmed', () => { @@ -272,9 +288,9 @@ suite('SessionStateSubscription', () => { )); // Confirmed state unchanged - assert.strictEqual(sub.verifiedValue!.summary.title, 'Test'); + assert.strictEqual(sub.verifiedValue!.title, 'Test'); // No more pending, value = confirmed - assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); + assert.strictEqual((sub.value as SessionState).title, 'Test'); }); test('foreign action updates confirmed and recomputes optimistic', () => { @@ -297,7 +313,7 @@ suite('SessionStateSubscription', () => { // Confirmed state should have SessionReady applied assert.strictEqual(sub.verifiedValue!.lifecycle, SessionLifecycle.Ready); // Optimistic should still have 'Local' title on top - assert.strictEqual((sub.value as SessionState).summary.title, 'Local'); + assert.strictEqual((sub.value as SessionState).title, 'Local'); }); test('server terminal turn action remains ignored by session subscription', () => { @@ -343,12 +359,12 @@ suite('SessionStateSubscription', () => { title: 'Pending', }); - assert.strictEqual((sub.value as SessionState).summary.title, 'Pending'); + assert.strictEqual((sub.value as SessionState).title, 'Pending'); sub.clearPending(); // Should fall back to confirmed - assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); + assert.strictEqual((sub.value as SessionState).title, 'Test'); }); test('ignores actions for different session', () => { @@ -363,7 +379,7 @@ suite('SessionStateSubscription', () => { 'copilot:/other-session', )); - assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); + assert.strictEqual((sub.value as SessionState).title, 'Test'); }); test('buffers envelopes before snapshot and replays after', () => { @@ -378,7 +394,7 @@ suite('SessionStateSubscription', () => { sub.handleSnapshot(makeSessionState(sessionUri), 1); - assert.strictEqual((sub.value! as SessionState).summary.title, 'Buffered'); + assert.strictEqual((sub.value! as SessionState).title, 'Buffered'); }); test('fires onDidChange on optimistic apply', () => { @@ -394,7 +410,7 @@ suite('SessionStateSubscription', () => { }); assert.strictEqual(fired.length, 1); - assert.strictEqual(fired[0].summary.title, 'Changed'); + assert.strictEqual(fired[0].title, 'Changed'); }); }); @@ -633,11 +649,32 @@ suite('AgentSubscriptionManager', () => { { type: ActionType.SessionTitleChanged, title: 'Routed' }, 2, )); - assert.strictEqual((ref.object.value as SessionState).summary.title, 'Routed'); + assert.strictEqual((ref.object.value as SessionState).title, 'Routed'); ref.dispose(); }); + test('isActionEnvelopeRelevantToSubscriptionUris filters by subscribed channel', () => { + assert.deepStrictEqual({ + rootVariant: isActionEnvelopeRelevantToSubscriptionUris( + makeEnvelope({ type: ActionType.RootActiveSessionsChanged, activeSessions: 1 }, 1, undefined, undefined, ROOT_STATE_URI), + ['ahp-root:'], + ), + rootOnlyGetsSession: isActionEnvelopeRelevantToSubscriptionUris( + makeEnvelope({ type: ActionType.SessionTitleChanged, title: 'Nope' }, 2), + ['ahp-root:'], + ), + exactSession: isActionEnvelopeRelevantToSubscriptionUris( + makeEnvelope({ type: ActionType.SessionTitleChanged, title: 'Yep' }, 3), + ['ahp-root:', sessionUri], + ), + }, { + rootVariant: true, + rootOnlyGetsSession: false, + exactSession: true, + }); + }); + test('creating session subscription for copilot: URI', async () => { const mgr = createManager(); const mySessionUri = URI.from({ scheme: 'copilot', path: '/my-session' }); @@ -674,9 +711,9 @@ suite('AgentSubscriptionManager', () => { }); assert.ok(clientSeq > 0); - assert.strictEqual((ref.object.value as SessionState).summary.title, 'Dispatched'); + assert.strictEqual((ref.object.value as SessionState).title, 'Dispatched'); // verifiedValue unchanged - assert.strictEqual(ref.object.verifiedValue!.summary.title, 'Test'); + assert.strictEqual(ref.object.verifiedValue!.title, 'Test'); ref.dispose(); }); @@ -736,7 +773,7 @@ suite('AgentSubscriptionManager', () => { if (subscribeAttempts === 1) { throw new Error('not found yet'); } - return { resource: resource.toString(), state: makeSessionState(resource.toString(), { summary: { ...makeSessionState(resource.toString()).summary, title: 'Retried' } }), fromSeq: 0 }; + return { resource: resource.toString(), state: makeSessionState(resource.toString(), { title: 'Retried' }), fromSeq: 0 }; }); const uri = URI.parse(sessionUri); @@ -750,7 +787,7 @@ suite('AgentSubscriptionManager', () => { assert.deepStrictEqual({ subscribeAttempts, - retriedTitle: (retryRef.object.value as SessionState).summary.title, + retriedTitle: (retryRef.object.value as SessionState).title, unmanagedIsRetry: mgr.getSubscriptionUnmanaged(uri) === retryRef.object, }, { subscribeAttempts: 2, diff --git a/src/vs/platform/agentHost/test/common/fileEditDiff.test.ts b/src/vs/platform/agentHost/test/common/fileEditDiff.test.ts new file mode 100644 index 0000000000000..16d7776fc5086 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/fileEditDiff.test.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import type { FileEdit } from '../../common/state/protocol/state.js'; +import { FileEditKind } from '../../common/state/sessionState.js'; +import { normalizeFileEdit } from '../../common/fileEditDiff.js'; + +suite('fileEditDiff', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const fileA = URI.file('/repo/a.ts').toString(); + const fileB = URI.file('/repo/b.ts').toString(); + const beforeContent = 'git-blob://before'; + const afterContent = 'git-blob://after'; + + test('normalizes added, modified, deleted, and renamed edits', () => { + const created: FileEdit = { after: { uri: fileA, content: { uri: afterContent } } }; + const modified: FileEdit = { before: { uri: fileA, content: { uri: beforeContent } }, after: { uri: fileA, content: { uri: afterContent } } }; + const deleted: FileEdit = { before: { uri: fileA, content: { uri: beforeContent } } }; + const renamed: FileEdit = { before: { uri: fileA, content: { uri: beforeContent } }, after: { uri: fileB, content: { uri: afterContent } } }; + + const summarize = (edit: FileEdit) => { + const n = normalizeFileEdit(edit); + return n && { + kind: n.kind, + resource: n.resource.toString(), + beforeUri: n.beforeUri?.toString(), + afterUri: n.afterUri?.toString(), + beforeContentUri: n.beforeContentUri?.toString(), + afterContentUri: n.afterContentUri?.toString(), + }; + }; + + assert.deepStrictEqual( + [created, modified, deleted, renamed].map(summarize), + [ + { kind: FileEditKind.Create, resource: fileA, beforeUri: undefined, afterUri: fileA, beforeContentUri: undefined, afterContentUri: afterContent }, + { kind: FileEditKind.Edit, resource: fileA, beforeUri: fileA, afterUri: fileA, beforeContentUri: beforeContent, afterContentUri: afterContent }, + { kind: FileEditKind.Delete, resource: fileA, beforeUri: fileA, afterUri: undefined, beforeContentUri: beforeContent, afterContentUri: undefined }, + { kind: FileEditKind.Rename, resource: fileB, beforeUri: fileA, afterUri: fileB, beforeContentUri: beforeContent, afterContentUri: afterContent }, + ] + ); + }); + + test('returns undefined when no usable URI is present', () => { + assert.strictEqual(normalizeFileEdit({}), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 3b37b65166105..b1d234b70a532 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -8,14 +8,19 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { Event } from '../../../../base/common/event.js'; import type { IDiffComputeService, IDiffCountResult } from '../../common/diffComputeService.js'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, IReviewedFileRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import type { Message } from '../../common/state/sessionState.js'; export class TestSessionDatabase implements ISessionDatabase { private readonly _edits: (IFileEditRecord & IFileEditContent)[] = []; private readonly _metadata = new Map(); + private readonly _drafts = new Map(); + private readonly _reviewedFiles: IReviewedFileRecord[] = []; getAllFileEditsCalls = 0; getFileEditsByTurnCalls = 0; + deleteTurnsAfterCalls: string[] = []; + deleteAllTurnsCalls = 0; addEdit(edit: IFileEditRecord & IFileEditContent): void { this._edits.push(edit); @@ -71,6 +76,19 @@ export class TestSessionDatabase implements ISessionDatabase { this._metadata.set(key, value); } + async setChatDraft(chat: URI, draft: Message | undefined): Promise { + const key = chat.toString(); + if (draft) { + this._drafts.set(key, draft); + } else { + this._drafts.delete(key); + } + } + + async getChatDraft(chat: URI): Promise { + return this._drafts.get(chat.toString()); + } + async close(): Promise { } async vacuumInto(_targetPath: string): Promise { } @@ -87,12 +105,42 @@ export class TestSessionDatabase implements ISessionDatabase { async truncateFromTurn(_turnId: string): Promise { } - async deleteTurnsAfter(_turnId: string): Promise { } + async deleteTurnsAfter(turnId: string): Promise { + this.deleteTurnsAfterCalls.push(turnId); + } - async deleteAllTurns(): Promise { } + async deleteAllTurns(): Promise { + this.deleteAllTurnsCalls++; + this._edits.length = 0; + } async remapTurnIds(_mapping: ReadonlyMap): Promise { } + async markFileReviewed(uri: URI, nonce: string): Promise { + if (!this._reviewedFiles.some(r => r.uri.toString() === uri.toString() && r.nonce === nonce)) { + this._reviewedFiles.push({ uri, nonce }); + } + } + + async unmarkFileReviewed(uri: URI, nonce: string): Promise { + const index = this._reviewedFiles.findIndex(r => r.uri.toString() === uri.toString() && r.nonce === nonce); + if (index >= 0) { + this._reviewedFiles.splice(index, 1); + } + } + + async getReviewedFiles(): Promise { + return [...this._reviewedFiles]; + } + + async getReviewedFilesForUri(uri: URI): Promise { + return this._reviewedFiles.filter(r => r.uri.toString() === uri.toString()); + } + + async isFileReviewed(uri: URI, nonce: string): Promise { + return this._reviewedFiles.some(r => r.uri.toString() === uri.toString() && r.nonce === nonce); + } + async setTurnCheckpointRef(_turnId: string, _ref: string): Promise { } async getTurnCheckpointRef(_turnId: string): Promise { return undefined; } @@ -174,7 +222,6 @@ export function encodeString(text: string): Uint8Array { export function createNoopGitService(): import('../../common/agentHostGitService.js').IAgentHostGitService { return { _serviceBrand: undefined, - isInsideWorkTree: async () => false, getCurrentBranch: async () => undefined, getDefaultBranch: async () => undefined, getBranches: async () => [], diff --git a/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts b/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts new file mode 100644 index 0000000000000..155fbe30147e3 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { readSessionWorkspaceless, SESSION_META_WORKSPACELESS_KEY, withSessionGitHubState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; + +suite('Session workspace-less meta', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('readSessionWorkspaceless returns false for absent / non-true values', () => { + assert.strictEqual(readSessionWorkspaceless(undefined), false); + assert.strictEqual(readSessionWorkspaceless({}), false); + assert.strictEqual(readSessionWorkspaceless({ [SESSION_META_WORKSPACELESS_KEY]: false }), false); + assert.strictEqual(readSessionWorkspaceless({ [SESSION_META_WORKSPACELESS_KEY]: 'true' }), false); + }); + + test('withSessionWorkspaceless round-trips the marker and preserves other slots', () => { + const withOther = withSessionGitHubState(undefined, { owner: 'octo' }); + const tagged = withSessionWorkspaceless(withOther, true); + + assert.strictEqual(readSessionWorkspaceless(tagged), true); + // Co-existing well-known slots are preserved. + assert.deepStrictEqual(tagged?.['github'], { owner: 'octo' }); + + // Clearing removes only the workspace-less slot; an otherwise empty bag collapses to undefined. + assert.strictEqual(withSessionWorkspaceless(tagged, false)?.[SESSION_META_WORKSPACELESS_KEY], undefined); + assert.strictEqual(withSessionWorkspaceless({ [SESSION_META_WORKSPACELESS_KEY]: true }, false), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts new file mode 100644 index 0000000000000..5c4a3a2cf0a3b --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IChannelServer, IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL } from '../../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../../common/agentHostClientByokLmChannel.js'; +import { registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; + +/** + * Regression coverage for the renderer reverse-RPC channel registration. The + * BYOK language-model bridge depends on `IAgentHostByokLmHandler`, registered by + * the chat contribution of every window that backs BYOK (the main workbench and + * the Agents app). The registration must still degrade gracefully should a + * window ever connect without binding the handler — `createInstance` then throws, + * and that must NOT abort the rest of `_connect` (client completion, + * action/notification wiring, root-state subscription), or the whole window loses + * its agent host. + */ +suite('registerAgentHostClientChannels', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function fakeChannelServer(): { server: IChannelServer; registered: string[] } { + const registered: string[] = []; + const server: IChannelServer = { + registerChannel: (name: string, _channel: IServerChannel) => { registered.push(name); }, + }; + return { server, registered }; + } + + /** + * Minimal {@link IInstantiationService} whose `createInstance` throws for the + * BYOK channel when `byokHandlerMissing`, mirroring the strict "UNKNOWN + * service agentHostByokLmHandler" failure in windows without the handler. + */ + function fakeInstantiationService(byokHandlerMissing: boolean): IInstantiationService { + return { + createInstance: (ctor: unknown) => { + if (ctor === AgentHostClientByokLmChannel && byokHandlerMissing) { + throw new Error('[createInstance] AgentHostClientByokLmChannel depends on UNKNOWN service agentHostByokLmHandler.'); + } + return {}; + }, + } as unknown as IInstantiationService; + } + + test('registers both channels when BYOK is enabled and the handler is available', () => { + const { server, registered } = fakeChannelServer(); + registerAgentHostClientChannels(server, fakeInstantiationService(false), new NullLogService(), undefined, true); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_BYOK_LM_CHANNEL]); + }); + + test('registers only the resource channel and does NOT throw when the BYOK handler is missing', () => { + const { server, registered } = fakeChannelServer(); + // Must not throw: the agent host connection has to come up even if a + // window connects without the handler and so cannot serve BYOK itself. + registerAgentHostClientChannels(server, fakeInstantiationService(true), new NullLogService(), undefined, true); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL]); + }); + + test('registers only the resource channel when BYOK is disabled', () => { + const { server, registered } = fakeChannelServer(); + registerAgentHostClientChannels(server, fakeInstantiationService(false), new NullLogService(), undefined, false); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL]); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 7dc3039be810c..47e95b51681f6 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -15,10 +15,11 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; import { AgentHostPermissionMode, AgentHostResourcePermissionError, IAgentHostResourceService } from '../../common/agentHostResourceService.js'; +import { ConfigurationTarget, type IConfigurationValue } from '../../../configuration/common/configuration.js'; import { ContentEncoding, ReconnectResultType } from '../../common/state/protocol/commands.js'; import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; -import { ActionType, type SessionActiveClientChangedAction, type SessionTitleChangedAction } from '../../common/state/sessionActions.js'; +import { ActionType, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionTitleChangedAction } from '../../common/state/sessionActions.js'; import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; @@ -26,14 +27,51 @@ import { CustomizationType, ROOT_STATE_URI, StateComponents, customizationId } f import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { TelemetryLevel } from '../../../telemetry/common/telemetry.js'; -import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +import { AgentHostCodexAgentEnabledSettingId } from '../../common/agentService.js'; +import { AgentHostAutoReplyEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AUTO_REPLY_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; +type RootConfigValue = boolean | string | AgentHostTerminalAutoApproveRules | undefined; + +interface ITestRootConfigNotificationParams { + readonly action?: { + readonly type?: string; + readonly config?: Record; + }; +} function isPingRequest(msg: ProtocolTransportMessage): msg is JsonRpcRequest & { method: 'ping' } { return hasKey(msg, { method: true, id: true }) && msg.method === 'ping'; } +/** + * Locate the `dispatchAction` notification that forwards a particular root + * config key. The connect flow sends several `RootConfigChanged` notifications + * (telemetry, session sync, terminal auto-approve), so matching on the config + * key is more robust than indexing into `sentMessages` by position. + */ +function findRootConfigNotification(messages: readonly ProtocolTransportMessage[], configKey: string): JsonRpcNotification { + const match = messages.find((msg): msg is JsonRpcNotification => { + if (!hasKey(msg, { method: true }) || msg.method !== 'dispatchAction') { + return false; + } + const params = (msg as JsonRpcNotification).params as ITestRootConfigNotificationParams | undefined; + return params?.action?.type === ActionType.RootConfigChanged && !!params.action.config && configKey in params.action.config; + }); + assert.ok(match, `Expected a RootConfigChanged notification carrying '${configKey}'`); + return match; +} + +function getRootConfig(notification: JsonRpcNotification): Record { + const params = notification.params as ITestRootConfigNotificationParams | undefined; + assert.ok(params?.action?.config); + return params.action.config; +} + +function findLastRootConfigNotification(messages: readonly ProtocolTransportMessage[], configKey: string): JsonRpcNotification { + return findRootConfigNotification([...messages].reverse(), configKey); +} + class TestProtocolTransport extends Disposable implements IProtocolTransport { private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; @@ -79,6 +117,23 @@ class CountingLogService extends NullLogService { } } +class TerminalAutoApproveConfigurationService extends TestConfigurationService { + + constructor( + configuration: Record, + private readonly _terminalAutoApproveInspectValue: IConfigurationValue>, + ) { + super(configuration); + } + + override inspect(key: string): IConfigurationValue { + if (key === TERMINAL_AUTO_APPROVE_SETTING_ID) { + return this._terminalAutoApproveInspectValue as IConfigurationValue; + } + return super.inspect(key); + } +} + suite('RemoteAgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -130,9 +185,32 @@ suite('RemoteAgentHostProtocolClient', () => { }; } - function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService()): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { - const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, logService, permissionService, new TestConfigurationService())); - return { client, transport }; + function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService()): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { + const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, logService, permissionService, configurationService)); + return { client, transport, configurationService }; + } + + async function connectClient(client: RemoteAgentHostProtocolClient, transport: TestProtocolTransport): Promise { + const connectPromise = client.connect(); + while (transport.sentMessages.length === 0) { + await Promise.resolve(); + } + const sent = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', + id: sent.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + await connectPromise; + } + + function fireConfigurationChange(configurationService: TestConfigurationService, settingId: string): void { + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([settingId]), + change: { keys: [settingId], overrides: [] }, + affectsConfiguration: configuration => configuration === settingId, + }); } async function assertRemoteProtocolError(promise: Promise, expected: { code: number; message: string; data?: unknown }): Promise { @@ -392,9 +470,9 @@ suite('RemoteAgentHostProtocolClient', () => { transport.fireMessage({ jsonrpc: '2.0', id: 1, result: { entries: [] } }); // Late notification — must not fan out as an action event. - const lateAction: SessionActiveClientChangedAction = { - type: ActionType.SessionActiveClientChanged, - activeClient: null, + const lateAction: SessionActiveClientRemovedAction = { + type: ActionType.SessionActiveClientRemoved, + clientId: 'c1', }; transport.fireMessage({ jsonrpc: '2.0', @@ -469,6 +547,155 @@ suite('RemoteAgentHostProtocolClient', () => { }, }, }); + const terminalAutoApproveEnabled = findRootConfigNotification(transport.sentMessages, AgentHostTerminalAutoApproveEnabledConfigKey); + assert.deepStrictEqual(terminalAutoApproveEnabled, { + jsonrpc: '2.0', + method: 'dispatchAction', + params: { + channel: ROOT_STATE_URI, + clientSeq: 0, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostTerminalAutoApproveEnabledConfigKey]: true }, + }, + }, + }); + const globalAutoApproveEnabled = findRootConfigNotification(transport.sentMessages, AgentHostGlobalAutoApproveEnabledConfigKey); + assert.deepStrictEqual(globalAutoApproveEnabled, { + jsonrpc: '2.0', + method: 'dispatchAction', + params: { + channel: ROOT_STATE_URI, + clientSeq: 0, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: false }, + }, + }, + }); + const terminalAutoApproveRules = findRootConfigNotification(transport.sentMessages, AgentHostTerminalAutoApproveRulesConfigKey); + assert.deepStrictEqual(terminalAutoApproveRules, { + jsonrpc: '2.0', + method: 'dispatchAction', + params: { + channel: ROOT_STATE_URI, + clientSeq: 0, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostTerminalAutoApproveRulesConfigKey]: {} }, + }, + }, + }); + const codexEnabled = findRootConfigNotification(transport.sentMessages, AgentHostCodexEnabledConfigKey); + assert.deepStrictEqual(codexEnabled, { + jsonrpc: '2.0', + method: 'dispatchAction', + params: { + channel: ROOT_STATE_URI, + clientSeq: 0, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostCodexEnabledConfigKey]: false }, + }, + }, + }); + }); + + test('forwards codex enablement on connect when the experiment-aware setting is on', async () => { + const transport = disposables.add(new TestClientProtocolTransport()); + const configurationService = new TestConfigurationService({ [AgentHostCodexAgentEnabledSettingId]: true }); + const { client } = createClient(transport, undefined, undefined, undefined, configurationService); + const connectPromise = client.connect(); + + transport.connectDeferred.complete(); + while (transport.sentMessages.length === 0) { + await Promise.resolve(); + } + + const sent = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', + id: sent.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + await connectPromise; + + const codexEnabled = findRootConfigNotification(transport.sentMessages, AgentHostCodexEnabledConfigKey); + assert.deepStrictEqual(getRootConfig(codexEnabled), { [AgentHostCodexEnabledConfigKey]: true }); + }); + + test('forwards auto-reply on connect and when the setting changes', async () => { + const configurationService = new TestConfigurationService({ [AUTO_REPLY_SETTING_ID]: true }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + + await connectClient(client, transport); + + const autoReplyEnabled = findRootConfigNotification(transport.sentMessages, AgentHostAutoReplyEnabledConfigKey); + assert.deepStrictEqual(getRootConfig(autoReplyEnabled), { [AgentHostAutoReplyEnabledConfigKey]: true }); + + transport.sentMessages.length = 0; + await configurationService.setUserConfiguration(AUTO_REPLY_SETTING_ID, false); + fireConfigurationChange(configurationService, AUTO_REPLY_SETTING_ID); + + const updatedAutoReplyEnabled = findLastRootConfigNotification(transport.sentMessages, AgentHostAutoReplyEnabledConfigKey); + assert.deepStrictEqual(getRootConfig(updatedAutoReplyEnabled), { [AgentHostAutoReplyEnabledConfigKey]: false }); + }); + + test('forwards terminal auto-approve rules on connect', async () => { + const configurationService = new TestConfigurationService({ + [TERMINAL_AUTO_APPROVE_SETTING_ID]: { + echo: null, + python: true, + '/^npm run build$/': { approve: true, matchCommandLine: true }, + }, + }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + + await connectClient(client, transport); + + const terminalAutoApproveRules = findRootConfigNotification(transport.sentMessages, AgentHostTerminalAutoApproveRulesConfigKey); + assert.deepStrictEqual(getRootConfig(terminalAutoApproveRules), { + [AgentHostTerminalAutoApproveRulesConfigKey]: { + echo: null, + python: true, + '/^npm run build$/': { approve: true, matchCommandLine: true }, + }, + }); + }); + + test('redispatches terminal auto-approve rules when the rule setting changes', async () => { + const configurationService = new TestConfigurationService(); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + await connectClient(client, transport); + transport.sentMessages.length = 0; + + await configurationService.setUserConfiguration(TERMINAL_AUTO_APPROVE_SETTING_ID, { python: true }); + fireConfigurationChange(configurationService, TERMINAL_AUTO_APPROVE_SETTING_ID); + + const terminalAutoApproveRules = findLastRootConfigNotification(transport.sentMessages, AgentHostTerminalAutoApproveRulesConfigKey); + assert.deepStrictEqual(getRootConfig(terminalAutoApproveRules), { + [AgentHostTerminalAutoApproveRulesConfigKey]: { python: true }, + }); + }); + + test('redispatches terminal auto-approve rules when ignored defaults change', async () => { + const configurationService = new TerminalAutoApproveConfigurationService({ + [TERMINAL_AUTO_APPROVE_SETTING_ID]: { echo: true, python: true }, + }, { + default: { value: { echo: true } }, + user: { value: { python: true } }, + }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + await connectClient(client, transport); + transport.sentMessages.length = 0; + + await configurationService.setUserConfiguration(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, true); + fireConfigurationChange(configurationService, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID); + + const terminalAutoApproveRules = findLastRootConfigNotification(transport.sentMessages, AgentHostTerminalAutoApproveRulesConfigKey); + assert.deepStrictEqual(getRootConfig(terminalAutoApproveRules), { + [AgentHostTerminalAutoApproveRulesConfigKey]: { python: true }, + }); }); test('rejects connect when host returns UnsupportedProtocolVersion (-32005)', async () => { @@ -692,13 +919,13 @@ suite('RemoteAgentHostProtocolClient', () => { return { service, calls }; } - test('SessionActiveClientChanged dispatches implicit reads for each customization', () => { + test('SessionActiveClientSet dispatches implicit reads for each customization', () => { const { service, calls } = createCapturingPermissionService(); const { client } = createClient(undefined, service); const sessionUri = URI.parse('ahp-session:/test'); client.dispatch(sessionUri.toString(), { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'c1', tools: [], @@ -724,7 +951,7 @@ suite('RemoteAgentHostProtocolClient', () => { const sessionUri = URI.parse('ahp-session:/test'); client.dispatch(sessionUri.toString(), { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'c1', tools: [], @@ -746,8 +973,8 @@ suite('RemoteAgentHostProtocolClient', () => { const { client } = createClient(undefined, service); const sessionUri = URI.parse('ahp-session:/test'); - const action: SessionActiveClientChangedAction = { - type: ActionType.SessionActiveClientChanged, + const action: SessionActiveClientSetAction = { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'c1', tools: [], @@ -763,14 +990,14 @@ suite('RemoteAgentHostProtocolClient', () => { assert.strictEqual(calls.length, 1); }); - test('null activeClient does not crash', () => { + test('active client removal does not crash', () => { const { service, calls } = createCapturingPermissionService(); const { client } = createClient(undefined, service); const sessionUri = URI.parse('ahp-session:/test'); client.dispatch(sessionUri.toString(), { - type: ActionType.SessionActiveClientChanged, - activeClient: null, + type: ActionType.SessionActiveClientRemoved, + clientId: 'c1', }); assert.strictEqual(calls.length, 0); diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts index 98269f52688c3..13c2fad00ad27 100644 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -51,8 +51,8 @@ suite('AgentConfigurationService', () => { provider: 'copilot', title: 't', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, workingDirectory, }; diff --git a/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts b/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts index 78a72e7dd0e51..eb8cb9f6c2ec9 100644 --- a/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts @@ -235,8 +235,8 @@ suite('AgentFeedbackServerTools', () => { provider: 'copilot', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index ab25163b2c4f5..7f0701c5f9e04 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -6,24 +6,28 @@ import assert from 'assert'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; -import { buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; +import { buildDefaultChangesetCatalog, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, SessionStatus, type ISessionFileDiff } from '../../common/state/sessionState.js'; -import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { buildSubagentSessionUri, SessionStatus, type ISessionFileDiff, type ISessionGitHubState } from '../../common/state/sessionState.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostChangesetCoordinator } from '../../node/agentHostChangesetCoordinator.js'; import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, StaticChangesetKind } from '../../common/agentHostChangesetService.js'; import { IAgentHostChangesetOperationService } from '../../common/agentHostChangesetOperationService.js'; import { IAgentHostFileMonitorOptions, IAgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { createNoopGitService } from '../common/sessionTestHelpers.js'; import { ChangesSummary } from '../../common/state/protocol/state.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; import { AgentHostChangesetSubscriptionService } from '../../node/agentHostChangesetSubscriptionService.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; suite('ChangesetSessionCoordinator', () => { @@ -35,12 +39,12 @@ suite('ChangesetSessionCoordinator', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }, { emitNotification }); - stateManager.setSessionChangesets(session, buildDefaultChangesetCatalogue(session)); + stateManager.setSessionChangesets(session, buildDefaultChangesetCatalog(session)); stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); } @@ -50,23 +54,37 @@ suite('ChangesetSessionCoordinator', () => { subscriptions: IAgentHostChangesetSubscriptionService; monitor: TestFileMonitorService; gitService: IAgentHostGitService & { readonly rootLookupCalls: string[]; waitForRootLookups(count: number): Promise }; + gitStateService: TestGitStateService; coordinator: AgentHostChangesetCoordinator; } { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - const configurationService = disposables.add(new AgentConfigurationService(stateManager, new NullLogService())); + const logService = new NullLogService(); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); const subscriptions = new AgentHostChangesetSubscriptionService(); const changesets = new TestChangesetService(subscriptions); const monitor = disposables.add(new TestFileMonitorService()); const gitService = createGitService(root); + const gitStateService = disposables.add(new TestGitStateService()); const operationContributionService: IAgentHostChangesetOperationService = { _serviceBrand: undefined, registerContribution: () => Disposable.None, + getOperations: () => [], updateOperations: () => { }, invokeChangesetOperation: async () => ({}), dispose: () => { }, }; - const coordinator = disposables.add(new AgentHostChangesetCoordinator(stateManager, operationContributionService, changesets, subscriptions, configurationService, monitor, gitService, new NullLogService())); - return { stateManager, changesets, subscriptions, monitor, gitService, coordinator }; + const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( + [ILogService, logService], + [IAgentConfigurationService, configurationService], + [IAgentHostChangesetOperationService, operationContributionService], + [IAgentHostChangesetService, changesets], + [IAgentHostChangesetSubscriptionService, subscriptions], + [IAgentHostFileMonitorService, monitor], + [IAgentHostGitService, gitService], + [IAgentHostGitStateService, gitStateService], + ), /*strict*/ true)); + const coordinator = disposables.add(instantiationService.createInstance(AgentHostChangesetCoordinator, stateManager)); + return { stateManager, changesets, subscriptions, monitor, gitService, gitStateService, coordinator }; } test('shares root watchers across sessions and fans out root changes to static refreshes', async () => { @@ -91,12 +109,12 @@ suite('ChangesetSessionCoordinator', () => { acquisitions: environment.monitor.acquisitions, branchRefreshes: environment.changesets.branchRefreshes, uncommittedRefreshes: environment.changesets.uncommittedRefreshes, - sessionRefreshes: environment.changesets.sessionRefreshes, + gitStateRefreshes: environment.gitStateService.refreshed, }, { acquisitions: ['file:///repo'], branchRefreshes: [firstSession], uncommittedRefreshes: [secondSession], - sessionRefreshes: [firstSession], + gitStateRefreshes: [firstSession, secondSession], }); }); @@ -128,7 +146,7 @@ suite('ChangesetSessionCoordinator', () => { await tick(); assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, rootLookups: environment.gitService.rootLookupCalls }, { acquisitions: [], rootLookups: [] }); - const summary = environment.stateManager.getSessionState(session)!.summary; + const summary = environment.stateManager.getSessionSummary(session)!; environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' }); environment.coordinator.onSessionMaterialized(session); await environment.monitor.waitForAcquisitions(1); @@ -150,7 +168,7 @@ suite('ChangesetSessionCoordinator', () => { environment.coordinator.onFirstSubscriber(URI.parse(buildSessionChangesetUri(session))); await tick(); - const summary = environment.stateManager.getSessionState(session)!.summary; + const summary = environment.stateManager.getSessionSummary(session)!; environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' }); environment.coordinator.onSessionMaterialized(session); await tick(); @@ -182,16 +200,6 @@ suite('ChangesetSessionCoordinator', () => { }); }); - test('routes git-state changes through the service subscription-driven recompute', async () => { - const session = AgentSession.uri('mock', 'session-1').toString(); - const environment = createEnvironment(); - createSession(environment.stateManager, session, 'file:///repo/worktree'); - - environment.coordinator.onSessionGitStateChanged(session, { baseBranchName: 'main' }); - - assert.deepStrictEqual(environment.changesets.recomputed, [session]); - }); - test('does not attach root state when watcher acquisition fails', async () => { const session = AgentSession.uri('mock', 'session-1').toString(); const environment = createEnvironment(); @@ -350,6 +358,24 @@ function createGitService(root: URI): IAgentHostGitService & { readonly rootLook }; } +class TestGitStateService extends Disposable implements IAgentHostGitStateService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidRefreshSessionGitState = this._register(new Emitter()); + readonly onDidRefreshSessionGitState = this._onDidRefreshSessionGitState.event; + + readonly refreshed: string[] = []; + + async refreshSessionGitState(sessionKey: string, _workingDirectory?: URI): Promise { + // Mirror the production service: record the refresh and notify + // listeners so the coordinator recomputes the subscribed changesets. + this.refreshed.push(sessionKey); + this._onDidRefreshSessionGitState.fire(sessionKey); + } + async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } + async attachSessionGitHubPullRequest(_sessionKey: string): Promise { } +} + class TestFileMonitorService extends Disposable implements IAgentHostFileMonitorService { declare readonly _serviceBrand: undefined; @@ -423,6 +449,7 @@ class TestChangesetService implements IAgentHostChangesetService { restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } persistChangesSummary(_sessionUri: string, _summary: ChangesSummary): void { } isStaticChangesetComputeActive(_changesetUri: string): boolean { return false; } + refreshChangesetCatalog(_session: string): void { } refreshBranchChangeset(session: string): void { this.branchRefreshes.push(session); } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index 924bcddbce201..1da3a92b2adb3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -6,14 +6,14 @@ import assert from 'assert'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { NullLogService } from '../../../log/common/log.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../../common/agentHostChangesetOperationService.js'; import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../common/state/protocol/channels-changeset/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation, type ISessionGitState } from '../../common/state/sessionState.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, ISessionGitHubState, MessageKind, SessionStatus, buildDefaultChatUri, type ChangesetOperation, type SessionSummary } from '../../common/state/sessionState.js'; import { AgentHostChangesetOperationService } from '../../node/agentHostChangesetOperationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; @@ -64,9 +64,17 @@ class TestContribution implements IChangesetOperationContribution { class TestGitStateService implements IAgentHostGitStateService { declare readonly _serviceBrand: undefined; - async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { + readonly onDidRefreshSessionGitState = Event.None; + + async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { } + + async getSessionGitHubState(_sessionKey: string): Promise { return undefined; } + + async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } + + async attachSessionGitHubPullRequest(_sessionKey: string): Promise { } } suite('AgentHostChangesetOperationService', () => { @@ -77,7 +85,6 @@ suite('AgentHostChangesetOperationService', () => { stateManager, new TestGitStateService(), new AgentHostChangesetSubscriptionService(), - disposables.add(new InstantiationService()), )); } @@ -152,6 +159,44 @@ suite('AgentHostChangesetOperationService', () => { assert.strictEqual(stateManager.getChangesetState(changesetUri)?.operations?.[0].status, ChangesetOperationStatus.Disabled); }); + test('rejects invocation while a turn is active even if the advertised status is idle', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const sessionKey = 'agent:/session'; + const changesetUri = buildUncommittedChangesetUri(sessionKey); + const summary: SessionSummary = { + resource: sessionKey, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + stateManager.createSession(summary); + stateManager.registerChangeset(changesetUri); + // Advertise the operation as Idle (e.g. a previous operation finished and + // a ChangesetOperationStatusChanged reset the status) ... + stateManager.dispatchServerAction(changesetUri, { + type: ActionType.ChangesetOperationsChanged, + operations: [{ id: testOperationId, label: 'Commit', scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle }], + }); + // ... while a chat turn is still streaming on the session. + stateManager.dispatchServerAction(buildDefaultChatUri(sessionKey), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hi', origin: { kind: MessageKind.User } }, + }); + + const service = createService(stateManager); + const handler = new TestHandler(); + disposables.add(service.registerContribution(new TestContribution(handler))); + + const error = await service.invokeChangesetOperation({ channel: changesetUri, operationId: testOperationId }).then(undefined, error => error); + + assert.match(error.message, /disabled while a turn is active/); + assert.strictEqual(handler.calls, 0); + assert.strictEqual(stateManager.getChangesetState(changesetUri)?.operations?.[0].status, ChangesetOperationStatus.Idle); + }); + test('publishes running and error state when a changeset operation fails', async () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const sessionKey = 'agent:/session'; diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 58052e501b4f9..d865d073288e7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -11,11 +11,12 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; -import { buildBranchChangesetUri, buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; import { ChangesetStatus, SessionStatus, withSessionGitState, type Changeset } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; +import { IAgentHostChangesetOperationService } from '../../common/agentHostChangesetOperationService.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -41,6 +42,22 @@ function createSubscriptionService(...changesets: string[]): IAgentHostChangeset }; } +/** + * Builds a no-op changeset operation service for tests. It advertises no + * operations, which mirrors the default behaviour of a session without any + * operation contributions. + */ +function createOperationService(): IAgentHostChangesetOperationService { + return { + _serviceBrand: undefined, + registerContribution: () => toDisposable(() => { }), + updateOperations: () => { }, + getOperations: () => undefined, + invokeChangesetOperation: async () => { throw new Error('not implemented'); }, + dispose: () => { }, + }; +} + suite.skip('AgentHostChangesetService', () => { const disposables = new DisposableStore(); @@ -55,12 +72,12 @@ suite.skip('AgentHostChangesetService', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }); - stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } @@ -73,6 +90,7 @@ suite.skip('AgentHostChangesetService', () => { createNoopGitService(), NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), )); }); @@ -258,15 +276,15 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); localStateManager.createSession({ resource: sessionUri.toString(), provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory: 'file:///wd', }); await sessionDb.setMetadata('agentHost.diffBaseBranch', 'main'); @@ -299,15 +317,16 @@ suite.skip('AgentHostChangesetService', () => { { workingDirectory: 'file:///wd', sessionUri: sessionUri.toString(), baseBranch: undefined }, { workingDirectory: 'file:///wd', sessionUri: sessionUri.toString(), baseBranch: 'main' }, ]); - // Each git diff lands as its own `changeset/fileSet` envelope. - // Walk the captured stream and reconstruct the per-changeset - // file lists to assert each matches the git service output. - const fileSets = envelopes - .filter(e => e.action.type === ActionType.ChangesetFileSet) as Array<{ channel: string; action: { file: { edit: unknown } } }>; - const sessionFileSets = fileSets.filter(e => e.channel === `${sessionUri.toString()}/changeset/session`); - const uncommittedFileSets = fileSets.filter(e => e.channel === `${sessionUri.toString()}/changeset/uncommitted`); - assert.deepStrictEqual(sessionFileSets.map(e => e.action.file.edit), gitDiffs); - assert.deepStrictEqual(uncommittedFileSets.map(e => e.action.file.edit), gitDiffs); + // Each compute pass lands as a single `changeset/contentChanged` + // envelope carrying the full file list. Walk the captured stream + // and reconstruct the per-changeset file lists to assert each + // matches the git service output. + const contentChanges = envelopes + .filter(e => e.action.type === ActionType.ChangesetContentChanged) as Array<{ channel: string; action: { files: Array<{ edit: unknown }> } }>; + const sessionContent = contentChanges.filter(e => e.channel === `${sessionUri.toString()}/changeset/session`); + const uncommittedContent = contentChanges.filter(e => e.channel === `${sessionUri.toString()}/changeset/uncommitted`); + assert.deepStrictEqual(sessionContent.at(-1)?.action.files.map(f => f.edit), gitDiffs); + assert.deepStrictEqual(uncommittedContent.at(-1)?.action.files.map(f => f.edit), gitDiffs); // The compute pass also persists the file list under the // legacy `'diffs'` slot so it survives restarts. The write @@ -335,7 +354,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -343,8 +362,8 @@ suite.skip('AgentHostChangesetService', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory: 'file:///wd', }); localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' })); @@ -371,7 +390,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -379,8 +398,8 @@ suite.skip('AgentHostChangesetService', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory: 'file:///wd', }); localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' })); @@ -404,15 +423,15 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); localStateManager.createSession({ resource: sessionUri.toString(), provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory: 'file:///wd', }); @@ -431,14 +450,15 @@ suite.skip('AgentHostChangesetService', () => { await diffsEmitted; // With no recorded edits, the edit-tracker aggregator returns an - // empty array — no `changeset/fileSet` envelopes are emitted. The - // important assertion is that we still ran the producer through - // to a `changeset/statusChanged → ready` envelope, which proves - // the fallback path executed without throwing. - const fileSets = envelopes + // empty array — the single `changeset/contentChanged` envelope + // carries an empty file list. The important assertion is that we + // still ran the producer through to a `changeset/statusChanged → + // ready` envelope, which proves the fallback path executed without + // throwing. + const contentChanges = envelopes .map(e => e.action) - .filter(a => a.type === ActionType.ChangesetFileSet); - assert.deepStrictEqual(fileSets, []); + .filter(a => a.type === ActionType.ChangesetContentChanged) as Array<{ files: unknown[] }>; + assert.deepStrictEqual(contentChanges.map(a => a.files), [[]]); const statusAction = envelopes .map(e => e.action) .find(a => a.type === ActionType.ChangesetStatusChanged); @@ -463,7 +483,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -471,8 +491,8 @@ suite.skip('AgentHostChangesetService', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory: 'file:///wd', }); @@ -537,7 +557,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -545,8 +565,8 @@ suite.skip('AgentHostChangesetService', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory: 'file:///wd', }); @@ -584,6 +604,7 @@ suite.skip('AgentHostChangesetService', () => { stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), + createOperationService(), subscriptionService, )); return { service, localStateManager, computes, subscriptions: subscriptionService.subscriptions }; @@ -596,11 +617,11 @@ suite.skip('AgentHostChangesetService', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory, }); - localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalogue(sessionStr)); + localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalog(sessionStr)); return sessionStr; } @@ -617,7 +638,7 @@ suite.skip('AgentHostChangesetService', () => { await timeout(0); assert.deepStrictEqual(computes, [], 'nothing computed while the working directory is unknown'); - const summary = localStateManager.getSessionState(sessionStr)!.summary; + const summary = localStateManager.getSessionSummary(sessionStr)!; localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); @@ -632,7 +653,7 @@ suite.skip('AgentHostChangesetService', () => { await service.computeUncommittedChangeset(sessionStr); assert.deepStrictEqual(computes, [], 'uncommitted compute deferred while the working directory is unknown'); - const summary = localStateManager.getSessionState(sessionStr)!.summary; + const summary = localStateManager.getSessionSummary(sessionStr)!; localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); @@ -648,7 +669,7 @@ suite.skip('AgentHostChangesetService', () => { // Last subscriber leaves before the working directory is known. subscriptions.delete(buildSessionChangesetUri(sessionStr)); - const summary = localStateManager.getSessionState(sessionStr)!.summary; + const summary = localStateManager.getSessionSummary(sessionStr)!; localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); @@ -669,7 +690,7 @@ suite.skip('AgentHostChangesetService', () => { await service.computeUncommittedChangeset(sessionStr); service.onSessionDisposed(sessionStr); - const summary = localStateManager.getSessionState(sessionStr)!.summary; + const summary = localStateManager.getSessionSummary(sessionStr)!; localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); @@ -904,6 +925,7 @@ suite.skip('AgentHostChangesetService', () => { createNoopGitService(), NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), subscriptionService, )); } @@ -995,10 +1017,10 @@ suite.skip('AgentHostChangesetService', () => { }); }); - test('per-turn URI streams incremental ChangesetFileSet / ChangesetFileRemoved as the same turn is recomputed', async () => { + test('per-turn URI streams a ChangesetContentChanged snapshot as the same turn is recomputed', async () => { // End-to-end variant exercising the real `computeTurnDiffs` path // — produces actual diff payloads from session-DB messages so - // `_publishChangesetDiffs` emits real per-file actions on each + // `_publishChangesetDiffs` emits a full content snapshot on each // recompute pass. const sessionDb = new SessionDatabase(':memory:'); disposables.add(toDisposable(() => sessionDb.close())); @@ -1010,6 +1032,7 @@ suite.skip('AgentHostChangesetService', () => { createNoopGitService(), NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), + createOperationService(), createSubscriptionService(buildTurnChangesetUri(sessionUri.toString(), 'turn-1')), )); @@ -1018,8 +1041,8 @@ suite.skip('AgentHostChangesetService', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), workingDirectory: 'file:///wd', }); @@ -1089,6 +1112,7 @@ suite.skip('AgentHostChangesetService', () => { 'mod': { parent: 'ref-orig', current: 'ref-mod' }, }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); @@ -1121,6 +1145,7 @@ suite.skip('AgentHostChangesetService', () => { // 'mod' is intentionally absent }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); @@ -1150,6 +1175,7 @@ suite.skip('AgentHostChangesetService', () => { 'mod': { parent: 'same-ref', current: 'same-ref' }, }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); @@ -1180,6 +1206,7 @@ suite.skip('AgentHostChangesetService', () => { 'mod': { parent: 'ref-orig', current: 'ref-mod' }, }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); diff --git a/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts new file mode 100644 index 0000000000000..0912e644c04a6 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IAgentHostByokLmHandler, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { AgentHostClientByokLmChannel, createAgentHostClientByokLmConnection } from '../../common/agentHostClientByokLmChannel.js'; + +suite('agentHostClientByokLmChannel', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function handlerOf( + chat: (request: IByokLmChatRequest) => Promise, + listModels: () => Promise = async () => [], + ): IAgentHostByokLmHandler { + return { _serviceBrand: undefined, chat: (request) => chat(request), listModels: () => listModels() }; + } + + /** + * Wire the node-side connection straight to the renderer server channel, + * standing in for the MessagePort transport so the full request → handler → + * response round-trip can be exercised without the renderer or the SDK. + */ + function bridge(handler: IAgentHostByokLmHandler) { + const server = new AgentHostClientByokLmChannel(handler); + const channel: IChannel = { + call(command: string, arg?: unknown): Promise { + return server.call(null, command, arg); + }, + listen(event: string): Event { + return server.listen(null, event); + }, + }; + return createAgentHostClientByokLmConnection(channel); + } + + test('round-trips a chat request to the handler and back', async () => { + let seen: IByokLmChatRequest | undefined; + const connection = bridge(handlerOf(async (request) => { + seen = request; + return { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }; + })); + + const request: IByokLmChatRequest = { vendor: 'acme', modelId: 'm', messages: [{ role: 'user', content: 'ping' }] }; + const result = await connection.chat(request); + + assert.deepStrictEqual(seen, request); + assert.deepStrictEqual(result, { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }); + }); + + test('forwards a bridge error result unchanged', async () => { + const connection = bridge(handlerOf(async () => ({ content: '', error: 'no model' }))); + const result = await connection.chat({ vendor: 'v', modelId: 'm', messages: [] }); + assert.strictEqual(result.error, 'no model'); + }); + + test('round-trips listModels to the handler', async () => { + const models: IByokLmModelInfo[] = [{ vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 128000 }]; + const connection = bridge(handlerOf(async () => ({ content: '' }), async () => models)); + assert.deepStrictEqual(await connection.listModels(), models); + }); + + test('rejects unknown channel commands', async () => { + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' }))); + await assert.rejects(() => server.call(null, 'frobnicate'), /Unknown command/); + }); + + test('exposes no events', () => { + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' }))); + assert.throws(() => server.listen(null, 'anything'), /No event/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 5096951eb4e62..6c8792c3956e9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -32,7 +32,6 @@ class TestGitService implements IAgentHostGitService { diff: { added: 1, removed: 0 }, }]; - async isInsideWorkTree(): Promise { return true; } async getCurrentBranch(): Promise { return 'feature/test'; } async getDefaultBranch(): Promise { return 'main'; } async getBranches(): Promise { return []; } @@ -89,6 +88,7 @@ class TestCopilotApiService implements ICopilotApiService { } async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.calls.push({ token: githubToken, request, options }); if (this.error) { @@ -111,6 +111,7 @@ class TestChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys(_sessionUri: string): Record | undefined { return undefined; } computeListEntryChanges(_sessionUri: string, _metadata: Record): ChangesSummary | undefined { return undefined; } + refreshChangesetCatalog(session: string): void { this.calls.push(`refreshChangesets:${session}`); } refreshBranchChangeset(session: string): void { this.calls.push(`refreshBranch:${session}`); } refreshSessionChangeset(session: string): void { this.calls.push(`refreshSession:${session}`); } onWorkingDirectoryAvailable(_session: string): void { } @@ -139,8 +140,8 @@ function setup(disposables: Pick, gitService: TestGitSer provider: 'copilot', title: 'Session', status: SessionStatus.Idle, - createdAt: 1, - modifiedAt: 1, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), workingDirectory: URI.file('/repo').toString(), }); stateManager.setSessionMeta(session.toString(), withSessionGitState(undefined, { @@ -154,7 +155,7 @@ function setup(disposables: Pick, gitService: TestGitSer if (options?.onCommittedError) { throw options.onCommittedError; } - }, createAgentService('gh-repo-token'), gitService, copilotApiService, changesets, new NullLogService()), + }, createAgentService('gh-repo-token'), gitService, copilotApiService, new NullLogService()), session, committedSessions, }; diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts index 0a0c10fdb3cd8..863a3694dbc45 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts @@ -38,14 +38,19 @@ suite('AgentHostCommitOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), ['commit']); }); - test('does not advertise commit without uncommitted changes or on the branch changeset', () => { + test('does not advertise commit without uncommitted changes', () => { const provider = createContribution(); - const actual = [ - provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }), - provider.getOperations({ sessionKey, changesetUri: buildSessionChangesetUri(sessionKey), changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }), - ]; + const operations = provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }); - assert.deepStrictEqual(actual, [undefined, undefined]); + assert.deepStrictEqual(operations?.map(op => op.id), []); + }); + + test('advertises commit on the session changeset when there are uncommitted changes', () => { + const provider = createContribution(); + + const operations = provider.getOperations({ sessionKey, changesetUri: buildSessionChangesetUri(sessionKey), changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }); + + assert.deepStrictEqual(operations?.map(op => op.id), []); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 02ae5777826c1..e8f5f65d23fbc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -11,13 +11,11 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams } from '../../common/state/protocol/channels-changeset/commands.js'; -import { ChangesSummary } from '../../common/state/protocol/state.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentHostDiscardChangesOperationHandler } from '../../node/agentHostDiscardChangesOperationHandler.js'; import type { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import type { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs } from '../../common/agentHostChangesetService.js'; class TestGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; @@ -25,7 +23,6 @@ class TestGitService implements IAgentHostGitService { readonly restoreCalls: { workingDirectory: string; paths: readonly string[]; options?: { readonly staged?: boolean; readonly ref?: string } }[] = []; restoreError: Error | undefined; - async isInsideWorkTree(): Promise { return true; } async getCurrentBranch(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } async getBranches(): Promise { return []; } @@ -57,38 +54,8 @@ class TestGitService implements IAgentHostGitService { async computeFileDiffsBetweenRefs(): Promise { return undefined; } } -class TestChangesetService implements IAgentHostChangesetService { - declare readonly _serviceBrand: undefined; - - readonly calls: string[] = []; - registerStaticChangesets(): void { } - restoreStaticChangeset(): void { } - parsePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } - applyPersistedStaticChangesets(): void { } - restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } - persistChangesSummary(_sessionUri: string, _summary: ChangesSummary): void { } - isStaticChangesetComputeActive(): boolean { return false; } - getListMetadataKeys(_sessionUri: string): Record | undefined { return undefined; } - computeListEntryChanges(_sessionUri: string, _metadata: Record): ChangesSummary | undefined { return undefined; } - refreshBranchChangeset(): void { } - refreshSessionChangeset(): void { } - onWorkingDirectoryAvailable(): void { } - recomputeSubscribedChangesets(): void { } - onSessionDisposed(): void { } - async computeUncommittedChangeset(session: string): Promise { - this.calls.push(`computeUncommitted:${session}`); - return `${session}/changeset/uncommitted`; - } - async computeTurnChangeset(): Promise { return ''; } - async computeCompareTurnsChangeset(): Promise { return ''; } - onToolCallEditsApplied(): void { } - onTurnComplete(): void { } - onSessionTruncated(): void { } -} - -function setup(disposables: Pick, opts?: { readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean }): { handler: AgentHostDiscardChangesOperationHandler; gitService: TestGitService; changesets: TestChangesetService; session: URI } { +function setup(disposables: Pick, opts?: { readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean }): { handler: AgentHostDiscardChangesOperationHandler; gitService: TestGitService; session: URI } { const gitService = new TestGitService(); - const changesets = new TestChangesetService(); const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agent:/session'); if (opts?.registerSession !== false) { @@ -97,18 +64,17 @@ function setup(disposables: Pick, opts?: { readonly with provider: 'copilot', title: 'Session', status: SessionStatus.Idle, - createdAt: 1, - modifiedAt: 1, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), workingDirectory: opts?.withWorkingDirectory === false ? undefined : URI.file('/repo').toString(), }); } const handler = new AgentHostDiscardChangesOperationHandler( sessionKey => stateManager.getSessionState(sessionKey), - changesets, gitService, new NullLogService(), ); - return { handler, gitService, changesets, session }; + return { handler, gitService, session }; } function makeResourceTarget(resource: URI): InvokeChangesetOperationParams['target'] { @@ -120,8 +86,8 @@ function makeResourceTarget(resource: URI): InvokeChangesetOperationParams['targ suite('AgentHostDiscardChangesOperationHandler', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('restores the targeted file and recomputes the uncommitted changeset on success', async () => { - const { handler, gitService, changesets, session } = setup(disposables); + test('restores the targeted file on success', async () => { + const { handler, gitService, session } = setup(disposables); const target = URI.file('/repo/src/file.ts'); const result = await handler.invoke({ @@ -132,17 +98,15 @@ suite('AgentHostDiscardChangesOperationHandler', () => { assert.deepStrictEqual({ restoreCalls: gitService.restoreCalls, - changesetCalls: changesets.calls, message: result.message, }, { restoreCalls: [{ workingDirectory: URI.file('/repo').toString(), paths: [target.fsPath], options: undefined }], - changesetCalls: [`computeUncommitted:${session.toString()}`], message: { markdown: 'Discarded changes to `file.ts`.' }, }); }); test('rejects channels that are not uncommitted-changeset URIs', async () => { - const { handler, gitService, changesets, session } = setup(disposables); + const { handler, gitService, session } = setup(disposables); const target = URI.file('/repo/src/file.ts'); let err: ProtocolError | undefined; @@ -159,16 +123,14 @@ suite('AgentHostDiscardChangesOperationHandler', () => { assert.deepStrictEqual({ code: err?.code, restoreCalls: gitService.restoreCalls.length, - changesetCalls: changesets.calls.length, }, { code: JsonRpcErrorCodes.InvalidParams, restoreCalls: 0, - changesetCalls: 0, }); }); test('throws AHP_SESSION_NOT_FOUND when the session is unknown', async () => { - const { handler, gitService, changesets } = setup(disposables, { registerSession: false }); + const { handler, gitService } = setup(disposables, { registerSession: false }); const session = URI.parse('agent:/missing'); const target = URI.file('/repo/src/file.ts'); @@ -186,16 +148,14 @@ suite('AgentHostDiscardChangesOperationHandler', () => { assert.deepStrictEqual({ code: err?.code, restoreCalls: gitService.restoreCalls.length, - changesetCalls: changesets.calls.length, }, { code: AHP_SESSION_NOT_FOUND, restoreCalls: 0, - changesetCalls: 0, }); }); test('rejects invocations without a Resource target', async () => { - const { handler, gitService, changesets, session } = setup(disposables); + const { handler, gitService, session } = setup(disposables); let err: ProtocolError | undefined; try { @@ -210,16 +170,14 @@ suite('AgentHostDiscardChangesOperationHandler', () => { assert.deepStrictEqual({ code: err?.code, restoreCalls: gitService.restoreCalls.length, - changesetCalls: changesets.calls.length, }, { code: JsonRpcErrorCodes.InvalidParams, restoreCalls: 0, - changesetCalls: 0, }); }); test('throws InternalError when the session has no working directory', async () => { - const { handler, gitService, changesets, session } = setup(disposables, { withWorkingDirectory: false }); + const { handler, gitService, session } = setup(disposables, { withWorkingDirectory: false }); const target = URI.file('/repo/src/file.ts'); let err: ProtocolError | undefined; @@ -236,16 +194,14 @@ suite('AgentHostDiscardChangesOperationHandler', () => { assert.deepStrictEqual({ code: err?.code, restoreCalls: gitService.restoreCalls.length, - changesetCalls: changesets.calls.length, }, { code: JsonRpcErrorCodes.InternalError, restoreCalls: 0, - changesetCalls: 0, }); }); test('wraps git restore failures in a ProtocolError without recomputing the changeset', async () => { - const { handler, gitService, changesets, session } = setup(disposables); + const { handler, gitService, session } = setup(disposables); gitService.restoreError = new Error('git restore failed'); const target = URI.file('/repo/src/file.ts'); @@ -264,17 +220,15 @@ suite('AgentHostDiscardChangesOperationHandler', () => { code: err?.code, messageContainsCause: err?.message.includes('git restore failed'), restoreCalls: gitService.restoreCalls.length, - changesetCalls: changesets.calls.length, }, { code: JsonRpcErrorCodes.InternalError, messageContainsCause: true, restoreCalls: 1, - changesetCalls: 0, }); }); test('honors cancellation before mutating the repository', async () => { - const { handler, gitService, changesets, session } = setup(disposables); + const { handler, gitService, session } = setup(disposables); const cts = disposables.add(new CancellationTokenSource()); cts.cancel(); const target = URI.file('/repo/src/file.ts'); @@ -288,6 +242,6 @@ suite('AgentHostDiscardChangesOperationHandler', () => { /cancelled/i, ); - assert.deepStrictEqual({ restoreCalls: gitService.restoreCalls.length, changesetCalls: changesets.calls.length }, { restoreCalls: 0, changesetCalls: 0 }); + assert.deepStrictEqual({ restoreCalls: gitService.restoreCalls.length }, { restoreCalls: 0 }); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts index a50a60231ef3f..6563db6c9143e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts @@ -111,8 +111,8 @@ suite('AgentHostFileCompletionProvider', () => { provider: 'copilot', title: 't', status: SessionStatus.Idle, - createdAt: 0, - modifiedAt: 0, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, workingDirectory, }; diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index eff82ceeaf7b6..9612a2c64218f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -371,10 +371,10 @@ suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => { await fs.writeFile(join(dir, 'a.txt'), 'original\n'); run('add', '.'); run('commit', '-q', '-m', 'init'); - const sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); + const ref = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); await fs.writeFile(join(dir, 'a.txt'), 'changed\n'); - const blob = await svc!.showBlob(URI.file(dir), sha, 'a.txt'); + const blob = await svc!.showBlob(URI.file(dir), ref, 'a.txt'); assert.ok(blob); assert.strictEqual(blob.toString(), 'original\n'); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts new file mode 100644 index 0000000000000..1de74b5cf6efa --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import type { IAgentService } from '../../common/agentService.js'; +import { readSessionGitHubState, readSessionGitState, withSessionGitState, SessionStatus, type ISessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { META_GIT_STATE } from '../../common/agentHostGitStateService.js'; +import { AgentHostGitStateService } from '../../node/agentHostGitStateService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import type { IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; +import { TestSessionDatabase, createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; + +const SESSION = 'mock:/session-1'; +const WORKING_DIRECTORY = 'file:///wd'; + +suite('AgentHostGitStateService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHarness() { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const db = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(db); + + const gitCalls: string[] = []; + let gitResult: ISessionGitState | undefined; + let gitError: Error | undefined; + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getSessionGitState: async (workingDirectory: URI) => { + gitCalls.push(workingDirectory.toString()); + if (gitError) { + throw gitError; + } + return gitResult; + }, + }; + + // The octokit and agent services are only used by the GitHub + // pull-request flow, which `refreshSessionGitState` does not touch. + const service = disposables.add(new AgentHostGitStateService( + stateManager, + gitService, + {} as unknown as IAgentHostOctoKitService, + {} as unknown as IAgentService, + new NullLogService(), + sessionDataService, + )); + + const runEvents: string[] = []; + disposables.add(service.onDidRefreshSessionGitState(key => runEvents.push(key))); + + return { + stateManager, + db, + service, + gitCalls, + runEvents, + setGitResult: (state: ISessionGitState | undefined) => { gitResult = state; }, + setGitError: (error: Error) => { gitError = error; }, + }; + } + + function seedSession(stateManager: AgentHostStateManager, options?: { workingDirectory?: string; gitState?: ISessionGitState }): void { + const summary: SessionSummary = { + resource: SESSION, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectory: options?.workingDirectory, + }; + // `restoreSession` materializes the session in `ready` lifecycle so the + // persistence path (which skips `creating` sessions) actually runs. + stateManager.restoreSession(summary, []); + if (options?.gitState) { + stateManager.setSessionMeta(SESSION, withSessionGitState(undefined, options.gitState)); + } + } + + test('does nothing when no working directory can be resolved', async () => { + const h = createHarness(); + seedSession(h.stateManager); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual({ + gitCalls: h.gitCalls, + runEvents: h.runEvents + }, { + gitCalls: [], + runEvents: [] + }); + }); + + test('resolves the working directory from the session summary when none is provided', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); + h.setGitResult({ branchName: 'feature' }); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual(h.gitCalls, [WORKING_DIRECTORY]); + }); + }); + + test('prefers an explicitly provided working directory over the session summary', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); + h.setGitResult({ branchName: 'feature' }); + + await h.service.refreshSessionGitState(SESSION, URI.parse('file:///explicit')); + + assert.deepStrictEqual(h.gitCalls, ['file:///explicit']); + }); + }); + + test('unchanged git state still fires the run-refresh event', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const gitState: ISessionGitState = { branchName: 'feature', uncommittedChanges: 1 }; + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY, gitState }); + h.setGitResult(gitState); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual(h.runEvents, [SESSION]); + }); + }); + + test('changed git state updates the session meta and fires the run-refresh event', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); + const next: ISessionGitState = { branchName: 'feature', baseBranchName: 'main', uncommittedChanges: 2 }; + h.setGitResult(next); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual({ + gitState: readSessionGitState(h.stateManager.getSessionState(SESSION)?._meta), + runEvents: h.runEvents, + }, { + gitState: next, + runEvents: [SESSION], + }); + }); + }); + + test('persists git state and derives GitHub state when git reports a GitHub repo', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); + const next: ISessionGitState = { branchName: 'feature', githubOwner: 'microsoft', githubRepo: 'vscode' }; + h.setGitResult(next); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual({ + github: readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta), + persistedGit: await h.db.getMetadata(META_GIT_STATE), + }, { + github: { owner: 'microsoft', repo: 'vscode' }, + persistedGit: JSON.stringify(next), + }); + }); + }); + + test('swallows git errors and fires no events', async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); + h.setGitError(new Error('git command failed')); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual({ + runEvents: h.runEvents + }, { + runEvents: [] + }); + }); + + test('coalesces concurrent refreshes for the same session', async () => { + await runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); + h.setGitResult({ branchName: 'feature' }); + + // Three concurrent refreshes collapse via the throttler: the first + // runs immediately and the last queued one runs after it settles; + // the middle request is dropped. + await Promise.all([ + h.service.refreshSessionGitState(SESSION, undefined), + h.service.refreshSessionGitState(SESSION, undefined), + h.service.refreshSessionGitState(SESSION, undefined), + ]); + + assert.strictEqual(h.gitCalls.length, 2); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index c2421fb6e5224..b50681d1813b2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -9,13 +9,22 @@ import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../co import type { SchemaValues } from '../../common/agentHostSchema.js'; import type { ModelSelection } from '../../common/state/protocol/state.js'; import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +import { COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +import { BrowserChatToolReferenceName } from '../../../browserView/common/browserChatToolReferenceNames.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../node/copilot/prompts/allPrompts.js'; -/** Builds a prompt context backed by an in-memory bag of customization settings. */ -function context(settings: SchemaValues = {}): IAgentHostPromptContext { - return { getSetting: key => settings[key] }; +/** + * Builds a prompt context backed by an in-memory bag of customization settings + * and an optional set of available tool names. + */ +function context(settings: SchemaValues = {}, tools: readonly string[] = [], workspaceless = false): IAgentHostPromptContext { + const toolNames = new Set(tools); + return { + getSetting: key => settings[key], + hasClientTool: name => toolNames.has(name), + workspaceless, + }; } suite('AgentHostPromptRegistry', () => { @@ -122,4 +131,113 @@ suite('AgentHostPromptRegistry', () => { assert.strictEqual(resolveOpus(true).mode, 'customize'); }); }); + + suite('workspace-less scratch/repoless wiring', () => { + test('appends the scratch instructions to the default config for a workspace-less chat', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig(undefined, context({}, [], true)), + { + mode: 'customize', + sections: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections, + content: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, + } + ); + }); + + test('is a no-op for a workspace-bound session', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig(undefined, context({}, [], false)), + COPILOT_AGENT_HOST_SYSTEM_MESSAGE + ); + }); + + test('composes with per-model customize content for a workspace-less chat', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['claude']; + resolveSectionOverrides(): Partial> { + return { guidelines: { action: 'append', content: 'Be concise.' } }; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context({}, [], true)), + { + mode: 'customize', + sections: { guidelines: { action: 'append', content: 'Be concise.' } }, + content: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, + } + ); + }); + + test('does not append scratch instructions to a full replace prompt', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['gpt-5']; + resolveFullSystemPrompt(): string { + return 'FULL PROMPT'; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'gpt-5-mini' }, context({}, [], true)), + { mode: 'replace', content: 'FULL PROMPT' } + ); + }); + }); + + suite('universal tool instructions wiring', () => { + // The browser line is the registered universal tool-instruction (see + // toolInstructions.ts). These guard that the registry layers it end-to-end; + // the composition/gating itself is covered in toolInstructions.test.ts. + const BROWSER_LINE = 'Use the browser tools (openBrowserPage, readPage, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.'; + const browserTools = [BrowserChatToolReferenceName.OpenBrowserPage, BrowserChatToolReferenceName.ReadPage]; + + test('is a no-op when the session exposes no matching tools', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + }); + + test('layers the browser tool_instructions onto the default config when browser tools are present', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'm' }, context({}, browserTools)), + { + mode: 'customize', + sections: { + identity: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections.identity, + tool_instructions: { action: 'append', content: `\n${BROWSER_LINE}` }, + }, + } + ); + }); + + test('composes the browser line with a per-model tool_instructions override', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['claude']; + resolveSectionOverrides(): Partial> { + return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, browserTools)), + { mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${BROWSER_LINE}` } } } + ); + }); + + test('leaves a per-model tool_instructions override untouched when no browser tools are present', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['claude']; + resolveSectionOverrides(): Partial> { + return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, ['anyTool'])), + { mode: 'customize', sections: { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } } } + ); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index eac99930b09f6..64566eef4d006 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -9,13 +9,41 @@ import type { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { GITHUB_REPO_PROTECTED_RESOURCE, type IAgentService } from '../../common/agentService.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, type IAgentService } from '../../common/agentService.js'; import { buildSessionChangesetUri } from '../../common/changesetUri.js'; -import { withSessionGitState, type ISessionFileDiff, SessionStatus } from '../../common/state/sessionState.js'; +import { withSessionGitHubState, withSessionGitState, type ISessionFileDiff, MessageKind, ResponsePartKind, SessionStatus, TurnState, type Turn } from '../../common/state/sessionState.js'; import type { IAgentHostGitService, IPushOptions } from '../../common/agentHostGitService.js'; import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import type { CreatedPullRequest, IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; +import type { AutoMergeMethod, CreatedPullRequest, IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; +import type { ICopilotApiService, ICopilotApiServiceRequestOptions, ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +import type Anthropic from '@anthropic-ai/sdk'; +import type { CCAModel } from '@vscode/copilot-api'; + +class TestCopilotApiService implements ICopilotApiService { + declare readonly _serviceBrand: undefined; + + readonly calls: { token: string; request: ICopilotUtilityChatCompletionRequest; options?: ICopilotApiServiceRequestOptions }[] = []; + response = 'Generated PR title\n\nGenerated PR description.'; + error: Error | undefined; + + messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator; + messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; + messages(): AsyncGenerator | Promise { + throw new Error('not used'); + } + async countTokens(): Promise { throw new Error('not used'); } + async models(): Promise { return []; } + async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { + this.calls.push({ token: githubToken, request, options }); + if (this.error) { + throw this.error; + } + return this.response; + } +} class TestGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; @@ -25,7 +53,6 @@ class TestGitService implements IAgentHostGitService { upstream = false; branchChanges: readonly ISessionFileDiff[] | undefined = [{ after: { uri: 'file:///repo/file.ts', content: { uri: 'file:///repo/file.ts' } } }]; - async isInsideWorkTree(): Promise { return true; } async getCurrentBranch(): Promise { return 'feature/test'; } async getDefaultBranch(): Promise { return 'main'; } async getBranches(): Promise { return []; } @@ -74,10 +101,15 @@ class TestOctoKitService implements IAgentHostOctoKitService { existingAfterCreateFailure: CreatedPullRequest | undefined; createError: Error | undefined; findAfterCreateError: Error | undefined; - created: CreatedPullRequest = { url: 'https://github.com/microsoft/vscode/pull/123', number: 123 }; + autoMergeError: Error | undefined; + created: CreatedPullRequest = { url: 'https://github.com/microsoft/vscode/pull/123', number: 123, nodeId: 'PR_node_123' }; + lastTitle: string | undefined; + lastBody: string | undefined; - async createPullRequest(_owner: string, _repo: string, _title: string, _body: string, _head: string, _base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise { + async createPullRequest(_owner: string, _repo: string, title: string, body: string, _head: string, _base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise { this.calls.push(`createPullRequest:${draft}`); + this.lastTitle = title; + this.lastBody = body; if (this.createError) { throw this.createError; } @@ -93,15 +125,29 @@ class TestOctoKitService implements IAgentHostOctoKitService { } return this.existing; } + async enablePullRequestAutoMerge(pullRequestId: string, mergeMethod: AutoMergeMethod, _token: string, _signal: AbortSignal): Promise { + this.calls.push(`enablePullRequestAutoMerge:${pullRequestId}:${mergeMethod}`); + if (this.autoMergeError) { + throw this.autoMergeError; + } + } } -function createAgentService(): IAgentService { +function createAgentService(withCopilotToken = false): IAgentService { return { - getAuthToken: resource => resource.resource === GITHUB_REPO_PROTECTED_RESOURCE.resource ? 'gh-token' : undefined, + getAuthToken: resource => { + if (resource.resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { + return 'gh-token'; + } + if (withCopilotToken && resource.resource === GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { + return 'copilot-token'; + } + return undefined; + }, } as IAgentService; } -function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[] } { +function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; copilotApiService: TestCopilotApiService } { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agent:/session'); const createdEvents: string[] = []; @@ -110,21 +156,39 @@ function setup(disposables: Pick, gitService: TestGitSer provider: 'copilot', title: 'Session', status: SessionStatus.Idle, - createdAt: 1, - modifiedAt: 1, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), workingDirectory: URI.file('/repo').toString(), }); - stateManager.setSessionMeta(session.toString(), withSessionGitState(undefined, { + // Git state and GitHub state now share the single `_meta` bag. + const sessionMeta = withSessionGitHubState(withSessionGitState(undefined, { hasGitHubRemote: true, githubOwner: 'microsoft', githubRepo: 'vscode', branchName: 'feature/test', baseBranchName: 'main', - })); + }), { + owner: 'microsoft', + repo: 'vscode', + }); + stateManager.setSessionMeta(session.toString(), sessionMeta); + const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); return { - handler: new AgentHostPullRequestOperationHandler(false, sessionKey => stateManager.getSessionState(sessionKey), event => createdEvents.push(`${event.sessionKey}:${event.branchName}`), createAgentService(), gitService, octoKitService, new NullLogService()), + handler: new AgentHostPullRequestOperationHandler( + options?.draft ?? false, + options?.autoMergeMethod, + sessionKey => { + const state = stateManager.getSessionState(sessionKey); + if (state && options?.turns) { + return { ...state, turns: options.turns }; + } + return state; + }, + event => createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`), + createAgentService(options?.withCopilotToken), gitService, octoKitService, copilotApiService, new NullLogService()), session, createdEvents, + copilotApiService, }; } @@ -160,7 +224,7 @@ suite('AgentHostPullRequestOperationHandler', () => { 'findPullRequestByHeadBranch:feature/test', 'createPullRequest:false', ], - createdEvents: ['agent:/session:feature/test'], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/123'], }); }); @@ -184,7 +248,7 @@ suite('AgentHostPullRequestOperationHandler', () => { message: { markdown: 'Pull request [#7](https://github.com/microsoft/vscode/pull/7) already exists.' }, octoCalls: ['findPullRequestByHeadBranch:feature/test'], followUp: { content: { uri: 'https://github.com/microsoft/vscode/pull/7', contentType: 'text/html' }, external: true }, - createdEvents: ['agent:/session:feature/test'], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/7'], }); }); @@ -233,7 +297,7 @@ suite('AgentHostPullRequestOperationHandler', () => { assert.deepStrictEqual({ message: result.message, octoCalls: octoKitService.calls, createdEvents }, { message: { markdown: 'Pull request [#8](https://github.com/microsoft/vscode/pull/8) already exists.' }, octoCalls: ['findPullRequestByHeadBranch:feature/test', 'createPullRequest:false', 'findPullRequestByHeadBranch:feature/test'], - createdEvents: ['agent:/session:feature/test'], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/8'], }); }); @@ -269,4 +333,149 @@ suite('AgentHostPullRequestOperationHandler', () => { createdEvents: [], }); }); + + // When a Copilot token is available, the handler asks the utility model + // for a title/description, feeding it the main session conversation (only + // the markdown text of requests/responses — reasoning, tool calls, and + // subagents are excluded) plus the changed-file summary. + test('generates the PR title and description from the conversation via the model', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const turns: Turn[] = [{ + id: 'turn-1', + message: { text: 'Add retry logic to the uploader', origin: { kind: MessageKind.User } }, + responseParts: [ + { kind: ResponsePartKind.Reasoning, id: 'r1', content: 'SECRET_REASONING_SHOULD_BE_EXCLUDED' }, + { kind: ResponsePartKind.Markdown, id: 'm1', content: 'I added exponential backoff to the uploader.' }, + ], + usage: undefined, + state: TurnState.Complete, + }]; + const { handler, session, copilotApiService } = setup(disposables, gitService, octoKitService, { withCopilotToken: true, turns }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + const userContent = copilotApiService.calls[0]?.request.messages.find(m => m.role === 'user')?.content ?? ''; + assert.deepStrictEqual({ + message: result.message, + token: copilotApiService.calls[0]?.token, + title: octoKitService.lastTitle, + body: octoKitService.lastBody, + includesUserRequest: userContent.includes('Add retry logic to the uploader'), + includesAgentResponse: userContent.includes('I added exponential backoff to the uploader.'), + excludesReasoning: !userContent.includes('SECRET_REASONING_SHOULD_BE_EXCLUDED'), + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123).' }, + token: 'copilot-token', + title: 'Generated PR title', + body: 'Generated PR description.', + includesUserRequest: true, + includesAgentResponse: true, + excludesReasoning: true, + }); + }); + + // Without a Copilot token the model is never called and the handler falls + // back to the branch-name based title/description. + test('falls back to branch-name title and description without a Copilot token', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const { handler, session, copilotApiService } = setup(disposables, gitService, octoKitService); + + await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + utilityCalls: copilotApiService.calls.length, + title: octoKitService.lastTitle, + body: octoKitService.lastBody, + }, { + utilityCalls: 0, + title: 'feature: test', + body: 'Created from `feature/test` targeting `main`.', + }); + }); + + // Model failures must not block PR creation — the handler falls back to the + // branch-name based title/description. + test('falls back to branch-name title and description when generation fails', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const copilotApiService = new TestCopilotApiService(); + copilotApiService.error = new Error('utility model unavailable'); + const { handler, session } = setup(disposables, gitService, octoKitService, { withCopilotToken: true, copilotApiService }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + title: octoKitService.lastTitle, + body: octoKitService.lastBody, + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123).' }, + title: 'feature: test', + body: 'Created from `feature/test` targeting `main`.', + }); + }); + + // The auto-merge variants create the PR and then ask GitHub to enable + // auto-merge with the requested merge method, reporting it in the result. + test('enables auto-merge with the requested merge method after creating the pull request', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService, { autoMergeMethod: 'SQUASH' }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_SQUASH }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + octoCalls: octoKitService.calls, + createdEvents, + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123) with auto-merge (squash) enabled.' }, + octoCalls: [ + 'findPullRequestByHeadBranch:feature/test', + 'createPullRequest:false', + 'enablePullRequestAutoMerge:PR_node_123:SQUASH', + ], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/123'], + }); + }); + + // Enabling auto-merge is best-effort: a failure (e.g. the repository does + // not allow the merge method) must not fail PR creation. + test('reports but does not fail when auto-merge cannot be enabled', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.autoMergeError = new Error('Auto-merge is not allowed for this repository'); + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService, { autoMergeMethod: 'MERGE' }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_MERGE }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + createdEvents, + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123), but auto-merge could not be enabled: Auto-merge is not allowed for this repository' }, + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/123'], + }); + }); + + // Without a pull request node id we cannot issue the GraphQL mutation, so + // auto-merge is reported as not enabled rather than silently skipped. + test('reports when the pull request node id is missing for auto-merge', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.created = { url: 'https://github.com/microsoft/vscode/pull/55', number: 55 }; + const { handler, session } = setup(disposables, gitService, octoKitService, { autoMergeMethod: 'REBASE' }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_REBASE }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + enableCalled: octoKitService.calls.some(call => call.startsWith('enablePullRequestAutoMerge:')), + }, { + message: { markdown: 'Created pull request [#55](https://github.com/microsoft/vscode/pull/55), but auto-merge could not be enabled: the pull request identifier was not returned by GitHub.' }, + enableCalled: false, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index 3cf2303ad1029..4385393695483 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -5,13 +5,24 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { Event } from '../../../../base/common/event.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestOperationContribution } from '../../node/agentHostPullRequestOperationProvider.js'; -import type { ISessionGitState } from '../../common/state/sessionState.js'; +import type { ISessionGitHubState, ISessionGitState } from '../../common/state/sessionState.js'; +import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { ChangesetKind } from '../../common/changesetUri.js'; +const nullGitStateService = new class implements IAgentHostGitStateService { + declare readonly _serviceBrand: undefined; + readonly onDidRefreshSessionGitState = Event.None; + async refreshSessionGitState(): Promise { } + async getSessionGitHubState(): Promise { return undefined; } + async setSessionGitHubState(): Promise { } + async attachSessionGitHubPullRequest(): Promise { } +}; + const githubBranchWithUncommittedChanges: ISessionGitState = { hasGitHubRemote: true, branchName: 'feature/test', @@ -26,6 +37,7 @@ suite('AgentHostPullRequestOperationContribution', () => { return disposables.add(new AgentHostPullRequestOperationContribution( disposables.add(new AgentHostStateManager(new NullLogService())), disposables.add(new InstantiationService()), + nullGitStateService, )); } @@ -34,7 +46,7 @@ suite('AgentHostPullRequestOperationContribution', () => { const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); - assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-draft-pr']); + assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-draft-pr']); }); test('does not advertise PR operations without GitHub branch changes', () => { @@ -47,15 +59,4 @@ suite('AgentHostPullRequestOperationContribution', () => { assert.deepStrictEqual(actual, [undefined, undefined]); }); - - test('hides PR operations immediately after handler reports PR creation', () => { - const provider = createContribution(); - - provider.onPullRequestCreated({ sessionKey: 'agent:/session', branchName: 'feature/test' }); - const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); - - assert.deepStrictEqual({ operations }, { - operations: undefined, - }); - }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts new file mode 100644 index 0000000000000..1d96162a4aa7d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../log/common/log.js'; +import { ICommonProperties } from '../../../telemetry/common/telemetry.js'; +import { AgentHostRestrictedTelemetrySender } from '../../node/agentHostRestrictedTelemetry.js'; + +/** The enhanced/restricted iKey (`copilot_v0_restricted_copilot_event`). */ +const GH_ENHANCED_IKEY = '3fdd7f28-937a-48c8-9a21-ba337db23bd1'; + +interface ICapturedPost { + url: string; + iKey: string; +} + +suite('AgentHostRestrictedTelemetrySender', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const commonProperties = {} as ICommonProperties; + + function createSender(): { sender: AgentHostRestrictedTelemetrySender; posts: ICapturedPost[] } { + const posts: ICapturedPost[] = []; + const fetchFn = (async (url: string | URL | Request, init?: RequestInit): Promise => { + const envelope = JSON.parse(String(init?.body)); + posts.push({ url: String(url), iKey: envelope.iKey }); + return { ok: true, status: 200 } as Response; + }) as typeof globalThis.fetch; + const sender = new AgentHostRestrictedTelemetrySender(commonProperties, new NullLogService(), 'https://default.example/telemetry', undefined, fetchFn); + return { sender, posts }; + } + + test('enhanced GH telemetry is dropped until the token opts in (rt=1), then routes to the enhanced iKey', () => { + const { sender, posts } = createSender(); + + // Public user (rt not opted in): the restricted sink must not emit, even with content. + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + assert.deepStrictEqual(posts, [], 'enhanced telemetry must not be sent without rt opt-in'); + + // Opt in, then flip back off: emits only while enabled, and to the enhanced iKey. + sender.setRestrictedTelemetryEnabled(true); + sender.setRestrictedTelemetryEndpoint('https://ghe.example'); + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + sender.setRestrictedTelemetryEnabled(false); + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + + assert.deepStrictEqual(posts, [{ url: 'https://ghe.example', iKey: GH_ENHANCED_IKEY }]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 1c069e5871536..cd2ba3ca8716f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -33,6 +33,7 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -57,8 +58,8 @@ suite('AgentHostSessionTitleController', () => { provider: 'copilot', title, status: SessionStatus.Idle, - createdAt: 1, - modifiedAt: 1, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), }; } @@ -135,7 +136,7 @@ suite('AgentHostSessionTitleController', () => { await Promise.resolve(); assert.deepStrictEqual({ - title: stateManager.getSessionState(session.toString())?.summary.title, + title: stateManager.getSessionState(session.toString())?.title, persistedTitle: await db.getMetadata('customTitle'), }, { title: 'Manual title', @@ -157,7 +158,7 @@ suite('AgentHostSessionTitleController', () => { assert.deepStrictEqual({ aborted: copilotApiService.utilityCalls[0].options?.signal?.aborted, - title: stateManager.getSessionState(session.toString())?.summary.title, + title: stateManager.getSessionState(session.toString())?.title, persistedTitle: await db.getMetadata('customTitle'), }, { aborted: true, @@ -175,7 +176,7 @@ suite('AgentHostSessionTitleController', () => { assert.deepStrictEqual({ calls: copilotApiService.utilityCalls.length, - title: stateManager.getSessionState(session.toString())?.summary.title, + title: stateManager.getSessionState(session.toString())?.title, titles: titleActions, persistedTitle: await db.getMetadata('customTitle'), }, { @@ -237,7 +238,7 @@ suite('AgentHostSessionTitleController', () => { const lastCall = copilotApiService.utilityCalls[copilotApiService.utilityCalls.length - 1]; const userMessage = lastCall.request.messages.find(message => message.role === 'user')?.content ?? ''; assert.deepStrictEqual({ - title: stateManager.getSessionState(session.toString())?.summary.title, + title: stateManager.getSessionState(session.toString())?.title, persistedTitle: await db.getMetadata('customTitle'), mentionsConversation: userMessage.includes('conversation'), includesUserRequest: userMessage.includes('Add dark mode toggle'), @@ -264,7 +265,7 @@ suite('AgentHostSessionTitleController', () => { assert.deepStrictEqual({ calls: copilotApiService.utilityCalls.length, - title: stateManager.getSessionState(session.toString())?.summary.title, + title: stateManager.getSessionState(session.toString())?.title, }, { calls: callsAfterSeed, title: 'Manual title', @@ -323,4 +324,67 @@ suite('AgentHostSessionTitleController', () => { keepsHeadAndTail: true, }); }); + + function turn(id: string, text: string, responseParts: ResponsePart[]): Turn { + return { + id, + message: { text, origin: { kind: MessageKind.User } }, + responseParts, + usage: undefined, + state: TurnState.Complete, + }; + } + + test('generateForkedTitle replaces the inherited title using the whole forked conversation', async () => { + const copilotApiService = new TestCopilotApiService(); + copilotApiService.response = 'Compaction strategy'; + const { controller, stateManager, session, db, titleActions } = setup(copilotApiService, 'Forked: Source title'); + + stateManager.seedDefaultChatTurns(session.toString(), [ + turn('turn-1', 'Add dark mode toggle', [textPart('Implemented the toggle in settings.')]), + turn('turn-2', 'Now compact the history', [textPart('Summarized earlier turns.')]), + ]); + const turns = stateManager.getSessionState(session.toString())!.turns; + controller.generateForkedTitle(session.toString(), undefined, turns, 'Forked: Source title', 'Source title'); + await waitForCondition(async () => await db.getMetadata('customTitle') === 'Compaction strategy', 'forked title should be persisted'); + + const userMessage = copilotApiService.utilityCalls[0]?.request.messages.find(message => message.role === 'user')?.content ?? ''; + assert.deepStrictEqual({ + titles: titleActions, + persistedTitle: await db.getMetadata('customTitle'), + mentionsConversation: userMessage.includes('conversation'), + framesAsBranch: userMessage.includes('branched from an earlier chat titled "Source title"'), + includesFirstTurn: userMessage.includes('Add dark mode toggle') && userMessage.includes('Implemented the toggle in settings.'), + includesSecondTurn: userMessage.includes('Now compact the history') && userMessage.includes('Summarized earlier turns.'), + }, { + titles: ['Compaction strategy'], + persistedTitle: 'Compaction strategy', + mentionsConversation: true, + framesAsBranch: true, + includesFirstTurn: true, + includesSecondTurn: true, + }); + }); + + test('generateForkedTitle does not clobber a title changed during generation', async () => { + const copilotApiService = new TestCopilotApiService(); + let resolveTitle!: (title: string) => void; + copilotApiService.responsePromise = new Promise(resolve => { resolveTitle = resolve; }); + const { controller, stateManager, session, db } = setup(copilotApiService, 'Forked: Source title'); + + stateManager.seedDefaultChatTurns(session.toString(), [turn('turn-1', 'Add dark mode toggle', [textPart('Done.')])]); + controller.generateForkedTitle(session.toString(), undefined, stateManager.getSessionState(session.toString())!.turns, 'Forked: Source title'); + await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'forked title generation should start'); + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'Manual title' }); + resolveTitle('Generated title'); + await Promise.resolve(); + + assert.deepStrictEqual({ + title: stateManager.getSessionState(session.toString())?.title, + persistedTitle: await db.getMetadata('customTitle'), + }, { + title: 'Manual title', + persistedTitle: undefined, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 5131ee5d87cee..03ba03b341818 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.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 { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, parseSubagentSessionUri, readHostBuildInfo, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; +import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, type ChatState, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; @@ -20,6 +20,7 @@ suite('AgentHostStateManager', () => { let disposables: DisposableStore; let manager: AgentHostStateManager; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); + const sessionChatUri = buildDefaultChatUri(sessionUri); function makeSessionSummary(resource?: string): SessionSummary { return { @@ -27,8 +28,8 @@ suite('AgentHostStateManager', () => { provider: 'copilot', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, }; } @@ -50,7 +51,7 @@ suite('AgentHostStateManager', () => { const chatState = manager.getDefaultChatState(sessionUri); assert.strictEqual(chatState?.turns.length, 0); assert.strictEqual(chatState?.activeTurn, undefined); - assert.strictEqual(state.summary.resource.toString(), sessionUri.toString()); + assert.strictEqual(manager.getSessionSummary(sessionUri)?.resource.toString(), sessionUri.toString()); }); test('getSnapshot returns undefined for unknown session', () => { @@ -219,13 +220,32 @@ suite('AgentHostStateManager', () => { assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); }); + test('default chat inherits the session working directory resolved at materialization', () => { + // A deferred (provisional) session is created with a pre-materialization + // working directory; materialization later resolves it to a different + // one (e.g. a git worktree) via markSessionPersisted. The default chat + // has no per-chat working-directory override, so getSessionState must + // project the RESOLVED session working directory, never the stale + // create-time value that was seeded onto the default chat. + manager.createSession({ ...makeSessionSummary(), workingDirectory: 'file:///provisional' }, { emitNotification: false }); + manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectory: 'file:///resolved-worktree' }); + + assert.deepStrictEqual({ + session: manager.getSessionState(sessionUri)?.workingDirectory, + defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectory, + }, { + session: 'file:///resolved-worktree', + defaultChat: 'file:///resolved-worktree', + }); + }); + test('getActiveTurnId returns active turn id after turnStarted', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -249,7 +269,7 @@ suite('AgentHostStateManager', () => { const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -264,7 +284,7 @@ suite('AgentHostStateManager', () => { test('turnComplete dispatches root/activeSessionsChanged back to 0', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -273,7 +293,7 @@ suite('AgentHostStateManager', () => { const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); @@ -291,25 +311,25 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(session2Uri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'a', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(session2Uri, { + manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'b', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 2); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); assert.strictEqual(manager.rootState.activeSessions, 1); - manager.dispatchServerAction(session2Uri, { + manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnComplete, turnId: 'turn-2', }); @@ -319,7 +339,7 @@ suite('AgentHostStateManager', () => { test('removeSession decrements active sessions when an active turn is stranded', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -360,14 +380,14 @@ suite('AgentHostStateManager', () => { // genuinely running. manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', }); @@ -382,12 +402,12 @@ suite('AgentHostStateManager', () => { // from state's point of view. The count must mirror that. manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'a', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'b', origin: { kind: MessageKind.User } }, @@ -395,7 +415,7 @@ suite('AgentHostStateManager', () => { assert.strictEqual(manager.rootState.activeSessions, 1); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-2', }); @@ -410,16 +430,16 @@ suite('AgentHostStateManager', () => { const events: Array<{ session: string; active: boolean }> = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatError, turnId: 'turn-1', error: { errorType: 'failed', message: 'boom' }, @@ -440,16 +460,16 @@ suite('AgentHostStateManager', () => { const events: Array<{ session: string; active: boolean }> = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); - manager.dispatchServerAction(session2Uri, { + manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'hi', origin: { kind: MessageKind.User } }, @@ -592,7 +612,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); // Start a turn → status becomes InProgress. - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -606,7 +626,7 @@ suite('AgentHostStateManager', () => { // Turn completes — status flips back to Idle. This schedules a summary // flush 100 ms later but we will call removeSession before it fires. - manager.dispatchServerAction(sessionUri, { + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); @@ -820,7 +840,7 @@ suite('AgentHostStateManager', () => { assert.deepStrictEqual( { afterAdd, - sessionTitle: state?.summary.title, + sessionTitle: state?.title, defaultChatTitle: state?.chats.find(c => c.resource === defaultChat)?.title, }, { @@ -908,7 +928,7 @@ suite('AgentHostStateManager', () => { const state = manager.getSessionState(sessionUri); assert.deepStrictEqual( { - sessionTitle: state?.summary.title, + sessionTitle: state?.title, defaultChatTitle: state?.chats.find(c => c.resource === defaultChat)?.title, peerTitle: state?.chats.find(c => c.resource === peerChat)?.title, peerStateTitle: manager.getChatState(peerChat)?.title, @@ -964,6 +984,585 @@ suite('AgentHostStateManager', () => { }, ); }); + + test('hasActiveTurn reflects a chat turn lifecycle', () => { + manager.createSession(makeSessionSummary()); + + const idle = manager.hasActiveTurn(sessionUri); + + manager.dispatchServerAction(sessionChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'a', origin: { kind: MessageKind.User } }, + }); + const afterStart = manager.hasActiveTurn(sessionUri); + + manager.dispatchServerAction(sessionChatUri, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + }); + const afterComplete = manager.hasActiveTurn(sessionUri); + + assert.deepStrictEqual( + { idle, afterStart, afterComplete }, + { idle: false, afterStart: true, afterComplete: false }, + ); + }); + + test('active-turn event observers see the updated active-turn state', () => { + // Operations are recomputed synchronously from the active-turn event, + // so hasActiveTurn must already reflect the lifecycle change when that + // event fires — otherwise operations would stay disabled at turn end. + manager.createSession(makeSessionSummary()); + + const observed: { active: boolean; hasActiveTurn: boolean }[] = []; + disposables.add(manager.onDidChangeSessionActiveTurn(e => { + observed.push({ active: e.active, hasActiveTurn: manager.hasActiveTurn(sessionUri) }); + })); + + manager.dispatchServerAction(sessionChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'a', origin: { kind: MessageKind.User } }, + }); + manager.dispatchServerAction(sessionChatUri, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + }); + + assert.deepStrictEqual(observed, [ + { active: true, hasActiveTurn: true }, + { active: false, hasActiveTurn: false }, + ]); + }); + + test('hasActiveTurn stays true until all concurrent chat turns finish', () => { + manager.createSession(makeSessionSummary()); + const defaultChat = buildDefaultChatUri(sessionUri); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const idle = manager.hasActiveTurn(sessionUri); + + // Start a turn on the default chat, then a concurrent turn on the peer. + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-default', + message: { text: 'a', origin: { kind: MessageKind.User } }, + }); + const afterDefaultStart = manager.hasActiveTurn(sessionUri); + + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const afterBothStart = manager.hasActiveTurn(sessionUri); + + // Completing the default chat must NOT clear while the peer streams. + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-default', + }); + const afterDefaultComplete = manager.hasActiveTurn(sessionUri); + + // Only once the peer finishes too does the session go idle. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-peer', + }); + const afterBothComplete = manager.hasActiveTurn(sessionUri); + + assert.deepStrictEqual( + { idle, afterDefaultStart, afterBothStart, afterDefaultComplete, afterBothComplete }, + { idle: false, afterDefaultStart: true, afterBothStart: true, afterDefaultComplete: true, afterBothComplete: false }, + ); + }); + + test('a running peer chat promotes the session summary to InProgress while the default chat is idle', () => { + manager.createSession(makeSessionSummary()); + const defaultChat = buildDefaultChatUri(sessionUri); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const idle = manager.getSessionState(sessionUri)?.status; + + // Only the peer (sub) chat starts streaming; the default chat stays idle. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const whilePeerRuns = manager.getSessionState(sessionUri)?.status; + + // Once the peer finishes the session falls back to idle. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-peer', + }); + const afterPeerComplete = manager.getSessionState(sessionUri)?.status; + + assert.deepStrictEqual( + { + idleHasInProgress: ((idle ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + whilePeerRunsHasInProgress: ((whilePeerRuns ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + afterPeerCompleteHasInProgress: ((afterPeerComplete ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + defaultChatStillIdle: ((manager.getChatState(defaultChat)?.status ?? SessionStatus.Idle) & SessionStatus.InProgress) === 0, + }, + { + idleHasInProgress: false, + whilePeerRunsHasInProgress: true, + afterPeerCompleteHasInProgress: false, + defaultChatStillIdle: true, + }, + ); + }); + + test('a running peer chat forwards its own status to the session catalog so its tab can show progress', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const envelopes: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + const peerCatalogStatus = () => manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.status ?? SessionStatus.Idle; + const chatUpdatesForPeer = () => envelopes.filter(e => e.action.type === ActionType.SessionChatUpdated && (e.action as { chat: string }).chat === peerChat).length; + + const idleCatalog = peerCatalogStatus(); + + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const runningCatalog = peerCatalogStatus(); + const updatesAfterStart = chatUpdatesForPeer(); + + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-peer', + }); + + assert.deepStrictEqual( + { + idleCatalogInProgress: (idleCatalog & SessionStatus.InProgress) === SessionStatus.InProgress, + runningCatalogInProgress: (runningCatalog & SessionStatus.InProgress) === SessionStatus.InProgress, + finalCatalogInProgress: (peerCatalogStatus() & SessionStatus.InProgress) === SessionStatus.InProgress, + emittedChatUpdateOnStart: updatesAfterStart >= 1, + }, + { + idleCatalogInProgress: false, + runningCatalogInProgress: true, + finalCatalogInProgress: false, + emittedChatUpdateOnStart: true, + }, + ); + }); + + test('active-turn event and active-session count flip once per session across concurrent chats', () => { + manager.createSession(makeSessionSummary()); + const defaultChat = buildDefaultChatUri(sessionUri); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const turnEvents: boolean[] = []; + disposables.add(manager.onDidChangeSessionActiveTurn(e => turnEvents.push(e.active))); + + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-default', + message: { text: 'a', origin: { kind: MessageKind.User } }, + }); + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const activeWhileBothRun = manager.rootState.activeSessions; + + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-default', + }); + const activeAfterFirstCompletes = manager.rootState.activeSessions; + + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-peer', + }); + + assert.deepStrictEqual( + { + turnEvents, + activeWhileBothRun, + activeAfterFirstCompletes, + activeAfterBothComplete: manager.rootState.activeSessions, + }, + { + // Exactly one true (first chat starts) and one false (last chat ends). + turnEvents: [true, false], + activeWhileBothRun: 1, + activeAfterFirstCompletes: 1, + activeAfterBothComplete: 0, + }, + ); + }); + + test('removeChat clears a peer chat that is removed mid-turn', () => { + manager.createSession(makeSessionSummary()); + const defaultChat = buildDefaultChatUri(sessionUri); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const turnEvents: boolean[] = []; + disposables.add(manager.onDidChangeSessionActiveTurn(e => turnEvents.push(e.active))); + + // Both the default chat and the peer chat start a concurrent turn. + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-default', + message: { text: 'a', origin: { kind: MessageKind.User } }, + }); + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const activeWhileBothRun = manager.hasActiveTurn(sessionUri); + + // Removing the peer mid-turn must not strand it in the active set: + // the session stays active because the default chat still streams. + manager.removeChat(sessionUri, peerChat); + const activeAfterPeerRemoved = manager.hasActiveTurn(sessionUri); + + // Completing the default chat is now enough to flip the session idle. + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-default', + }); + + assert.deepStrictEqual( + { + turnEvents, + activeWhileBothRun, + activeAfterPeerRemoved, + activeAfterDefaultComplete: manager.hasActiveTurn(sessionUri), + activeSessions: manager.rootState.activeSessions, + }, + { + turnEvents: [true, false], + activeWhileBothRun: true, + activeAfterPeerRemoved: true, + activeAfterDefaultComplete: false, + activeSessions: 0, + }, + ); + }); + + test('removeChat flips the session idle when the removed peer held the last active turn', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const turnEvents: boolean[] = []; + disposables.add(manager.onDidChangeSessionActiveTurn(e => turnEvents.push(e.active))); + + // Only the peer chat has an active turn. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const activeWhilePeerRuns = manager.hasActiveTurn(sessionUri); + + // Removing that peer is the last active chat, so the session must + // flip back to idle instead of staying permanently active. + manager.removeChat(sessionUri, peerChat); + + assert.deepStrictEqual( + { + turnEvents, + activeWhilePeerRuns, + activeAfterPeerRemoved: manager.hasActiveTurn(sessionUri), + activeSessions: manager.rootState.activeSessions, + }, + { + turnEvents: [true, false], + activeWhilePeerRuns: true, + activeAfterPeerRemoved: false, + activeSessions: 0, + }, + ); + }); + }); + + // Characterization tests (task A3): pin down the *current* catalog behavior + // — the default-chat pointer set up by `_ensureDefaultChat`, the + // `restoreChat` re-registration path, and the rolled-up session summary + // produced by the SessionSummaryNotifier — so the upcoming `providerData` + // change cannot silently regress them. + suite('catalog characterization (A3)', () => { + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + test('_ensureDefaultChat seeds a single inheriting default chat and points defaultChat at it on createSession', () => { + manager.createSession(makeSessionSummary()); + const state = manager.getSessionState(sessionUri); + + assert.deepStrictEqual( + { + defaultChat: state?.defaultChat, + defaultChatIsDeterministic: state?.defaultChat === buildDefaultChatUri(sessionUri), + chatResources: state?.chats.map(c => c.resource.toString()), + // Empty title => the default chat inherits the session title for display. + defaultChatTitle: state?.chats[0]?.title, + defaultChatStatePresent: manager.getDefaultChatState(sessionUri) !== undefined, + }, + { + defaultChat: buildDefaultChatUri(sessionUri), + defaultChatIsDeterministic: true, + chatResources: [buildDefaultChatUri(sessionUri)], + defaultChatTitle: '', + defaultChatStatePresent: true, + }, + ); + }); + + test('_ensureDefaultChat seeds the default-chat pointer on restoreSession too', () => { + const turns = [ + { + id: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart], + usage: undefined, + state: TurnState.Complete, + }, + ]; + manager.restoreSession(makeSessionSummary(), turns); + const state = manager.getSessionState(sessionUri); + + assert.deepStrictEqual( + { + defaultChat: state?.defaultChat, + chatResources: state?.chats.map(c => c.resource.toString()), + defaultChatTurns: manager.getDefaultChatState(sessionUri)?.turns.length, + }, + { + defaultChat: buildDefaultChatUri(sessionUri), + chatResources: [buildDefaultChatUri(sessionUri)], + defaultChatTurns: 1, + }, + ); + }); + + test('restoreChat re-registers a peer chat in place, seeding turns and draft without dispatching SessionChatAdded', () => { + manager.restoreSession(makeSessionSummary(), []); + + const envelopes: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + const turns = [ + { + id: 'peer-turn-1', + message: { text: 'restored', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'history' } satisfies MarkdownResponsePart], + usage: undefined, + state: TurnState.Complete, + }, + ]; + const draft = { text: 'work in progress', origin: { kind: MessageKind.User } }; + manager.restoreChat(sessionUri, peerChat, { title: 'Restored Peer', turns, draft }); + + const peerState = manager.getChatState(peerChat); + assert.deepStrictEqual( + { + chatResources: manager.getSessionState(sessionUri)?.chats.map(c => c.resource.toString()).sort(), + restoredTitle: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, + peerTurns: peerState?.turns.length, + peerDraft: peerState?.draft?.text, + // restoreChat runs before clients subscribe, so it adds the + // catalog entry in place rather than via a dispatched action. + chatAddedEvents: envelopes.filter(e => e.action.type === ActionType.SessionChatAdded).length, + }, + { + chatResources: [buildDefaultChatUri(sessionUri), peerChat].sort(), + restoredTitle: 'Restored Peer', + peerTurns: 1, + peerDraft: 'work in progress', + chatAddedEvents: 0, + }, + ); + }); + + test('restoreChat is a no-op for an already-registered chat URI', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const turns = [ + { + id: 'ignored-turn', + message: { text: 'ignored', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }, + ]; + manager.restoreChat(sessionUri, peerChat, { title: 'Ignored', turns }); + + assert.deepStrictEqual( + { + chatCount: manager.getSessionState(sessionUri)?.chats.length, + title: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, + // The existing (empty) chat state is preserved; the supplied turns are dropped. + peerTurns: manager.getChatState(peerChat)?.turns.length, + }, + { + chatCount: 2, + title: 'Peer', + peerTurns: 0, + }, + ); + }); + + test('restoreChat for an unknown session is a no-op', () => { + manager.restoreChat('copilot:/missing', peerChat, { turns: [] }); + + assert.strictEqual(manager.getChatState(peerChat), undefined); + }); + + test('SessionSummaryNotifier rolls a running peer chat up onto the session summary and emits one coalesced SessionSummaryChanged', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + const summaryHasInProgress = () => ((manager.getSessionSummary(sessionUri)?.status ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress; + const idleRollup = summaryHasInProgress(); + + // Only the peer chat streams; the default chat stays idle. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const runningRollup = summaryHasInProgress(); + + await new Promise(r => setTimeout(r, 150)); + + const summaryChanges = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged) as SessionSummaryChangedParams[]; + + assert.deepStrictEqual( + { + idleRollup, + runningRollup, + summaryChangedCount: summaryChanges.length, + notifiedStatusHasInProgress: ((summaryChanges[0]?.changes.status ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + notifiedSession: summaryChanges[0]?.session, + }, + { + idleRollup: false, + runningRollup: true, + summaryChangedCount: 1, + notifiedStatusHasInProgress: true, + notifiedSession: sessionUri, + }, + ); + }); + }); + }); + + // Exercises the opaque, agent-owned `providerData` blob the StateManager + // records alongside a peer-chat catalog entry (G-B1). The StateManager must + // store the string verbatim, return it unchanged, keep it off the default + // chat, and drop it when the chat or its session goes away — it must NEVER + // parse or otherwise interpret the value. + suite('providerData (G-B1)', () => { + const peerChat = buildChatUri(sessionUri, 'peer-1'); + const peerChat2 = buildChatUri(sessionUri, 'peer-2'); + + test('addChat records providerData verbatim and getChatProviderData returns it unchanged', () => { + manager.createSession(makeSessionSummary()); + // A deliberately structured-but-opaque blob: the StateManager must + // not parse it, so embedded JSON / quotes must round-trip exactly. + const blob = '{"sdkSessionId":"abc-123","model":{"id":"x\\"y"}}'; + manager.addChat(sessionUri, peerChat, { title: 'Peer', providerData: blob }); + + assert.deepStrictEqual( + { + providerData: manager.getChatProviderData(peerChat), + // The blob is stored separately and never leaks onto the + // protocol-visible catalog entry / chat state. + summaryHasBlob: (manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat) as { providerData?: unknown } | undefined)?.providerData !== undefined, + chatStateHasBlob: (manager.getChatState(peerChat) as { providerData?: unknown } | undefined)?.providerData !== undefined, + }, + { + providerData: blob, + summaryHasBlob: false, + chatStateHasBlob: false, + }, + ); + }); + + test('the default chat never carries providerData', () => { + manager.createSession(makeSessionSummary()); + + assert.strictEqual(manager.getChatProviderData(buildDefaultChatUri(sessionUri)), undefined); + }); + + test('addChat without providerData stores nothing', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + assert.strictEqual(manager.getChatProviderData(peerChat), undefined); + }); + + test('addChat is idempotent and preserves the originally stored providerData', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer', providerData: 'first' }); + // Re-adding the same chat URI is a no-op; it must not clobber the blob. + manager.addChat(sessionUri, peerChat, { title: 'Ignored', providerData: 'second' }); + + assert.strictEqual(manager.getChatProviderData(peerChat), 'first'); + }); + + test('restoreChat records providerData verbatim alongside turns', () => { + manager.restoreSession(makeSessionSummary(), []); + const blob = 'opaque-restore-token'; + manager.restoreChat(sessionUri, peerChat, { title: 'Restored', turns: [], providerData: blob }); + + assert.strictEqual(manager.getChatProviderData(peerChat), blob); + }); + + test('restoreChat without providerData stores nothing', () => { + manager.restoreSession(makeSessionSummary(), []); + manager.restoreChat(sessionUri, peerChat, { title: 'Restored', turns: [] }); + + assert.strictEqual(manager.getChatProviderData(peerChat), undefined); + }); + + test('removeChat drops the chat providerData', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer', providerData: 'blob' }); + manager.removeChat(sessionUri, peerChat); + + assert.strictEqual(manager.getChatProviderData(peerChat), undefined); + }); + + test('removeSession drops providerData for every peer chat', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer 1', providerData: 'blob-1' }); + manager.addChat(sessionUri, peerChat2, { title: 'Peer 2', providerData: 'blob-2' }); + + manager.removeSession(sessionUri); + + assert.deepStrictEqual( + { + peer1: manager.getChatProviderData(peerChat), + peer2: manager.getChatProviderData(peerChat2), + }, + { + peer1: undefined, + peer2: undefined, + }, + ); + }); }); }); @@ -1029,4 +1628,51 @@ suite('Subagent URI helpers', () => { 'copilot:/session-1//nested/../kept/subagent/', ); }); + + suite('mergeSessionWithDefaultChat', () => { + function makeSessionState(workingDirectory?: string): SessionState { + return { + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + workingDirectory, + }; + } + + function makeChatState(workingDirectory?: string): ChatState { + return { + resource: 'copilot:/test-session/chat/peer', + title: 'Peer', + status: SessionStatus.Idle, + modifiedAt: new Date().toISOString(), + workingDirectory, + turns: [], + }; + } + + test('resolves the per-chat working directory override over the session default', () => { + const merged = mergeSessionWithDefaultChat( + makeSessionState('file:///session-wd'), + makeChatState('file:///peer-worktree'), + ); + assert.strictEqual(merged.workingDirectory, 'file:///peer-worktree'); + }); + + test('falls back to the session working directory when the chat does not override it', () => { + const merged = mergeSessionWithDefaultChat( + makeSessionState('file:///session-wd'), + makeChatState(undefined), + ); + assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + }); + + test('falls back to the session working directory when no chat state is hydrated', () => { + const merged = mergeSessionWithDefaultChat(makeSessionState('file:///session-wd'), undefined); + assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + assert.deepStrictEqual(merged.turns, []); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts new file mode 100644 index 0000000000000..c2b212977cda5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { AgentSession } from '../../common/agentService.js'; +import type { ToolDefinition } from '../../common/state/protocol/state.js'; +import { IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; +import { AgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; + +interface IRestrictedCall { + eventName: string; + properties: TelemetryProps | undefined; +} + +class TestRestrictedTelemetryService implements ITelemetryService, IAgentHostRestrictedTelemetry { + declare readonly _serviceBrand: undefined; + + telemetryLevel = TelemetryLevel.USAGE; + sendErrorTelemetry = true; + sessionId = 'sessionId'; + machineId = 'machineId'; + sqmId = 'sqmId'; + devDeviceId = 'devDeviceId'; + firstSessionDate = 'firstSessionDate'; + + readonly enhancedEvents: IRestrictedCall[] = []; + + publicLog(): void { } + publicLogError(): void { } + publicLog2(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } + + sendGHTelemetryEvent(): void { } + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { + this.enhancedEvents.push({ eventName, properties }); + } + sendInternalMSFTTelemetryEvent(): void { } + setCopilotTrackingId(): void { } + setRestrictedTelemetryEndpoint(): void { } + setRestrictedTelemetryEnabled(): void { } +} + +suite('AgentHostTelemetryReporter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = 'agent-session://copilot/abc'; + const tools: ToolDefinition[] = [{ name: 'grep' }, { name: 'edit' }]; + + test('assistantMessageReceived emits request.options.tools keyed on the service request id, and no-ops without one or without tools', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.assistantMessageReceived(session, undefined, tools); // dropped: no service request id + reporter.assistantMessageReceived(session, 'svc-1', []); // dropped: no tools + reporter.assistantMessageReceived(session, 'svc-1', tools); // emitted + + assert.deepStrictEqual(service.enhancedEvents, [{ + eventName: 'request.options.tools', + properties: { + headerRequestId: 'svc-1', + conversationId: AgentSession.id(session), + messagesJson: JSON.stringify(tools), + }, + }]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index 35050bc512621..7a8f05da86bbf 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ITelemetryData, ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +import { IAgentHostRestrictedTelemetry, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; import { AgentHostTelemetryService, updateAgentHostTelemetryLevelFromConfig } from '../../node/agentHostTelemetryService.js'; class TestTelemetryService implements ITelemetryService { @@ -42,6 +43,31 @@ class TestTelemetryService implements ITelemetryService { setCommonProperty(): void { } } +class TestRestrictedSink implements IAgentHostRestrictedTelemetry { + readonly enhanced: string[] = []; + readonly standard: string[] = []; + readonly trackingIds: (string | undefined)[] = []; + readonly endpoints: (string | undefined)[] = []; + readonly enabledFlags: boolean[] = []; + + sendGHTelemetryEvent(eventName: string, _properties?: TelemetryProps): void { + this.standard.push(eventName); + } + sendEnhancedGHTelemetryEvent(eventName: string, _properties?: TelemetryProps): void { + this.enhanced.push(eventName); + } + sendInternalMSFTTelemetryEvent(): void { } + setCopilotTrackingId(trackingId: string | undefined): void { + this.trackingIds.push(trackingId); + } + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + this.endpoints.push(endpointUrl); + } + setRestrictedTelemetryEnabled(enabled: boolean): void { + this.enabledFlags.push(enabled); + } +} + suite('AgentHostTelemetryService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -88,4 +114,45 @@ suite('AgentHostTelemetryService', () => { assert.strictEqual(service.telemetryLevel, TelemetryLevel.ERROR); }); + + test('enhanced GH telemetry is gated on the restricted (rt) opt-in; standard GH telemetry is not', () => { + const restricted = new TestRestrictedSink(); + const service = disposables.add(new AgentHostTelemetryService(new TestTelemetryService(), restricted)); + + service.sendEnhancedGHTelemetryEvent('request.options.tools'); // dropped: rt disabled by default + service.sendGHTelemetryEvent('completion'); // sent: standard GH telemetry is not rt-gated + service.setRestrictedTelemetryEnabled(true); + service.sendEnhancedGHTelemetryEvent('request.options.tools'); // sent: rt now enabled + service.setCopilotTrackingId('tid-1'); + service.setRestrictedTelemetryEndpoint('https://ghe.example/telemetry'); + + assert.deepStrictEqual({ + enhanced: restricted.enhanced, + standard: restricted.standard, + trackingIds: restricted.trackingIds, + endpoints: restricted.endpoints, + }, { + enhanced: ['request.options.tools'], + standard: ['completion'], + trackingIds: ['tid-1'], + endpoints: ['https://ghe.example/telemetry'], + }); + // The rt opt-in is mirrored onto the sender (defense in depth), matching the extension's + // opted-in-only restricted reporter. + assert.deepStrictEqual(restricted.enabledFlags, [true]); + }); + + test('enhanced GH telemetry stays suppressed when telemetry is disabled, even for an rt=1 user', () => { + const delegate = new TestTelemetryService(); + delegate.telemetryLevel = TelemetryLevel.ERROR; // user opted below USAGE + const restricted = new TestRestrictedSink(); + const service = disposables.add(new AgentHostTelemetryService(delegate, restricted)); + + service.setRestrictedTelemetryEnabled(true); // rt=1 + service.sendEnhancedGHTelemetryEvent('request.options.tools'); + service.sendGHTelemetryEvent('completion'); + + // Neither standard nor enhanced GH telemetry is delegated below USAGE, regardless of rt. + assert.deepStrictEqual({ enhanced: restricted.enhanced, standard: restricted.standard }, { enhanced: [], standard: [] }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts new file mode 100644 index 0000000000000..0a6e58076c474 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { AgentSession, IAgent } from '../../common/agentService.js'; +import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; +import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; +import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; +import { AgentSideEffects } from '../../node/agentSideEffects.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; +import { MockAgent } from './mockAgent.js'; + +class FakeChangesetService implements IAgentHostChangesetService { + declare readonly _serviceBrand: undefined; + registerStaticChangesets(): void { } + restoreStaticChangeset(): void { } + parsePersistedStaticChangesets(): { session?: undefined } { return {}; } + applyPersistedStaticChangesets(): void { } + restorePersistedStaticChangesets(): { session?: undefined } { return {}; } + persistChangesSummary(): void { } + isStaticChangesetComputeActive(): boolean { return false; } + getListMetadataKeys() { return undefined; } + computeListEntryChanges() { return undefined; } + refreshBranchChangeset(): void { } + refreshSessionChangeset(): void { } + refreshChangesetCatalog(): void { } + onWorkingDirectoryAvailable(): void { } + recomputeSubscribedChangesets(): void { } + onSessionDisposed(): void { } + async computeUncommittedChangeset(session: string): Promise { return `${session}/changeset/uncommitted`; } + async computeTurnChangeset(session: string): Promise { return `${session}/x`; } + async computeCompareTurnsChangeset(session: string): Promise { return `${session}/y`; } + onToolCallEditsApplied(): void { } + onTurnComplete(): void { } + onSessionTruncated(): void { } +} + +class CapturingTelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + readonly telemetryLevel = TelemetryLevel.USAGE; + readonly sessionId = 'test-session'; + readonly machineId = 'test-machine'; + readonly sqmId = 'test-sqm'; + readonly devDeviceId = 'test-dev-device'; + readonly firstSessionDate = 'test-first-session-date'; + readonly sendErrorTelemetry = false; + readonly events: { eventName: string; data: unknown }[] = []; + + publicLog(): void { } + publicLog2(eventName: string, data?: unknown): void { + this.events.push({ eventName, data }); + } + publicLogError(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } +} + +/** + * Integration tests covering the {@link AgentHostToolCallTracker} as it is + * driven through {@link AgentSideEffects}. These exercise the full wiring + * (tool-call start stamping, completion emission, dedup and the in-flight + * leak guard) so we cover both the tracker and its integration with the + * side-effect dispatch in one place. + */ +suite('AgentSideEffects — tool call telemetry', () => { + + const disposables = new DisposableStore(); + let stateManager: AgentHostStateManager; + let agent: MockAgent; + let sideEffects: AgentSideEffects; + let telemetry: CapturingTelemetryService; + + const sessionUri = AgentSession.uri('mock', 'session-1'); + const sessionKey = sessionUri.toString(); + const defaultChatUri = buildDefaultChatUri(sessionUri); + + function setupSession(): void { + stateManager.createSession({ + resource: sessionKey, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); + } + + function startTurn(turnId: string, text = 'hello'): void { + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId, + message: { text, origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); + } + + function fire(action: ChatAction): void { + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action }); + } + + function toolStart(turnId: string, toolCallId: string, toolName: string, contributor?: ToolCallContributor): void { + fire({ type: ActionType.ChatToolCallStart, turnId, toolCallId, toolName, displayName: toolName, contributor }); + } + + function toolComplete(turnId: string, toolCallId: string, result: ToolCallResult): void { + fire({ type: ActionType.ChatToolCallComplete, turnId, toolCallId, result }); + } + + function toolEvents(): { eventName: string; data: Record }[] { + return telemetry.events + .filter(e => e.eventName === 'languageModelToolInvoked') + .map(e => { + const data = e.data as Record; + return { + eventName: e.eventName, + data: { ...data, invocationTimeMs: typeof data.invocationTimeMs === 'number' && data.invocationTimeMs >= 0 }, + }; + }); + } + + setup(() => { + agent = new MockAgent(); + disposables.add(toDisposable(() => agent.dispose())); + stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const agentList = observableValue('agents', [agent]); + telemetry = new CapturingTelemetryService(); + + const logService = new NullLogService(); + const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const telemetryService = disposables.add(new AgentHostTelemetryService(telemetry)); + const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( + [ILogService, logService], + [IAgentConfigurationService, configService], + [IAgentHostChangesetService, new FakeChangesetService()], + [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], + [ITelemetryService, telemetryService], + ), /*strict*/ true)); + sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + onTurnComplete: () => { }, + })); + disposables.add(sideEffects.registerProgressListener(agent)); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('emits a successful agent-host tool invocation', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-1', 'bash'); + toolComplete('turn-1', 'tc-1', { success: true, pastTenseMessage: 'ran' }); + + assert.deepStrictEqual(toolEvents(), [{ + eventName: 'languageModelToolInvoked', + data: { + result: 'success', + chatSessionId: sessionKey, + toolId: 'bash', + toolExtensionId: undefined, + toolSourceKind: 'agentHost', + provider: 'mock', + invocationTimeMs: true, + }, + }]); + }); + + test('emits userCancelled with mcp source kind for a denied mcp tool', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-mcp', 'lookup', { kind: ToolCallContributorKind.MCP, customizationId: 'c1' }); + toolComplete('turn-1', 'tc-mcp', { success: false, pastTenseMessage: 'denied', error: { message: 'denied', code: 'denied' } }); + + assert.deepStrictEqual(toolEvents(), [{ + eventName: 'languageModelToolInvoked', + data: { + result: 'userCancelled', + chatSessionId: sessionKey, + toolId: 'lookup', + toolExtensionId: undefined, + toolSourceKind: 'mcp', + provider: 'mock', + invocationTimeMs: true, + }, + }]); + }); + + test('emits client source kind for a client-contributed tool', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-client', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + toolComplete('turn-1', 'tc-client', { success: true, pastTenseMessage: 'ran tests' }); + + assert.deepStrictEqual(toolEvents(), [{ + eventName: 'languageModelToolInvoked', + data: { + result: 'success', + chatSessionId: sessionKey, + toolId: 'run_tests', + toolExtensionId: undefined, + toolSourceKind: 'client', + provider: 'mock', + invocationTimeMs: true, + }, + }]); + }); + + test('emits error for a failure without a cancellation code', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-err', 'bash'); + toolComplete('turn-1', 'tc-err', { success: false, pastTenseMessage: 'boom', error: { message: 'boom' } }); + + assert.strictEqual(toolEvents()[0].data.result, 'error'); + }); + + test('emits a single event when a tool completion is duplicated', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-dup', 'bash'); + toolComplete('turn-1', 'tc-dup', { success: true, pastTenseMessage: 'ran' }); + toolComplete('turn-1', 'tc-dup', { success: true, pastTenseMessage: 'ran' }); + + assert.strictEqual(toolEvents().length, 1); + }); + + test('drops an in-flight tool call when the turn is cancelled before completion', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-inflight', 'bash'); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + // A late completion after the turn ended must not emit: the start entry + // was cleared, so there is no timing to report. + toolComplete('turn-1', 'tc-inflight', { success: true, pastTenseMessage: 'ran' }); + + assert.strictEqual(toolEvents().length, 0); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts new file mode 100644 index 0000000000000..02e517098dfcb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; +import { deriveToolInvokedResult, toolSourceKindFromContributor } from '../../node/agentHostToolCallTracker.js'; + +function result(success: boolean, code?: string): ToolCallResult { + return { + success, + pastTenseMessage: 'done', + error: code !== undefined || !success ? { message: 'failed', code } : undefined, + }; +} + +suite('agentHostToolCallTracker', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('deriveToolInvokedResult maps success/cancel/error buckets', () => { + const actual = { + success: deriveToolInvokedResult(result(true)), + denied: deriveToolInvokedResult(result(false, 'denied')), + rejected: deriveToolInvokedResult(result(false, 'rejected')), + cancelled: deriveToolInvokedResult(result(false, 'cancelled')), + otherCode: deriveToolInvokedResult(result(false, 'timeout')), + noCode: deriveToolInvokedResult(result(false)), + }; + assert.deepStrictEqual(actual, { + success: 'success', + denied: 'userCancelled', + rejected: 'userCancelled', + cancelled: 'userCancelled', + otherCode: 'error', + noCode: 'error', + }); + }); + + test('toolSourceKindFromContributor maps contributor kind', () => { + const mcp: ToolCallContributor = { kind: ToolCallContributorKind.MCP, customizationId: 'c1' }; + const client: ToolCallContributor = { kind: ToolCallContributorKind.Client, clientId: 'x' }; + const actual = { + none: toolSourceKindFromContributor(undefined), + mcp: toolSourceKindFromContributor(mcp), + client: toolSourceKindFromContributor(client), + }; + assert.deepStrictEqual(actual, { + none: 'agentHost', + mcp: 'mcp', + client: 'client', + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index d3f01542c5f5f..93f0ddf251473 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; @@ -13,7 +14,7 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; -import { MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -34,6 +35,7 @@ class FakeChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys() { return undefined; } computeListEntryChanges() { return undefined; } + refreshChangesetCatalog(): void { } refreshBranchChangeset(): void { } refreshSessionChangeset(): void { } onWorkingDirectoryAvailable(): void { } @@ -85,6 +87,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const sessionUri = AgentSession.uri('mock', 'session-1'); const sessionKey = sessionUri.toString(); + const defaultChatUri = buildDefaultChatUri(sessionUri); function setupSession(): void { stateManager.createSession({ @@ -92,19 +95,12 @@ suite('AgentSideEffects — turn tracker telemetry', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), }); stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); } - function setModel(id: string): void { - stateManager.dispatchServerAction(sessionKey, { - type: ActionType.SessionModelChanged, - model: { id }, - }); - } - function setAutoApprove(level: string): void { // Establish config on the authoritative session state via the state // manager API. Mutating the object returned by `getSessionState` would @@ -122,22 +118,22 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); } - function startTurn(turnId: string, text = 'hello'): void { + function startTurn(turnId: string, text = 'hello', modelId?: string): void { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId, - message: { text, origin: { kind: MessageKind.User } }, + message: { text, origin: { kind: MessageKind.User }, model: modelId ? { id: modelId } : undefined }, }; // Dispatch into the state manager so `getActiveTurnId` returns the // active turn (the progress-listener path relies on this) and then // invoke `handleAction` so the side-effect (which calls // `agent.sendMessage` and `turnTracker.turnStarted`) runs. - stateManager.dispatchClientAction(sessionKey, action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionKey, action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); } function fire(action: ChatAction): void { - agent.fireProgress({ kind: 'action', session: sessionUri, action }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action }); } function completedEvents(): { eventName: string; data: unknown }[] { @@ -179,9 +175,8 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits turnCompleted with timing, model and permissionLevel on success', () => { setupSession(); - setModel('gpt-5.5'); setAutoApprove('autopilot'); - startTurn('turn-1'); + startTurn('turn-1', 'hello', 'gpt-5.5'); fire({ type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'p1', content: 'hi' } }); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1' }); @@ -274,7 +269,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { setupSession(); startTurn('turn-1'); - sideEffects.handleAction(sessionKey, { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); @@ -309,8 +304,8 @@ suite('AgentSideEffects — turn tracker telemetry', () => { id: 'q-err', message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionKey, setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionKey, setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); await new Promise(r => setTimeout(r, 10)); @@ -326,7 +321,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { setupSession(); startTurn('turn-1'); - sideEffects.handleAction(sessionKey, { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); diff --git a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts index e41f93800ce09..6daa264734deb 100644 --- a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts @@ -19,7 +19,7 @@ import type { IFileService } from '../../../files/common/files.js'; import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { RequestService } from '../../../request/node/requestService.js'; -import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage } from '../../node/agentSdkDownloader.js'; +import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage, type IAgentSdkDownloadProgress } from '../../node/agentSdkDownloader.js'; import { ClaudeSdkPackage } from '../../node/claude/claudeAgentSdkService.js'; import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; import type { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -128,7 +128,7 @@ suite('resolveSdkTarget', () => { ensureNoDisposablesAreLeakedInTestSuite(); function fakePkg(hasSeparateMuslLinuxPackage: boolean): IAgentSdkPackage { - return { id: 'test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; + return { id: 'test', displayName: 'Test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; } test('returns - for supported (platform, arch)', () => { @@ -239,13 +239,13 @@ suite('AgentSdkDownloader', () => { version: productConfig?.version ?? '1.0.0', urlTemplate: productConfig?.urlTemplate ?? `http://127.0.0.1:${server.port}/sdk-{sdkTarget}.tgz`, }; - return new AgentSdkDownloader( + return disposables.add(new AgentSdkDownloader( makeEnvService(userDataPath), makeProductService(config), makeRequestService(disposables), makeFileService(disposables), new NullLogService(), - ); + )); } test('isAvailable: false when no env override and no product config', () => { @@ -280,6 +280,32 @@ suite('AgentSdkDownloader', () => { assert.ok(fs.existsSync(path.join(root, '.complete'))); }); + test('loadSdkRoot: reports monotonic download progress ending at totalBytes', async () => { + const downloader = makeDownloader(); + const samples: IAgentSdkDownloadProgress[] = []; + disposables.add(downloader.onDidDownloadProgress(p => samples.push(p))); + + await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); + + const tarballSize = (await fsp.stat(fixture.tarballPath)).size; + // One `started`, ≥1 `progress`, one terminal `completed`, all sharing a + // single downloadId and carrying the brand display name. + assert.ok(samples.length >= 2, 'expected at least a started and a completed frame'); + assert.strictEqual(samples[0].phase, 'started'); + const completed = samples[samples.length - 1]; + assert.strictEqual(completed.phase, 'completed'); + assert.ok(samples.every(s => s.downloadId === samples[0].downloadId), 'all frames share one downloadId'); + assert.ok(samples.every(s => s.displayName === 'Claude'), 'all frames carry the brand display name'); + + // receivedBytes is monotonically non-decreasing and reaches the total + // reported via Content-Length. + for (let i = 1; i < samples.length; i++) { + assert.ok(samples[i].receivedBytes >= samples[i - 1].receivedBytes, 'receivedBytes must be monotonic'); + } + assert.strictEqual(completed.totalBytes, tarballSize); + assert.strictEqual(completed.receivedBytes, tarballSize); + }); + test('loadSdkRoot: cache hit returns immediately without re-downloading', async () => { const downloader = makeDownloader(); await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 387d4f4e3ce1d..d54a83a24e5df 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9,7 +9,7 @@ import type { CCAModel } from '@vscode/copilot-api'; import { mkdtempSync, readFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -22,11 +22,11 @@ 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 } from '../../common/agentService.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IRestoredSubagentSession, SubagentChatSignal, type IAgentChatDataChange, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionResult, type IAgentLegacyChat, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseSubagentSessionUri, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseChatUri, parseSubagentSessionUri, ChatOriginKind, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -79,6 +79,7 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -149,7 +150,7 @@ suite('AgentService (node dispatcher)', () => { // Start a turn so there's an active turn to map events to service.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -158,7 +159,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(service.onDidAction(e => envelopes.push(e))); copilotAgent.fireProgress({ - kind: 'action', session, + kind: 'action', resource: URI.parse(buildDefaultChatUri(session.toString())), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hello' } }, }); assert.ok(envelopes.some(e => e.action.type === ActionType.ChatResponsePart)); @@ -301,12 +302,12 @@ suite('AgentService (node dispatcher)', () => { })); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Please help me fix the TypeScript compile errors', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); - await waitForCondition(() => svc.stateManager.getSessionState(session.toString())?.summary.title === 'Fix TypeScript compile errors', 'generated title should be applied'); + await waitForCondition(() => svc.stateManager.getSessionState(session.toString())?.title === 'Fix TypeScript compile errors', 'generated title should be applied'); await waitForCondition(async () => await db.getMetadata('customTitle') !== undefined, 'generated title should be persisted'); assert.deepStrictEqual({ @@ -328,7 +329,7 @@ suite('AgentService (node dispatcher)', () => { const { svc, session, db } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Explain workspace search indexing', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -337,7 +338,7 @@ suite('AgentService (node dispatcher)', () => { await Promise.resolve(); assert.deepStrictEqual({ - title: svc.stateManager.getSessionState(session.toString())?.summary.title, + title: svc.stateManager.getSessionState(session.toString())?.title, persistedTitle: await db.getMetadata('customTitle'), }, { title: 'Explain workspace search indexing', @@ -352,7 +353,7 @@ suite('AgentService (node dispatcher)', () => { const { svc, session, db } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Create tests for terminal persistence', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -367,7 +368,7 @@ suite('AgentService (node dispatcher)', () => { await waitForCondition(async () => await db.getMetadata('customTitle') === 'Manual title', 'manual title should be persisted'); assert.deepStrictEqual({ - title: svc.stateManager.getSessionState(session.toString())?.summary.title, + title: svc.stateManager.getSessionState(session.toString())?.title, persistedTitle: await db.getMetadata('customTitle'), }, { title: 'Manual title', @@ -382,7 +383,7 @@ suite('AgentService (node dispatcher)', () => { const { svc, session, db } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Investigate flaky terminal tests', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -403,23 +404,27 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('does not generate an AI title for forked sessions with an existing title', async () => { + test('generates an AI title for forked sessions from the forked chat', async () => { const copilotApiService = new TestCopilotApiService(); copilotApiService.response = 'Source generated title'; const { svc, session: sourceSession } = await setupTitleGeneration(copilotApiService); svc.dispatchAction( - sourceSession.toString(), + buildDefaultChatUri(sourceSession.toString()), { type: ActionType.ChatTurnStarted, turnId: 'source-turn', message: { text: 'Seed fork title', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); - await waitForCondition(() => svc.stateManager.getSessionState(sourceSession.toString())?.summary.title === 'Source generated title', 'source generated title should be applied'); + await waitForCondition(() => svc.stateManager.getSessionState(sourceSession.toString())?.title === 'Source generated title', 'source generated title should be applied'); svc.dispatchAction( - sourceSession.toString(), + buildDefaultChatUri(sourceSession.toString()), { type: ActionType.ChatTurnComplete, turnId: 'source-turn' }, 'test-client', 2, ); await waitForCondition(() => (svc.stateManager.getSessionState(sourceSession.toString())?.turns.length ?? 0) === 1, 'source turn should be complete before forking'); + + // The fork inherits a `Forked: …` placeholder, then regenerates a + // content-derived title from the copied chat. + copilotApiService.response = 'Forked branch title'; const forkedSession = await svc.createSession({ provider: 'copilot', fork: { @@ -428,19 +433,18 @@ suite('AgentService (node dispatcher)', () => { turnId: 'source-turn', }, }); + await waitForCondition(() => svc.stateManager.getSessionState(forkedSession.toString())?.title === 'Forked branch title', 'forked session should get a content-generated title'); - svc.dispatchAction( - forkedSession.toString(), - { type: ActionType.ChatTurnStarted, turnId: 'fork-turn-1', message: { text: 'Continue from the fork', origin: { kind: MessageKind.User } } }, - 'test-client', 3, - ); - + const forkedCall = copilotApiService.utilityCalls[copilotApiService.utilityCalls.length - 1]; + const userMessage = forkedCall.request.messages.find(message => message.role === 'user')?.content ?? ''; assert.deepStrictEqual({ - title: svc.stateManager.getSessionState(forkedSession.toString())?.summary.title, + title: svc.stateManager.getSessionState(forkedSession.toString())?.title, utilityCalls: copilotApiService.utilityCalls.length, + includesForkedChat: userMessage.includes('Seed fork title'), }, { - title: 'Forked: Source generated title', - utilityCalls: 1, + title: 'Forked branch title', + utilityCalls: 2, + includesForkedChat: true, }); }); }); @@ -483,7 +487,7 @@ suite('AgentService (node dispatcher)', () => { async function dispatchTurnAndWait(svc: AgentService, agent: MockAgent, session: URI, attachments: MessageResourceAttachment[] | { type: MessageAttachmentKind.EmbeddedResource; label: string; data: string; contentType: string; displayKind?: string }[]): Promise { svc.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', @@ -795,6 +799,48 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions[0].summary, 'My Custom Title'); }); + test('listSessions overlays the AH-owned workspaceless marker for any agent', async () => { + // The AH service owns `agentHost.workspaceless` in the central session + // database and overlays it onto every agent's summary `_meta` — so an + // agent that persists/re-emits nothing itself still restores as a quick + // chat. Pre-seed the AH key with no agent-side re-emit. + const db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadata('agentHost.workspaceless', 'true'); + + const sessionId = 'test-session-workspaceless'; + const sessionUri = AgentSession.uri('copilot', sessionId); + + const sessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + openDatabase: (): IReference => ({ + object: db, + dispose: () => { }, + }), + tryOpenDatabase: async (): Promise | undefined> => ({ + object: db, + dispose: () => { }, + }), + deleteSessionData: async () => { }, + onWillDeleteSessionData: Event.None, + cleanupOrphanedData: async () => { }, + whenIdle: async () => { }, + }; + + // The agent returns the session with NO `_meta.workspaceless` of its own. + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); + + const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + + const sessions = await svc.listSessions(); + assert.strictEqual(sessions.length, 1); + assert.deepStrictEqual(sessions[0]._meta, { workspaceless: true }); + }); + test('listSessions uses SDK title when no custom title exists', async () => { service.registerProvider(copilotAgent); copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' }; @@ -821,8 +867,8 @@ suite('AgentService (node dispatcher)', () => { provider: 'subagent', title: 'Explore', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), }, [], ); @@ -879,7 +925,7 @@ suite('AgentService (node dispatcher)', () => { // materialization completes), the session must stay visible so // renderer-side caches don't evict the in-flight session. service.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -895,7 +941,7 @@ suite('AgentService (node dispatcher)', () => { // listSessions refresh in this window would evict the just-finished // session, reintroducing #321269's sibling eviction bug). service.dispatchAction( - session.toString(), + buildDefaultChatUri(session.toString()), { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, 'test-client', 2, ); @@ -1245,7 +1291,6 @@ suite('AgentService (node dispatcher)', () => { const calls: string[] = []; const gitService = { _serviceBrand: undefined, - isInsideWorkTree: async () => true, getCurrentBranch: async () => undefined, getDefaultBranch: async () => undefined, getBranches: async () => [], @@ -1278,7 +1323,9 @@ suite('AgentService (node dispatcher)', () => { agent.sessionMetadataOverrides = { workingDirectory }; localService.registerProvider(agent); - const session = await localService.createSession({ provider: 'copilot' }); + // A normal session passes an input workingDirectory, so it is not + // inferred workspace-less; `_meta` carries only the git overlay. + const session = await localService.createSession({ provider: 'copilot', workingDirectory }); // _attachGitState is fire-and-forget; drain microtasks until the // git service's promise has resolved and setSessionMeta has run. @@ -1337,10 +1384,9 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('createSession skips git overlay when no working directory or no git state', async () => { + test('createSession infers workspace-less (and skips git overlay) when no working directory', async () => { const gitService = { _serviceBrand: undefined, - isInsideWorkTree: async () => false, getCurrentBranch: async () => undefined, getDefaultBranch: async () => undefined, getBranches: async () => [], @@ -1379,7 +1425,9 @@ suite('AgentService (node dispatcher)', () => { const sessions = await localService.listSessions(); assert.strictEqual(sessions.length, 1); - assert.strictEqual(localService.stateManager.getSessionState(session.toString())?._meta, undefined); + // No input workingDirectory → inferred workspace-less (tagged), and no + // git overlay because there is no working directory to probe. + assert.deepStrictEqual(localService.stateManager.getSessionState(session.toString())?._meta, { workspaceless: true }); }); test.skip('createSession strips git-only catalogue entries for non-git working directory', async () => { @@ -1473,51 +1521,61 @@ suite('AgentService (node dispatcher)', () => { ]); }); - test('subscribe lazily attaches git state when an existing session has no _meta.git', async () => { + test('subscribe lazily attaches git state when an existing session has no _meta.git', () => { // Regression test: previously AgentService was constructed without - // a git service, so _attachGitState always bailed and `_meta.git` + // a git service, so the git probe always bailed and `_meta.git` // was never populated. This test ensures the lazy-fire path on // subscribe() actually invokes the git service and writes git // state into the session's `_meta`. - const workingDirectory = URI.file('/workspace/repo'); - const gitState = { - hasGitHubRemote: false, - branchName: 'feature/lazy', - baseBranchName: 'main', - upstreamBranchName: undefined, - incomingChanges: 0, - outgoingChanges: 0, - uncommittedChanges: 0, - }; - const calls: string[] = []; - const gitService = createNoopGitService(); - gitService.getSessionGitState = async (uri: URI) => { calls.push(uri.fsPath); return gitState; }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; - localService.registerProvider(agent); - - // Seed a session and clear its _meta so subscribe must lazily - // recompute git state. - const session = await localService.createSession({ provider: 'copilot' }); - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } - localService.stateManager.setSessionMeta(session.toString(), undefined); - calls.length = 0; + // + // subscribe() kicks off the git-state refresh as fire-and-forget + // (it does not await it), so the test must yield to let that async + // work run before asserting. Fake timers are used because the + // refresh is rate-limited (it only settles after a delay). + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const workingDirectory = URI.file('/workspace/repo'); + const gitState = { + hasGitHubRemote: false, + branchName: 'feature/lazy', + baseBranchName: 'main', + upstreamBranchName: undefined, + incomingChanges: 0, + outgoingChanges: 0, + uncommittedChanges: 0, + }; + const calls: string[] = []; + const gitService = createNoopGitService(); + gitService.getSessionGitState = async (uri: URI) => { calls.push(uri.fsPath); return gitState; }; + const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.resolvedWorkingDirectory = workingDirectory; + agent.sessionMetadataOverrides = { workingDirectory }; + localService.registerProvider(agent); + + // Seed a session and clear its _meta so subscribe must lazily + // recompute git state. A microtask drain lets the + // createSession-triggered refresh record its call so we can + // reset the probes to a clean baseline. + const session = await localService.createSession({ provider: 'copilot' }); + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + localService.stateManager.setSessionMeta(session.toString(), undefined); + calls.length = 0; - await localService.subscribe(session, 'client-1'); - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } + // subscribe fires the git-state refresh without awaiting it, so + // advance time to let that fire-and-forget refresh run and write + // _meta.git. + await localService.subscribe(session, 'client-1'); + await timeout(5_000); - assert.deepStrictEqual(calls, [workingDirectory.fsPath]); - assert.deepStrictEqual( - localService.stateManager.getSessionState(session.toString())?._meta, - { git: gitState }, - ); + assert.deepStrictEqual(calls, [workingDirectory.fsPath]); + assert.deepStrictEqual( + localService.stateManager.getSessionState(session.toString())?._meta, + { git: gitState }, + ); + }); }); test('subscribe to a registered session changeset URI returns a changeset snapshot', async () => { @@ -1583,11 +1641,11 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot', activeClient }); assert.deepStrictEqual({ - activeClient: service.stateManager.getSessionState(session.toString())?.activeClient, - dispatchedActiveClientChanged: envelopes.some(e => e.action.type === ActionType.SessionActiveClientChanged), + activeClients: service.stateManager.getSessionState(session.toString())?.activeClients, + dispatchedActiveClientSet: envelopes.some(e => e.action.type === ActionType.SessionActiveClientSet), }, { - activeClient, - dispatchedActiveClientChanged: false, + activeClients: [activeClient], + dispatchedActiveClientSet: false, }); }); @@ -1596,7 +1654,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot' }); - assert.strictEqual(service.stateManager.getSessionState(session.toString())?.activeClient, undefined); + assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.activeClients, []); }); }); @@ -1739,6 +1797,33 @@ suite('AgentService (node dispatcher)', () => { suite('restoreSession', () => { + async function waitForDraft(db: TestSessionDatabase, chat: URI, expected: unknown): Promise { + for (let i = 0; i < 20; i++) { + if (JSON.stringify(await db.getChatDraft(chat)) === JSON.stringify(expected)) { + return; + } + await new Promise(resolve => setTimeout(resolve, 5)); + } + assert.deepStrictEqual(await db.getChatDraft(chat), expected); + } + + test('restores the AH-owned workspaceless marker onto the summary _meta for any agent', async () => { + // The workspace-less marker is owned by the AH service and overlaid on + // restore from the central session DB — the agent (MockAgent) re-emits + // nothing itself, yet the restored session still carries the tag. + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + copilotAgent.sessionMessages = []; + await db.setMetadata('agentHost.workspaceless', 'true'); + + await localService.restoreSession(sessionResource); + + assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?._meta, { workspaceless: true }); + }); + test('restores a session with message history', async () => { service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); @@ -1763,6 +1848,66 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state!.turns[0].state, TurnState.Complete); }); + test('restores the default chat\'s independently-renamed title', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + copilotAgent.sessionMessages = []; + + // The host persists an independent default-chat rename under this key; + // restore must seed it back or the main chat tab reverts to the session title. + const defaultChatUri = buildDefaultChatUri(sessionResource.toString()); + await db.setMetadata(`customChatTitle:${defaultChatUri}`, 'Renamed Default Chat'); + + await localService.restoreSession(sessionResource); + + const state = localService.stateManager.getSessionState(sessionResource.toString()); + assert.strictEqual(state?.chats.find(c => c.resource === defaultChatUri)?.title, 'Renamed Default Chat'); + }); + + test('persists chat drafts to session metadata', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + const draft = { + text: 'draft text', + origin: { kind: MessageKind.User }, + model: { id: 'opus-4.7' }, + agent: { uri: 'agent://reviewer' }, + }; + + localService.dispatchAction(buildDefaultChatUri(session.toString()), { + type: ActionType.ChatDraftChanged, + draft, + }, 'test-client', 1); + + await waitForDraft(db, URI.parse(buildDefaultChatUri(session.toString())), draft); + }); + + test('restores chat drafts from session metadata', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const { session } = await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + const draft = { + text: 'restored draft', + origin: { kind: MessageKind.User }, + model: { id: 'opus-4.7' }, + agent: { uri: 'agent://reviewer' }, + }; + await db.setChatDraft(URI.parse(buildDefaultChatUri(sessionResource.toString())), draft); + (copilotAgent as MockAgent & { getChatDraft(chat: URI): Promise }).getChatDraft = chat => db.getChatDraft(chat) as Promise; + copilotAgent.sessionMessages = []; + + await localService.restoreSession(sessionResource); + + assert.deepStrictEqual(localService.stateManager.getSessionState(session.toString())?.draft, draft); + }); + test('restores a session with tool calls', async () => { service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); @@ -2131,6 +2276,60 @@ suite('AgentService (node dispatcher)', () => { assert.ok(mdParts.length > 0, 'Should have markdown content'); }); + test('eagerly registers subagent child sessions during parent restore', async () => { + // An agent that surfaces its subagent children from the parent's + // reconstructed history, exercising the eager-registration path. + class EagerSubagentMockAgent extends MockAgent { + async getSubagentSessions(session: URI): Promise { + if (parseSubagentSessionUri(session)) { + return []; + } + const parent = session.toString(); + const out: IRestoredSubagentSession[] = []; + const seen = new Set(); + for (const rec of this.sessionMessages) { + if (rec.type === 'subagent_started' && !seen.has(rec.toolCallId)) { + seen.add(rec.toolCallId); + const childUri = buildSubagentSessionUri(parent, rec.toolCallId); + const turns = await this.getSessionMessages(URI.parse(childUri)); + if (turns.length > 0) { + out.push({ resource: URI.parse(childUri), toolCallId: rec.toolCallId, title: rec.agentDisplayName, turns }); + } + } + } + return out; + } + } + + const agent = new EagerSubagentMockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + service.registerProvider(agent); + const { session } = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session; + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] }, + { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' }, + { type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' }, + { type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } }, + ]; + + await service.restoreSession(sessionResource); + + // The subagent child state must already exist WITHOUT any client + // subscribing to it: parent restore registered it eagerly. + const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'); + const childState = service.stateManager.getSessionState(childSessionUri); + assert.ok(childState, 'subagent child should be eagerly registered during parent restore'); + assert.strictEqual(childState!.turns.length, 1, 'child should have its reconstructed turn'); + const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'child should contain the inner tool call'); + }); + test('inner assistant messages from subagent route via envelope agentId (fixture)', async () => { // Regression for the SDK migration away from the deprecated // `data.parentToolCallId` to the envelope-level `agentId`. Newer @@ -2250,7 +2449,7 @@ suite('AgentService (node dispatcher)', () => { // scheme fallback instead of throwing `no provider for session`. const created: { session: string; chat: string }[] = []; class MultiChatAgent extends MockAgent { - async createChat(session: URI, chat: URI): Promise { + override async createChat(session: URI, chat: URI): Promise { created.push({ session: session.toString(), chat: chat.toString() }); } } @@ -2276,7 +2475,7 @@ suite('AgentService (node dispatcher)', () => { test('routes a tracked session and registers the chat with its title in the catalog', async () => { class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI): Promise { } + override async createChat(_session: URI, _chat: URI): Promise { } } const agent = disposables.add(new MultiChatAgent('copilot')); service.registerProvider(agent); @@ -2292,10 +2491,10 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('creates the backing conversation before registering the chat in the catalog', async () => { + test('creates the backing chat before registering the chat in the catalog', async () => { let catalogHadChatDuringCreate: boolean | undefined; class MultiChatAgent extends MockAgent { - async createChat(session: URI, chat: URI): Promise { + override async createChat(session: URI, chat: URI): Promise { const state = service.stateManager.getSessionState(session.toString()); catalogHadChatDuringCreate = !!state?.chats.some(c => c.resource.toString() === chat.toString()); } @@ -2321,11 +2520,11 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('disposeChat removes the chat from the catalog and tears down the conversation', async () => { + test('disposeChat removes the chat from the catalog and tears down the chat', async () => { const disposed: string[] = []; class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI): Promise { } - async disposeChat(_session: URI, chat: URI): Promise { + override async createChat(_session: URI, _chat: URI): Promise { } + override async disposeChat(_session: URI, chat: URI): Promise { disposed.push(chat.toString()); } } @@ -2347,14 +2546,538 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('restoreSession re-registers persisted peer chats with their history', async () => { + test('restoreSession preserves peer chat catalog order regardless of load timing', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI): Promise { } + override async getSessionMessages(session: URI): Promise { + // Resolve in the reverse of catalog order so a resolution-order + // append would scramble the catalog; the restore must keep a,b,c. + const delays: Record = { 'peer-a': 30, 'peer-b': 15, 'peer-c': 0 }; + await timeout(delays[parseChatUri(session)?.chatId ?? ''] ?? 0); + return []; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // Seed the orchestrator catalog in a,b,c order via createChat. + await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-a'))); + await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-b'))); + await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-c'))); + + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + const peerChatIds = (state?.chats ?? []) + .map(c => parseChatUri(c.resource)?.chatId) + .filter((id): id is string => !!id && id.startsWith('peer-')); + assert.deepStrictEqual(peerChatIds, ['peer-a', 'peer-b', 'peer-c']); + }); + + test('fork seeds the new chat with remapped source turns and forwards fork to the provider', async () => { + let receivedFork: IAgentCreateChatForkSource | undefined; + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise { + receivedFork = options?.fork; + } + } + const agent = disposables.add(new MultiChatAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + // Seed the source (default) chat with two turns and a title. + const sourceTurns: Turn[] = [ + { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + { id: 't2', state: TurnState.Complete, message: { text: 'second', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]; + service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); + service.stateManager.updateChatTitle(session.toString(), buildDefaultChatUri(session.toString()), 'My Session'); + + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); + + const newChatState = service.stateManager.getChatState(chatUri.toString()); + const newTurnIds = newChatState?.turns.map(t => t.id) ?? []; + assert.deepStrictEqual({ + forkSource: receivedFork?.source.toString(), + forkTurnId: receivedFork?.turnId, + mappingSize: receivedFork?.turnIdMapping?.size, + mappedFromT1: receivedFork?.turnIdMapping?.get('t1'), + newTurnCount: newTurnIds.length, + newTurnIsRemapped: newTurnIds[0] !== undefined && newTurnIds[0] !== 't1', + title: newChatState?.title, + }, { + forkSource: session.toString(), + forkTurnId: 't1', + mappingSize: 1, + mappedFromT1: newTurnIds[0], + newTurnCount: 1, + newTurnIsRemapped: true, + title: 'Forked: My Session', + }); + }); + + test('fork with an unknown turn id drops the fork and seeds no turns', async () => { + let receivedFork: IAgentCreateChatForkSource | undefined; + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise { + receivedFork = options?.fork; + } + } + const agent = disposables.add(new MultiChatAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + const sourceTurns: Turn[] = [ + { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]; + service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); + + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await service.createChat(session, chatUri, { fork: { source: session, turnId: 'missing' } }); + + const newChatState = service.stateManager.getChatState(chatUri.toString()); + assert.deepStrictEqual({ + forkForwarded: receivedFork !== undefined, + newTurnCount: newChatState?.turns.length ?? 0, + }, { + forkForwarded: false, + newTurnCount: 0, + }); + }); + + test('a peer chat backing session is filtered out of listSessions and stays filtered across a restart', async () => { + // Per-session databases so the backing SDK session's marker is + // isolated from the parent session's own database. + const dbs = new Map(); + const dbFor = (session: URI): TestSessionDatabase => { + const key = session.toString(); + let db = dbs.get(key); + if (!db) { + db = new TestSessionDatabase(); + dbs.set(key, db); + } + return db; + }; + const perSessionDataService: ISessionDataService = { + ...createSessionDataService(), + openDatabase: (session: URI): IReference => ({ object: dbFor(session), dispose: () => { } }), + tryOpenDatabase: async (session: URI): Promise | undefined> => ({ object: dbFor(session), dispose: () => { } }), + }; + + const backingSdkId = 'backing-sdk-id'; + const backingUri = AgentSession.uri('copilot', backingSdkId).toString(); + // A Claude-like agent whose peer-chat backing is a fresh SDK session + // it also enumerates from listSessions — the leak this fix suppresses. + class LeakyMultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI): Promise { + return { providerData: 'blob', backingSession: AgentSession.uri(this.id, backingSdkId) }; + } + override async listSessions(): Promise { + const base = await super.listSessions(); + return [...base, { session: AgentSession.uri(this.id, backingSdkId), startTime: Date.now(), modifiedTime: Date.now() }]; + } + } + + const agent = disposables.add(new LeakyMultiChatAgent('copilot')); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + const session = await svc.createSession({ provider: 'copilot' }); + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await svc.createChat(session, chatUri); + + const beforeRestart = await svc.listSessions(); + + // Simulate a host restart: a fresh service over the same persisted + // databases, with a fresh agent still leaking the backing session. + const restartAgent = disposables.add(new LeakyMultiChatAgent('copilot')); + const restarted = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + restarted.registerProvider(restartAgent); + const afterRestart = await restarted.listSessions(); + + assert.deepStrictEqual({ + leakedBeforeRestart: beforeRestart.map(s => s.session.toString()).includes(backingUri), + markerPersisted: await dbFor(AgentSession.uri('copilot', backingSdkId)).getMetadata('peerChatBacking'), + leakedAfterRestart: afterRestart.map(s => s.session.toString()).includes(backingUri), + }, { + leakedBeforeRestart: false, + markerPersisted: chatUri.toString(), + leakedAfterRestart: false, + }); + }); + }); + + // ---- chat surface routing (G-C1) ---------------------------- + + suite('chat surface routing', () => { + + /** + * An agent that exposes the chat surface AND the legacy + * `(session, chat?)` peer-chat methods, recording which path the + * orchestrator takes. + */ + class ChatSurfaceAgent extends MockAgent { + readonly sessionCreateCalls: URI[] = []; + readonly sessionDisposeCalls: URI[] = []; + readonly legacyCreateChatCalls: URI[] = []; + readonly chatCalls: { op: string; args: string[] }[] = []; + + override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise { + const result = await super.createSession(config); + this.sessionCreateCalls.push(result.session); + return result; + } + + override async disposeSession(session: URI): Promise { + this.sessionDisposeCalls.push(session); + await super.disposeSession(session); + } + + // The legacy peer-chat method is present too; it must NOT be used + // when the chats surface exists. + override async createChat(_session: URI, chat: URI): Promise { + this.legacyCreateChatCalls.push(chat); + } + + override readonly chats: IAgentChats = { + createChat: async (chat: URI, options?: IAgentCreateChatOptions) => { + const session = parseChatUri(chat)!.session; + this.chatCalls.push({ op: 'createChat', args: [session, chat.toString(), options?.title ?? ''] }); + return { providerData: 'pd' }; + }, + fork: async (chat: URI, source: IAgentCreateChatForkSource) => { + const session = parseChatUri(chat)!.session; + this.chatCalls.push({ op: 'fork', args: [session, chat.toString(), source.source.toString(), source.turnId] }); + return { providerData: 'pd-fork' }; + }, + disposeChat: async (chat: URI) => { + this.chatCalls.push({ op: 'disposeChat', args: [chat.toString()] }); + }, + sendMessage: async () => { }, + abort: async () => { }, + changeModel: async () => { }, + changeAgent: async () => { }, + getMessages: async (chat: URI) => { + this.chatCalls.push({ op: 'getMessages', args: [chat.toString()] }); + return []; + }, + }; + } + + test('createSession/createChat/disposeChat/disposeSession prefer the chat surface over legacy methods', async () => { + const agent = disposables.add(new ChatSurfaceAgent('copilot')); + service.registerProvider(agent); + + const session = await service.createSession({ provider: 'copilot' }); + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await service.createChat(session, chatUri, { title: 'Peer' }); + await service.disposeChat(session, chatUri); + await service.disposeSession(session); + + assert.deepStrictEqual({ + sessionCreate: agent.sessionCreateCalls.map(s => s.toString()), + sessionDispose: agent.sessionDisposeCalls.map(s => s.toString()), + legacyCreateChat: agent.legacyCreateChatCalls.length, + chatOps: agent.chatCalls.map(c => c.op), + createChatArgs: agent.chatCalls.find(c => c.op === 'createChat')?.args, + disposeChatArg: agent.chatCalls.find(c => c.op === 'disposeChat')?.args[0], + }, { + sessionCreate: [session.toString()], + sessionDispose: [session.toString()], + legacyCreateChat: 0, + chatOps: ['createChat', 'disposeChat'], + createChatArgs: [session.toString(), chatUri.toString(), 'Peer'], + disposeChatArg: chatUri.toString(), + }); + }); + + test('fork routes to chats.fork with the resolved source chat', async () => { + const agent = disposables.add(new ChatSurfaceAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + const sourceTurns: Turn[] = [ + { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]; + service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); + + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); + + const forkCall = agent.chatCalls.find(c => c.op === 'fork'); + assert.deepStrictEqual(forkCall?.args, [session.toString(), chatUri.toString(), session.toString(), 't1']); + }); + + test('restore reads the default chat via chats.getMessages on the default chat URI', async () => { + const agent = disposables.add(new ChatSurfaceAgent('copilot')); + service.registerProvider(agent); + const { session } = await agent.createSession(); + service.stateManager.deleteSession(session.toString()); + + await service.restoreSession(session); + + const getMessages = agent.chatCalls.filter(c => c.op === 'getMessages').map(c => c.args[0]); + assert.deepStrictEqual(getMessages, [buildDefaultChatUri(session)]); + }); + }); + + // ---- spawn channel routing (G-D1) ----------------------------------- + + suite('spawn channel routing', () => { + + /** + * An agent that exposes the first-class spawn membership channel, + * with a test hook to fire {@link IAgent.onDidSpawnChat}. + */ + class SpawnChannelAgent extends MockAgent { + private readonly _onDidSpawnChat = new Emitter(); + readonly onDidSpawnChat = this._onDidSpawnChat.event; + + fireSpawn(e: IAgentSpawnChatEvent): void { + this._onDidSpawnChat.fire(e); + } + + override dispose(): void { + this._onDidSpawnChat.dispose(); + super.dispose(); + } + } + + test('onDidSpawnChat adds the chat to the catalog with a Tool origin from its parent', async () => { + const agent = disposables.add(new SpawnChannelAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + const parentChat = URI.parse(buildDefaultChatUri(session.toString())); + const spawned = URI.parse(buildChatUri(session, 'spawned-1')); + agent.fireSpawn({ + session, + chat: spawned, + parent: { chat: parentChat, toolCallId: 'tc-task-1' }, + title: 'Explore', + }); + + const chatState = service.stateManager.getChatState(spawned.toString()); + const sessionChats = (service.stateManager.getSessionState(session.toString())?.chats ?? []).map(c => c.resource); + assert.deepStrictEqual({ + title: chatState?.title, + origin: chatState?.origin, + inCatalog: sessionChats.includes(spawned.toString()), + }, { + title: 'Explore', + origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tc-task-1' }, + inCatalog: true, + }); + }); + + test('onDidSpawnChat without a parent adds the chat with no tool origin', async () => { + const agent = disposables.add(new SpawnChannelAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + const spawned = URI.parse(buildChatUri(session, 'spawned-2')); + agent.fireSpawn({ session, chat: spawned }); + + const chatState = service.stateManager.getChatState(spawned.toString()); + assert.deepStrictEqual({ + origin: chatState?.origin, + inCatalog: chatState !== undefined, + }, { + origin: undefined, + inCatalog: true, + }); + }); + }); + + // ---- subagent membership sequencing (DR1: unified spawn channel) ---- + + suite('subagent membership sequencing', () => { + + /** Fires a parent turn on the session's default chat. */ + function startParentTurn(session: URI, turnId: string): void { + service.dispatchAction( + buildDefaultChatUri(session.toString()), + { type: ActionType.ChatTurnStarted, turnId, message: { text: 'go', origin: { kind: MessageKind.User } } }, + 'client-test', 1, + ); + } + + test('a subagent_started signal yields exactly one catalog entry with the parent origin, title, and a started turn', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + taskDescription: 'Review package.json structure', + }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + const chatState = service.stateManager.getChatState(subagentUri); + const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); + assert.deepStrictEqual({ + catalogEntries: matching.length, + title: chatState?.title, + origin: chatState?.origin, + interactivity: chatState?.interactivity, + hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + }, { + catalogEntries: 1, + // The concise per-task description names the tab (distinct even for + // two subagents of the same type), not the agent-type display name. + title: 'Review package.json structure', + origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' }, + interactivity: 'read-only', + hasStartedTurn: true, + }); + }); + + test('a subagent_started signal without a taskDescription falls back to the agent display name for the tab title', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + assert.strictEqual(service.stateManager.getChatState(subagentUri)?.title, 'Explore'); + }); + + test('membership stays a single entry when the agent also mirrors the subagent onto onDidSpawnChat, regardless of order', async () => { + // Mirror the real copilot/claude agents, which ALSO bridge their + // subagent signals onto onDidSpawnChat. The orchestrator's + // progress sequencer and the agent's spawn bridge both funnel to the + // idempotent _onChatSpawned, so the catalog must gain exactly + // one entry no matter which listener runs first. + class BridgingSubagentAgent extends MockAgent { + private readonly _onDidSpawnChat = new Emitter(); + readonly onDidSpawnChat = this._onDidSpawnChat.event; + private readonly _bridge = this.onDidSessionProgress(signal => { + const e = SubagentChatSignal.toSpawnEvent(signal); + if (e) { + this._onDidSpawnChat.fire(e); + } + }); + + override dispose(): void { + this._bridge.dispose(); + this._onDidSpawnChat.dispose(); + super.dispose(); + } + } + + const agent = new BridgingSubagentAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + agent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); + assert.deepStrictEqual({ + catalogEntries: matching.length, + origin: service.stateManager.getChatState(subagentUri)?.origin, + hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + }, { + catalogEntries: 1, + origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' }, + hasStartedTurn: true, + }); + }); + + test('an inner tool call arriving before subagent_started is buffered and drained onto the subagent chat', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + // Parent task tool starts. + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // Inner tool arrives BEFORE subagent_started (buffered). + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), parentToolCallId: 'tc-sub', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), parentToolCallId: 'tc-sub', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // subagent_started arrives and drains the buffer. + copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + const subState = service.stateManager.getSessionState(subagentUri); + const innerOnSubagent = subState?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'); + const innerOnParent = service.stateManager.getSessionState(session.toString())?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'); + assert.deepStrictEqual({ innerOnSubagent, innerOnParent }, { innerOnSubagent: true, innerOnParent: false }); + }); + + test('a subagent chat survives subagent_completed (stays live and subscribable, its turn completed)', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + assert.ok(service.stateManager.getChatState(subagentUri), 'precondition: subagent chat present after start'); + + copilotAgent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId: 'tc-sub' }); + + const stillInCatalog = (service.stateManager.getSessionState(session.toString())?.chats ?? []).some(c => c.resource === subagentUri); + assert.deepStrictEqual({ + hasChatState: service.stateManager.getChatState(subagentUri) !== undefined, + stillInCatalog, + hasActiveTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + }, { + hasChatState: true, + stillInCatalog: true, + hasActiveTurn: false, + }); + }); + }); + + // ---- peer-chat catalog persistence (B2: orchestrator-owned) --------- + + suite('peer chat catalog persistence', () => { + + /** Polls the persisted peer-chat catalog blob until it appears or times out. */ + async function readCatalog(db: TestSessionDatabase): Promise<{ uri: string; providerData?: string }[]> { + for (let i = 0; i < 50; i++) { + const raw = await db.getMetadata('peerChats'); + if (raw !== undefined) { + return JSON.parse(raw); + } + await timeout(0); + } + return []; + } + + test('createChat persists providerData; restore re-materializes from the orchestrator catalog before reading history', async () => { + const materializeOrder: { call: string; uri: string; providerData?: string }[] = []; class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI): Promise { } - async getChats(session: URI): Promise { - return [URI.parse(buildChatUri(session, 'peer-1'))]; + override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> { + return { providerData: 'blob-1' }; + } + async materializeChat(chat: URI, providerData: string | undefined): Promise { + materializeOrder.push({ call: 'materialize', uri: chat.toString(), providerData }); } override async getSessionMessages(session: URI): Promise { if (session.scheme === 'ahp-chat') { + materializeOrder.push({ call: 'getMessages', uri: session.toString() }); return [{ id: 'peer-turn-1', state: TurnState.Complete, @@ -2366,24 +3089,333 @@ suite('AgentService (node dispatcher)', () => { return []; } } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); - service.registerProvider(agent); - const { session } = await agent.createSession(); - service.stateManager.deleteSession(session.toString()); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); - await service.restoreSession(session); + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + await readCatalog(db); - const state = service.stateManager.getSessionState(session.toString()); - const peerUri = buildChatUri(session, 'peer-1'); - const peerChatState = service.stateManager.getChatState(URI.parse(peerUri).toString()); + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + const peerChatState = localService.stateManager.getChatState(peerUri.toString()); assert.deepStrictEqual({ - inCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri), + order: materializeOrder.map(o => o.call), + materializedWith: materializeOrder.find(o => o.call === 'materialize')?.providerData, + inCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri.toString()), + restoredProviderData: localService.stateManager.getChatProviderData(peerUri.toString()), peerTurnIds: peerChatState?.turns.map(t => t.id) ?? [], }, { + // The default chat is read first; peer materialize must precede + // the peer history read on restore. + order: ['getMessages', 'materialize', 'getMessages'], + materializedWith: 'blob-1', inCatalog: true, + restoredProviderData: 'blob-1', peerTurnIds: ['peer-turn-1'], }); }); + + test('onDidChangeChatData re-persists the updated providerData blob', async () => { + const onDidChangeChatData = disposables.add(new Emitter()); + class MultiChatAgent extends MockAgent { + readonly onDidChangeChatData = onDidChangeChatData.event; + override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> { + return { providerData: 'v1' }; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + const afterCreate = await readCatalog(db); + + onDidChangeChatData.fire({ chat: peerUri, providerData: 'v2' }); + // Wait for the re-persist write to flush. + let updated = afterCreate; + for (let i = 0; i < 50; i++) { + updated = await readCatalog(db); + if (updated.find(e => e.uri === peerUri.toString())?.providerData === 'v2') { + break; + } + await timeout(0); + } + + assert.deepStrictEqual({ + afterCreate: afterCreate.find(e => e.uri === peerUri.toString())?.providerData, + afterChange: updated.find(e => e.uri === peerUri.toString())?.providerData, + }, { + afterCreate: 'v1', + afterChange: 'v2', + }); + }); + + test('disposeChat removes the chat from the persisted catalog', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> { + return { providerData: 'blob-1' }; + } + override async disposeChat(_session: URI, _chat: URI): Promise { } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + const afterCreate = await readCatalog(db); + + await localService.disposeChat(session, peerUri); + let afterDispose = afterCreate; + for (let i = 0; i < 50; i++) { + afterDispose = await readCatalog(db); + if (!afterDispose.some(e => e.uri === peerUri.toString())) { + break; + } + await timeout(0); + } + + assert.deepStrictEqual({ + afterCreate: afterCreate.map(e => e.uri), + afterDispose: afterDispose.map(e => e.uri), + }, { + afterCreate: [peerUri.toString()], + afterDispose: [], + }); + }); + + // ---- BC1: one-time legacy `*.chats` migration on restore ---------- + + test('legacy *.chats with no peerChats catalog migrates once into the orchestrator catalog', async () => { + class LegacyAgent extends MockAgent { + listLegacyCallCount = 0; + override async createChat(): Promise { } + async materializeChat(): Promise { } + async listLegacyChats(session: URI): Promise { + this.listLegacyCallCount++; + return [ + { uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }, + { uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' }, + ]; + } + override async getSessionMessages(session: URI): Promise { + if (session.scheme === 'ahp-chat') { + return [{ + id: `${parseChatUri(session)?.chatId}-turn`, + state: TurnState.Complete, + message: { text: 'legacy hi', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }]; + } + return []; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // Seed a persisted title for one legacy chat so we can assert + // history + title are restored. + const legacyAUri = URI.parse(buildChatUri(session, 'legacy-a')); + const legacyBUri = URI.parse(buildChatUri(session, 'legacy-b')); + await db.setMetadata(`customChatTitle:${legacyAUri.toString()}`, 'Legacy A Title'); + + // No peerChats key exists (undefined catalog) -> migration runs. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalogAfterFirst = await readCatalog(db); + + // Second restore: catalog now present -> legacy read not consulted again. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const stateA = localService.stateManager.getChatState(legacyAUri.toString()); + const stateB = localService.stateManager.getChatState(legacyBUri.toString()); + assert.deepStrictEqual({ + legacyCalls: agent.listLegacyCallCount, + catalog: catalogAfterFirst.map(e => ({ uri: e.uri, providerData: e.providerData })), + aTitle: stateA?.title, + aTurns: stateA?.turns.map(t => t.id) ?? [], + aProviderData: localService.stateManager.getChatProviderData(legacyAUri.toString()), + bTurns: stateB?.turns.map(t => t.id) ?? [], + bProviderData: localService.stateManager.getChatProviderData(legacyBUri.toString()), + }, { + legacyCalls: 1, + catalog: [ + { uri: legacyAUri.toString(), providerData: 'lp-a' }, + { uri: legacyBUri.toString(), providerData: 'lp-b' }, + ], + aTitle: 'Legacy A Title', + aTurns: ['legacy-a-turn'], + aProviderData: 'lp-a', + bTurns: ['legacy-b-turn'], + bProviderData: 'lp-b', + }); + }); + + test('an empty ([]) peerChats catalog does not resurrect legacy chats', async () => { + class LegacyAgent extends MockAgent { + listLegacyCallCount = 0; + async listLegacyChats(session: URI): Promise { + this.listLegacyCallCount++; + return [{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }]; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // Known-empty catalog must be treated as "no peer chats", never migrated. + await db.setMetadata('peerChats', '[]'); + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + assert.deepStrictEqual({ + legacyCalls: agent.listLegacyCallCount, + peerChats: (state?.chats ?? []).map(c => parseChatUri(c.resource)?.chatId).filter(id => id !== 'default'), + }, { + legacyCalls: 0, + peerChats: [], + }); + }); + + test('a valid new-format peerChats catalog restores without consulting legacy chats', async () => { + class LegacyAgent extends MockAgent { + listLegacyCallCount = 0; + override async createChat(): Promise { + return { providerData: 'new-blob' }; + } + async materializeChat(): Promise { } + async listLegacyChats(session: URI): Promise { + this.listLegacyCallCount++; + return [{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }]; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + await readCatalog(db); + + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + assert.deepStrictEqual({ + legacyCalls: agent.listLegacyCallCount, + peerInCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri.toString()), + legacyInCatalog: state?.chats.some(c => parseChatUri(c.resource)?.chatId === 'legacy-a') ?? false, + }, { + legacyCalls: 0, + peerInCatalog: true, + legacyInCatalog: false, + }); + }); + // ---- RV-1: legacy migration persists the catalog atomically ---------- + + test('legacy migration persists the whole set in one write (never a subset, even across a re-restore)', async () => { + class LegacyAgent extends MockAgent { + override async createChat(): Promise { } + async materializeChat(): Promise { } + async listLegacyChats(session: URI): Promise { + return [ + { uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }, + { uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' }, + { uri: URI.parse(buildChatUri(session, 'legacy-c')), providerData: 'lp-c' }, + ]; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // Absent peerChats key => migration runs and must write the full set once. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalog = await readCatalog(db); + + const restoredIds = (localService.stateManager.getSessionState(session.toString())?.chats ?? []) + .map(c => parseChatUri(c.resource)?.chatId) + .filter(id => id !== 'default'); + assert.deepStrictEqual({ + catalogIds: catalog.map(e => parseChatUri(URI.parse(e.uri))?.chatId), + restoredIds, + }, { + catalogIds: ['legacy-a', 'legacy-b', 'legacy-c'], + restoredIds: ['legacy-a', 'legacy-b', 'legacy-c'], + }); + }); + + test('a rejected migration write leaves the catalog absent (not a subset) so migration re-runs', async () => { + class FailingCatalogDatabase extends TestSessionDatabase { + failPeerChatsWrites = 1; + override async setMetadata(key: string, value: string): Promise { + if (key === 'peerChats' && this.failPeerChatsWrites > 0) { + this.failPeerChatsWrites--; + throw new Error('simulated catalog write failure'); + } + return super.setMetadata(key, value); + } + } + class LegacyAgent extends MockAgent { + override async createChat(): Promise { } + async materializeChat(): Promise { } + async listLegacyChats(session: URI): Promise { + return [ + { uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }, + { uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' }, + ]; + } + } + const db = new FailingCatalogDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // First restore: the single catalog write is rejected. Because the write + // is all-or-nothing, the key must stay absent (never a proper subset). + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalogAfterFailedWrite = await db.getMetadata('peerChats'); + + // Second restore: catalog still absent => migration re-runs and now + // persists the complete set. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalog = await readCatalog(db); + + assert.deepStrictEqual({ + catalogAfterFailedWrite, + catalogIds: catalog.map(e => parseChatUri(URI.parse(e.uri))?.chatId), + }, { + catalogAfterFailedWrite: undefined, + catalogIds: ['legacy-a', 'legacy-b'], + }); + }); }); suite('subscriber refcount eviction', () => { @@ -2410,7 +3442,7 @@ suite('AgentService (node dispatcher)', () => { // when the refcount reaches zero, otherwise we'd drop live state // mid-response. service.dispatchAction( - sessionResource.toString(), + buildDefaultChatUri(sessionResource.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); @@ -2420,7 +3452,9 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); }); - test('a restored idle session is evicted when its last subscriber drops', async () => { + test('a restored idle session is NOT evicted when its last subscriber drops', async () => { + // Idle-session eviction is currently disabled; the cached state should + // remain in memory after the last subscriber drops. service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); @@ -2435,10 +3469,12 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'restored idle session should be evicted'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'restored idle session should stay cached while eviction is disabled'); }); - test('multiple subscribers keep a restored session alive until all drop', async () => { + test('restored session stays cached after all subscribers drop', async () => { + // With idle eviction disabled, the session state remains cached even + // after every subscriber drops. service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); @@ -2456,7 +3492,7 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'still subscribed by client-2'); service.unsubscribe(sessionResource, 'client-2'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'evicted after last subscriber'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'session stays cached after all subscribers drop while eviction is disabled'); }); test('subagent subscriber pins the parent session against eviction', async () => { @@ -2486,10 +3522,10 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent must stay while child is subscribed'); assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child still present'); - // Child drops — both can now be evicted + // Child drops — with idle eviction disabled, both stay cached. service.unsubscribe(childUri, 'client-child'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'parent evicted after subagent drops'); - assert.strictEqual(service.stateManager.getSessionState(childUri.toString()), undefined, 'child also evicted with parent'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent stays cached after subagent drops while eviction is disabled'); + assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child stays cached after subagent drops while eviction is disabled'); }); test('nested subagent subscriber pins ancestor session against eviction', async () => { @@ -2521,10 +3557,9 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(childUri.toString()), 'intermediate child still present'); }); - test('depth-2 subagent eviction evicts the root session state', async () => { - // Regression: when a depth-2 subagent URI unsubscribes the eviction - // must reach all the way to the root, not stop at the intermediate - // parent and leave root state cached indefinitely. + test('depth-2 subagent unsubscribe does NOT evict the root session state', async () => { + // Idle-session eviction is currently disabled, so the root state + // should remain cached after the depth-2 subscriber drops. service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); @@ -2542,7 +3577,7 @@ suite('AgentService (node dispatcher)', () => { service.addSubscriber(nestedUri, 'client-nested'); service.unsubscribe(nestedUri, 'client-nested'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'root state must be evicted when no subscribers remain'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'root state stays cached when no subscribers remain while eviction is disabled'); }); }); @@ -2718,12 +3753,12 @@ suite('AgentService (node dispatcher)', () => { const sessionResource = await service.createSession({ provider: 'copilot' }); service.addSubscriber(sessionResource, 'client-1'); service.dispatchAction( - sessionResource.toString(), + buildDefaultChatUri(sessionResource.toString()), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); service.dispatchAction( - sessionResource.toString(), + buildDefaultChatUri(sessionResource.toString()), { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, 'client-1', 2, ); @@ -3057,7 +4092,7 @@ suite('AgentService (node dispatcher)', () => { // The state manager should have the worktree path, not the source path const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString()); + assert.strictEqual(state?.workingDirectory, worktreeDir.toString()); }); test('createSession falls back to config working directory when agent does not resolve', async () => { @@ -3069,7 +4104,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir }); const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.summary.workingDirectory, sourceDir.toString()); + assert.strictEqual(state?.workingDirectory, sourceDir.toString()); }); test('restoreSession uses agent working directory in state', async () => { @@ -3088,7 +4123,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(session); const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString()); + assert.strictEqual(state?.workingDirectory, worktreeDir.toString()); }); }); @@ -3216,7 +4251,7 @@ suite('AgentService (node dispatcher)', () => { assertBackingChangesetsComputing(service.stateManager, sessionStr); // `markSessionPersisted` (called from `_onDidMaterializeSession`) - // re-spreads `state.summary`. A future change to that spread + // re-spreads flattened session metadata. A future change to that spread // could drop the catalogue or invalidate the backing snapshots; // the post-materialization re-assertion is what catches it. provisionalAgent.materialize(session, URI.file('/wd')); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 01a10fbcedb89..361bee74a4267 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -17,13 +17,14 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { AgentSession, IAgent } from '../../common/agentService.js'; -import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; +import { AgentSession, IAgent, SubagentChatSignal } from '../../common/agentService.js'; +import { buildDefaultChangesetCatalog } from '../../common/changesetUri.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; -import { ChangesSummary, CustomizationType } from '../../common/state/protocol/state.js'; +import { ChangesSummary, ChatOriginKind, CustomizationType, SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -59,6 +60,7 @@ class FakeChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys(_sessionUri: string): Record | undefined { return undefined; } computeListEntryChanges(_sessionUri: string, _metadata: Record): ChangesSummary | undefined { return undefined; } + refreshChangesetCatalog(session: string): void { /* no-op */ } refreshBranchChangeset(): void { /* no-op */ } refreshSessionChangeset(): void { /* no-op */ } onWorkingDirectoryAvailable(): void { /* no-op */ } @@ -142,6 +144,7 @@ suite('AgentSideEffects', () => { let telemetryService: TestTelemetryService; const sessionUri = AgentSession.uri('mock', 'session-1'); + const defaultChatUri = buildDefaultChatUri(sessionUri); function setupSession(workingDirectory?: string): void { stateManager.createSession({ @@ -149,17 +152,17 @@ suite('AgentSideEffects', () => { provider: 'mock', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }); - stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } - function startTurn(turnId: string): void { - stateManager.dispatchClientAction(sessionUri.toString(), { type: ActionType.ChatTurnStarted, turnId, message: { text: 'hello', origin: { kind: MessageKind.User } } }, + function startTurn(turnId: string, channel = defaultChatUri): void { + stateManager.dispatchClientAction(channel, { type: ActionType.ChatTurnStarted, turnId, message: { text: 'hello', origin: { kind: MessageKind.User } } }, { clientId: 'test', clientSeq: 1 }, ); } @@ -194,6 +197,13 @@ suite('AgentSideEffects', () => { }); } + async function waitForSendMessageCalls(count: number): Promise { + if (agent.sendMessageCalls.length >= count) { + return; + } + await Event.toPromise(Event.filter(agent.onDidSendMessage, () => agent.sendMessageCalls.length >= count)); + } + setup(async () => { fileService = disposables.add(new FileService(new NullLogService())); const memFs = disposables.add(new InMemoryFileSystemProvider()); @@ -215,6 +225,21 @@ suite('AgentSideEffects', () => { sessionDataService: createNullSessionDataService(), onTurnComplete: () => { }, }, undefined, disposables.add(new AgentHostTelemetryService(telemetryService))); + + // Mimic the orchestrator's spawn channel: in production AgentService adds + // a subagent's chat to the catalog (via _onChatSpawned) before + // AgentSideEffects starts its turn. Registered here (ahead of each test's + // registerProgressListener) so the subagent chat exists first. addChat is + // idempotent, matching the real spawn-channel/side-effects overlap. + disposables.add(agent.onDidSessionProgress(signal => { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + stateManager.addChat(spawn.session.toString(), spawn.chat.toString(), { + title: spawn.title, + origin: spawn.parent ? { kind: ChatOriginKind.Tool, chat: spawn.parent.chat.toString(), toolCallId: spawn.parent.toolCallId } : undefined, + }); + } + })); }); teardown(() => { @@ -233,18 +258,37 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', message: { text: 'hello world', origin: { kind: MessageKind.User } }, }; - sideEffects.handleAction(sessionUri.toString(), action); + sideEffects.handleAction(defaultChatUri, action); - // sendMessage is async but fire-and-forget; wait a tick - await new Promise(r => setTimeout(r, 10)); + await waitForSendMessageCalls(1); + + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined, chat: URI.parse(defaultChatUri) }]); + }); + + test('passes the dispatching client id to sendMessage', async () => { + setupSession(); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hello world', origin: { kind: MessageKind.User } }, + }; + sideEffects.handleAction(defaultChatUri, action, 'client-B'); - assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined }]); + await waitForSendMessageCalls(1); + + assert.deepStrictEqual(agent.sendMessageCalls, [{ + session: URI.parse(sessionUri.toString()), + prompt: 'hello world', + attachments: undefined, + chat: URI.parse(defaultChatUri), + senderClientId: 'client-B', + }]); }); test('logs telemetry when sending a direct user message', () => { setupSession(); const activeClientAction: SessionAction = { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'test-client', tools: [{ name: 'testTool', inputSchema: { type: 'object' } }], @@ -254,7 +298,7 @@ suite('AgentSideEffects', () => { stateManager.dispatchClientAction(sessionUri.toString(), activeClientAction, { clientId: 'test', clientSeq: 1 }); sideEffects.handleAction(sessionUri.toString(), activeClientAction); const fileUri = URI.file('/workspace/direct.ts'); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'direct.ts', displayKind: 'document' }] }, @@ -276,7 +320,7 @@ suite('AgentSideEffects', () => { }]); }); - test('parses protocol attachment URI strings before passing them to the agent', () => { + test('parses protocol attachment URI strings before passing them to the agent', async () => { setupSession(); const fileUri = URI.file('/workspace/test.ts'); const action: ChatAction = { @@ -285,16 +329,18 @@ suite('AgentSideEffects', () => { message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }] }, }; - sideEffects.handleAction(sessionUri.toString(), action); + sideEffects.handleAction(defaultChatUri, action); + await waitForSendMessageCalls(1); assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }], + chat: URI.parse(defaultChatUri), }]); }); - test('passes protocol selection attachment range straight through to the agent', () => { + test('passes protocol selection attachment range straight through to the agent', async () => { setupSession(); const fileUri = URI.file('/workspace/selection.ts'); const action: ChatAction = { @@ -318,7 +364,8 @@ suite('AgentSideEffects', () => { }, }; - sideEffects.handleAction(sessionUri.toString(), action); + sideEffects.handleAction(defaultChatUri, action); + await waitForSendMessageCalls(1); assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), @@ -335,6 +382,7 @@ suite('AgentSideEffects', () => { }, }, }], + chat: URI.parse(defaultChatUri), }]); }); @@ -351,7 +399,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - noAgentSideEffects.handleAction(sessionUri.toString(), { + noAgentSideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -387,13 +435,13 @@ suite('AgentSideEffects', () => { message: { text: '/rename Renamed Session', origin: { kind: MessageKind.User } }, }; // Mirror production: the reducer applies the turn, then side effects run. - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); await new Promise(r => setTimeout(r, 10)); assert.deepStrictEqual(agent.sendMessageCalls, []); const state = stateManager.getSessionState(sessionUri.toString()); - assert.strictEqual(state?.summary.title, 'Renamed Session'); + assert.strictEqual(state?.title, 'Renamed Session'); assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined); const part = state?.turns.at(-1)?.responseParts[0]; assert.strictEqual(part?.kind, ResponsePartKind.Markdown); @@ -408,13 +456,13 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', message: { text: '/rename', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); await new Promise(r => setTimeout(r, 10)); assert.deepStrictEqual(agent.sendMessageCalls, []); const state = stateManager.getSessionState(sessionUri.toString()); - assert.strictEqual(state?.summary.title, 'Test'); + assert.strictEqual(state?.title, 'Test'); assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined); }); @@ -426,11 +474,11 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', message: { text: '/renamed thing', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: '/renamed thing', attachments: undefined }]); + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), chat: URI.parse(defaultChatUri), prompt: '/renamed thing', attachments: undefined }]); }); }); @@ -444,8 +492,8 @@ suite('AgentSideEffects', () => { provider: 'mock', title: '', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, }); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); @@ -457,7 +505,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Fix the login bug', origin: { kind: MessageKind.User } }, @@ -476,7 +524,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: ' ', origin: { kind: MessageKind.User } }, @@ -493,7 +541,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: longMessage, origin: { kind: MessageKind.User } }, @@ -514,7 +562,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); // Complete the first turn so turns.length becomes 1. - stateManager.dispatchServerAction(sessionUri.toString(), { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', }); @@ -522,7 +570,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-2', message: { text: 'second message', origin: { kind: MessageKind.User } }, @@ -539,8 +587,8 @@ suite('AgentSideEffects', () => { provider: 'mock', title: 'User Renamed', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, }); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); @@ -548,7 +596,7 @@ suite('AgentSideEffects', () => { const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, @@ -563,7 +611,7 @@ suite('AgentSideEffects', () => { test('calls abortSession on the agent', async () => { setupSession(); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); @@ -574,29 +622,67 @@ suite('AgentSideEffects', () => { }); }); - // ---- handleAction: session/modelChanged ----------------------------- + // ---- handleAction: chat/turnStarted model selection -------------------- - suite('handleAction — session/modelChanged', () => { + suite('handleAction — chat/turnStarted model selection', () => { - test('calls changeModel on the agent', async () => { + test('calls changeModel on the agent before sending the message', async () => { setupSession(); - sideEffects.handleAction(sessionUri.toString(), { - type: ActionType.SessionModelChanged, - model: { id: 'gpt-5' }, + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, }); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: undefined }]); + assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }]); + }); + + test('waits for model selection before sending the message', async () => { + setupSession(); + let resolveChangeModel!: () => void; + const changeModelSettled = new Promise(resolve => { resolveChangeModel = resolve; }); + let resolveSend!: () => void; + const sendStarted = new Promise(resolve => { resolveSend = resolve; }); + agent.changeModel = async (session, model, chat) => { + agent.changeModelCalls.push({ session, model, chat }); + await changeModelSettled; + }; + agent.sendMessage = async (session, chat, prompt, attachments) => { + agent.sendMessageCalls.push({ session, prompt, attachments, chat }); + resolveSend(); + }; + + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, + }); + await Promise.resolve(); + + assert.deepStrictEqual({ + changeModelCalls: agent.changeModelCalls, + sendMessageCalls: agent.sendMessageCalls, + }, { + changeModelCalls: [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }], + sendMessageCalls: [], + }); + + resolveChangeModel(); + await sendStarted; + + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello', attachments: undefined, chat: URI.parse(defaultChatUri) }]); }); test('forwards the chat channel for an additional (peer) chat', async () => { setupSession(); - const chatChannel = `${sessionUri.toString()}#peer-1`; - sideEffects.handleAction(sessionUri.toString(), { - type: ActionType.SessionModelChanged, - model: { id: 'gpt-5' }, - }, chatChannel); + const chatChannel = buildChatUri(sessionUri.toString(), 'peer-1'); + sideEffects.handleAction(chatChannel, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, + }); await new Promise(r => setTimeout(r, 10)); @@ -604,29 +690,31 @@ suite('AgentSideEffects', () => { }); }); - // ---- handleAction: session/agentChanged ----------------------------- + // ---- handleAction: chat/turnStarted agent selection -------------------- - suite('handleAction — session/agentChanged', () => { + suite('handleAction — chat/turnStarted agent selection', () => { - test('calls changeAgent on the agent for the session default chat', async () => { + test('calls changeAgent on the agent for the session default chat before sending the message', async () => { setupSession(); - sideEffects.handleAction(sessionUri.toString(), { - type: ActionType.SessionAgentChanged, - agent: { uri: 'file:///agents/reviewer.md' }, + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } }, }); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.changeAgentCalls, [{ session: URI.parse(sessionUri.toString()), agent: { uri: 'file:///agents/reviewer.md' }, chat: undefined }]); + assert.deepStrictEqual(agent.changeAgentCalls, [{ session: URI.parse(sessionUri.toString()), agent: { uri: 'file:///agents/reviewer.md' }, chat: URI.parse(defaultChatUri) }]); }); test('forwards the chat channel for an additional (peer) chat', async () => { setupSession(); - const chatChannel = `${sessionUri.toString()}#peer-1`; - sideEffects.handleAction(sessionUri.toString(), { - type: ActionType.SessionAgentChanged, - agent: { uri: 'file:///agents/reviewer.md' }, - }, chatChannel); + const chatChannel = buildChatUri(sessionUri.toString(), 'peer-1'); + sideEffects.handleAction(chatChannel, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } }, + }); await new Promise(r => setTimeout(r, 10)); @@ -647,7 +735,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hi' } }, }); @@ -664,14 +752,14 @@ suite('AgentSideEffects', () => { const listener = sideEffects.registerProgressListener(agent); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'before' } }, }); assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.ChatResponsePart).length, 1); listener.dispose(); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-2', content: 'after' } }, }); assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.ChatResponsePart).length, 1); @@ -707,7 +795,7 @@ suite('AgentSideEffects', () => { } return e.action.agents[0]?.models.length === 1; })); - agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]); + agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, maxOutputTokens: 16000, maxPromptTokens: 112000, supportsVision: false }]); await envelope; const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged); @@ -718,6 +806,8 @@ suite('AgentSideEffects', () => { provider: 'mock', name: 'mock Model', maxContextWindow: 128000, + maxOutputTokens: 16000, + maxPromptTokens: 112000, supportsVision: false, policyState: undefined, configSchema: undefined, @@ -775,15 +865,43 @@ suite('AgentSideEffects', () => { id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); assert.strictEqual(agent.setPendingMessagesCalls.length, 1); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); + // Default chat: no `chat` arg, so the agent routes to the session's default chat. + assert.strictEqual(agent.setPendingMessagesCalls[0].chat, undefined); }); - test('syncs queued message to agent on ChatPendingMessageSet', () => { + test('syncs a peer chat steering message with the peer chat URI as the `chat` arg', () => { + setupSession(); + const peerChatUri = URI.parse(buildChatUri(sessionUri.toString(), 'peer-steer')); + stateManager.addChat(sessionUri.toString(), peerChatUri.toString()); + + const action = { + type: ActionType.ChatPendingMessageSet as const, + kind: PendingMessageKind.Steering, + id: 'steer-peer', + message: { text: 'steer the peer', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(peerChatUri.toString(), action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(peerChatUri.toString(), action); + + assert.strictEqual(agent.setPendingMessagesCalls.length, 1); + assert.deepStrictEqual({ + session: agent.setPendingMessagesCalls[0].session.toString(), + chat: agent.setPendingMessagesCalls[0].chat?.toString(), + steeringId: agent.setPendingMessagesCalls[0].steeringMessage?.id, + }, { + session: sessionUri.toString(), + chat: peerChatUri.toString(), + steeringId: 'steer-peer', + }); + }); + + test('syncs queued message to agent on ChatPendingMessageSet', async () => { setupSession(); const action = { @@ -792,8 +910,8 @@ suite('AgentSideEffects', () => { id: 'q-1', message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); // Queued messages are not forwarded to the agent; the server controls consumption assert.strictEqual(agent.setPendingMessagesCalls.length, 1); @@ -801,11 +919,12 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); // Session was idle, so the queued message is consumed immediately + await waitForSendMessageCalls(1); assert.strictEqual(agent.sendMessageCalls.length, 1); assert.strictEqual(agent.sendMessageCalls[0].prompt, 'queued message'); }); - test('parses queued protocol attachment URI strings before passing them to the agent', () => { + test('parses queued protocol attachment URI strings before passing them to the agent', async () => { setupSession(); const fileUri = URI.file('/workspace/queued.ts'); const action: ChatAction = { @@ -815,11 +934,13 @@ suite('AgentSideEffects', () => { message: { text: 'queued message', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }] }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); + await waitForSendMessageCalls(1); assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), + chat: URI.parse(defaultChatUri), prompt: 'queued message', attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }], }]); @@ -834,8 +955,8 @@ suite('AgentSideEffects', () => { id: 'q-telemetry', message: { text: 'queued message', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); assert.deepStrictEqual(telemetryService.events, [{ eventName: 'agentHost.userMessageSent', @@ -860,8 +981,8 @@ suite('AgentSideEffects', () => { id: 'q-rm', message: { text: 'will be removed', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); agent.setPendingMessagesCalls.length = 0; @@ -871,8 +992,8 @@ suite('AgentSideEffects', () => { kind: PendingMessageKind.Queued, id: 'q-rm', }; - stateManager.dispatchClientAction(sessionUri.toString(), removeAction, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(sessionUri.toString(), removeAction); + stateManager.dispatchClientAction(defaultChatUri, removeAction, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(defaultChatUri, removeAction); assert.strictEqual(agent.setPendingMessagesCalls.length, 1); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); @@ -883,19 +1004,19 @@ suite('AgentSideEffects', () => { // Add two queued messages const setA = { type: ActionType.ChatPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-a', message: { text: 'A', origin: { kind: MessageKind.User } } }; - stateManager.dispatchClientAction(sessionUri.toString(), setA, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setA); + stateManager.dispatchClientAction(defaultChatUri, setA, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setA); const setB = { type: ActionType.ChatPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-b', message: { text: 'B', origin: { kind: MessageKind.User } } }; - stateManager.dispatchClientAction(sessionUri.toString(), setB, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(sessionUri.toString(), setB); + stateManager.dispatchClientAction(defaultChatUri, setB, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(defaultChatUri, setB); agent.setPendingMessagesCalls.length = 0; // Reorder const reorderAction = { type: ActionType.ChatQueuedMessagesReordered as const, order: ['q-b', 'q-a'] }; - stateManager.dispatchClientAction(sessionUri.toString(), reorderAction, { clientId: 'test', clientSeq: 3 }); - sideEffects.handleAction(sessionUri.toString(), reorderAction); + stateManager.dispatchClientAction(defaultChatUri, reorderAction, { clientId: 'test', clientSeq: 3 }); + sideEffects.handleAction(defaultChatUri, reorderAction); assert.strictEqual(agent.setPendingMessagesCalls.length, 1); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); @@ -906,7 +1027,7 @@ suite('AgentSideEffects', () => { suite('queued message consumption', () => { - test('auto-starts turn from queued message on idle', () => { + test('auto-starts turn from queued message on idle', async () => { setupSession(); disposables.add(sideEffects.registerProgressListener(agent)); @@ -918,8 +1039,8 @@ suite('AgentSideEffects', () => { id: 'q-auto', message: { text: 'auto queued', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); // Message should NOT be consumed yet (turn is active) assert.strictEqual(agent.sendMessageCalls.length, 0); @@ -929,7 +1050,7 @@ suite('AgentSideEffects', () => { // Fire idle → turn completes → queued message should be consumed agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); @@ -940,6 +1061,7 @@ suite('AgentSideEffects', () => { assert.ok(turnStarted, 'should dispatch session/turnStarted for queued message'); assert.strictEqual((turnStarted!.action as { queuedMessageId?: string }).queuedMessageId, 'q-auto'); + await waitForSendMessageCalls(1); assert.strictEqual(agent.sendMessageCalls.length, 1); assert.strictEqual(agent.sendMessageCalls[0].prompt, 'auto queued'); @@ -964,16 +1086,16 @@ suite('AgentSideEffects', () => { id: 'q-after-abort', message: { text: 'queued behind abort', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); // Not consumed yet — the turn is still active. assert.strictEqual(agent.sendMessageCalls.length, 0); // Cancel the active turn (client abort). const cancelAction = { type: ActionType.ChatTurnCancelled as const, turnId: 'turn-1' }; - stateManager.dispatchClientAction(sessionUri.toString(), cancelAction, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(sessionUri.toString(), cancelAction); + stateManager.dispatchClientAction(defaultChatUri, cancelAction, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(defaultChatUri, cancelAction); // The queued message must NOT auto-start, and must remain queued. assert.strictEqual(agent.sendMessageCalls.length, 0, 'cancelling must not drain queued messages'); @@ -982,7 +1104,7 @@ suite('AgentSideEffects', () => { assert.strictEqual(state?.queuedMessages?.[0].id, 'q-after-abort'); }); - test('intercepts queued /rename and drains the message queued behind it', () => { + test('intercepts queued /rename and drains the message queued behind it', async () => { setupSession(); // `/rename` persists the new title, so use a side effects instance // whose `openDatabase` returns a real database (the suite default @@ -1007,28 +1129,29 @@ suite('AgentSideEffects', () => { id: msg.id, message: { text: msg.text, origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - renameSideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, setAction); } // Fire idle → turn completes → `/rename` is consumed and intercepted, // then the message queued behind it must be drained to the agent. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); // The `/rename` must not reach the agent; only the message behind it does + await waitForSendMessageCalls(1); assert.strictEqual(agent.sendMessageCalls.length, 1); assert.strictEqual(agent.sendMessageCalls[0].prompt, 'after rename'); // Both queued messages should be drained from state const state = stateManager.getSessionState(sessionUri.toString()); assert.strictEqual(state?.queuedMessages, undefined); - assert.strictEqual(state?.summary.title, 'Queued Title'); + assert.strictEqual(state?.title, 'Queued Title'); }); - test('drains a peer chat queued message to the owning session with the chat arg', () => { + test('drains a peer chat queued message to the owning session with the chat arg', async () => { setupSession(); const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-q')); stateManager.addChat(sessionUri.toString(), chatUri.toString()); @@ -1051,12 +1174,13 @@ suite('AgentSideEffects', () => { // Idle on the peer chat → the queued message drains to the parent // session URI with the chat channel passed as the `chat` argument - // so the harness routes it to the right peer SDK conversation. + // so the harness routes it to the right peer SDK chat. agent.fireProgress({ - kind: 'action', session: chatUri, + kind: 'action', resource: chatUri, action: { type: ActionType.ChatTurnComplete, turnId: 'pturn-1' }, }); + await waitForSendMessageCalls(1); assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'peer queued', @@ -1078,8 +1202,8 @@ suite('AgentSideEffects', () => { id: 'q-wait', message: { text: 'should wait', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), setAction); + stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, setAction); // No turn started for the queued message const turnStarted = envelopes.find(e => e.action.type === ActionType.ChatTurnStarted); @@ -1105,8 +1229,8 @@ suite('AgentSideEffects', () => { id: 'steer-rm', message: { text: 'steer me', origin: { kind: MessageKind.User } }, }; - stateManager.dispatchClientAction(sessionUri.toString(), action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(sessionUri.toString(), action); + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); // Removal is not dispatched synchronously; it waits for the agent let removal = envelopes.find(e => @@ -1118,7 +1242,7 @@ suite('AgentSideEffects', () => { // Simulate the agent consuming the steering message agent.fireProgress({ kind: 'steering_consumed', - session: sessionUri, + chat: URI.parse(defaultChatUri), id: 'steer-rm', }); @@ -1135,9 +1259,9 @@ suite('AgentSideEffects', () => { }); }); - // ---- handleAction: session/activeClientChanged ---------------------- + // ---- handleAction: session/activeClientSet ---------------------- - suite('handleAction — session/activeClientChanged', () => { + suite('handleAction — session/activeClientSet', () => { setup(() => { disposables.add(sideEffects.registerProgressListener(agent)); @@ -1155,7 +1279,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const action: SessionAction = { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'test-client', tools: [], @@ -1187,29 +1311,31 @@ suite('AgentSideEffects', () => { const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }; let currentCustomizations: readonly Customization[] = []; agent.getSessionCustomizations = async () => currentCustomizations; - agent.setClientCustomizations = async (session, clientId, customizations) => { + agent.syncClientCustomizations = (session, clientId, customizations) => { agent.setClientCustomizationsCalls.push({ clientId, customizations }); const loading: PluginCustomization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loading } }; currentCustomizations = [loading]; agent.fireProgress({ kind: 'action', - session, + resource: session, action: { type: ActionType.SessionCustomizationsChanged, customizations: [...currentCustomizations], }, }); - await new Promise(resolve => setTimeout(resolve, 0)); - const loaded: PluginCustomization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } }; - currentCustomizations = [loaded]; - agent.fireProgress({ - kind: 'action', - session, - action: { - type: ActionType.SessionCustomizationUpdated, - customization: loaded, - }, - }); + void (async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + const loaded: PluginCustomization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } }; + currentCustomizations = [loaded]; + agent.fireProgress({ + kind: 'action', + resource: session, + action: { + type: ActionType.SessionCustomizationUpdated, + customization: loaded, + }, + }); + })(); return currentCustomizations.map(customization => ({ customization: customization as PluginCustomization })); }; @@ -1217,7 +1343,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); sideEffects.handleAction(sessionUri.toString(), { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'test-client', tools: [], @@ -1249,7 +1375,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const action: SessionAction = { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'test-client', tools: [] @@ -1270,18 +1396,17 @@ suite('AgentSideEffects', () => { }); }); - test('clears client customizations when activeClient is null', () => { + test('removes the active client when it is removed', () => { setupSession(); const action: SessionAction = { - type: ActionType.SessionActiveClientChanged, - activeClient: null, + type: ActionType.SessionActiveClientRemoved, + clientId: 'test-client', }; sideEffects.handleAction(sessionUri.toString(), action); - assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{ - clientId: '', - customizations: [], + assert.deepStrictEqual(agent.removeActiveClientCalls, [{ + clientId: 'test-client', }]); }); }); @@ -1325,7 +1450,7 @@ suite('AgentSideEffects', () => { }; sideEffects.handleAction(sessionUri.toString(), action); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello world', origin: { kind: MessageKind.User } }, @@ -1409,12 +1534,12 @@ suite('AgentSideEffects', () => { test('routes confirmation to correct agent via _toolCallAgents', () => { setupSession(); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); // Fire tool_start to register the tool call agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', contributor: undefined, @@ -1422,7 +1547,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-conf-1', invocationMessage: 'Reading file', toolInput: undefined, @@ -1432,7 +1557,7 @@ suite('AgentSideEffects', () => { // Fire tool_ready asking for permission (non-write, so not auto-approved) agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-conf-1', toolName: '', displayName: '', @@ -1443,7 +1568,7 @@ suite('AgentSideEffects', () => { }); // Now confirm the tool call - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-conf-1', @@ -1458,11 +1583,11 @@ suite('AgentSideEffects', () => { test('handles denial of tool call', () => { setupSession(); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, @@ -1470,7 +1595,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-deny-1', invocationMessage: 'Running command', toolInput: undefined, @@ -1478,7 +1603,7 @@ suite('AgentSideEffects', () => { }, }); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-deny-1', @@ -1503,7 +1628,7 @@ suite('AgentSideEffects', () => { // tool_start puts the tool call into Streaming state agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-ready-1', toolName: 'runTask', displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, @@ -1519,7 +1644,7 @@ suite('AgentSideEffects', () => { // tool_ready without confirmationTitle should dispatch the ready // action and advance the tool call to Running agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-ready-1', toolName: '', displayName: '', @@ -1546,7 +1671,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-1', toolName: 'write', displayName: 'Write File', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, @@ -1557,7 +1682,7 @@ suite('AgentSideEffects', () => { // tool_ready with confirmationTitle should dispatch the ready // action and advance the tool call to PendingConfirmation agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-1', toolName: '', displayName: '', @@ -1578,6 +1703,74 @@ suite('AgentSideEffects', () => { 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); }); + test('tool_ready for an additional chat is emitted on that chat channel', async () => { + setupSession(); + const chatUri = buildChatUri(sessionUri.toString(), 'peer'); + stateManager.addChat(sessionUri.toString(), chatUri); + stateManager.setSessionConfig(sessionUri.toString(), { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Permissions]: { allow: [], deny: [] } } }); + startTurn('turn-peer', chatUri); + disposables.add(sideEffects.registerProgressListener(agent)); + + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(chatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-peer', + toolCallId: 'tc-peer-perm', toolName: 'write', displayName: 'Write File', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(chatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-peer-perm', toolName: '', displayName: '', + invocationMessage: 'Write .env', toolInput: '{"path":".env"}', + confirmationTitle: 'Write .env', edits: undefined, + }, + permissionKind: undefined, permissionPath: undefined, + }); + + const chatState = await waitForState(stateManager, () => { + const s = stateManager.getChatState(chatUri); + const p = s?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm'); + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); + const defaultState = stateManager.getSessionState(sessionUri.toString()); + const defaultPart = defaultState?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm'); + const peerPart = chatState.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm'); + const readyEnvelope = envelopes.find(e => e.action.type === ActionType.ChatToolCallReady && hasKey(e.action, { toolCallId: true }) && e.action.toolCallId === 'tc-peer-perm'); + + assert.deepStrictEqual({ + peerToolStatus: peerPart?.kind === ResponsePartKind.ToolCall + ? peerPart.toolCall.status + : undefined, + defaultHasTool: defaultPart !== undefined, + readyEnvelopeChannel: readyEnvelope?.channel, + }, { + peerToolStatus: ToolCallStatus.PendingConfirmation, + defaultHasTool: false, + readyEnvelopeChannel: chatUri, + }); + + sideEffects.handleAction(chatUri, { + type: ActionType.ChatToolCallConfirmed, + turnId: 'turn-peer', + toolCallId: 'tc-peer-perm', + approved: true, + confirmed: 'user-action' as const, + selectedOptionId: 'allow-session', + } as ChatAction); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'tc-peer-perm', approved: true }, + ]); + assert.deepStrictEqual(stateManager.getSessionState(sessionUri.toString())?.config?.values[SessionConfigKey.Permissions], { allow: ['write'], deny: [] }); + }); + test('pending_confirmation for a tool inside a subagent routes to the subagent session', async () => { // Regression: a `pending_confirmation` signal for a client tool // inside a subagent must dispatch ChatToolCallReady against @@ -1590,7 +1783,7 @@ suite('AgentSideEffects', () => { // Parent tool that delegates to a subagent. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, @@ -1598,18 +1791,18 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded, }, }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' }); // Inner client tool starts inside the subagent. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, @@ -1619,10 +1812,10 @@ suite('AgentSideEffects', () => { // Permission flow fires `pending_confirmation` for the inner // client tool. The signal must be routed to the subagent - // session — not to the parent — even though the signal carries - // only the parent session URI. + // chat — not to the parent — when the signal carries the parent + // chat URI and parentToolCallId. agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', @@ -1632,8 +1825,8 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - // The subagent session must contain the ChatToolCallReady. - const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); + // The subagent chat must contain the ChatToolCallReady. + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); const subState = await waitForState(stateManager, () => { const s = stateManager.getSessionState(subagentUri); const inner = s?.activeTurn?.responseParts.find( @@ -1673,7 +1866,7 @@ suite('AgentSideEffects', () => { // Start a tool in the active turn agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-noop', toolName: 'view', displayName: 'Read', @@ -1684,14 +1877,14 @@ suite('AgentSideEffects', () => { // Complete the turn — state manager no longer has an active turn agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-noop', result: { success: true, pastTenseMessage: 'Read file' }, }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); @@ -1701,7 +1894,7 @@ suite('AgentSideEffects', () => { // Simulate the hook-triggered continuation: tool actions // arrive without a new protocol turn being started agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: '', toolCallId: 'tc-orphan', toolName: 'view', displayName: 'Read', @@ -1712,7 +1905,7 @@ suite('AgentSideEffects', () => { // Now the pending_confirmation arrives — this must NOT be dropped agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-orphan', toolName: 'view', displayName: 'Read', @@ -1733,6 +1926,76 @@ suite('AgentSideEffects', () => { }); }); + // ---- ChatToolCallComplete routing ----------------------------------- + + suite('handleAction — chat/toolCallComplete routing', () => { + + test('forwards session + default chat URI for a default-chat completion', () => { + // Regression: agents key their sessions by session id, but the + // chat URI's path is a base64 blob. The session URI must be passed + // so the lookup resolves instead of silently dropping the call. + setupSession(); + + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-1', + toolCallId: 'tc-default', + result: { success: true, pastTenseMessage: 'done' }, + }); + + assert.deepStrictEqual( + agent.clientToolCallCompleteCalls.map(c => ({ session: c.session.toString(), chat: c.chat?.toString(), toolCallId: c.toolCallId })), + [{ session: sessionUri.toString(), chat: defaultChatUri, toolCallId: 'tc-default' }], + ); + }); + + test('forwards owning session + chat URI for an additional-chat completion', () => { + setupSession(); + const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-1'); + + sideEffects.handleAction(peerChatUri, { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-1', + toolCallId: 'tc-peer', + result: { success: true, pastTenseMessage: 'done' }, + }); + + assert.deepStrictEqual( + agent.clientToolCallCompleteCalls.map(c => ({ session: c.session.toString(), chat: c.chat?.toString(), toolCallId: c.toolCallId })), + [{ session: sessionUri.toString(), chat: peerChatUri, toolCallId: 'tc-peer' }], + ); + }); + + test('forwards parent peer chat URI for a subagent-chat completion', () => { + setupSession(); + const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-subagent-parent'); + stateManager.addChat(sessionUri.toString(), peerChatUri); + startTurn('turn-peer', peerChatUri); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(peerChatUri), + toolCallId: 'tc-parent', + agentName: 'explore', + agentDisplayName: 'Explore', + }); + + const subagentChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); + sideEffects.handleAction(subagentChatUri, { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-subagent', + toolCallId: 'tc-inner', + result: { success: true, pastTenseMessage: 'done' }, + }); + + assert.deepStrictEqual( + agent.clientToolCallCompleteCalls.map(c => ({ session: c.session.toString(), chat: c.chat?.toString(), toolCallId: c.toolCallId })), + [{ session: sessionUri.toString(), chat: peerChatUri, toolCallId: 'tc-inner' }], + ); + }); + }); + // ---- Session-level auto-approve (config) ---------------------------- suite('session config auto-approve', () => { @@ -1763,7 +2026,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -1771,7 +2034,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-bypass-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -1780,7 +2043,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-bypass-1', toolName: '', displayName: '', @@ -1803,7 +2066,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-bypass-shell-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, @@ -1811,7 +2074,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-bypass-shell-1', invocationMessage: 'Run rm -rf /', toolInput: undefined, @@ -1820,7 +2083,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-bypass-shell-1', toolName: '', displayName: '', @@ -1838,13 +2101,46 @@ suite('AgentSideEffects', () => { ]); }); - test('marks pending client tool approval for client-side auto-approval in bypass mode', async () => { + test('does NOT auto-approve a shell command that opted out of the sandbox, even in bypass mode', () => { setupSessionWithConfig('autoApprove'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-sandboxbypass-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + + agent.fireProgress({ + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-sandboxbypass-1', toolName: '', displayName: '', + invocationMessage: 'Run cat ~/something.txt', toolInput: 'cat ~/something.txt', + confirmationTitle: 'Run command', edits: undefined, + }, + permissionKind: 'shell', permissionPath: undefined, + requestSandboxBypass: true, + }); + + // A read-only command like `cat` (or even session-level bypass) + // would normally auto-approve, but opting out of the sandbox is an + // elevation of privilege the user must confirm, so no auto-approval + // response is sent. + assert.deepStrictEqual(agent.respondToPermissionCalls, []); + }); + + test('marks pending client tool approval for client-side auto-approval in bypass mode', async () => { + setupSessionWithConfig('autoApprove'); + startTurn('turn-1', defaultChatUri); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-client-approve-1', toolName: 'runTask', displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' }, @@ -1853,7 +2149,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-client-approve-1', toolName: 'runTask', displayName: 'Run Task', @@ -1880,7 +2176,7 @@ suite('AgentSideEffects', () => { permissionCalls: [], }); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-client-approve-1', @@ -1899,7 +2195,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -1907,7 +2203,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-default-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -1916,7 +2212,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-default-1', toolName: '', displayName: '', @@ -1942,7 +2238,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -1950,7 +2246,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-mid-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -1959,7 +2255,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-mid-1', toolName: '', displayName: '', @@ -1987,7 +2283,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -1995,7 +2291,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-auto-1', invocationMessage: 'Write file', toolInput: undefined, @@ -2004,7 +2300,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-auto-1', toolName: '', displayName: '', @@ -2030,7 +2326,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2038,7 +2334,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-env-1', invocationMessage: 'Write .env', toolInput: undefined, @@ -2047,7 +2343,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-env-1', toolName: '', displayName: '', @@ -2071,7 +2367,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2079,7 +2375,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-pkg-1', invocationMessage: 'Write package.json', toolInput: undefined, @@ -2088,7 +2384,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-pkg-1', toolName: '', displayName: '', @@ -2107,7 +2403,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2115,7 +2411,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-lock-1', invocationMessage: 'Write yarn.lock', toolInput: undefined, @@ -2124,7 +2420,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-lock-1', toolName: '', displayName: '', @@ -2143,7 +2439,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2151,7 +2447,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-git-1', invocationMessage: 'Write .git/config', toolInput: undefined, @@ -2160,7 +2456,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-git-1', toolName: '', displayName: '', @@ -2184,7 +2480,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2192,7 +2488,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-read-1', invocationMessage: 'Read file', toolInput: undefined, @@ -2201,7 +2497,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-read-1', toolName: '', displayName: '', @@ -2226,7 +2522,7 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2234,7 +2530,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-read-2', invocationMessage: 'Read file', toolInput: undefined, @@ -2243,7 +2539,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-read-2', toolName: '', displayName: '', @@ -2291,8 +2587,8 @@ suite('AgentSideEffects', () => { provider: 'mock', title: 'Initial', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, }); @@ -2352,7 +2648,7 @@ suite('AgentSideEffects', () => { const state = localService.stateManager.getSessionState(sessionResource.toString()); assert.ok(state); - assert.strictEqual(state!.summary.title, 'Restored Title'); + assert.strictEqual(state!.title, 'Restored Title'); }); test('SessionConfigChanged persists merged config values to the database', async () => { @@ -2372,8 +2668,8 @@ suite('AgentSideEffects', () => { provider: 'mock', title: 'Initial', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, }); session.config = { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }; @@ -2400,14 +2696,14 @@ suite('AgentSideEffects', () => { suite('subagent sessions', () => { - test('subagent_started creates a subagent session and dispatches content on parent tool call', () => { + test('subagent_started creates a subagent chat and dispatches content on parent tool call', () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); // Start a parent tool call agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, @@ -2415,7 +2711,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating task...', toolInput: undefined, @@ -2425,19 +2721,21 @@ suite('AgentSideEffects', () => { // Fire subagent_started agent.fireProgress({ - kind: 'subagent_started', session: sessionUri, + kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'code-reviewer', agentDisplayName: 'Code Reviewer', agentDescription: 'Reviews code', }); - // Verify the subagent session was created - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + // Verify the subagent chat was created + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState, 'subagent session should exist'); - assert.strictEqual(subState!.summary.title, 'Code Reviewer'); - assert.ok(subState!.activeTurn, 'subagent should have an active turn'); + assert.ok(subState, 'subagent chat should exist'); + const subagentSummary = subState!.chats.find(c => c.resource === subagentUri); + assert.strictEqual(subagentSummary?.title, 'Code Reviewer'); + assert.deepStrictEqual(subagentSummary?.origin, { kind: 'tool', chat: defaultChatUri, toolCallId: 'tc-1' }); + assert.ok(subState!.activeTurn, 'subagent chat should have an active turn'); // Verify content was dispatched on the parent tool call const parentState = stateManager.getSessionState(sessionUri.toString()); @@ -2452,19 +2750,94 @@ suite('AgentSideEffects', () => { } }); + test('nested subagent_started routes its discovery content block to the immediate parent chat (arbitrary depth)', () => { + // Regression: for a subagent spawned by another subagent, the + // `subagent_started` signal's `chat` is the top-level chat, but + // its spawning tool call lives in the immediate parent's subagent + // chat. The discovery `ChatToolCallContentChanged` must land there + // (resolved via `parentToolCallId`) — dispatching it on the + // top-level chat is a no-op, leaving the nested subagent + // undiscoverable and hanging any client tool it runs. Driven three + // levels deep to prove the resolution is not capped at two: each + // level's block lands on its immediate parent chat via a single + // flat-map lookup, independent of depth. + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Level-1 subagent spawned from the default chat. + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l1', agentName: 'l1', agentDisplayName: 'L1', agentDescription: 'first' }); + + // Level-2 subagent's spawning tool runs INSIDE the level-1 + // subagent (parentToolCallId = tc-l1), so it lands on the level-1 + // subagent chat rather than the default chat. + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l1', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l2', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l1', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l2', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // Level-2 subagent starts. Its spawning tool (tc-l2) lives in the + // level-1 subagent chat, so the signal carries parentToolCallId = tc-l1. + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l2', agentName: 'l2', agentDisplayName: 'L2', agentDescription: 'second', parentToolCallId: 'tc-l1' }); + + // Level-3 subagent's spawning tool runs INSIDE the level-2 + // subagent (parentToolCallId = tc-l2), landing on the level-2 chat. + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l2', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l3', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l2', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l3', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // Level-3 subagent starts. Its spawning tool (tc-l3) lives in the + // level-2 subagent chat, so the signal carries parentToolCallId = tc-l2. + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l3', agentName: 'l3', agentDisplayName: 'L3', agentDescription: 'third', parentToolCallId: 'tc-l2' }); + + const l1ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l1'); + const l2ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l2'); + const l3ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l3'); + + assert.ok(stateManager.getSessionState(l2ChatUri), 'level-2 subagent chat should exist'); + assert.ok(stateManager.getSessionState(l3ChatUri), 'level-3 subagent chat should exist'); + + // Asserts a subagent's discovery block landed on `parentChatUri`'s + // `spawningToolId` tool call, pointing at `childChatUri`. + const assertDiscoveryBlock = (parentChatUri: string, spawningToolId: string, childChatUri: string, label: string) => { + const parentState = stateManager.getSessionState(parentChatUri); + const spawningTool = parentState?.activeTurn?.responseParts.find(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === spawningToolId); + assert.ok(spawningTool && spawningTool.kind === ResponsePartKind.ToolCall, `${spawningToolId} should live in ${label}`); + const tc = spawningTool.toolCall; + // `content` only exists on the running/completed variants of the + // ToolCallState union; the spawning tool is running here. + assert.strictEqual(tc.status, ToolCallStatus.Running, `${spawningToolId} should be running in ${label}`); + if (tc.status !== ToolCallStatus.Running) { + return; + } + const block = tc.content?.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); + assert.ok(block, `the discovery block for ${spawningToolId} must land on ${label}`); + assert.strictEqual((block as { resource: string }).resource, childChatUri); + }; + + // Each level's discovery block lands on its immediate parent chat. + assertDiscoveryBlock(l1ChatUri, 'tc-l2', l2ChatUri, 'the level-1 chat'); + assertDiscoveryBlock(l2ChatUri, 'tc-l3', l3ChatUri, 'the level-2 chat'); + + // Nested spawning tools must NOT be misrouted to the top-level + // default chat, where they do not exist. + const defaultState = stateManager.getSessionState(sessionUri.toString()); + const l2ToolInDefault = defaultState?.activeTurn?.responseParts.find(rp => rp.kind === ResponsePartKind.ToolCall && (rp.toolCall.toolCallId === 'tc-l2' || rp.toolCall.toolCallId === 'tc-l3')); + assert.strictEqual(l2ToolInDefault, undefined, 'nested spawning tools must not appear in the top-level chat'); + }); + test('events with parentToolCallId route to subagent session', () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Fire an inner tool start with parentToolCallId agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined, @@ -2472,7 +2845,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, @@ -2480,14 +2853,14 @@ suite('AgentSideEffects', () => { }, }); - // Verify the inner tool call is on the subagent session's turn, not the parent - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + // Verify the inner tool call is on the subagent chat's turn, not the parent + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState?.activeTurn); const innerTool = subState!.activeTurn!.responseParts.find( rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' ); - assert.ok(innerTool, 'inner tool call should be in subagent session'); + assert.ok(innerTool, 'inner tool call should be in subagent chat'); // Verify the parent session does NOT have the inner tool call const parentState = stateManager.getSessionState(sessionUri.toString()); @@ -2507,12 +2880,12 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // Inner event arrives but `subagent_started` never does. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2520,7 +2893,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined, @@ -2530,7 +2903,7 @@ suite('AgentSideEffects', () => { // Parent tool completes (e.g. it errored before delegating). agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-1', @@ -2541,9 +2914,9 @@ suite('AgentSideEffects', () => { // Now a late `subagent_started` for the same toolCallId arrives. // This is unusual but possible after a reconnect/replay. The // drain must NOT replay the (cleared) buffered inner tool call. - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState, 'subagent session should still be created'); const innerTool = subState!.activeTurn?.responseParts.find( @@ -2558,15 +2931,15 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Completing the parent tool call must NOT tear down the // subagent session — background subagents keep running after // their parent tool call returns. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-1', @@ -2574,62 +2947,61 @@ suite('AgentSideEffects', () => { }, }); - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); let subState = stateManager.getSessionState(subagentUri); assert.ok(subState); assert.ok(subState!.activeTurn, 'subagent turn should still be active after parent tool completes'); // The SDK's `subagent.completed`/`subagent.failed` event is what // actually closes the subagent session. - agent.fireProgress({ kind: 'subagent_completed', session: sessionUri, toolCallId: 'tc-1' }); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1' }); subState = stateManager.getSessionState(subagentUri); assert.strictEqual(subState!.activeTurn, undefined, 'subagent turn should be completed'); assert.strictEqual(subState!.turns.length, 1); }); - test('cancelSubagentSessions cancels all subagent sessions', () => { + test('cancelSubagentSessions cancels all subagent chats', () => { setupSession(); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); // Start two parent tool calls with subagents - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); - // Cancel via parent turn cancellation - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', }); - // Both subagent sessions should have their turns completed (cancelled) - const sub1 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-1`); - const sub2 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-2`); + // Both subagent chats should have their turns completed (cancelled) + const sub1 = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-1')); + const sub2 = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-2')); assert.strictEqual(sub1?.activeTurn, undefined, 'sub1 turn should be cancelled'); assert.strictEqual(sub2?.activeTurn, undefined, 'sub2 turn should be cancelled'); }); - test('removeSubagentSessions removes all subagent sessions from state', () => { + test('removeSubagentSessions removes all subagent chats from state', () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - assert.ok(stateManager.getSessionState(subagentUri)); + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); + assert.ok(stateManager.getChatState(subagentUri)); sideEffects.removeSubagentSessions(sessionUri.toString()); - assert.strictEqual(stateManager.getSessionState(subagentUri), undefined, 'subagent session should be removed'); + assert.strictEqual(stateManager.getChatState(subagentUri), undefined, 'subagent chat should be removed'); }); test('deltas with parentToolCallId route to subagent session', () => { @@ -2637,18 +3009,18 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Fire a delta with parentToolCallId agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1', action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-sub', content: 'thinking...' } }, }); // Verify the delta went to the subagent session - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState?.activeTurn); const markdownPart = subState!.activeTurn!.responseParts.find( @@ -2662,9 +3034,9 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); // Verify subagent content is on the running tool const runningState = stateManager.getSessionState(sessionUri.toString()); @@ -2676,7 +3048,7 @@ suite('AgentSideEffects', () => { // Complete the tool — the SDK result has its own content agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-1', @@ -2707,12 +3079,12 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // 1. Parent tool starts (the `task` invocation). - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // 2. Inner tool fires BEFORE subagent_started (race condition). agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined, @@ -2720,7 +3092,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, @@ -2729,9 +3101,9 @@ suite('AgentSideEffects', () => { }); // 3. subagent_started arrives later. - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); const subState = stateManager.getSessionState(subagentUri); assert.ok(subState?.activeTurn, 'subagent session should exist'); @@ -2758,14 +3130,14 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Parent task tool spawns a subagent. - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Inner tool inside the subagent requests permission to read a file // inside the parent workspace. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', contributor: undefined, @@ -2773,7 +3145,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-read-1', invocationMessage: 'Read file', toolInput: undefined, @@ -2781,7 +3153,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'inner-read-1', toolName: '', displayName: '', @@ -2819,14 +3191,14 @@ suite('AgentSideEffects', () => { values: { autoApprove: 'autoApprove' }, }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Inner write outside the workspace would normally NOT auto-approve, // but session-level autoApprove on the parent must apply. agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -2834,7 +3206,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-write-1', invocationMessage: 'Write file', toolInput: undefined, @@ -2842,7 +3214,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'inner-write-1', toolName: '', displayName: '', @@ -2859,6 +3231,136 @@ suite('AgentSideEffects', () => { }); }); + // ---- Session inputNeeded production ------------------------------------- + + suite('session inputNeeded production', () => { + + function sessionInputNeeded() { + return stateManager.getSessionState(sessionUri.toString())?.inputNeeded ?? []; + } + + test('chat input request is produced and removed on completion', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatInputRequested, + request: { id: 'req-1', questions: [] }, + }); + + const produced = sessionInputNeeded(); + assert.deepStrictEqual(produced.map(r => ({ kind: r.kind, chat: r.chat })), [ + { kind: SessionInputRequestKind.ChatInput, chat: defaultChatUri }, + ]); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatInputCompleted, + requestId: 'req-1', + response: SessionInputResponseKind.Accept, + }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('tool confirmation is produced while pending and removed once confirmed', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-1', toolName: 'write', displayName: 'Write', + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: 'tc-1', invocationMessage: 'Write file', confirmationTitle: 'Write file', + }); + + const pending = sessionInputNeeded(); + assert.deepStrictEqual( + pending.map(r => ({ kind: r.kind, chat: r.chat, toolCallId: r.kind === SessionInputRequestKind.ToolConfirmation ? r.toolCall.toolCallId : undefined })), + [{ kind: SessionInputRequestKind.ToolConfirmation, chat: defaultChatUri, toolCallId: 'tc-1' }], + ); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', + toolCallId: 'tc-1', approved: true, confirmed: ToolCallConfirmationReason.UserAction, + }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('client tool execution is produced while running and removed once complete', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-client', toolName: 'toolSearch', displayName: 'Search for Tools', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: 'tc-client', invocationMessage: 'Searching', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const running = sessionInputNeeded(); + assert.deepStrictEqual( + running.map(r => ({ kind: r.kind, chat: r.chat, clientId: r.kind === SessionInputRequestKind.ToolClientExecution ? r.clientId : undefined })), + [{ kind: SessionInputRequestKind.ToolClientExecution, chat: defaultChatUri, clientId: 'client-1' }], + ); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallComplete, turnId: 'turn-1', + toolCallId: 'tc-client', result: { success: true, pastTenseMessage: 'Searched' }, + }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('ending the turn clears the chat\'s outstanding requests', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatInputRequested, + request: { id: 'req-1', questions: [] }, + }); + assert.strictEqual(sessionInputNeeded().length, 1); + + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('a blocker inside a subagent is produced against the subagent chat', async () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Delegate Task' }, + }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' }); + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', + action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-inner', toolName: 'write', displayName: 'Write' }, + }); + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', + action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-inner', invocationMessage: 'Write file', confirmationTitle: 'Write file' }, + }); + + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); + const produced = await waitForState(stateManager, () => { + const entry = sessionInputNeeded().find(r => r.kind === SessionInputRequestKind.ToolConfirmation); + return entry?.kind === SessionInputRequestKind.ToolConfirmation ? entry : undefined; + }); + + assert.deepStrictEqual({ chat: produced.chat, toolCallId: produced.toolCall.toolCallId }, { chat: subagentUri, toolCallId: 'tc-inner' }); + }); + }); + // ---- Session permissions ------------------------------------------------ suite('session permissions', () => { @@ -2869,7 +3371,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -2877,7 +3379,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -2886,7 +3388,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-1', toolName: '', displayName: '', @@ -2918,11 +3420,11 @@ suite('AgentSideEffects', () => { schema: { type: 'object', properties: {} }, values: {}, }); - startTurn('turn-1'); + startTurn('turn-1', defaultChatUri); disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -2930,7 +3432,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-perm-2', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -2939,7 +3441,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-2', toolName: '', displayName: '', @@ -2949,7 +3451,7 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - sideEffects.handleAction(sessionUri.toString(), { + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-perm-2', @@ -2975,7 +3477,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -2983,7 +3485,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-perm-3', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -2992,7 +3494,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-perm-3', toolName: '', displayName: '', @@ -3018,7 +3520,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, @@ -3026,7 +3528,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, @@ -3034,7 +3536,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'subagent_started', session: sessionUri, + kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', @@ -3042,7 +3544,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, @@ -3050,7 +3552,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, @@ -3059,7 +3561,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, + kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'inner-perm-1', toolName: '', displayName: '', @@ -3096,7 +3598,7 @@ suite('AgentSideEffects', () => { // tool_start + tool_ready + tool_complete with a recorded file edit. agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-edit-1', toolName: 'write', displayName: 'Write', contributor: undefined, @@ -3104,7 +3606,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-edit-1', invocationMessage: 'Write file', toolInput: undefined, @@ -3112,7 +3614,7 @@ suite('AgentSideEffects', () => { }, }); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallComplete, turnId: 'turn-1', toolCallId: 'tc-edit-1', @@ -3145,7 +3647,7 @@ suite('AgentSideEffects', () => { disposables.add(localSideEffects.registerProgressListener(agent)); agent.fireProgress({ - kind: 'action', session: sessionUri, + kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, }); @@ -3170,7 +3672,7 @@ suite('AgentSideEffects', () => { onTurnComplete: () => { }, }, undefined, NullTelemetryService, changesets); - localSideEffects.handleAction(sessionUri.toString(), { + localSideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-1', }); diff --git a/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts b/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts new file mode 100644 index 0000000000000..c3127b222e4c8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IByokLmBridgeConnection, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; + +/** + * Pins the behaviour of {@link ByokLmBridgeRegistry}: it surfaces the models of a + * single *serving* connection (preferring one that actually has models), routes + * inference there, excludes connections whose enumeration rejects, and notifies + * listeners on model/connection changes. + */ +suite('ByokLmBridgeRegistry', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + /** A scripted bridge connection; `chat` is unused by these tests. */ + function connectionOf(listModels: () => Promise, onDidChangeModels?: Emitter): IByokLmBridgeConnection { + return { + chat: async (): Promise => ({ content: '' }), + listModels, + ...(onDidChangeModels ? { onDidChangeModels: onDidChangeModels.event } : {}), + }; + } + + /** Resolves once every microtask-queued registry refresh has settled. */ + const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + + test('surfaces the serving window\'s models and routes inference to it; a non-serving window is excluded', async () => { + const registry = new ByokLmBridgeRegistry(); + // A serving window (its listModels resolves) and a window that connected + // without a BYOK handler, whose bridge rejects. + const serving = connectionOf(async () => [{ vendor: 'acme', id: 'claude' }, { vendor: 'acme', id: 'gpt' }]); + const nonServing: IByokLmBridgeConnection = { + chat: async (): Promise => ({ content: '' }), + listModels: async () => { throw new Error('no BYOK handler in this window'); }, + }; + const regServing = registry.register('editor', serving); + const regNonServing = registry.register('no-handler', nonServing); + + const models = await registry.listModels(); + + assert.deepStrictEqual({ + models, + cached: registry.getModels(), + serving: registry.getServingConnection() === serving, + }, { + models: [{ vendor: 'acme', id: 'claude' }, { vendor: 'acme', id: 'gpt' }], + cached: [{ vendor: 'acme', id: 'claude' }, { vendor: 'acme', id: 'gpt' }], + serving: true, + }); + + regServing.dispose(); + regNonServing.dispose(); + }); + + test('a window that answers with an empty list is still a valid serving target', async () => { + const registry = new ByokLmBridgeRegistry(); + const only = connectionOf(async () => []); + const reg = registry.register('client-only', only); + await registry.listModels(); + + assert.deepStrictEqual({ + models: registry.getModels(), + serving: registry.getServingConnection() === only, + }, { models: [], serving: true }); + + reg.dispose(); + }); + + test('a window that answered empty does not shadow a peer that has models, even when it connected first', async () => { + const registry = new ByokLmBridgeRegistry(); + // The Agents app connects first and answers empty (its BYOK extension has + // not registered models yet); a peer window answers with models. The peer + // must win — an empty-but-serving window must never shadow a populated one. + const empty = connectionOf(async () => []); + const withModels = connectionOf(async () => [{ vendor: 'acme', id: 'claude' }]); + const regEmpty = registry.register('agents', empty); + const regWithModels = registry.register('editor', withModels); + + await registry.listModels(); + + assert.deepStrictEqual({ + models: registry.getModels(), + serving: registry.getServingConnection() === withModels, + }, { + models: [{ vendor: 'acme', id: 'claude' }], + serving: true, + }); + + regEmpty.dispose(); + regWithModels.dispose(); + }); + + test('unregistering the serving connection drops its models and notifies listeners', async () => { + const registry = new ByokLmBridgeRegistry(); + let changes = 0; + store.add(registry.onDidChangeModels(() => { changes++; })); + + const reg = registry.register('client-a', connectionOf(async () => [{ vendor: 'acme', id: 'claude' }])); + await registry.listModels(); + assert.strictEqual(registry.getModels().length, 1); + + const changesBeforeDispose = changes; + reg.dispose(); + + assert.deepStrictEqual({ + models: registry.getModels(), + serving: registry.getServingConnection(), + firedOnDispose: changes > changesBeforeDispose, + }, { + models: [], + serving: undefined, + firedOnDispose: true, + }); + }); + + test('re-enumerates and notifies when a connection reports onDidChangeModels', async () => { + const registry = new ByokLmBridgeRegistry(); + const onDidChange = store.add(new Emitter()); + let models: IByokLmModelInfo[] = []; + const connection: IByokLmBridgeConnection = { + chat: async (): Promise => ({ content: '' }), + listModels: async () => models, + onDidChangeModels: onDidChange.event, + }; + const reg = registry.register('client-a', connection); + await registry.listModels(); + assert.strictEqual(registry.getModels().length, 0); + + let changed = false; + store.add(registry.onDidChangeModels(() => { changed = true; })); + models = [{ vendor: 'acme', id: 'claude' }]; + onDidChange.fire(); + await flush(); + + assert.deepStrictEqual({ changed, models: registry.getModels() }, { + changed: true, + models: [{ vendor: 'acme', id: 'claude' }], + }); + + reg.dispose(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts new file mode 100644 index 0000000000000..c7eeb608f95f5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../log/common/log.js'; +import type { IByokLmChatRequest, IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; + +/** + * Exercises the inference path end-to-end without the Copilot SDK runtime: + * the test plays the runtime's role by POSTing OpenAI Chat Completions + * requests at the loopback proxy, and plays the renderer's role with a fake + * {@link IByokLmChatRequest} -> {@link IByokLmChatResult} bridge function. The + * only contract under test is the OpenAI wire format in, the bridge DTO out, + * and the SSE wire format back. + */ +suite('ByokLmProxyService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + + async function withProxy( + chat: (request: IByokLmChatRequest) => Promise, + run: (handle: IByokLmProxyHandle) => Promise, + ): Promise { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', { chat, listModels: async () => [] }); + const service = new ByokLmProxyService(new NullLogService(), registry); + const handle = await service.start(); + try { + await run(handle); + } finally { + handle.dispose(); + registration.dispose(); + service.dispose(); + } + } + + function chatUrl(handle: IByokLmProxyHandle, vendor: string): string { + return `${handle.providerBaseUrl(vendor)}/chat/completions`; + } + + function authHeaders(handle: IByokLmProxyHandle): Record { + return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}.${sessionId}` }; + } + + test('serves the unauthenticated health check', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(`${handle.baseUrl}/`); + assert.strictEqual(response.status, 200); + assert.strictEqual(await response.text(), 'ok'); + }, + ); + }); + + test('rejects requests without a valid bearer token', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 401); + }, + ); + }); + + test('rejects a nonce-only bearer token (no session id)', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}` }, + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 401); + }, + ); + }); + + test('returns 404 for an authenticated but unknown route', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(`${handle.baseUrl}/v/acme/responses`, { + method: 'POST', + headers: authHeaders(handle), + body: '{}', + }); + assert.strictEqual(response.status, 404); + }, + ); + }); + + test('forwards a chat request to the bridge and streams an SSE completion', async () => { + let captured: IByokLmChatRequest | undefined; + await withProxy( + async (request) => { + captured = request; + return { content: 'hello from byok' }; + }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'claude', messages: [{ role: 'user', content: 'hi' }] }), + }); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('content-type'), 'text/event-stream'); + const text = await response.text(); + assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); + assert.ok(text.trimEnd().endsWith('data: [DONE]')); + }, + ); + assert.strictEqual(captured?.vendor, 'acme'); + assert.strictEqual(captured?.modelId, 'claude'); + assert.deepStrictEqual(captured?.messages, [{ role: 'user', content: 'hi', toolCalls: undefined, toolCallId: undefined }]); + }); + + test('decodes a url-encoded vendor path segment', async () => { + let captured: IByokLmChatRequest | undefined; + await withProxy( + async (request) => { captured = request; return { content: 'ok' }; }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme corp'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 200); + await response.text(); + }, + ); + assert.strictEqual(captured?.vendor, 'acme corp'); + }); + + test('rejects a vendor that decodes to a multi-segment path (%2F)', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + // `encodeURIComponent('a/b')` → `a%2Fb`, which survives the + // pre-decode segment check but decodes back into `a/b`. + const response = await fetch(chatUrl(handle, 'a/b'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 404); + }, + ); + }); + + test('streams assistant tool calls as OpenAI tool_call deltas', async () => { + await withProxy( + async () => ({ content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [{ role: 'user', content: 'weather?' }] }), + }); + const text = await response.text(); + assert.ok(text.includes('"tool_calls"'), `expected tool_calls in SSE: ${text}`); + assert.ok(text.includes('"finish_reason":"tool_calls"'), `expected tool_calls finish reason: ${text}`); + assert.ok(text.includes('getWeather')); + }, + ); + }); + + test('returns a 502 when the bridge reports an error', async () => { + await withProxy( + async () => ({ content: '', error: 'model unavailable' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 502); + const body = await response.json() as { error?: { message?: string } }; + assert.strictEqual(body.error?.message, 'model unavailable'); + }, + ); + }); + + test('returns a 502 when the bridge throws', async () => { + await withProxy( + async () => { throw new Error('bridge exploded'); }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 502); + const body = await response.json() as { error?: { message?: string } }; + assert.strictEqual(body.error?.message, 'bridge exploded'); + }, + ); + }); + + test('rejects a malformed JSON body with 400', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: 'not json', + }); + assert.strictEqual(response.status, 400); + }, + ); + }); + + test('returns a 503 when no renderer bridge is connected', async () => { + const registry = new ByokLmBridgeRegistry(); + const service = new ByokLmProxyService(new NullLogService(), registry); + const handle = await service.start(); + try { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 503); + } finally { + handle.dispose(); + service.dispose(); + } + }); + + test('routes requests to a serving window and excludes a non-serving one', async () => { + const registry = new ByokLmBridgeRegistry(); + const calls: string[] = []; + // The serving window (editor): enumerates models and answers chat. + const regServing = registry.register('editor', { + chat: async () => { calls.push('serving'); return { content: 'from serving' }; }, + listModels: async () => [{ vendor: 'acme', id: 'claude' }], + }); + // A non-serving window (connected without a BYOK handler): its bridge + // rejects, so it must never be picked for routing even though it is connected. + const regNonServing = registry.register('no-handler', { + chat: async () => { calls.push('no-handler'); return { content: 'from non-serving' }; }, + listModels: async () => { throw new Error('no BYOK handler'); }, + }); + const service = new ByokLmProxyService(new NullLogService(), registry); + // Warm the cache so the serving window is known before routing. + await registry.listModels(); + const handle = await service.start(); + try { + const res = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', headers: authHeaders(handle), + body: JSON.stringify({ model: 'claude', messages: [] }), + }); + assert.deepStrictEqual({ + routedToServing: (await res.text()).includes('from serving'), + calls, + }, { routedToServing: true, calls: ['serving'] }); + } finally { + handle.dispose(); + regServing.dispose(); + regNonServing.dispose(); + service.dispose(); + } + }); + + test('rebinds with a fresh nonce after every handle is disposed', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', { chat: async () => ({ content: 'ok' }), listModels: async () => [] }); + const service = new ByokLmProxyService(new NullLogService(), registry); + const first = await service.start(); + const firstNonce = first.nonce; + first.dispose(); + const second = await service.start(); + try { + assert.notStrictEqual(second.nonce, firstNonce); + } finally { + second.dispose(); + registration.dispose(); + service.dispose(); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts b/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts new file mode 100644 index 0000000000000..299a27183bd41 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { + bridgeResultToSseFrames, + openAiRequestToBridge, + OpenAiTranslationError, + type IOpenAiChatRequest, +} from '../../node/copilot/byokOpenAiTranslation.js'; + +suite('byokOpenAiTranslation', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('openAiRequestToBridge', () => { + + test('maps roles, text content, tools and options', () => { + const body: IOpenAiChatRequest = { + model: 'claude-sonnet', + temperature: 0.5, + max_tokens: 256, + messages: [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: [{ type: 'text', text: 'hi ' }, { type: 'text', text: 'there' }] }, + { + role: 'assistant', + content: '', + tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }], + }, + { role: 'tool', tool_call_id: 'call_1', content: 'sunny' }, + ], + tools: [{ type: 'function', function: { name: 'getWeather', description: 'weather', parameters: { type: 'object' } } }], + }; + + const result = openAiRequestToBridge('acme', body); + + assert.deepStrictEqual(result, { + vendor: 'acme', + modelId: 'claude-sonnet', + messages: [ + { role: 'system', content: 'be helpful', toolCalls: undefined, toolCallId: undefined }, + { role: 'user', content: 'hi there', toolCalls: undefined, toolCallId: undefined }, + { role: 'assistant', content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], toolCallId: undefined }, + { role: 'tool', content: 'sunny', toolCalls: undefined, toolCallId: 'call_1' }, + ], + tools: [{ name: 'getWeather', description: 'weather', parametersSchema: { type: 'object' } }], + modelOptions: { temperature: 0.5, max_tokens: 256 }, + }); + }); + + test('throws when model is missing', () => { + assert.throws(() => openAiRequestToBridge('acme', { messages: [] }), OpenAiTranslationError); + }); + + test('throws when an assistant tool call is missing its function name', () => { + assert.throws(() => openAiRequestToBridge('acme', { + model: 'm', + messages: [{ role: 'assistant', content: '', tool_calls: [{ id: 'call_1', type: 'function', function: { arguments: '{}' } }] }], + }), OpenAiTranslationError); + }); + + test('omits tools and options when absent', () => { + const result = openAiRequestToBridge('acme', { model: 'm', messages: [{ role: 'user', content: 'hello' }] }); + assert.strictEqual(result.tools, undefined); + assert.strictEqual(result.modelOptions, undefined); + }); + }); + + suite('bridgeResultToSseFrames', () => { + + function parseFrames(frames: string[]): unknown[] { + return frames + .map(frame => frame.replace(/^data: /, '').trim()) + .filter(payload => payload !== '[DONE]') + .map(payload => JSON.parse(payload)); + } + + test('emits role, content and stop frames terminated by [DONE]', () => { + const result: IByokLmChatResult = { content: 'hello world' }; + const frames = bridgeResultToSseFrames(result, 'm'); + + assert.strictEqual(frames[frames.length - 1], 'data: [DONE]\n\n'); + const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record; finish_reason: string | null }> }>; + assert.deepStrictEqual(parsed.map(p => p.choices[0].delta), [ + { role: 'assistant' }, + { content: 'hello world' }, + {}, + ]); + assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'stop'); + }); + + test('encodes tool calls and a tool_calls finish reason', () => { + const result: IByokLmChatResult = { + content: '', + toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], + }; + const frames = bridgeResultToSseFrames(result, 'm'); + const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record; finish_reason: string | null }> }>; + + const toolDelta = parsed.find(p => p.choices[0].delta.tool_calls !== undefined); + assert.deepStrictEqual(toolDelta?.choices[0].delta.tool_calls, [ + { index: 0, id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }, + ]); + assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'tool_calls'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 24dade78d60a6..19c501ffc5fb4 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -39,10 +39,16 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import type * as http from 'http'; import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { type AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { ResponsePartKind, ToolResultContentType, type ClientPluginCustomization } from '../../common/state/sessionState.js'; @@ -70,6 +76,23 @@ import { // #region Test fixtures +/** + * The {@link IFileService} + {@link INativeEnvironmentService} pair the + * Phase 16 customization disk scan / watcher needs at session construction + * time. Nothing is seeded under `userHome`, so the scan is deterministically + * empty — these only exist so `new ClaudeAgentSession` can read `userHome` + * and start its watcher without throwing. + */ +function claudeFileEnvServices(disposables: Pick): [typeof IFileService | typeof INativeEnvironmentService, IFileService | INativeEnvironmentService][] { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const env: Partial = { userHome: URI.file('/mock-home') }; + return [ + [IFileService, fileService], + [INativeEnvironmentService, env as INativeEnvironmentService], + ]; +} + const ANTHROPIC_MODEL: CCAModel = { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', @@ -216,6 +239,8 @@ class StubCopilotApiService implements ICopilotApiService { readonly messagesCallCount = { count: 0 }; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + messages( token: string, request: Anthropic.MessageCreateParamsStreaming, @@ -333,6 +358,10 @@ class ProxyRoundTripSdkService implements IClaudeAgentSdkService { return []; } + async canLoadWithoutDownload(): Promise { + return true; + } + async getSessionInfo(_sessionId: string): Promise { return undefined; } @@ -349,12 +378,19 @@ class ProxyRoundTripSdkService implements IClaudeAgentSdkService { return []; } + async forkSession(sessionId: string): Promise<{ sessionId: string }> { + return { sessionId: `forked-${sessionId}` }; + } + + async deleteSession(): Promise { /* not exercised by the proxy round-trip */ } + async createSdkMcpServer(): Promise { throw new Error('not implemented in integration test fake'); } async tool(): Promise { throw new Error('not implemented in integration test fake'); } + async query(_params: { prompt: string | AsyncIterable; options?: Options }): Promise { throw new Error('query not used in proxy round-trip integration test'); } + async startup(params: { options: Options; initializeTimeoutMs?: number }): Promise { this.capturedStartupOptions.push(params.options); - const settings = params.options.settings; const settingsEnv = (settings && typeof settings === 'object' && settings.env) ? settings.env : {}; const baseUrl = settingsEnv['ANTHROPIC_BASE_URL']; @@ -453,10 +489,12 @@ class RoundTripQuery implements AsyncGenerator { async interrupt(): Promise { /* not used */ } setPermissionMode(): never { throw new Error('not modeled'); } + setMcpPermissionModeOverride(): never { throw new Error('not modeled'); } setModel(): never { throw new Error('not modeled'); } setMaxThinkingTokens(): never { throw new Error('not modeled'); } applyFlagSettings(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } + reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } supportedModels(): never { throw new Error('not modeled'); } supportedAgents(): never { throw new Error('not modeled'); } @@ -595,6 +633,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], + ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -613,7 +652,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { // First send materializes — drives `startup()`, which performs // the real HTTP round-trip on the real proxy. - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(created.session, 'hi', undefined, 'turn-1'); // Snapshot what flowed through the integration in a single // assertion so the failure surface is the whole pipeline. @@ -724,6 +763,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], + ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -733,7 +773,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { const sessionId = created.session.path.replace(/^\//, ''); sdk.queryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(created.session, 'hi', undefined, 'turn-1'); const startup = sdk.capturedStartupOptions[0]; assert.ok(typeof startup.canUseTool === 'function', 'canUseTool was wired into Options'); @@ -782,6 +822,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], + ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -822,7 +863,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { } })); - await agent.sendMessage(created.session, 'please read /tmp/x', undefined, 'turn-1'); + await agent.chats.sendMessage(created.session, 'please read /tmp/x', undefined, 'turn-1'); // Snapshot the agent-side emission stream as a single shape so // the failure surface is the whole pipeline. diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 348add320d40d..e0adc28a70802 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -4,15 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import type Anthropic from '@anthropic-ai/sdk'; -import type { GetSessionMessagesOptions, McpSdkServerConfigWithInstance, Options, PermissionMode, Query, SDKMessage, SDKSessionInfo, SDKUserMessage, SdkMcpToolDefinition, SessionMessage, Settings, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentInfo, ForkSessionOptions, ForkSessionResult, GetSessionMessagesOptions, McpSdkServerConfigWithInstance, McpServerStatus, ModelInfo, Options, PermissionMode, Query, SDKMessage, SDKSessionInfo, SDKUserMessage, SdkMcpToolDefinition, SessionMessage, SessionMutationOptions, Settings, SlashCommand, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; import { makeAssistantMessage, makeContentBlockStartText, makeContentBlockStartThinking, + makeContentBlockStartToolUse, makeContentBlockStop, makeMessageStart, makeMessageStop, @@ -21,8 +24,10 @@ import { makeSystemInitMessage, makeTextDelta, makeThinkingDelta, + makeUserToolResultMessage, } from './claudeMapSessionEventsTestUtils.js'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import type { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; @@ -35,10 +40,14 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { FileService } from '../../../files/common/fileService.js'; -import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +import { IAgentChatDataChange, IAgentMaterializeSessionEvent, IAgentSpawnChatEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri, customizationId, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, parseChatUri, parseDefaultChatUri, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ProtectedResourceMetadata, ChatInputAnswerState, ChatInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type ChatInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; @@ -46,7 +55,7 @@ import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; +import { ClaudeAgent, fromSdkModelInfo } from '../../node/claude/claudeAgent.js'; import { ClaudeAgentSession } from '../../node/claude/claudeAgentSession.js'; import { ClaudeSessionMetadataStore } from '../../node/claude/claudeSessionMetadataStore.js'; import { ClaudeAgentSdkService, IClaudeAgentSdkService, IClaudeSdkBindings } from '../../node/claude/claudeAgentSdkService.js'; @@ -64,6 +73,25 @@ interface IStartCall { readonly token: string; } +/** + * Enumerate the agent's live peer-chat backings for a session as channel URI + * strings. Replaces the removed `IAgent.getChats` for tests that assert peer + * chat lifecycle at the agent level (the orchestrator now owns the durable + * catalog). + */ +function listPeerChats(agent: ClaudeAgent, session: URI): string[] { + const backings = (agent as unknown as { _chatBackings: Map })._chatBackings; + const sessionId = AgentSession.id(session); + return [...backings.keys()].filter(key => { + const parsed = parseChatUri(URI.parse(key)); + return !!parsed && AgentSession.id(URI.parse(parsed.session)) === sessionId; + }); +} + +function defaultChatUri(session: URI): URI { + return URI.parse(buildDefaultChatUri(session)); +} + class FakeAgentPluginManager implements IAgentPluginManager { declare readonly _serviceBrand: undefined; readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); @@ -121,6 +149,7 @@ class FakeCopilotApiService implements ICopilotApiService { countTokens(): Promise { throw new Error('not used in ClaudeAgent tests'); } responses(): Promise { throw new Error('not used in ClaudeAgent tests'); } utilityChatCompletion(): Promise { throw new Error('not used in ClaudeAgent tests'); } + resolveRestrictedTelemetryContext() { return Promise.resolve({ restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }); } } const FakeProductService: IProductService = { @@ -185,9 +214,36 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { */ queryAdvance: ((index: number) => Promise) | undefined; + /** + * Optional gate awaited by {@link FakeQuery.return}. Models the SDK's + * teardown awaiting the subprocess's actual exit, so tests can assert + * remove-all defers `deleteSession` until the live query has fully torn + * down. Resolves immediately when undefined. + */ + queryReturnGate: Promise | undefined; + + /** + * Phase 16 — programmable live SDK customization snapshot, read by + * {@link FakeQuery.supportedCommands} / `supportedAgents` / + * `mcpServerStatus`. `supportedCommands` defaults to `[]`; the other two + * stay unmodeled (throwing) until a test opts in, preserving the + * snapshot-failure coverage. + */ + supportedCommandsResult: SlashCommand[] = []; + supportedAgentsResult: AgentInfo[] | undefined = undefined; + mcpServerStatusResult: McpServerStatus[] | undefined = undefined; + + /** Phase 19 — programmable native model enumeration. */ + supportedModelsResult: ModelInfo[] = []; + supportedModelsCallCount = 0; + readonly supportedModelsOptions: Options[] = []; + /** All warm queries produced by {@link startup}. Last entry is the most recent. */ readonly warmQueries: FakeWarmQuery[] = []; + /** All queries produced by {@link query} (native model enumeration). */ + readonly enumerationQueries: FakeQuery[] = []; + /** * Programmable rejection for {@link listSessions}. Set per test to * simulate the SDK dynamic import failing (corrupt postinstall, @@ -204,6 +260,10 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { return this.sessionList; } + async canLoadWithoutDownload(): Promise { + return true; + } + /** * Fake for {@link IClaudeAgentSdkService.getSessionInfo}. Tests stage * `sessionList` and the fake searches it by id; setting @@ -275,6 +335,38 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { return this.subagentMessagesByKey.get(`${sessionId}::${agentId}`) ?? []; } + /** + * Phase 6.5: programmable fork. Tests capture the forwarded + * `(sessionId, options)` and program the resulting new session id (or a + * rejection). Defaults to a deterministic `forked-` id so suites + * that don't care about the exact value don't have to set it. + */ + forkSessionCalls: { sessionId: string; options: ForkSessionOptions | undefined }[] = []; + forkSessionResult: ForkSessionResult | undefined; + forkSessionRejection: Error | undefined; + + async forkSession(sessionId: string, options?: ForkSessionOptions): Promise { + this.forkSessionCalls.push({ sessionId, options }); + if (this.forkSessionRejection) { + throw this.forkSessionRejection; + } + return this.forkSessionResult ?? { sessionId: `forked-${sessionId}` }; + } + + /** + * Programmable session deletion (remove-all truncation). Tests + * capture the deleted ids; `deleteSessionRejection` simulates SDK throw. + */ + deleteSessionCalls: string[] = []; + deleteSessionRejection: Error | undefined; + + async deleteSession(sessionId: string, _options?: SessionMutationOptions): Promise { + this.deleteSessionCalls.push(sessionId); + if (this.deleteSessionRejection) { + throw this.deleteSessionRejection; + } + } + async startup(params: { options: Options; initializeTimeoutMs?: number }): Promise { this.startupCallCount++; this.capturedStartupOptions.push(params.options); @@ -291,6 +383,18 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { return warm; } + async query(params: { prompt: string | AsyncIterable; options?: Options }): Promise { + if (params.options) { + this.supportedModelsOptions.push(params.options); + } + if (typeof params.prompt === 'string') { + throw new Error('FakeClaudeAgentSdkService.query: enumeration always passes an AsyncIterable prompt'); + } + const query = new FakeQuery(params.prompt, this); + this.enumerationQueries.push(query); + return query; + } + /** * Optional async hook invoked inside {@link startup} after the call is * counted but before resolving. Tests use it to stage a race where @@ -391,6 +495,7 @@ class FakeQuery implements AsyncGenerator { interruptCount = 0; returnCount = 0; throwCount = 0; + closeCount = 0; /** Modes recorded by `setPermissionMode` calls in plan/turn order. */ readonly recordedPermissionModes: PermissionMode[] = []; @@ -438,6 +543,9 @@ class FakeQuery implements AsyncGenerator { async return(_value: void): Promise> { this.returnCount++; + if (this._sdk.queryReturnGate) { + await this._sdk.queryReturnGate; + } return { done: true, value: undefined }; } @@ -456,16 +564,27 @@ class FakeQuery implements AsyncGenerator { this.recordedPermissionModes.push(mode); } async setModel(model?: string): Promise { this.recordedModels.push(model); } + setMcpPermissionModeOverride(): never { throw new Error('FakeQuery: setMcpPermissionModeOverride not modeled'); } setMaxThinkingTokens(): never { throw new Error('FakeQuery: setMaxThinkingTokens not modeled'); } async applyFlagSettings(s: Settings): Promise { this.recordedFlagSettings.push(s); } initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } + reinitialize(): never { throw new Error('FakeQuery: reinitialize not modeled'); } supportedCommands(): never { - return Promise.resolve([]) as never; + return Promise.resolve(this._sdk.supportedCommandsResult) as never; + } + supportedModels(): Promise { + this._sdk.supportedModelsCallCount++; + return Promise.resolve(this._sdk.supportedModelsResult); + } + supportedAgents(): never { + if (this._sdk.supportedAgentsResult === undefined) { throw new Error('FakeQuery: supportedAgents not modeled'); } + return Promise.resolve(this._sdk.supportedAgentsResult) as never; + } + mcpServerStatus(): never { + if (this._sdk.mcpServerStatusResult === undefined) { throw new Error('FakeQuery: mcpServerStatus not modeled'); } + return Promise.resolve(this._sdk.mcpServerStatusResult) as never; } - supportedModels(): never { throw new Error('FakeQuery: supportedModels not modeled'); } - supportedAgents(): never { throw new Error('FakeQuery: supportedAgents not modeled'); } - mcpServerStatus(): never { throw new Error('FakeQuery: mcpServerStatus not modeled'); } getContextUsage(): never { throw new Error('FakeQuery: getContextUsage not modeled'); } usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(): never { throw new Error('FakeQuery: usage_EXPERIMENTAL not modeled'); } /** Phase 11 — programmable tool-name snapshot returned by `reloadPlugins()`. */ @@ -494,7 +613,7 @@ class FakeQuery implements AsyncGenerator { stopTask(): never { throw new Error('FakeQuery: stopTask not modeled'); } reloadSkills(): never { throw new Error('FakeQuery: reloadSkills not modeled'); } backgroundTasks(): never { throw new Error('FakeQuery: backgroundTasks not modeled'); } - close(): void { /* no-op */ } + close(): void { this.closeCount++; } [Symbol.asyncDispose](): Promise { return Promise.resolve(); } } @@ -603,6 +722,7 @@ interface ITestContext { readonly stateManager: AgentHostStateManager; readonly configService: AgentConfigurationService; readonly instantiationService: IInstantiationService; + readonly fileService: IFileService; } /** @@ -627,7 +747,7 @@ class CapturingLogService extends NullLogService { function createTestContext( disposables: Pick, - overrides?: { logService?: ILogService; database?: TestSessionDatabase }, + overrides?: { logService?: ILogService; database?: TestSessionDatabase; rootConfig?: Record; userHome?: URI }, ): ITestContext { const proxy = new FakeClaudeProxyService(); const api = new FakeCopilotApiService(); @@ -642,7 +762,14 @@ function createTestContext( const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + // In-memory file service the session's customization scan / agent-name + // resolution runs against; exposed so tests can seed `.claude/**` files. + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const services = new ServiceCollection( + [IFileService, fileService], + [INativeEnvironmentService, { userHome: overrides?.userHome ?? URI.file('/mock-home') } as INativeEnvironmentService], [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -654,8 +781,13 @@ function createTestContext( [IProductService, FakeProductService], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + // Phase 19: seed root config (e.g. `claudeUseCopilotProxy`) BEFORE the agent + // resolves its transport mode in the constructor. + if (overrides?.rootConfig) { + configService.updateRootConfig(overrides.rootConfig); + } const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); - return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService }; + return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService, fileService }; } /** Drains the microtask queue so awaited refresh writes settle. */ @@ -663,6 +795,20 @@ function tick(): Promise { return new Promise(resolve => setImmediate(resolve)); } +/** + * A two-turn source transcript (`u1`/`a1`, `u2`/`a2`) used by the Phase 6.5 + * fork tests. Forking at `u1` keeps `[u1]` inclusive, anchored on that turn's + * last assistant envelope `a1`. + */ +function forkSourceMessages(sourceId: string): SessionMessage[] { + return [ + { type: 'user', uuid: 'u1', session_id: sourceId, parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text: 'apple' }] } }, + { type: 'assistant', uuid: 'a1', session_id: sourceId, parent_tool_use_id: null, message: { id: 'msg_a1', role: 'assistant', content: [{ type: 'text', text: 'apple!' }] } }, + { type: 'user', uuid: 'u2', session_id: sourceId, parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text: 'banana' }] } }, + { type: 'assistant', uuid: 'a2', session_id: sourceId, parent_tool_use_id: null, message: { id: 'msg_a2', role: 'assistant', content: [{ type: 'text', text: 'banana!' }] } }, + ]; +} + /** * Stub for {@link IAgentSdkDownloader} consumed by tests that need a real * `ClaudeAgentSdkService` constructor but override `_loadSdk` themselves — @@ -671,11 +817,40 @@ function tick(): Promise { function stubAgentSdkDownloader(): IAgentSdkDownloader { return { _serviceBrand: undefined, + onDidDownloadProgress: Event.None, isAvailable: () => false, + isSdkResolvableWithoutDownload: async () => false, loadSdkRoot: () => { throw new Error('test stub: downloader.loadSdkRoot should not be called'); }, }; } +/** + * Foundational services every {@link ClaudeAgentSession} requires for its + * customization disk scan: an in-memory {@link IFileService} (nothing is + * seeded under the mock home, so the scan is deterministically empty in + * tests) and a mock {@link INativeEnvironmentService} supplying `userHome`. + * Spread into each test {@link ServiceCollection}. + */ +function claudeFileEnvServices(disposables: Pick): [typeof IFileService | typeof INativeEnvironmentService, IFileService | INativeEnvironmentService][] { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + return [ + [IFileService, fileService], + [INativeEnvironmentService, { userHome: URI.file('/mock-home') } as INativeEnvironmentService], + ]; +} + +/** + * A real {@link AgentConfigurationService} backed by a fresh + * {@link AgentHostStateManager} for minimal test {@link ServiceCollection}s + * that don't otherwise build one. `ClaudeAgent` always receives this service + * via DI in production, so tests must register it too. + */ +function createTestAgentConfigService(disposables: Pick): AgentConfigurationService { + const logService = new NullLogService(); + return disposables.add(new AgentConfigurationService(disposables.add(new AgentHostStateManager(logService)), logService)); +} + // #endregion suite('ClaudeAgent', () => { @@ -713,6 +888,90 @@ suite('ClaudeAgent', () => { assert.deepStrictEqual(agent.models.get(), []); }); + test('fromSdkModelInfo projects ModelInfo without commercial metadata and reuses the effort schema', () => { + const projected = fromSdkModelInfo( + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: 'desc', supportedEffortLevels: ['low', 'high'] }, + 'claude', + ); + assert.deepStrictEqual({ + provider: projected.provider, + id: projected.id, + name: projected.name, + supportsVision: projected.supportsVision, + hasConfigSchema: projected.configSchema !== undefined, + hasPolicyState: projected.policyState !== undefined, + hasMeta: projected._meta !== undefined, + }, { + provider: 'claude', + id: 'claude-sonnet-4-5-20250929', + name: 'Claude Sonnet 4.5', + supportsVision: false, + hasConfigSchema: true, + hasPolicyState: false, + hasMeta: false, + }); + }); + + test('native transport: getProtectedResources omits the Copilot resource', () => { + const { agent } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + assert.deepStrictEqual( + agent.getProtectedResources().map(r => r.resource), + ['https://api.github.com/repos'], + ); + }); + + test('native transport: models populate from supportedModels() with no proxy start and no CAPI models() call', async () => { + const { agent, proxy, api, sdk } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + let capiModelsCalls = 0; + api.models = async () => { capiModelsCalls++; return []; }; + sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] }, + ]; + // The constructor kicks off an initial native refresh; `_fetchNativeModels` + // awaits a real `mkdtemp` before enumerating, so poll until it lands. + for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { + await tick(); + } + await tick(); + assert.deepStrictEqual({ + models: agent.models.get().map(m => ({ id: m.id, name: m.name })), + proxyStarts: proxy.startCalls.length, + supportedModelsCalls: sdk.supportedModelsCallCount, + capiModelsCalls, + }, { + models: [{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }], + proxyStarts: 0, + supportedModelsCalls: 1, + capiModelsCalls: 0, + }); + }); + + test('native model enumeration closes the throwaway query (no leaked subprocess)', async () => { + const { sdk } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + sdk.supportedModelsResult = [ + { value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '' }, + ]; + // The constructor kicks off the initial native enumeration; wait for it. + for (let i = 0; i < 100 && sdk.supportedModelsCallCount === 0; i++) { + await tick(); + } + await tick(); + assert.deepStrictEqual({ + queries: sdk.enumerationQueries.length, + closed: sdk.enumerationQueries[0]?.closeCount, + }, { + queries: 1, + closed: 1, + }); + }); + + test('native transport: authenticate never starts the proxy', async () => { + const { agent, proxy } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + const accepted = await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + assert.deepStrictEqual({ accepted, proxyStarts: proxy.startCalls.length }, { accepted: true, proxyStarts: 0 }); + }); + test('createSession before authenticate throws ProtocolError(AHP_AUTH_REQUIRED) with protected resources', async () => { const { agent } = createTestContext(disposables); @@ -740,12 +999,44 @@ suite('ClaudeAgent', () => { accepted: true, startCalls: ['tok'], models: [ - { provider: 'claude', id: 'claude-opus-4.6', name: 'Claude Opus 4.6', maxContextWindow: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, - { provider: 'claude', id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', maxContextWindow: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, + { provider: 'claude', id: 'claude-opus-4.6', name: 'Claude Opus 4.6', maxContextWindow: 200_000, maxOutputTokens: 8192, maxPromptTokens: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, + { provider: 'claude', id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', maxContextWindow: 200_000, maxOutputTokens: 8192, maxPromptTokens: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, ], }); }); + test('authenticate populates full pricing metadata from billing tokenPrices', async () => { + const modelWithPricing = makeModel({ + id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', vendor: 'Anthropic', + billing: { + is_premium: false, multiplier: 2, restricted_to: [], + // Runtime CAPI fields not yet declared on the SDK type: + tokenPrices: { inputPrice: 3, cachePrice: 0.3, cacheWritePrice: 3.75, outputPrice: 15, longContext: { inputPrice: 6, cachePrice: 0.6, cacheWritePrice: 7.5, outputPrice: 30 } }, + priceCategory: 'medium', + } as CCAModel['billing'], + }); + + const { agent, api } = createTestContext(disposables); + api.models = async () => [modelWithPricing]; + await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + const models = agent.models.get(); + assert.strictEqual(models.length, 1); + assert.deepStrictEqual(models[0]._meta, { + multiplierNumeric: 2, + inputCost: 3, + cacheCost: 0.3, + cacheWriteCost: 3.75, + outputCost: 15, + longContextInputCost: 6, + longContextCacheCost: 0.6, + longContextCacheWriteCost: 7.5, + longContextOutputCost: 30, + priceCategory: 'medium', + }); + }); + test('authenticate surfaces the CAPI chat-default model first; ties preserve insertion order', async () => { // `IAgentModelInfo` carries no explicit `isDefault` bit; the // picker uses `models[0]` as the de facto default at @@ -947,6 +1238,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, api], [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], @@ -1010,6 +1302,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, api], [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], @@ -1031,15 +1324,16 @@ suite('ClaudeAgent', () => { test('phase-stub graduation: abortSession + changeModel no longer throw', async () => { // Phase 9 graduation: both methods land in this phase. They are - // idempotent on unknown session URIs (no-op rather than throw) + // idempotent on unknown default chat URIs (no-op rather than throw) // because the workbench may race a session dispose with these // calls; matching CopilotAgent's permissive surface keeps the // AgentSideEffects.handleAction `.catch()` path quiet on common // paths. Behavior on known sessions is exercised by the dedicated // Phase 9 suites below. const { agent } = createTestContext(disposables); - await agent.abortSession(URI.parse('claude:/unknown')); - await agent.changeModel(URI.parse('claude:/unknown'), { id: 'claude-opus-4.6' }); + const chat = defaultChatUri(URI.parse('claude:/unknown')); + await agent.chats.abort(chat); + await agent.chats.changeModel(chat, { id: 'claude-opus-4.6' }); }); test('AgentService surfaces the registered ClaudeAgent in the providers map', () => { @@ -1078,6 +1372,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, api], [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], @@ -1140,12 +1435,38 @@ suite('ClaudeAgent', () => { }); }); + test('createSession without a workingDirectory materializes in a shared scratch dir (workspace-less quick chat)', async () => { + // Regression: a workspace-less quick chat gave Claude no cwd, so it + // threw "workingDirectory is required" at materialize. The scratch-dir + // fallback is now shared with the Copilot agent. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/claude-qc-home-`)); + const { agent, sdk } = createTestContext(disposables, { userHome }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({}); + const sessionId = AgentSession.id(created.session); + const expected = URI.joinPath(userHome, '.copilot', 'chats', sessionId); + assert.strictEqual(created.workingDirectory?.fsPath, expected.fsPath); + await fs.access(expected.fsPath); + + // Drive materialize via the first send; before the fix this rejected + // with "workingDirectory is required". + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(created.session, 'hi', undefined, 'turn-1'); + assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.cwd, expected.fsPath); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + } + }); + test('createProvisional creates a session without SDK startup contact', async () => { const { sdk, instantiationService } = createTestContext(disposables); const session = disposables.add(ClaudeAgentSession.createProvisional( 'test-session', AgentSession.uri('claude', 'test-session'), + URI.parse(buildDefaultChatUri(AgentSession.uri('claude', 'test-session'))), URI.file('/workspace'), undefined, undefined, @@ -1173,6 +1494,7 @@ suite('ClaudeAgent', () => { const session = disposables.add(ClaudeAgentSession.createProvisional( 'test-session', AgentSession.uri('claude', 'test-session'), + URI.parse(buildDefaultChatUri(AgentSession.uri('claude', 'test-session'))), URI.file('/workspace'), undefined, undefined, @@ -1210,28 +1532,29 @@ suite('ClaudeAgent', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work-resume'), model: initialModel }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); // Phase 2: user changes the model post-materialize — this hits the // runtime path inside session.setModel and rewrites the overlay. const updatedModel = { id: 'claude-opus-4.6', config: { thinkingLevel: 'medium' } }; - await agent.changeModel(created.session, updatedModel); + await agent.chats.changeModel(defaultChatUri(created.session), updatedModel); // Phase 3: simulate cross-window resume by tearing the in-memory // entry down and forcing the resume branch on the next send. await agent.disposeSession(created.session); sdk.sessionList = [{ sessionId, cwd: '/work-resume', summary: '', lastModified: Date.now() }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'turn 2', undefined, 'turn-2'); - - // Phase 4: confirm the overlay still carries the updated model from - // Phase 2. If materialize wrote unconditionally on resume, the - // overlay would carry whatever model the resume path passed in - // (typically the initial materialize-time model). - const metadataAfterResume = await agent.getSessionMetadata(created.session); + await agent.chats.sendMessage(defaultChatUri(created.session), 'turn 2', undefined, 'turn-2'); + + // Phase 4: confirm the resume started the SDK with the updated model + // from Phase 2. Model selection is no longer surfaced on + // `IAgentSessionMetadata`; the observable effect of the overlay is the + // model the resume query is started with. If materialize wrote + // unconditionally on resume, the SDK would start with the initial + // materialize-time model instead. assert.deepStrictEqual( - metadataAfterResume?.model, - updatedModel, + { model: sdk.capturedStartupOptions.at(-1)?.model, effort: sdk.capturedStartupOptions.at(-1)?.effort }, + { model: 'claude-opus-4-6', effort: 'medium' }, 'resume must not clobber the overlay model', ); }); @@ -1248,7 +1571,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const expected = AgentSession.uri('claude', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); - const result = await agent.createSession({ session: expected }); + const result = await agent.createSession({ session: expected, workingDirectory: URI.file('/work') }); assert.deepStrictEqual({ session: result.session.toString(), @@ -1259,40 +1582,389 @@ suite('ClaudeAgent', () => { }); }); - test('createSession({ fork }) throws TODO: Phase 6.5 with no side effects', async () => { - // Phase-6 update: fork is deferred to Phase 6.5 because Claude's - // `forkSession(sessionId, { upToMessageId })` takes a message UUID, - // not an event id, and the protocol-turn-ID → message-UUID - // translation needs `sdk.getSessionMessages` (also Phase 6.5). - // Locking the throw message here so a half-implementation can't - // land in Phase 6 without re-greening this case. - const { agent, sessionData, sdk } = createTestContext(disposables); + test('createSession({ fork }) forks at the anchor uuid, then materializes lazily on first sendMessage', async () => { + // Fork translates turnId u1 → its last-assistant uuid a1 (INCLUSIVE), + // returns non-provisional WITHOUT starting the Query; the first + // sendMessage resumes from disk (Options.resume) — see CONTEXT M9. + const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - await assert.rejects( - agent.createSession({ - fork: { - session: AgentSession.uri('claude', 'src-uuid'), - turnIndex: 0, - turnId: 'turn-1', - }, - }), - /Phase 6\.5/, - ); + const sourceId = 'src-uuid'; + const sourceUri = AgentSession.uri('claude', sourceId); + sdk.sessionMessagesById.set(sourceId, forkSourceMessages(sourceId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const events: IAgentMaterializeSessionEvent[] = []; + disposables.add(agent.onDidMaterializeSession(e => events.push(e))); + + const result = await agent.createSession({ fork: { session: sourceUri, turnIndex: 0, turnId: 'u1' } }); + const newUri = AgentSession.uri('claude', 'forked-1'); + + // Snapshot fork-time state: file written, no Query, no materialize event. + const atForkTime = { + getMessagesCall: sdk.getSessionMessagesCalls[0], + forkCall: sdk.forkSessionCalls[0], + materializeCount: events.length, + startupCount: sdk.capturedStartupOptions.length, + resultSession: result.session.toString(), + resultCwd: result.workingDirectory?.fsPath, + provisional: result.provisional, + }; + + // First send resumes the forked file: the Query starts with `resume`. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(defaultChatUri(newUri), 'next', undefined, 'turn-1'); assert.deepStrictEqual({ - openDatabaseCalls: sessionData.openDatabaseCallCount, - tryOpenDatabaseCalls: sessionData.tryOpenDatabaseCallCount, - startupCallCount: sdk.startupCallCount, - listSessionsCallCount: sdk.listSessionsCallCount, + atForkTime, + afterSend: { + materializeCount: events.length, + materializeUri: events[0]?.session.toString(), + startupResume: sdk.capturedStartupOptions[0]?.resume, + startupSessionId: sdk.capturedStartupOptions[0]?.sessionId, + }, }, { - openDatabaseCalls: 0, - tryOpenDatabaseCalls: 0, - startupCallCount: 0, - listSessionsCallCount: 0, + atForkTime: { + getMessagesCall: { sessionId: sourceId, options: { includeSystemMessages: true } }, + forkCall: { sessionId: sourceId, options: { upToMessageId: 'a1' } }, + materializeCount: 0, + startupCount: 0, + resultSession: newUri.toString(), + resultCwd: URI.file('/work').fsPath, + provisional: undefined, + }, + afterSend: { + materializeCount: 1, + materializeUri: newUri.toString(), + startupResume: 'forked-1', + startupSessionId: undefined, + }, + }); + }); + + test('createSession({ fork }) at the last turn anchors on that turn\'s assistant', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const sourceId = 'src-uuid'; + sdk.sessionMessagesById.set(sourceId, forkSourceMessages(sourceId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + await agent.createSession({ fork: { session: AgentSession.uri('claude', sourceId), turnIndex: 1, turnId: 'u2' } }); + + assert.deepStrictEqual(sdk.forkSessionCalls[0], { sessionId: sourceId, options: { upToMessageId: 'a2' } }); + }); + + test('truncateSession(turnId) resolves the anchor, restarts at it on the same id, and prunes the DB', async () => { + const database = new TestSessionDatabase(); + const { agent, sdk } = createTestContext(disposables, { database }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + await agent.truncateSession(created.session, 'u1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); + + assert.deepStrictEqual({ + startupCount: sdk.startupCallCount, + rebuildResume: sdk.capturedStartupOptions[1]?.resume, + rebuildResumeAt: sdk.capturedStartupOptions[1]?.resumeSessionAt, + sameUri: agent.getSessionForTesting(created.session)?.sessionUri.toString() === created.session.toString(), + prunedAfter: database.deleteTurnsAfterCalls, + getMessagesCall: sdk.getSessionMessagesCalls.at(-1), + }, { + startupCount: 2, + rebuildResume: sessionId, + rebuildResumeAt: 'a1', + sameUri: true, + prunedAfter: ['u1'], + getMessagesCall: { sessionId, options: { includeSystemMessages: true } }, + }); + }); + + test('truncateSession cold-resumes an unloaded session, then applies the anchor on the next turn', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + + // Unload the session from memory; the transcript stays resumable. + await agent.disposeSession(created.session); + assert.strictEqual(agent.getSessionForTesting(created.session), undefined, 'unloaded'); + sdk.sessionList = [{ sessionId, cwd: '/work', summary: '', lastModified: Date.now() }]; + + await agent.truncateSession(created.session, 'u1'); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); + + const last = sdk.capturedStartupOptions.at(-1); + assert.deepStrictEqual({ + resume: last?.resume, + resumeAt: last?.resumeSessionAt, + sessionPresent: agent.getSessionForTesting(created.session) !== undefined, + }, { + resume: sessionId, + resumeAt: 'a1', + sessionPresent: true, + }); + }); + + test('truncateSession throws when the turn is not in the transcript', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + + await assert.rejects(() => agent.truncateSession(created.session, 'no-such-turn'), /turn no-such-turn not found/); + }); + + test('truncateSession on a provisional session is a no-op', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + + await agent.truncateSession(created.session, 'u1'); + + assert.deepStrictEqual({ + startupCount: sdk.startupCallCount, + getMessagesCalls: sdk.getSessionMessagesCalls.length, + }, { + startupCount: 0, + getMessagesCalls: 0, + }); + }); + + test('truncateSession() with no turnId clears the session in place (deleteSession + fresh same id)', async () => { + const database = new TestSessionDatabase(); + const { agent, sdk } = createTestContext(disposables, { database }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + + await agent.truncateSession(created.session); + + // The next turn materializes FRESH (non-resume) on the SAME id. + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); + const last = sdk.capturedStartupOptions.at(-1); + assert.deepStrictEqual({ + deleted: sdk.deleteSessionCalls, + allTurnsPruned: database.deleteAllTurnsCalls, + lastSessionId: last?.sessionId, + lastResume: last?.resume, + lastResumeAt: last?.resumeSessionAt, + sessionPresent: agent.getSessionForTesting(created.session) !== undefined, + }, { + deleted: [sessionId], + allTurnsPruned: 1, + lastSessionId: sessionId, + lastResume: undefined, + lastResumeAt: undefined, + sessionPresent: true, + }); + }); + + test('truncateSession() with no turnId awaits the live query teardown (subprocess exit) before deleteSession', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + + // Block the live query's teardown (models `transport.waitForExit()` — + // the subprocess not yet exited / still flushing the transcript). + const exitGate = new DeferredPromise(); + sdk.queryReturnGate = exitGate.p; + + const truncated = agent.truncateSession(created.session); + await timeout(0); + // deleteSession MUST NOT run while the subprocess is still alive: a + // premature delete would race the dying writer re-flushing `.jsonl`. + assert.deepStrictEqual(sdk.deleteSessionCalls, [], 'deleteSession ran before the subprocess exited'); + + exitGate.complete(); + await truncated; + assert.deepStrictEqual(sdk.deleteSessionCalls, [sessionId]); + }); + + test('truncateSession() with no turnId on an UNLOADED session deletes + recreates fresh on the same id, preserving the overlay (cold remove-all)', async () => { + const database = new TestSessionDatabase(); + const { agent, sdk, instantiationService } = createTestContext(disposables, { database }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + + // Unload the session from memory; the transcript stays on disk. The + // remove-all path then has no live `existing` and must read the cwd + // from `getSessionInfo` before deleting + recreating. + await agent.disposeSession(created.session); + assert.strictEqual(agent.getSessionForTesting(created.session), undefined, 'unloaded'); + sdk.sessionList = [{ sessionId, cwd: '/work', summary: '', lastModified: Date.now() }]; + + // Seed a permissionMode overlay the cold recreate must carry forward. + const metaStore = instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'); + await metaStore.write(created.session, { permissionMode: 'plan' }); + + await agent.truncateSession(created.session); + + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); + const last = sdk.capturedStartupOptions.at(-1); + assert.deepStrictEqual({ + deleted: sdk.deleteSessionCalls, + cwdConsulted: sdk.getSessionInfoCalls.includes(sessionId), + allTurnsPruned: database.deleteAllTurnsCalls, + lastSessionId: last?.sessionId, + lastResume: last?.resume, + lastResumeAt: last?.resumeSessionAt, + permissionMode: last?.permissionMode, + sessionPresent: agent.getSessionForTesting(created.session) !== undefined, + }, { + deleted: [sessionId], + cwdConsulted: true, + allTurnsPruned: 1, + lastSessionId: sessionId, + lastResume: undefined, + lastResumeAt: undefined, + permissionMode: 'plan', + sessionPresent: true, }); }); + test('createSession({ fork }) inherits the source permissionMode overlay', async () => { + const { agent, sdk, instantiationService } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const sourceId = 'src-uuid'; + const sourceUri = AgentSession.uri('claude', sourceId); + // Seed the SOURCE overlay; the fork must copy it onto the new session + // so it reaches `Options.permissionMode` at materialize. + const metaStore = instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'); + await metaStore.write(sourceUri, { permissionMode: 'plan' }); + + sdk.sessionMessagesById.set(sourceId, forkSourceMessages(sourceId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const result = await agent.createSession({ fork: { session: sourceUri, turnIndex: 0, turnId: 'u1' } }); + + // Fork defers the Query; materialize it via the first send. The resume + // path reads the inherited overlay into `Options.permissionMode`. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(defaultChatUri(result.session), 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.permissionMode, 'plan'); + }); + + test('createSession({ fork }) with a create-config model override persists it on the fork', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const sourceId = 'src-uuid'; + sdk.sessionMessagesById.set(sourceId, forkSourceMessages(sourceId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const result = await agent.createSession({ + fork: { session: AgentSession.uri('claude', sourceId), turnIndex: 0, turnId: 'u1' }, + model: { id: 'claude-opus-4.6' }, + }); + + // The fork's model override is no longer surfaced on metadata; its + // observable effect is the model the forked session's SDK query is + // started with on its first send. + const forkedId = AgentSession.id(result.session); + sdk.nextQueryMessages = [makeSystemInitMessage(forkedId), makeResultSuccess(forkedId)]; + await agent.chats.sendMessage(defaultChatUri(result.session), 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.model, 'claude-opus-4-6'); + }); + + test('createSession({ fork }) rejects when the turnId is not in the transcript', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const sourceId = 'src-uuid'; + sdk.sessionMessagesById.set(sourceId, forkSourceMessages(sourceId)); + + await assert.rejects( + agent.createSession({ fork: { session: AgentSession.uri('claude', sourceId), turnIndex: 9, turnId: 'no-such-turn' } }), + /not found in transcript/, + ); + assert.strictEqual(sdk.forkSessionCalls.length, 0, 'no fork when the anchor cannot be resolved'); + }); + + test('createSession({ fork }) rejects when the forked session has no working directory', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const sourceId = 'src-uuid'; + sdk.sessionMessagesById.set(sourceId, forkSourceMessages(sourceId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + // No `sessionList` entry → `getSessionInfo('forked-1')` resolves + // undefined (no cwd), and no `config.workingDirectory` is supplied. + // Fail fast here rather than at the first `sendMessage`. + await assert.rejects( + agent.createSession({ fork: { session: AgentSession.uri('claude', sourceId), turnIndex: 0, turnId: 'u1' } }), + /no working directory/, + ); + }); + + test('createSession({ fork }) rejects a subagent source with no SDK contact', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const subagentUri = URI.parse(buildSubagentSessionUri(AgentSession.uri('claude', 'parent').toString(), 'tool-call-1')); + + await assert.rejects( + agent.createSession({ fork: { session: subagentUri, turnIndex: 0, turnId: 'u1' } }), + /subagent/, + ); + assert.deepStrictEqual({ + getMessages: sdk.getSessionMessagesCalls.length, + fork: sdk.forkSessionCalls.length, + }, { getMessages: 0, fork: 0 }); + }); + + test('createSession({ fork }) rejects a provisional/never-sent source', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + // A plain createSession is provisional until the first sendMessage. + const provisional = await agent.createSession({ workingDirectory: URI.file('/src') }); + + await assert.rejects( + agent.createSession({ fork: { session: provisional.session, turnIndex: 0, turnId: 'u1' } }), + /provisional/, + ); + assert.strictEqual(sdk.forkSessionCalls.length, 0); + }); + test('first sendMessage on a provisional session materializes it (single startup, single materialize event)', async () => { // Phase 6 §5.1 Test 3 (tracer). Forces the materialize spine into // existence: `_provisionalSessions` map, `_materializeProvisional`, @@ -1324,7 +1996,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ startupCallCount: sdk.startupCallCount, @@ -1343,6 +2015,43 @@ suite('ClaudeAgent', () => { }); }); + test('materialize resolves the SDK agent name from the file frontmatter, not the filename', async () => { + // `_resolveAgentName` parses the selected `~/.claude/agents/.md`: + // the SDK keys agents by their frontmatter `name`, which need not match + // the filename. A selection pointing at `foo.md` whose frontmatter says + // `name: my-real-agent` must start the SDK up with agent=my-real-agent. + const { agent, sdk, fileService } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const agentFile = URI.file('/mock-home/.claude/agents/foo.md'); + await fileService.writeFile(agentFile, VSBuffer.fromString('---\nname: my-real-agent\ndescription: A real agent\n---\nbody')); + + const created = await agent.createSession({ workingDirectory: URI.file('/work'), agent: { uri: agentFile.toString() } }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'my-real-agent'); + }); + + test('materialize resolves a built-in (claude-internal) agent selection to its name', async () => { + // A built-in agent (e.g. `Explore`) has no editable file on disk; it is + // selected via a synthetic `claude-internal:/agent/` URI. Materialize + // must decode the name from the path and start the SDK with agent=Explore + // (the inverse of `nonEditableUri`). + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work'), agent: { uri: 'claude-internal:/agent/Explore' } }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'Explore'); + }); + test('materialize event payload shape — { session, workingDirectory, project: undefined }', async () => { // Phase 6 §5.1 Test 4. Pins the {@link IAgentMaterializeSessionEvent} // payload independently of the tracer in Test 3. The default @@ -1363,7 +2072,7 @@ suite('ClaudeAgent', () => { assert.ok(agent.onDidMaterializeSession); disposables.add(agent.onDidMaterializeSession(e => events.push(e))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.strictEqual(events.length, 1, 'event fires exactly once'); const ev = events[0]; @@ -1401,7 +2110,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ model: sdk.capturedStartupOptions[0]?.model, @@ -1436,7 +2145,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ model: sdk.capturedStartupOptions[0]?.model, @@ -1482,7 +2191,7 @@ suite('ClaudeAgent', () => { ]; // First turn — materializes; resolves on result(idx=1). - await agent.sendMessage(created.session, 'turn-1', undefined, 'turn-id-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'turn-1', undefined, 'turn-id-1'); // Snapshot before the second send so we can assert the second send // did NOT call startup() again. @@ -1490,7 +2199,7 @@ suite('ClaudeAgent', () => { const queryCallsAfterTurn1 = sdk.warmQueries[0]?.queryCallCount ?? -1; // Second turn — pushes onto the existing Query. - const p2 = agent.sendMessage(created.session, 'turn-2', undefined, 'turn-id-2'); + const p2 = agent.chats.sendMessage(defaultChatUri(created.session), 'turn-2', undefined, 'turn-id-2'); // Drain microtasks so `await entry.setPermissionMode(...)` resolves // and `entry.send(...)` synchronously pushes the second prompt onto // the in-flight queue BEFORE we release the iterator gate. Otherwise @@ -1549,7 +2258,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const actionSignals = signals.filter(s => s.kind === 'action'); const partActions = actionSignals @@ -1583,14 +2292,14 @@ suite('ClaudeAgent', () => { partIdsMatch: part.part.id === firstDelta.partId && part.part.id === secondDelta.partId, turnId: part.turnId, deltaTexts: [firstDelta.content, secondDelta.content], - session: partActions[0].s.kind === 'action' ? partActions[0].s.session.toString() : undefined, + session: partActions[0].s.kind === 'action' ? partActions[0].s.resource.toString() : undefined, }, { partKindIsMarkdown: true, partPrecedesDelta: true, partIdsMatch: true, turnId: 'turn-1', deltaTexts: ['hello ', 'world'], - session: created.session.toString(), + session: buildDefaultChatUri(created.session), }); }); @@ -1618,7 +2327,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const actionSignals = signals.filter(s => s.kind === 'action'); const partActions = actionSignals @@ -1697,7 +2406,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const tail = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -1760,7 +2469,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const usage = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -1799,7 +2508,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const partActions = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -1862,7 +2571,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const responsePartCount = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -1877,6 +2586,79 @@ suite('ClaudeAgent', () => { }); }); + test('D3: subagent spawn mirrors onto onDidSpawnChat while the subagent signals still flow', async () => { + // The membership channel (onDidSpawnChat) is derived from the + // subagent_started signal the agent already emits on + // onDidSessionProgress — the orchestrator records the spawn edge on the + // unified chat catalog. A completed subagent chat stays live and + // subscribable (removed only on session teardown). The + // signals must STILL be forwarded verbatim so the existing + // AgentSideEffects subagent handling (turn lifecycle + parent tool-call + // content) is preserved. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + const PARENT = 'toolu_parent_sa'; + + const innerAssistant = makeAssistantMessage(sessionId, [ + { type: 'text', text: 'searching the workspace', citations: null }, + ]); + innerAssistant.parent_tool_use_id = PARENT; + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + // Stream the spawning Task tool_use so the registry records the spawn. + makeStreamEvent(sessionId, makeMessageStart()), + makeStreamEvent(sessionId, makeContentBlockStartToolUse(0, PARENT, 'Task')), + makeStreamEvent(sessionId, makeContentBlockStop(0)), + makeStreamEvent(sessionId, makeMessageStop()), + // Canonical Task assistant records subagent metadata (subagent_type). + makeAssistantMessage(sessionId, [ + { type: 'tool_use', id: PARENT, name: 'Task', input: { description: 'Count files', subagent_type: 'Explore', prompt: 'go' } }, + ]), + // Inner subagent content (parent_tool_use_id set) prepends subagent_started. + innerAssistant, + // Parent tool result completes the foreground subagent. + makeUserToolResultMessage(sessionId, PARENT, 'done'), + makeResultSuccess(sessionId), + ]; + + const signals: AgentSignal[] = []; + disposables.add(agent.onDidSessionProgress(s => signals.push(s))); + const spawned: IAgentSpawnChatEvent[] = []; + disposables.add(agent.onDidSpawnChat!(e => spawned.push(e))); + + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); + + const subagentChatUri = buildSubagentChatUri(created.session, PARENT); + + assert.deepStrictEqual({ + startedSignals: signals.filter(s => s.kind === 'subagent_started').length, + completedSignals: signals.filter(s => s.kind === 'subagent_completed').length, + spawned: spawned.map(e => ({ + session: e.session.toString(), + chat: e.chat.toString(), + parentChat: e.parent?.chat.toString(), + parentToolCallId: e.parent?.toolCallId, + title: e.title, + })), + }, { + startedSignals: 1, + completedSignals: 1, + spawned: [{ + session: created.session.toString(), + chat: subagentChatUri, + parentChat: buildDefaultChatUri(created.session), + parentToolCallId: PARENT, + // Titled by the Task tool's concise `description` input, not the + // agent-type name (`subagent_type: 'Explore'`). + title: 'Count files', + }], + }); + }); + test('canonical SDKAssistantMessage with text content does not double-emit signals already produced by stream_event partials (Phase 6.1 / Cycle F)', async () => { // CONTEXT.md M8:875 — partials are advisory, final // `SDKAssistantMessage` is canonical. With `includePartialMessages: @@ -1906,7 +2688,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const partActions = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -1943,7 +2725,7 @@ suite('ClaudeAgent', () => { sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; // Snapshot before the SDK has streamed any messages. - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session); assert.ok(session, 'session is materialized'); @@ -1984,7 +2766,7 @@ suite('ClaudeAgent', () => { // queued by `entry.send`). Without this we'd race materialize. const materialized = Event.toPromise(agent.onDidMaterializeSession); - const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -2051,6 +2833,7 @@ suite('ClaudeAgent', () => { const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -2074,7 +2857,7 @@ suite('ClaudeAgent', () => { // Kick off the materialize. It will pass the post-startup abort // gate, create the wrapper, then park inside `setMetadata`. - const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -2130,7 +2913,7 @@ suite('ClaudeAgent', () => { // Materializing now requires a provisional record; without it // the sequencer task throws synchronously inside the queued fn. - const sendErr = await agent.sendMessage(created.session, 'hi', undefined, 'turn-1') + const sendErr = await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1') .then(() => undefined, err => err); assert.deepStrictEqual({ @@ -2180,7 +2963,7 @@ suite('ClaudeAgent', () => { const events: IAgentMaterializeSessionEvent[] = []; disposables.add(agent.onDidMaterializeSession(e => events.push(e))); - await agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(sessionUri), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ startupCallCount: sdk.startupCallCount, @@ -2219,7 +3002,7 @@ suite('ClaudeAgent', () => { const sessionUri = AgentSession.uri('claude', 'ghost-session-id'); // sdk.sessionList stays empty — getSessionInfo resolves undefined. - const sendErr = await agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1') + const sendErr = await agent.chats.sendMessage(defaultChatUri(sessionUri), 'hi', undefined, 'turn-1') .then(() => undefined, err => err); assert.deepStrictEqual({ @@ -2243,7 +3026,7 @@ suite('ClaudeAgent', () => { // `sessionAdded` for createSession-spawned sessions), so // `_readSessionPermissionMode` returned `undefined`, the // fallback `'default'` won, and a plan-mode session silently - // downgraded to default mode mid-conversation. + // downgraded to default mode mid-chat. // // The fix: only forward `setPermissionMode` when the live config // actually carries a value, leaving the session's seeded @@ -2271,10 +3054,10 @@ suite('ClaudeAgent', () => { cwd: URI.file('/work').fsPath, }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(sessionUri, 'turn-1', undefined, 't1'); + await agent.chats.sendMessage(defaultChatUri(sessionUri), 'turn-1', undefined, 't1'); sdk.nextQueryMessages = [makeResultSuccess(sessionId)]; - await agent.sendMessage(sessionUri, 'turn-2', undefined, 't2'); + await agent.chats.sendMessage(defaultChatUri(sessionUri), 'turn-2', undefined, 't2'); const fakeQuery = sdk.warmQueries.at(-1)?.produced; assert.deepStrictEqual({ @@ -2310,7 +3093,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(AgentSession.id(matCreated.session)), makeResultSuccess(AgentSession.id(matCreated.session)), ]; - await agent.sendMessage(matCreated.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(matCreated.session), 'hi', undefined, 'turn-1'); // Leave a second session provisional. const provCreated = await agent.createSession({ workingDirectory: URI.file('/work-prov') }); @@ -2341,10 +3124,10 @@ suite('ClaudeAgent', () => { matWarmAsyncDisposed: matWarm.asyncDisposeCount > asyncDisposeBefore, // A post-shutdown sendMessage to the provisional URI must // fail because the provisional record was cleared. - provDropped: await agent.sendMessage(provCreated.session, 'late', undefined, 'turn-late') + provDropped: await agent.chats.sendMessage(defaultChatUri(provCreated.session), 'late', undefined, 'turn-late') .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), // Same for the materialized URI. - matDropped: await agent.sendMessage(matCreated.session, 'late', undefined, 'turn-late') + matDropped: await agent.chats.sendMessage(defaultChatUri(matCreated.session), 'late', undefined, 'turn-late') .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), }, { memoized: true, @@ -2376,7 +3159,8 @@ suite('ClaudeAgent', () => { const sessionUri = created.session; const observed: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => { - if (AgentSession.id(s.session) === AgentSession.id(sessionUri)) { + const resource = s.kind === 'action' ? s.resource : s.chat; + if ((parseDefaultChatUri(resource) ?? resource.toString()) === sessionUri.toString()) { observed.push(s); } })); @@ -2402,7 +3186,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const deltas = observed.flatMap(s => s.kind === 'action' && s.action.type === ActionType.ChatDelta @@ -2437,7 +3221,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-explicit'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-explicit'); const drained = sdk.warmQueries[0]?.produced?.drainedPrompts ?? []; assert.deepStrictEqual({ @@ -2470,7 +3254,7 @@ suite('ClaudeAgent', () => { const fileUri = URI.file('/work/src/foo.ts'); const dirUri = URI.file('/work/src/bar'); - await agent.sendMessage(created.session, 'review please', [ + await agent.chats.sendMessage(defaultChatUri(created.session), 'review please', [ { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, { type: MessageAttachmentKind.Resource, uri: dirUri.toString(), label: 'bar', displayKind: 'directory' }, ], 'turn-1'); @@ -2589,8 +3373,8 @@ suite('ClaudeAgent', () => { // key, and the agent does not surface a double-dispose error. const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const r1 = await agent.createSession({}); - await agent.createSession({}); + const r1 = await agent.createSession({ workingDirectory: URI.file('/work') }); + await agent.createSession({ workingDirectory: URI.file('/work') }); const p1 = agent.disposeSession(r1.session); const p2 = agent.shutdown(); @@ -2617,7 +3401,7 @@ suite('ClaudeAgent', () => { // into SDK-side or DB-side deletion. const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({}); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); // Make the SDK report the just-created session as if its // metadata had been written by an earlier `query()` turn — // that's the steady state once Phase 6 sendMessage lands. @@ -2682,6 +3466,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, new FakeCopilotApiService()], [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], @@ -2753,6 +3538,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, new FakeCopilotApiService()], [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], @@ -2782,48 +3568,6 @@ suite('ClaudeAgent', () => { }); }); - test('createSession.model round-trips through the per-session DB to listSessions[].model (Phase 6.1 I8 + I7 + C2)', async () => { - // Phase 6.1 Cycle E (drift I8). Closes the missing-metadata leak: - // `IAgentCreateSessionConfig.model` is supposed to be persisted - // per-session and surface back via `listSessions(): IAgentSessionMetadata.model`. - // Pre-fix, only `customizationDirectory` was overlayed; `model` - // was silently dropped. The CopilotAgent reference path - // (`copilotAgent.ts:1483-1564`, `_META_MODEL`/`_serializeModelSelection`/ - // `_storeSessionMetadata`/`_readSessionMetadata`) shows the - // canonical shape: a JSON-serialised `ModelSelection` keyed by - // a provider-private metadata constant. - // Round-trip: createSession({ model }) → sendMessage materializes - // (writes sidecar) → SDK reports the session in its listing → - // listSessions surfaces the persisted `model`. - const { agent, sdk } = createTestContext(disposables); - await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - - const created = await agent.createSession({ - workingDirectory: URI.file('/work'), - model: { id: 'claude-opus-4.6', config: { thinking: 'extended' } }, - }); - const sessionId = AgentSession.id(created.session); - - sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); - - sdk.sessionList = [{ - sessionId, - summary: 'Round trip', - lastModified: 1234, - }]; - const list = await agent.listSessions(); - const entry = list.find(r => AgentSession.id(r.session) === sessionId); - - assert.deepStrictEqual({ - model: entry?.model, - summary: entry?.summary, - }, { - model: { id: 'claude-opus-4.6', config: { thinking: 'extended' } }, - summary: 'Round trip', - }); - }); - test('listSessions returns an empty list (does not reject) when the SDK fails to load', async () => { // Copilot-reviewer comment: `AgentService.listSessions` fans out // across providers via `Promise.all` (agentService.ts:202-204). @@ -2837,6 +3581,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, new FakeCopilotApiService()], [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, createNullSessionDataService()], @@ -2884,6 +3629,7 @@ suite('ClaudeAgent', () => { const services = new ServiceCollection( [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, new FakeCopilotApiService()], [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], @@ -2910,7 +3656,6 @@ suite('ClaudeAgent', () => { modifiedTime: sidecar?.modifiedTime, workingDirectory: sidecar?.workingDirectory?.toString(), customizationDirectory: sidecar?.customizationDirectory?.toString(), - model: sidecar?.model, }, external: { session: external?.session.toString(), @@ -2919,7 +3664,6 @@ suite('ClaudeAgent', () => { modifiedTime: external?.modifiedTime, workingDirectory: external?.workingDirectory?.toString(), customizationDirectory: external?.customizationDirectory, - model: external?.model, }, unknown, sdkLookups: sdk.getSessionInfoCalls.slice().sort(), @@ -2931,7 +3675,6 @@ suite('ClaudeAgent', () => { modifiedTime: 5000, workingDirectory: URI.file('/work').toString(), customizationDirectory: URI.file('/cust').toString(), - model: { id: 'claude-opus-4.6', config: { thinkingLevel: 'high' } }, }, external: { session: externalUri.toString(), @@ -2940,7 +3683,6 @@ suite('ClaudeAgent', () => { modifiedTime: 6000, workingDirectory: URI.file('/raw-cli').toString(), customizationDirectory: undefined, - model: undefined, }, unknown: undefined, sdkLookups: ['external', 'sidecar', 'unknown'], @@ -2958,8 +3700,8 @@ suite('ClaudeAgent', () => { // Mirror of `CopilotAgent.shutdown()` at copilotAgent.ts:1246. const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - await agent.createSession({}); - await agent.createSession({}); + await agent.createSession({ workingDirectory: URI.file('/work') }); + await agent.createSession({ workingDirectory: URI.file('/work') }); const first = agent.shutdown(); const second = agent.shutdown(); @@ -3028,9 +3770,12 @@ suite('ClaudeAgent', () => { listSessions: async () => [{ sessionId: 's', summary: 's', lastModified: 1 }], getSessionInfo: async () => undefined, startup: async () => { throw new Error('TestableClaudeAgentSdkService: startup not modeled'); }, + query: () => { throw new Error('not modeled'); }, getSessionMessages: async () => [], listSubagents: async () => [], getSubagentMessages: async () => [], + forkSession: async () => { throw new Error('not modeled'); }, + deleteSession: async () => { throw new Error('not modeled'); }, createSdkMcpServer: () => { throw new Error('not modeled'); }, tool: () => { throw new Error('not modeled'); }, }; @@ -3073,6 +3818,7 @@ suite('ClaudeAgent', () => { listSessions: async () => [], getSessionInfo: async () => undefined, startup: async () => { throw new Error('not used'); }, + query: () => { throw new Error('not modeled'); }, getSessionMessages: async () => [], listSubagents: async (sessionId, options) => { listCalls.push({ sessionId, options }); @@ -3082,6 +3828,8 @@ suite('ClaudeAgent', () => { getCalls.push({ sessionId, agentId, options }); return [{ uuid: 'u1' } as unknown as SessionMessage]; }, + forkSession: async () => { throw new Error('not modeled'); }, + deleteSession: async () => { throw new Error('not modeled'); }, createSdkMcpServer: () => { throw new Error('not modeled'); }, tool: () => { throw new Error('not modeled'); }, }; @@ -3197,19 +3945,22 @@ suite('ClaudeAgent', () => { } const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], [ICopilotApiService, new FakeCopilotApiService()], [IClaudeProxyService, new RecordingProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], [IAgentPluginManager, new FakeAgentPluginManager()], + [IAgentHostGitService, createNoopGitService()], [IProductService, FakeProductService], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); await agent.authenticate('https://api.github.com', 'tok'); - await agent.createSession({}); + await agent.createSession({ workingDirectory: URI.file('/work') }); agent.dispose(); assert.strictEqual(proxyDisposed, true); @@ -3248,6 +3999,7 @@ suite('ClaudeAgent', () => { const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -3266,7 +4018,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -3298,8 +4050,9 @@ suite('ClaudeAgent', () => { // Unknown ids (SDK-owned tools, stale workbench races) must NOT throw. const { agent } = createTestContext(disposables); const session = URI.parse('claude:/sess-1'); + const chat = URI.parse(buildDefaultChatUri(session)); assert.doesNotThrow(() => { - agent.onClientToolCallComplete(session, 'toolu_unknown', { success: true, pastTenseMessage: 'ran' }); + agent.onClientToolCallComplete(session, chat, 'toolu_unknown', { success: true, pastTenseMessage: 'ran' }); }); }); @@ -3312,10 +4065,10 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); const tools: ToolDefinition[] = [{ name: 'echo', description: 'Echo back', inputSchema: { type: 'object', properties: { msg: { type: 'string' } }, required: ['msg'] } }]; - agent.setClientTools(created.session, 'client-1', tools); + agent.getOrCreateActiveClient(created.session, { clientId: 'client-1' }).tools = tools; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); const opts = sdk.capturedStartupOptions[0]; assert.ok(opts.mcpServers, 'mcpServers populated'); @@ -3343,13 +4096,13 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); - agent.setClientTools(created.session, 'client-1', [{ name: 'echo', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'client-1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.queryAdvance = undefined; advance.complete(); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); const lastBuild = sdk.createSdkMcpServerCalls[sdk.createSdkMcpServerCalls.length - 1]; assert.deepStrictEqual({ @@ -3363,6 +4116,112 @@ suite('ClaudeAgent', () => { }); }); + test('a pending truncation anchor reaches the next rebuild as Options.resumeSessionAt, consumed once', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Pause after the first result so the pipeline doesn't auto-rebind on its own. + const advance = new DeferredPromise(); + sdk.queryAdvance = async (i: number) => { if (i === 2) { await advance.p; } }; + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); + + // Stage a pending truncation anchor, then send again. The pending anchor + // alone (no tool/customization diff) must force an anchored rebuild. + await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); + sdk.queryAdvance = undefined; + advance.complete(); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); + + assert.deepStrictEqual({ + startupCount: sdk.startupCallCount, + firstResumeAt: sdk.capturedStartupOptions[0].resumeSessionAt, + secondResumeAt: sdk.capturedStartupOptions[1]?.resumeSessionAt, + }, { + startupCount: 2, + firstResumeAt: undefined, + secondResumeAt: 'anchor-uuid', + }); + }); + + test('the truncation anchor is applied exactly once and not leaked to later rebuilds', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); + // A later tool-driven rebind must NOT resurrect the consumed anchor. + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'third', undefined, 'turn-3'); + + const anchored = sdk.capturedStartupOptions.filter(o => o.resumeSessionAt === 'anchor-uuid'); + assert.deepStrictEqual({ + anchoredCount: anchored.length, + lastResumeAt: sdk.capturedStartupOptions.at(-1)?.resumeSessionAt, + }, { + anchoredCount: 1, + lastResumeAt: undefined, + }); + }); + + test('a rebuild that fails after reading the anchor keeps it staged so the next send retries the truncation', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); + + // The anchor-carrying rebuild fails at startup (one-shot). The anchor + // must NOT be cleared — losing it would silently proceed without + // `resumeSessionAt`, undoing the checkpoint restore. + sdk.startupRejection = new Error('transient startup failure'); + await assert.rejects(() => agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2')); + + // Retry: the staged anchor is re-applied on the next (now-succeeding) send. + await agent.chats.sendMessage(defaultChatUri(created.session), 'second-retry', undefined, 'turn-2b'); + assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.resumeSessionAt, 'anchor-uuid'); + }); + + test('truncateToTurn / pruneAllTurns reach the session database', async () => { + const database = new TestSessionDatabase(); + const { agent, sdk } = createTestContext(disposables, { database }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); + const session = agent.getSessionForTesting(created.session)!; + + await session.truncateToTurn('turn-1', 'anchor-uuid'); + await session.pruneAllTurns(); + + assert.deepStrictEqual( + { afterCalls: database.deleteTurnsAfterCalls, allCalls: database.deleteAllTurnsCalls }, + { afterCalls: ['turn-1'], allCalls: 1 }, + ); + }); + test('setClientTools with an equal snapshot does NOT restart', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); @@ -3377,13 +4236,13 @@ suite('ClaudeAgent', () => { ]; const tools: ToolDefinition[] = [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }]; - agent.setClientTools(created.session, 'c1', tools); - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = tools; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); - agent.setClientTools(created.session, 'c1', [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }]; advance.complete(); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 1, 'equal snapshot should NOT yield-restart'); }); @@ -3391,7 +4250,7 @@ suite('ClaudeAgent', () => { test('setClientTools on an unknown session id is silently dropped', () => { const { agent } = createTestContext(disposables); assert.doesNotThrow(() => { - agent.setClientTools(URI.parse('claude:/never-existed'), 'c1', [{ name: 't', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(URI.parse('claude:/never-existed'), { clientId: 'c1' }).tools = [{ name: 't', inputSchema: { type: 'object' } }]; }); }); @@ -3400,9 +4259,9 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const created = await agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); - agent.setClientTools(created.session, 'c1', [{ name: 'echo', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); // Completion for an unknown tool_use_id is a benign no-op (no parked // handler in this test path because we don't drive the real MCP @@ -3415,13 +4274,14 @@ suite('ClaudeAgent', () => { test('onClientToolCallComplete walks subagent URIs to the root session', () => { const { agent } = createTestContext(disposables); const root = URI.parse('claude:/root-1'); + const chat = URI.parse(buildDefaultChatUri(root)); // Build a depth-2 subagent URI (subagent of a subagent). const depth1 = URI.parse(buildSubagentSessionUri(root, 'tu_outer')); const depth2 = URI.parse(buildSubagentSessionUri(depth1, 'tu_inner')); // No session is registered for `root`; the walk should reach root and // then silently no-op (entry not found). Just assert no throw. assert.doesNotThrow(() => { - agent.onClientToolCallComplete(depth2, 'tu_anything', { success: true, pastTenseMessage: 'ran' }); + agent.onClientToolCallComplete(depth2, chat, 'tu_anything', { success: true, pastTenseMessage: 'ran' }); }); }); @@ -3435,9 +4295,9 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const created = await agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); - agent.setClientTools(created.session, 'c1', [{ name: 'echo', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); await assert.doesNotReject(agent.disposeSession(created.session)); }); @@ -3446,13 +4306,13 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const created = await agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); - agent.setClientTools(created.session, 'c1', [{ name: 'echo', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); // Change tools to force a rebind path (must use yield-restart, NOT Query.setMcpServers). - agent.setClientTools(created.session, 'c1', [{ name: 'echo2', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo2', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); // If `Query.setMcpServers` had been called, `FakeQuery.setMcpServers` would have thrown. assert.strictEqual(sdk.startupCallCount, 2, 'rebind path used yield-restart, not setMcpServers'); }); @@ -3464,7 +4324,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); // Initial snapshot before materialize starts. - agent.setClientTools(created.session, 'c1', [{ name: 'first', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'first', inputSchema: { type: 'object' } }]; // Pause startup #1 so we can inject an update during the gap. const startupReached = new DeferredPromise(); @@ -3477,11 +4337,11 @@ suite('ClaudeAgent', () => { }; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(created.session, 'go', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); // Wait until the materializer has snapshotted ['first'] into the diff // and is paused inside `sdk.startup`. THEN inject the update. await startupReached.p; - agent.setClientTools(created.session, 'c1', [{ name: 'second', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'second', inputSchema: { type: 'object' } }]; startupGate.complete(); await send; @@ -3525,12 +4385,12 @@ suite('ClaudeAgent', () => { }; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(sessionUri), 'hi', undefined, 'turn-1'); // Wait until the resume's `sdk.startup` is in flight, then inject the // update. Pre-fix the call hit the silent-drop branch because no // provisional was registered for the resume. await startupReached.p; - agent.setClientTools(sessionUri, 'c1', [{ name: 'resumed', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(sessionUri, { clientId: 'c1' }).tools = [{ name: 'resumed', inputSchema: { type: 'object' } }]; startupGate.complete(); await send; @@ -3558,15 +4418,15 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1); // Stage a rebind whose startup will reject. - agent.setClientTools(created.session, 'c1', [{ name: 'echo', inputSchema: { type: 'object' } }]); + agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.startupRejection = new Error('simulated rebind startup failure'); sdk.queryAdvance = undefined; advance.complete(); - await assert.rejects(agent.sendMessage(created.session, 'second', undefined, 'turn-2')); + await assert.rejects(agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2')); // Pre-fix: `_buildClientMcpServers` consumed the diff, but the SDK // startup that followed rejected without re-marking dirty, so the next @@ -3575,7 +4435,7 @@ suite('ClaudeAgent', () => { // send retries the rebind and succeeds. sdk.startupRejection = undefined; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'third', undefined, 'turn-3'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'third', undefined, 'turn-3'); assert.deepStrictEqual({ startupCount: sdk.startupCallCount, lastSnapshot: sdk.createSdkMcpServerCalls.at(-1)?.toolNames, @@ -3605,6 +4465,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { } as unknown as IAgentConfigurationService; const sessionData = new RecordingSessionDataService(createSessionDataService()); const services = new ServiceCollection( + ...claudeFileEnvServices(disposables), [ILogService, new NullLogService()], [IAgentConfigurationService, fakeConfigService], [IClaudeAgentSdkService, sdk], @@ -3615,6 +4476,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { const session = disposables.add(ClaudeAgentSession.createProvisional( 'session-id', URI.parse('claude:/session-id'), + URI.parse(buildDefaultChatUri('claude:/session-id')), URI.file('/workspace'), undefined, undefined, @@ -3626,7 +4488,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { instantiationService, )); await session.materialize({ - proxyHandle: { baseUrl: 'http://127.0.0.1:0', nonce: 'n', dispose: () => { } }, + transport: { kind: 'proxy', handle: { baseUrl: 'http://127.0.0.1:0', nonce: 'n', dispose: () => { } } }, canUseTool: async () => ({ behavior: 'deny', message: 'unused' }), isResume: false, }); @@ -3684,8 +4546,8 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { provider: 'claude', title: 't', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), }); // Seed an initial `config` object directly: the // `SessionConfigChanged` reducer no-ops when `state.config` is @@ -3696,7 +4558,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { values: { ...(seedConfig ?? {}) }, }; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool callback was wired into Options'); @@ -3765,7 +4627,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { assert.deepStrictEqual(captured, { kind: 'pending_confirmation', - session: sessionUri, + chat: URI.parse(buildDefaultChatUri(sessionUri)), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tu_shape', @@ -3808,7 +4670,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { // with `deny` and clears the entry. const { ctx, canUseTool, sessionUri } = await materialize(); - const session = ctx.agent['_sessions'].get(AgentSession.id(sessionUri))?.session; + const session = ctx.agent['_sessions'].get(AgentSession.id(sessionUri))?.defaultChat; assert.ok(session, 'session is materialized'); const ac = new AbortController(); @@ -3959,8 +4821,8 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { provider: 'claude', title: 't', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), }); // Seed `state.config` so `updateSessionConfig` writes propagate // (the `SessionConfigChanged` reducer no-ops when `state.config` @@ -3978,7 +4840,7 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { } })); - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool callback was wired into Options'); return { ctx, canUseTool, inputRequests, sessionUri: created.session }; @@ -4073,7 +4935,7 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { }, { signal: { kind: 'pending_confirmation', - session: sessionUri, + chat: URI.parse(buildDefaultChatUri(sessionUri)), state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tu_plan_ok', @@ -4167,8 +5029,8 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = provider: 'claude', title: 't', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), }); (state as { config?: SessionConfigState }).config = { schema: { type: 'object', properties: {} }, @@ -4191,9 +5053,9 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = makeResultSuccess(sessionId), ]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); ctx.configService.updateSessionConfig(created.session.toString(), { permissionMode: 'acceptEdits' }); - const p2 = ctx.agent.sendMessage(created.session, 'hi-2', undefined, 'turn-2'); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi-2', undefined, 'turn-2'); // Drain microtasks so `await entry.setPermissionMode('acceptEdits')` // resolves and the second prompt lands in the in-flight queue before // the iterator yields its `result(idx=2)` (see the multi-turn reuse @@ -4228,8 +5090,8 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = provider: 'claude', title: 't', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), }); (state as { config?: SessionConfigState }).config = { schema: { type: 'object', properties: {} }, @@ -4237,7 +5099,7 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const fakeQuery = ctx.sdk.warmQueries.at(-1)?.produced; assert.deepStrictEqual({ @@ -4265,7 +5127,7 @@ suite('ClaudeAgent (Phase 7 §3.7 — onElicitation cancel stub)', () => { const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const onElicitation = ctx.sdk.capturedStartupOptions[0]?.onElicitation; assert.ok(onElicitation, 'onElicitation callback was wired into Options'); @@ -4293,7 +5155,7 @@ suite('ClaudeAgent (Phase 8 — file edit tracking via SDK message stream)', () const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); return { ctx, sessionId, sessionUri: created.session }; } @@ -4368,7 +5230,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { makeResultSuccess(sessionId), ...(opts?.extraMessages ?? [makeResultSuccess(sessionId)]), ]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const warm = ctx.sdk.warmQueries[0]; const query = warm.produced!; return { ctx, sessionUri: created.session, sessionId, warm, query, advance }; @@ -4383,12 +5245,12 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { model: { id: 'claude-opus-4.6' }, }); - await ctx.agent.changeModel(created.session, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'medium' } }); + await ctx.agent.chats.changeModel(defaultChatUri(created.session), { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'medium' } }); assert.strictEqual(ctx.sdk.startupCallCount, 0); const sid = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const opts = ctx.sdk.capturedStartupOptions[0]; assert.deepStrictEqual({ model: opts.model, effort: opts.effort }, { model: 'claude-sonnet-4-6', effort: 'medium' }); }); @@ -4396,8 +5258,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { test('changeModel on a materialized session queues a model+effort bundle that drains at the next yield boundary', async () => { const { ctx, sessionUri, query, advance } = await materialize(); - await ctx.agent.changeModel(sessionUri, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); - const p2 = ctx.agent.sendMessage(sessionUri, 'next', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -4415,8 +5277,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const log = new CapturingLogService(); const { ctx, sessionUri, query, advance } = await materialize({ logService: log }); - await ctx.agent.changeModel(sessionUri, { id: 'claude-opus-4.6', config: { thinkingLevel: 'max' } }); - const p2 = ctx.agent.sendMessage(sessionUri, 'next', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-opus-4.6', config: { thinkingLevel: 'max' } }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -4428,8 +5290,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { test('changeModel with same id and unchanged effort skips the SDK setters', async () => { const { ctx, sessionUri, query, advance } = await materialize(); - await ctx.agent.changeModel(sessionUri, { id: 'claude-opus-4.6' }); - const p2 = ctx.agent.sendMessage(sessionUri, 'next', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-opus-4.6' }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -4446,7 +5308,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Start a long turn that parks at the gate so steering has // something to steer into. - const longSend = ctx.agent.sendMessage(sessionUri, 'long task', undefined, 'turn-2'); + const longSend = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'long task', undefined, 'turn-2'); await tick(); ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-1', message: { text: 'switch topic', origin: { kind: MessageKind.User } } }, []); @@ -4482,7 +5344,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const signals: AgentSignal[] = []; disposables.add(ctx.agent.onDidSessionProgress(s => signals.push(s))); - const longSend = ctx.agent.sendMessage(sessionUri, 'long task', undefined, 'turn-2'); + const longSend = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'long task', undefined, 'turn-2'); await tick(); ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-9', message: { text: 'steer', origin: { kind: MessageKind.User } } }, []); @@ -4519,10 +5381,10 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 0) { await stall.p; } }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - const inFlight = ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const inFlight = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); await tick(); - await ctx.agent.abortSession(created.session); + await ctx.agent.chats.abort(defaultChatUri(created.session)); await assert.rejects(inFlight, (err: unknown) => isCancellationError(err)); // Unblock the (now-aborted) iterator so it terminates cleanly. @@ -4533,7 +5395,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Next sendMessage rebuilds via resume mode. const startupBefore = ctx.sdk.startupCallCount; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, 'next', undefined, 'turn-2'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'next', undefined, 'turn-2'); assert.strictEqual(ctx.sdk.startupCallCount, startupBefore + 1, 'rebind called startup again'); const resumeOpts = ctx.sdk.capturedStartupOptions[ctx.sdk.startupCallCount - 1]; @@ -4552,7 +5414,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Materialize the session by driving one full turn so canUseTool is wired into Options. ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool was wired into Options'); @@ -4563,7 +5425,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { }); await tick(); - await ctx.agent.abortSession(created.session); + await ctx.agent.chats.abort(defaultChatUri(created.session)); const result = await permissionPromise; assert.deepStrictEqual(result, { behavior: 'deny', message: 'User declined' }); }); @@ -4580,7 +5442,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 1) { throw new Error('subprocess crashed'); } }; await assert.rejects( - ctx.agent.sendMessage(created.session, 'hi', undefined, 'turn-1'), + ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'), (err: Error) => err.message.includes('subprocess crashed'), ); @@ -4588,7 +5450,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = undefined; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; const startupBefore = ctx.sdk.startupCallCount; - await ctx.agent.sendMessage(created.session, 'recover', undefined, 'turn-2'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'recover', undefined, 'turn-2'); assert.strictEqual(ctx.sdk.startupCallCount, startupBefore + 1, 'crash recovery called startup again'); const resumeOpts = ctx.sdk.capturedStartupOptions[ctx.sdk.startupCallCount - 1]; assert.strictEqual(resumeOpts.resume, sid); @@ -4599,8 +5461,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Hot-swap model + effort on the live query so the bijective // cache picks up the new values. - await ctx.agent.changeModel(sessionUri, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); - const p2 = ctx.agent.sendMessage(sessionUri, 'apply', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'apply', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -4608,10 +5470,10 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Now abort and resend; the rebound query MUST receive the same // model + effort via the rebind's re-apply pass. - await ctx.agent.abortSession(sessionUri); + await ctx.agent.chats.abort(defaultChatUri(sessionUri)); ctx.sdk.queryAdvance = undefined; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(sessionUri, 'after-abort', undefined, 'turn-3'); + await ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'after-abort', undefined, 'turn-3'); const reboundQuery = ctx.sdk.warmQueries[1].produced!; assert.deepStrictEqual({ @@ -4642,7 +5504,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 1) { await advance.p; } }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid)]; - const inFlight = ctx.agent.sendMessage(created.session, 'long task', undefined, 'turn-1'); + const inFlight = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'long task', undefined, 'turn-1'); await tick(); // Subscribe BEFORE injecting steering so we capture the @@ -4710,7 +5572,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { makeResultSuccess(sid), // final (unblocked by advance2) ]; - const inFlight = ctx.agent.sendMessage(created.session, 'long task', undefined, 'turn-1'); + const inFlight = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'long task', undefined, 'turn-1'); let inFlightResolved = false; void inFlight.then(() => { inFlightResolved = true; }, () => { inFlightResolved = true; }); await tick(); @@ -4877,7 +5739,12 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const services = new ServiceCollection( + [IFileService, fileService], + [INativeEnvironmentService, { userHome: URI.file('/mock-home') } as INativeEnvironmentService], [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -4890,7 +5757,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); - return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService }; + return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService, fileService }; } test('setClientCustomizations forwards each item as a SessionCustomizationUpdated action', async () => { @@ -4908,7 +5775,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { } })); - const synced = await agent.setClientCustomizations(created.session, 'client-1', [ + const synced = await agent.syncClientCustomizations(created.session, 'client-1', [ makeClientCustomization('https://a', 'A'), makeClientCustomization('https://b', 'B'), ]); @@ -4927,8 +5794,8 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'b'), workingDirectory: URI.file('/work') }); pm.syncResult = [makeSyncedRef('https://shared', '/p/shared')]; - await agent.setClientCustomizations(s1.session, 'c', [makeClientCustomization('https://shared', 'S')]); - await agent.setClientCustomizations(s2.session, 'c', [makeClientCustomization('https://shared', 'S')]); + await agent.syncClientCustomizations(s1.session, 'c', [makeClientCustomization('https://shared', 'S')]); + await agent.syncClientCustomizations(s2.session, 'c', [makeClientCustomization('https://shared', 'S')]); // One fire per per-session diff change confirms fan-out. let changes = 0; @@ -4947,9 +5814,9 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'two'), workingDirectory: URI.file('/work') }); pm.syncResult = [makeSyncedRef('https://shared', '/p/shared'), makeSyncedRef('https://a', '/p/a')]; - await agent.setClientCustomizations(s1.session, 'c', []); + await agent.syncClientCustomizations(s1.session, 'c', []); pm.syncResult = [makeSyncedRef('https://shared', '/p/shared'), makeSyncedRef('https://b', '/p/b')]; - await agent.setClientCustomizations(s2.session, 'c', []); + await agent.syncClientCustomizations(s2.session, 'c', []); // `IAgent.getCustomizations()` is the provider-level catalogue // (host-configured), NOT an aggregator across sessions. Claude has @@ -4966,10 +5833,33 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work') }); assert.strictEqual(created.provisional, true); - await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); const customizations = await agent.getSessionCustomizations!(created.session); - assert.strictEqual(customizations.length, 1); + // The client-pushed customization, plus the curated read-only built-ins + // always present pre-materialize for discoverability (before a live SDK + // set exists): the built-in agents directory and the "Built-in" skills + // container. + assert.deepStrictEqual(customizations.map(c => c.uri), ['https://a', 'file:///mock-home/.claude/agents', 'agent-builtin:/skills']); + }); + + test('getSessionCustomizations overlays the enablement state onto client-pushed entries', async () => { + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + + await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + // Disable the client-pushed entry; the projection must reflect it. + agent.setCustomizationEnabled(customizationId('https://a'), false); + // Disabling a DISCOVERED entry's id must be a no-op — the enablement + // overlay is applied to the client-pushed tier only. + agent.setCustomizationEnabled(customizationId('agent-builtin:/skills'), false); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.find(c => c.uri === 'https://a')?.enabled, false); + assert.strictEqual(customizations.find(c => c.uri === 'agent-builtin:/skills')?.enabled, true, 'discovered entries are not toggled by the enablement map'); }); test('send pre-flight: dirty customizations triggers a rebind (SDK plugin URI set is captured at startup, so any change must restart the Query)', async () => { @@ -4988,17 +5878,17 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1); // Customization sync flips dirty; the next sendMessage's // pre-flight rebinds so `Options.plugins` on the new Query // includes the new path. pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; - await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); const firstQuery = sdk.warmQueries[0].produced!; - const p2 = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + const p2 = agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5025,8 +5915,8 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; pm.syncResult = [makeSyncedRef('https://x', '/p/x')]; - await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://x', 'X')]); - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://x', 'X')]); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session)!; // First-turn materialize consumed the dirty bit from the sync // above (plugin path baked into `Options.plugins` of the @@ -5039,7 +5929,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const gate = new DeferredPromise(); sdk.queryAdvance = async (i: number) => { if (i === 2) { await gate.p; } }; - const inflight = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + const inflight = agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); await new Promise(r => setImmediate(r)); // Toggle a SYNCED customization during the in-flight turn. The @@ -5067,12 +5957,84 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.setClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + + const customizations = await agent.getSessionCustomizations!(created.session); + // SDK snapshot failed → `sdk` stays undefined → unfiltered fallback: + // the client-pushed entry survives (UI not blanked) and the curated + // built-ins are appended (the built-in agents directory and the skills + // container) since there is no live set to derive from. + assert.deepStrictEqual(customizations.map(c => c.uri), ['https://a', 'file:///mock-home/.claude/agents', 'agent-builtin:/skills'], 'client-pushed projection survives SDK snapshot failure'); + }); + + test('getSessionCustomizations derives the Built-in container from the live SDK command set post-materialize', async () => { + // Once materialized, the runtime's real built-ins are exactly the SDK + // commands we don't discover on disk — surfaced read-only with the + // SDK's own descriptions, replacing the curated pre-materialize seed. + const pm = new FakeAgentPluginManager(); + const { agent, sdk } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // A successful snapshot: one SDK-only command, no agents/MCP. (No disk + // skills exist under /work, so the command becomes a built-in.) + sdk.supportedCommandsResult = [{ name: 'sdkcmd', description: 'Provided by the runtime.', argumentHint: '' }]; + sdk.supportedAgentsResult = []; + sdk.mcpServerStatusResult = []; + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.length, 1); + const container = customizations[0]; + assert.strictEqual(container.type, CustomizationType.Directory); + assert.strictEqual(container.uri, 'agent-builtin:/skills'); + + // The single child is the SDK command (with the SDK's description), + // proving the live command set — not the curated hardcoded list — + // drives the post-materialize built-ins. + const child = container.children?.[0]; + assert.ok(child); + assert.strictEqual(child.type, CustomizationType.Skill); + assert.deepStrictEqual( + { count: container.children?.length, name: child.name, description: child.description }, + { count: 1, name: 'sdkcmd', description: 'Provided by the runtime.' } + ); + }); + + test('getSessionCustomizations surfaces a native plugin captured from the live SDK init.plugins (path filter)', async () => { + // Native plugins are auto-loaded by the runtime; the host only surfaces + // them. Post-materialize, a plugin survives only when the captured + // `system/init.plugins` reports its resolved root path — proving the + // pipeline captures `message.plugins` and the discovery filter consumes it. + const pm = new FakeAgentPluginManager(); + const { agent, sdk, fileService } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Seed an enabled native plugin under the mock user home cache. + const root = '/mock-home/.claude/plugins/cache/m/tg/1.0.0'; + await fileService.writeFile(URI.file('/mock-home/.claude/settings.json'), VSBuffer.fromString(JSON.stringify({ enabledPlugins: { 'tg@m': true } }))); + await fileService.writeFile(URI.file(`${root}/.claude-plugin/plugin.json`), VSBuffer.fromString(JSON.stringify({ name: 'tg' }))); + + sdk.supportedCommandsResult = []; + sdk.supportedAgentsResult = []; + sdk.mcpServerStatusResult = []; + // The live session reports the plugin loaded at its resolved root. + const init = makeSystemInitMessage(sessionId); + init.plugins = [{ name: 'tg', path: root }]; + sdk.nextQueryMessages = [init, makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); - assert.strictEqual(customizations.length, 1, 'client-pushed projection survives SDK snapshot failure'); - assert.strictEqual(customizations[0].uri, 'https://a'); + assert.deepStrictEqual( + customizations.filter(c => c.type === CustomizationType.Plugin).map(c => c.name), + ['tg@m'], + 'native plugin survives post-materialize because the captured init.plugins reports its root', + ); }); test('changeAgent on a provisional session stashes the selection (no SDK contact) and lands on Options.agent at materialize', async () => { @@ -5083,11 +6045,11 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); - await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/code-reviewer.md' }); + await agent.chats.changeAgent(defaultChatUri(created.session), { uri: 'file:///foo/agents/code-reviewer.md' }); assert.strictEqual(sdk.startupCallCount, 0, 'no SDK startup from changeAgent on provisional'); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'code-reviewer', 'agent name resolved from file URI basename'); }); @@ -5104,13 +6066,13 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, undefined, 'no agent on first startup'); // Mid-session agent change: flips dirty, next send rebinds // (SDK has no working runtime hook to swap the agent in place). - await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/planner.md' }); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.chats.changeAgent(defaultChatUri(created.session), { uri: 'file:///foo/agents/planner.md' }); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 2, 'rebind on agent change'); assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, 'planner', 'agent baked into rebuilt Options'); @@ -5131,15 +6093,483 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'planner'); - await agent.changeAgent!(created.session, undefined); - await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await agent.chats.changeAgent(defaultChatUri(created.session), undefined); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 2); assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, undefined, 'cleared agent omitted from rebuilt Options'); }); + + // #region Multi-chat — additional (non-default) peer chats + + test('createChat persists a peer chat; getChats lists it; disposeChat removes it', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + + await agent.chats.createChat(chatUri); + const afterCreate = listPeerChats(agent, created.session); + + // Idempotent re-create must not duplicate the catalog entry. + await agent.chats.createChat(chatUri); + const afterRecreate = listPeerChats(agent, created.session); + + await agent.chats.disposeChat(chatUri); + const afterDispose = listPeerChats(agent, created.session); + + assert.deepStrictEqual({ afterCreate, afterRecreate, afterDispose }, { + afterCreate: [chatUri.toString()], + afterRecreate: [chatUri.toString()], + afterDispose: [], + }); + }); + + test('createChat / disposeChat on the default chat URI are no-ops', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const defaultChat = URI.parse(buildChatUri(created.session.toString(), 'default')); + + await agent.chats.createChat(defaultChat); + await agent.chats.disposeChat(defaultChat); + + assert.deepStrictEqual(listPeerChats(agent, created.session), []); + }); + + test('createChat({ fork }) forks the source chat; the peer chat resumes its own forked SDK session', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + // Parent session with a two-turn transcript; fork the peer chat at u1. + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + const forkCall = sdk.forkSessionCalls[0]; + + // Sending to the peer chat resumes ITS forked chat, not the parent's. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'next', undefined, 'turn-1'); + + assert.deepStrictEqual({ + forkCall, + chats: listPeerChats(agent, created.session), + startupResume: sdk.capturedStartupOptions[0]?.resume, + }, { + forkCall: { sessionId: parentId, options: { upToMessageId: 'a1' } }, + chats: [chatUri.toString()], + startupResume: 'forked-1', + }); + }); + + test('createChat({ fork }) with an unknown turn falls back to a fresh chat', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'does-not-exist' }); + + assert.deepStrictEqual({ + forked: sdk.forkSessionCalls.length, + chats: listPeerChats(agent, created.session), + }, { + forked: 0, + chats: [chatUri.toString()], + }); + }); + + test('sendMessage to a peer chat targets a chat distinct from the parent session', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + // The peer chat's startup resumed `forked-1`; the parent session was + // never materialized (no fresh `sessionId` startup for the parent). + assert.deepStrictEqual({ + startupCount: sdk.capturedStartupOptions.length, + resume: sdk.capturedStartupOptions[0]?.resume, + parentMaterialized: sdk.capturedStartupOptions.some(o => o.sessionId === parentId), + }, { + startupCount: 1, + resume: 'forked-1', + parentMaterialized: false, + }); + }); + + test('changeModel on a peer chat persists in the catalog so a later resume picks it up', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + // Change the peer chat's model before it is materialized. + await agent.chats.changeModel(chatUri, { id: 'claude-opus-4.6' }); + + // First send materializes (resumes) the chat with the changed model. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.model, 'claude-opus-4-6'); + }); + + test('disposing the parent session disposes its peer chats', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.createChat(chatUri); + + await agent.disposeSession(created.session); + + // The persisted catalog still records the chat (dispose tears down live + // state, not on-disk history), but no in-memory chat survives — + // re-disposing the chat is a clean no-op. + await agent.chats.disposeChat(chatUri); + assert.deepStrictEqual(listPeerChats(agent, created.session), []); + }); + + test('setPendingMessages routes steering to a materialized peer chat, warns for an unknown one', async () => { + const logService = new CapturingLogService(); + const { agent, sdk } = createTestContext(disposables, { logService }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + // Known materialized peer chat: resolved via the `chat` arg, no warning. + logService.warns.length = 0; + agent.setPendingMessages!(created.session, { id: 'p1', message: { text: 'steer', origin: { kind: MessageKind.User } } }, [], chatUri); + const warnAfterKnown = logService.warns.filter(w => w.includes('setPendingMessages')); + + // Unknown peer chat URI: not found, warns. + const unknownChat = URI.parse(buildChatUri(created.session.toString(), 'chat-missing')); + agent.setPendingMessages!(created.session, undefined, [], unknownChat); + const warnAfterUnknown = logService.warns.filter(w => w.includes('setPendingMessages')); + + assert.deepStrictEqual({ knownWarns: warnAfterKnown.length, unknownWarns: warnAfterUnknown.length }, { knownWarns: 0, unknownWarns: 1 }); + }); + + test('changeAgent on a peer chat persists to its overlay so a later resume picks it up', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + // Select a custom agent for the peer chat before it is materialized; the + // selection lands on the chat's own overlay (mirrors changeModel). + await agent.chats.changeAgent(chatUri, { uri: 'file:///foo/agents/planner.md' }); + + // First send materializes (resumes) the chat with the selected agent. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'planner'); + }); + + test('sendMessage routes each peer chat to its own forked chat', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + + // Two peer chats, each forked from a different turn into its own SDK + // chat. Staging distinct fork results pins per-chat identity. + const chatA = URI.parse(buildChatUri(created.session.toString(), 'chat-a')); + sdk.forkSessionResult = { sessionId: 'forked-a' }; + await agent.chats.fork(chatA, { source: created.session, turnId: 'u1' }); + + const chatB = URI.parse(buildChatUri(created.session.toString(), 'chat-b')); + sdk.forkSessionResult = { sessionId: 'forked-b' }; + await agent.chats.fork(chatB, { source: created.session, turnId: 'u2' }); + + sdk.sessionList = [ + { sessionId: 'forked-a', summary: 'a', lastModified: 1, cwd: URI.file('/work').fsPath }, + { sessionId: 'forked-b', summary: 'b', lastModified: 1, cwd: URI.file('/work').fsPath }, + ]; + + // Each send must resume the chat backing THAT chat, never the + // other and never the (un-materialized) parent session. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-a'), makeResultSuccess('forked-a')]; + await agent.chats.sendMessage(chatA, 'to a', undefined, 'turn-a'); + sdk.nextQueryMessages = [makeSystemInitMessage('forked-b'), makeResultSuccess('forked-b')]; + await agent.chats.sendMessage(chatB, 'to b', undefined, 'turn-b'); + + assert.deepStrictEqual({ + chats: listPeerChats(agent, created.session).sort(), + resumeA: sdk.capturedStartupOptions[0]?.resume, + resumeB: sdk.capturedStartupOptions[1]?.resume, + parentMaterialized: sdk.capturedStartupOptions.some(o => o.sessionId === parentId), + }, { + chats: [chatA.toString(), chatB.toString()].sort(), + resumeA: 'forked-a', + resumeB: 'forked-b', + parentMaterialized: false, + }); + }); + + test('restart round-trip: a forked peer chat re-materializes from the orchestrator\'s providerData on a fresh agent backed by the same database', async () => { + const database = new TestSessionDatabase(); + + // --- First "process": create a forked peer chat with a model override. + // `createChat` hands the orchestrator an opaque `providerData` blob to + // persist. --- + const ctxA = createTestContext(disposables, { database }); + await ctxA.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await ctxA.agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + ctxA.sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + ctxA.sdk.forkSessionResult = { sessionId: 'forked-1' }; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + const createResult = await ctxA.agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }, { + model: { id: 'claude-opus-4.6' }, + }); + const providerData = createResult?.providerData; + const catalogBefore = listPeerChats(ctxA.agent, created.session); + + // --- Simulate a restart: a brand-new agent over the SAME database. + // Nothing carries over in memory; the parent + forked transcripts + // survive on disk, staged in the fresh SDK's session list. The + // orchestrator hands the persisted `providerData` back via + // `materializeChat` to re-attach the chat's backing. --- + const ctxB = createTestContext(disposables, { database }); + await ctxB.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + ctxB.sdk.sessionList = [ + { sessionId: parentId, summary: 'parent', lastModified: 1, cwd: URI.file('/work').fsPath }, + { sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }, + ]; + + await ctxB.agent.materializeChat!(chatUri, providerData); + // Catalog reappears from the re-attached live backing without SDK contact. + const catalogAfter = listPeerChats(ctxB.agent, created.session); + + // First send on the restored chat resumes its forked chat with + // the persisted model override — history + per-chat model both came back. + ctxB.sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await ctxB.agent.chats.sendMessage(chatUri, 'after restart', undefined, 'turn-1'); + + assert.deepStrictEqual({ + providerData: providerData && JSON.parse(providerData), + catalogBefore, + catalogAfter, + resume: ctxB.sdk.capturedStartupOptions[0]?.resume, + model: ctxB.sdk.capturedStartupOptions[0]?.model, + }, { + providerData: { sdkSessionId: 'forked-1', model: { id: 'claude-opus-4.6' } }, + catalogBefore: [chatUri.toString()], + catalogAfter: [chatUri.toString()], + resume: 'forked-1', + model: 'claude-opus-4-6', + }); + }); + + test('changeModel on a peer chat fires onDidChangeChatData with the refreshed providerData', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + const createResult = await agent.chats.createChat(chatUri); + const sdkSessionId = JSON.parse(createResult!.providerData!).sdkSessionId as string; + + const changes: IAgentChatDataChange[] = []; + disposables.add(agent.onDidChangeChatData!(e => changes.push(e))); + + await agent.chats.changeModel(chatUri, { id: 'claude-opus-4.6' }); + + assert.deepStrictEqual(changes.map(c => ({ chat: c.chat.toString(), providerData: JSON.parse(c.providerData) })), [ + { chat: chatUri.toString(), providerData: { sdkSessionId, model: { id: 'claude-opus-4.6' } } }, + ]); + }); + + // #endregion + + // #region Multi-chat — chat surface (G-C1 adoption) + + test('createSession mints a provisional session and disposeSession tears it down', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + // Tearing the session down must not throw and must be idempotent. + await agent.disposeSession(created.session); + await agent.disposeSession(created.session); + + assert.deepStrictEqual({ + scheme: created.session.scheme, + provisional: created.provisional, + }, { + scheme: 'claude', + provisional: true, + }); + }); + + test('chats.createChat persists a peer chat; getChats lists it; chats.disposeChat removes it', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + + await agent.chats!.createChat(chatUri); + const afterCreate = listPeerChats(agent, created.session); + + await agent.chats!.disposeChat(chatUri); + const afterDispose = listPeerChats(agent, created.session); + + assert.deepStrictEqual({ afterCreate, afterDispose }, { + afterCreate: [chatUri.toString()], + afterDispose: [], + }); + }); + + test('chats.fork forks the source chat; chats.sendMessage resumes the peer chat\'s own forked SDK session', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats!.fork(chatUri, { source: created.session, turnId: 'u1' }); + + const forkCall = sdk.forkSessionCalls[0]; + + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats!.sendMessage(chatUri, 'next', undefined, 'turn-1'); + + assert.deepStrictEqual({ + forkCall, + chats: listPeerChats(agent, created.session), + startupResume: sdk.capturedStartupOptions[0]?.resume, + }, { + forkCall: { sessionId: parentId, options: { upToMessageId: 'a1' } }, + chats: [chatUri.toString()], + startupResume: 'forked-1', + }); + }); + + test('chats addresses the default chat by the default chat URI (sendMessage routes to the default chat; getMessages mirrors getSessionMessages)', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + const chat = defaultChatUri(created.session); + await agent.chats!.sendMessage(chat, 'hi', undefined, 'turn-1'); + + const viaChats = await agent.chats!.getMessages(chat); + const viaLegacy = await agent.getSessionMessages(created.session); + + assert.deepStrictEqual({ + startupSessionId: sdk.capturedStartupOptions[0]?.sessionId, + resume: sdk.capturedStartupOptions[0]?.resume, + messagesMatchLegacy: JSON.stringify(viaChats) === JSON.stringify(viaLegacy), + }, { + startupSessionId: sessionId, + resume: undefined, + messagesMatchLegacy: true, + }); + }); + + test('chats.changeModel on a peer fires onDidChangeChatData with the refreshed providerData (parity with legacy changeModel)', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + const createResult = await agent.chats!.createChat(chatUri); + const sdkSessionId = JSON.parse(createResult!.providerData!).sdkSessionId as string; + + const changes: IAgentChatDataChange[] = []; + disposables.add(agent.onDidChangeChatData!(e => changes.push(e))); + + await agent.chats.changeModel(chatUri, { id: 'claude-opus-4.6' }); + + assert.deepStrictEqual(changes.map(c => ({ chat: c.chat.toString(), providerData: JSON.parse(c.providerData) })), [ + { chat: chatUri.toString(), providerData: { sdkSessionId, model: { id: 'claude-opus-4.6' } } }, + ]); + }); + + test('chats.changeAgent on a peer persists to its overlay so a later resume picks it up (parity with legacy changeAgent)', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats!.fork(chatUri, { source: created.session, turnId: 'u1' }); + + // Select a custom agent for the peer chat before it is materialized. + await agent.chats!.changeAgent(chatUri, { uri: 'file:///foo/agents/reviewer.md' }); + + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats!.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'reviewer'); + }); + + // #endregion }); // #endregion diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index 131b7a240a613..f0d3ac51c4e42 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -12,6 +12,7 @@ import { ActionType } from '../../common/state/sessionActions.js'; import { ResponsePartKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/protocol/state.js'; import { ClaudeMapperState, mapSDKMessageToAgentSignals } from '../../node/claude/claudeMapSessionEvents.js'; +import { CLAUDE_USER_DECLINED_MESSAGE } from '../../node/claude/claudeToolDenial.js'; import { encodeForwardedChatError, PROXY_ERROR_PREFIX } from '../../node/shared/forwardedChatError.js'; import { SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; import { @@ -136,7 +137,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.strictEqual(out.length, 3); const start = out[0]; assert.ok(start.kind === 'action' && start.action.type === ActionType.ChatResponsePart); - assert.strictEqual(start.session.toString(), SESSION_STR); + assert.strictEqual(start.resource.toString(), SESSION_STR); assert.strictEqual(start.action.turnId, TURN_ID); assert.strictEqual(start.action.part.kind, ResponsePartKind.Markdown); const partId = start.action.part.id; @@ -145,7 +146,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(out.slice(1), [ { kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatDelta, turnId: TURN_ID, @@ -155,7 +156,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { }, { kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatDelta, turnId: TURN_ID, @@ -195,7 +196,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { ); assert.deepStrictEqual(deltaSignals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatReasoning, turnId: TURN_ID, @@ -223,7 +224,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallStart, turnId: TURN_ID, @@ -253,12 +254,12 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { state, log, r(), - CLIENT_ID, + () => CLIENT_ID, ); assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallStart, turnId: TURN_ID, @@ -271,6 +272,31 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(log.warns, []); }); + test('Test 10b — a tool denied by the user maps to result.error.code = denied', () => { + const log = new NullLogService(); + const state = new ClaudeMapperState(); + const resolver = r(); + + mapSDKMessageToAgentSignals(makeStreamEvent(SESSION_ID, makeContentBlockStartToolUse(0, 'tu_d', 'Bash')), SESSION, TURN_ID, state, log, resolver); + mapSDKMessageToAgentSignals(makeStreamEvent(SESSION_ID, makeContentBlockStop(0)), SESSION, TURN_ID, state, log, resolver); + + const signals = mapSDKMessageToAgentSignals( + makeUserToolResultMessage(SESSION_ID, 'tu_d', CLAUDE_USER_DECLINED_MESSAGE, { isError: true }), + SESSION, + TURN_ID, + state, + log, + r(), + ); + + const signal = signals[0]; + if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatToolCallComplete) { + throw new Error(`expected a ChatToolCallComplete action, got ${signal.kind}`); + } + assert.strictEqual(signal.action.result.success, false); + assert.deepStrictEqual(signal.action.result.error, { message: CLAUDE_USER_DECLINED_MESSAGE, code: 'denied' }); + }); + test('Test 9 — input_json_delta emits ChatToolCallDelta scoped to the open tool_use block', () => { const log = new NullLogService(); const state = new ClaudeMapperState(); @@ -290,7 +316,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallDelta, turnId: TURN_ID, @@ -317,7 +343,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallReady, turnId: TURN_ID, @@ -355,7 +381,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [{ kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatToolCallComplete, turnId: TURN_ID, @@ -409,6 +435,10 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { const complete = signals[0]; assert.ok(complete.kind === 'action' && complete.action.type === ActionType.ChatToolCallComplete); assert.strictEqual(complete.action.result.success, false); + // A genuine failure whose message is not one of the known deny strings + // must NOT be classified as a cancellation: no `error.code` is set, so + // telemetry reports `error` rather than `userCancelled`. + assert.strictEqual(complete.action.result.error?.code, undefined); }); test('tool_result content as TextBlock array unwraps to ToolResultTextContent[]', () => { @@ -583,7 +613,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, [ { kind: 'action', - session: SESSION, + resource: SESSION, action: { type: ActionType.ChatUsage, turnId: TURN_ID, diff --git a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts index 1624281974adb..95dd9c6c20875 100644 --- a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts @@ -56,6 +56,8 @@ type MessagesResult = class FakeCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + messagesResult: MessagesResult = { kind: 'error', error: new Error('not configured') }; modelsResult: { kind: 'value'; value: CCAModel[] } | { kind: 'error'; error: Error } = { kind: 'value', value: [] }; @@ -1252,6 +1254,7 @@ suite('ClaudeProxyService', () => { models: () => Promise.resolve([]), responses: () => Promise.reject(new Error('not used')), utilityChatCompletion: () => Promise.reject(new Error('not used')), + resolveRestrictedTelemetryContext: () => Promise.resolve({ restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); const handle = await service.start(TOKEN); @@ -1321,6 +1324,7 @@ suite('ClaudeProxyService', () => { models: fake.models.bind(fake), responses: fake.responses.bind(fake), utilityChatCompletion: fake.utilityChatCompletion.bind(fake), + resolveRestrictedTelemetryContext: fake.resolveRestrictedTelemetryContext.bind(fake), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); const handle = await service.start(TOKEN); diff --git a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts index 600271bbcaa2c..1798bcffcdbb2 100644 --- a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { URI } from '../../../../base/common/uri.js'; import { NullLogService } from '../../../log/common/log.js'; import { ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState } from '../../common/state/protocol/state.js'; -import { mapSessionMessagesToTurns } from '../../node/claude/claudeReplayMapper.js'; +import { mapSessionMessagesToTurns, resolveForkAnchorUuid } from '../../node/claude/claudeReplayMapper.js'; suite('claudeReplayMapper', () => { @@ -267,4 +267,209 @@ suite('claudeReplayMapper', () => { assert.strictEqual(turns[1].id, 'u2'); assert.strictEqual(turns[1].message.text, 'how about now'); }); + + test('Fixture 10: prompt-less subagent transcript (inner messages) maps to one turn', () => { + // A subagent transcript from `getSubagentMessages` carries a + // `parent_tool_use_id` on every envelope and has NO synthetic spawning + // user prompt, so it opens directly with an assistant message. The + // builder must synthesize an empty-prompt turn rather than dropping the + // inner assistant content (which would lose the whole transcript on + // replay). Shape mirrors a real captured subagent transcript. + const parent = 'toolu_parent'; + const messages: SessionMessage[] = [ + { + type: 'assistant', uuid: 'sa1', session_id: 'sess-1', parent_tool_use_id: parent, + message: { id: 'msg_sa1', role: 'assistant', content: [{ type: 'thinking', thinking: 'planning', signature: 'sig' }] }, + }, + { + type: 'assistant', uuid: 'sa2', session_id: 'sess-1', parent_tool_use_id: parent, + message: { id: 'msg_sa2', role: 'assistant', content: [{ type: 'tool_use', id: 'tu_inner', name: 'Bash', input: { command: 'ls' } }] }, + }, + { + type: 'user', uuid: 'sa3', session_id: 'sess-1', parent_tool_use_id: parent, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tu_inner', content: 'file-a.txt\nfile-b.txt' }] }, + }, + { + type: 'assistant', uuid: 'sa4', session_id: 'sess-1', parent_tool_use_id: parent, + message: { id: 'msg_sa4', role: 'assistant', content: [{ type: 'text', text: 'Done. SUBAGENT_ONLY_MARKER_xyz' }] }, + }, + ]; + + const turns = mapSessionMessagesToTurns(messages, session, logService); + + assert.strictEqual(turns.length, 1, 'inner assistant messages must form a single synthesized turn'); + assert.strictEqual(turns[0].id, 'sa1', 'turn id anchors on the first inner assistant envelope'); + assert.strictEqual(turns[0].message.text, '', 'subagent turn has no user prompt'); + assert.strictEqual(turns[0].state, TurnState.Complete, 'tool_result drains the pending tool_use'); + const markdown = turns[0].responseParts.filter(p => p.kind === ResponsePartKind.Markdown); + assert.ok(markdown.some(p => p.kind === ResponsePartKind.Markdown && p.content.includes('SUBAGENT_ONLY_MARKER_xyz')), + 'the subagent final text (with marker) must survive replay'); + const toolCall = turns[0].responseParts.find(p => p.kind === ResponsePartKind.ToolCall); + assert.ok(toolCall && toolCall.kind === ResponsePartKind.ToolCall && toolCall.toolCall.status === ToolCallStatus.Completed, + 'inner Bash tool call must be reconstructed as Completed'); + }); + + test('Fixture 10b: top-level assistant before any user message is still dropped', () => { + // Guard the narrow behavior: a top-level (non-inner) assistant envelope + // arriving before any user message remains anomalous and is dropped, so + // the synthesize-on-open path is scoped strictly to subagent transcripts. + const messages: SessionMessage[] = [ + makeAssistantText('a1', 'orphan reply'), + makeUser('u1', 'hello'), + makeAssistantText('a2', 'world'), + ]; + + const turns = mapSessionMessagesToTurns(messages, session, logService); + + assert.strictEqual(turns.length, 1, 'the orphan top-level assistant must NOT synthesize a turn'); + assert.strictEqual(turns[0].id, 'u1'); + }); +}); + +suite('resolveForkAnchorUuid', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function makeUser(uuid: string, text: string): SessionMessage { + return { + type: 'user', + uuid, + session_id: 'sess-1', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text }] }, + }; + } + + function makeAssistantText(uuid: string, text: string): SessionMessage { + return { + type: 'assistant', + uuid, + session_id: 'sess-1', + parent_tool_use_id: null, + message: { id: `msg_${uuid}`, role: 'assistant', content: [{ type: 'text', text }] }, + }; + } + + function makeAssistantToolUse(uuid: string, toolUseId: string, name: string, input: unknown = {}): SessionMessage { + return { + type: 'assistant', + uuid, + session_id: 'sess-1', + parent_tool_use_id: null, + message: { id: `msg_${uuid}`, role: 'assistant', content: [{ type: 'tool_use', id: toolUseId, name, input }] }, + }; + } + + function makeUserToolResult(uuid: string, toolUseId: string, text: string): SessionMessage { + return { + type: 'user', + uuid, + session_id: 'sess-1', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: text }] }, + }; + } + + function makeSystem(uuid: string, subtype: string, text?: string): SessionMessage { + return { + type: 'system', + uuid, + session_id: 'sess-1', + parent_tool_use_id: null, + message: { subtype, ...(text !== undefined ? { text } : {}) }, + }; + } + + // 3-turn transcript shared by the fork-position fixtures. + const threeTurns: SessionMessage[] = [ + makeUser('u1', 'apple'), + makeAssistantText('a1', 'apple!'), + makeUser('u2', 'banana'), + makeAssistantText('a2', 'banana!'), + makeUser('u3', 'cherry'), + makeAssistantText('a3', 'cherry!'), + ]; + + test('fork at turn 0 → last assistant uuid of turn 0', () => { + assert.strictEqual(resolveForkAnchorUuid(threeTurns, 'u1'), 'a1'); + }); + + test('fork at turn 1 → last assistant uuid of turn 1', () => { + assert.strictEqual(resolveForkAnchorUuid(threeTurns, 'u2'), 'a2'); + }); + + test('fork at the last turn → last assistant uuid of that turn', () => { + assert.strictEqual(resolveForkAnchorUuid(threeTurns, 'u3'), 'a3'); + }); + + test('turn with multiple assistant envelopes → the LAST one', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'do a thing'), + makeAssistantText('a1', 'thinking'), + makeAssistantToolUse('a2', 'tool-1', 'Read'), + makeUserToolResult('r1', 'tool-1', 'file contents'), + makeUser('u2', 'next'), + makeAssistantText('a3', 'ok'), + ]; + assert.strictEqual(resolveForkAnchorUuid(messages, 'u1'), 'a2', 'must return the last assistant envelope of the target turn'); + }); + + test('user-tool-results between assistants does not flip the turn', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'go'), + makeAssistantToolUse('a1', 'tool-1', 'Read'), + makeUserToolResult('r1', 'tool-1', 'contents'), + makeAssistantText('a2', 'done'), + makeUser('u2', 'next'), + makeAssistantText('a3', 'ok'), + ]; + assert.strictEqual(resolveForkAnchorUuid(messages, 'u1'), 'a2', 'tool_result envelope must not end the turn'); + }); + + test('system-notification mid-turn does not flip the turn', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'go'), + makeSystem('s1', 'compact_boundary'), + makeAssistantText('a1', 'done'), + makeUser('u2', 'next'), + makeAssistantText('a2', 'ok'), + ]; + assert.strictEqual(resolveForkAnchorUuid(messages, 'u1'), 'a1', 'system notification must not end the turn'); + }); + + test('user-only target turn (no assistant) falls back to the user-text uuid', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'apple'), + makeAssistantText('a1', 'apple!'), + makeUser('u2', 'unanswered'), + ]; + assert.strictEqual(resolveForkAnchorUuid(messages, 'u2'), 'u2', 'fall back to the user-text envelope uuid'); + }); + + test('turnId not found → undefined', () => { + assert.strictEqual(resolveForkAnchorUuid(threeTurns, 'nope'), undefined); + }); + + test('empty transcript → undefined', () => { + assert.strictEqual(resolveForkAnchorUuid([], 'u1'), undefined); + }); + + test('CLI-echo user envelopes are skipped by the shared parser', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'what model'), + { + type: 'user', + uuid: 'echo-1', + session_id: 'sess-1', + parent_tool_use_id: null, + message: { role: 'user', content: '/model' }, + }, + makeAssistantText('a1', 'opus'), + makeUser('u2', 'next'), + makeAssistantText('a2', 'ok'), + ]; + // The CLI-echo envelope must not be treated as the start of a new turn, + // so turn u1's anchor is still a1 (not echo-1, not undefined). + assert.strictEqual(resolveForkAnchorUuid(messages, 'u1'), 'a1'); + }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts index 66cbed0b26a82..4c6b8dbfeb859 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkMessageRouter.test.ts @@ -19,6 +19,7 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSignal } from '../../common/agentService.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; import { ClaudeSdkMessageRouter } from '../../node/claude/claudeSdkMessageRouter.js'; import { SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; import { createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -55,6 +56,7 @@ function createRouter(disposables: Pick): IRouterHarness const router = disposables.add(inst.createInstance( ClaudeSdkMessageRouter, URI.parse('claude:/sess-1'), + URI.parse(buildDefaultChatUri('claude:/sess-1')), dbRef, subagents, undefined, diff --git a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts index e76fe8a63f49c..2a80a5570676d 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { buildOptions, buildSubprocessEnv } from '../../node/claude/claudeSdkOptions.js'; -import type { IClaudeProxyHandle } from '../../node/claude/claudeProxyService.js'; +import type { ClaudeTransport, IClaudeProxyHandle } from '../../node/claude/claudeProxyService.js'; suite('claudeSdkOptions / buildSubprocessEnv', () => { @@ -78,6 +78,42 @@ suite('claudeSdkOptions / buildSubprocessEnv', () => { assert.strictEqual(env.ELECTRON_RUN_AS_NODE, '1'); }); + + test('native mode (proxied=false) inherits auth vars + PATH (SDK replace semantics) while still stripping VSCODE_*/ELECTRON_*/NODE_OPTIONS', () => { + clearAndSet({ + VSCODE_PID: '1234', + ELECTRON_NO_ATTACH_CONSOLE: '1', + NODE_OPTIONS: '--inspect', + ANTHROPIC_API_KEY: 'sk-user-key', + CLAUDE_CODE_OAUTH_TOKEN: 'sk-ant-oat-user', + PATH: '/usr/bin', + HOME: '/Users/test', + }); + + const env = buildSubprocessEnv(false); + + assert.deepStrictEqual({ + // Inherited so the user's own credentials reach the `claude` subprocess. + anthropicKey: env.ANTHROPIC_API_KEY, + oauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + path: env.PATH, + home: env.HOME, + // Still stripped — these break the Electron-node subprocess. + vscodePid: env.VSCODE_PID, + electronOther: env.ELECTRON_NO_ATTACH_CONSOLE, + nodeOptions: env.NODE_OPTIONS, + runAsNode: env.ELECTRON_RUN_AS_NODE, + }, { + anthropicKey: 'sk-user-key', + oauthToken: 'sk-ant-oat-user', + path: '/usr/bin', + home: '/Users/test', + vscodePid: undefined, + electronOther: undefined, + nodeOptions: undefined, + runAsNode: '1', + }); + }); }); suite('claudeSdkOptions / buildOptions plugins projection', () => { @@ -89,6 +125,7 @@ suite('claudeSdkOptions / buildOptions plugins projection', () => { nonce: 'n', dispose: () => { }, }; + const proxyTransport: ClaudeTransport = { kind: 'proxy', handle: proxyHandle }; function input(plugins: readonly URI[] | undefined) { return { @@ -107,7 +144,7 @@ suite('claudeSdkOptions / buildOptions plugins projection', () => { test('non-empty plugins project to Options.plugins as local entries', async () => { const opts = await buildOptions( input([URI.file('/p/a'), URI.file('/p/b')]), - proxyHandle, + proxyTransport, () => { }, () => { }, ); @@ -118,12 +155,90 @@ suite('claudeSdkOptions / buildOptions plugins projection', () => { }); test('empty plugins array omits Options.plugins', async () => { - const opts = await buildOptions(input([]), proxyHandle, () => { }, () => { }); + const opts = await buildOptions(input([]), proxyTransport, () => { }, () => { }); assert.strictEqual(opts.plugins, undefined); }); test('undefined plugins omits Options.plugins', async () => { - const opts = await buildOptions(input(undefined), proxyHandle, () => { }, () => { }); + const opts = await buildOptions(input(undefined), proxyTransport, () => { }, () => { }); assert.strictEqual(opts.plugins, undefined); }); + + test('proxy transport sets ANTHROPIC_BASE_URL + per-session ANTHROPIC_AUTH_TOKEN', async () => { + const opts = await buildOptions(input(undefined), proxyTransport, () => { }, () => { }); + const env = (opts.settings as { env?: Record }).env ?? {}; + assert.deepStrictEqual({ + baseUrl: env.ANTHROPIC_BASE_URL, + authToken: env.ANTHROPIC_AUTH_TOKEN, + nonessential: env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, + }, { + baseUrl: 'http://127.0.0.1:0', + authToken: 'n.s1', + nonessential: '1', + }); + }); + + test('native transport omits ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN (subprocess env carries the user credentials)', async () => { + const opts = await buildOptions(input(undefined), { kind: 'native' }, () => { }, () => { }); + const env = (opts.settings as { env?: Record }).env ?? {}; + assert.deepStrictEqual({ + baseUrl: env.ANTHROPIC_BASE_URL, + authToken: env.ANTHROPIC_AUTH_TOKEN, + nonessential: env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, + }, { + baseUrl: undefined, + authToken: undefined, + nonessential: '1', + }); + }); +}); + +suite('claudeSdkOptions / buildOptions resumeSessionAt projection', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const proxyHandle: IClaudeProxyHandle = { + baseUrl: 'http://127.0.0.1:0', + nonce: 'n', + dispose: () => { }, + }; + const proxyTransport: ClaudeTransport = { kind: 'proxy', handle: proxyHandle }; + + function input(isResume: boolean, resumeSessionAt: string | undefined) { + return { + sessionId: 's1', + workingDirectory: URI.file('/tmp/x'), + model: undefined, + abortController: new AbortController(), + permissionMode: 'default' as const, + canUseTool: async () => ({ behavior: 'allow' as const, updatedInput: {} }), + isResume, + mcpServers: undefined, + ...(resumeSessionAt !== undefined ? { resumeSessionAt } : {}), + }; + } + + test('resume + resumeSessionAt projects onto Options.resume and Options.resumeSessionAt', async () => { + const opts = await buildOptions(input(true, 'anchor-uuid'), proxyTransport, () => { }, () => { }); + assert.deepStrictEqual( + { resume: opts.resume, sessionId: opts.sessionId, resumeSessionAt: opts.resumeSessionAt }, + { resume: 's1', sessionId: undefined, resumeSessionAt: 'anchor-uuid' }, + ); + }); + + test('resume without resumeSessionAt omits Options.resumeSessionAt', async () => { + const opts = await buildOptions(input(true, undefined), proxyTransport, () => { }, () => { }); + assert.deepStrictEqual( + { resume: opts.resume, resumeSessionAt: opts.resumeSessionAt }, + { resume: 's1', resumeSessionAt: undefined }, + ); + }); + + test('non-resume startup never carries resumeSessionAt even when provided', async () => { + const opts = await buildOptions(input(false, 'anchor-uuid'), proxyTransport, () => { }, () => { }); + assert.deepStrictEqual( + { sessionId: opts.sessionId, resume: opts.resume, resumeSessionAt: opts.resumeSessionAt }, + { sessionId: 's1', resume: undefined, resumeSessionAt: undefined }, + ); + }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts index 02ddac0e5c860..0851e0f9f5801 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Query, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { Query, SDKMessage, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import assert from 'assert'; import { DeferredPromise } from '../../../../base/common/async.js'; @@ -20,6 +20,7 @@ import { ServiceCollection } from '../../../instantiation/common/serviceCollecti import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; import { ClaudeSdkPipeline, IRematerializer } from '../../node/claude/claudeSdkPipeline.js'; import { SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; import { createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -58,6 +59,7 @@ class ImmediatelyDoneQuery implements Query { async setModel(): Promise { /* not exercised here */ } async applyFlagSettings(_settings: Parameters[0]): Promise { /* not exercised here */ } async setPermissionMode(): Promise { /* not exercised here */ } + async setMcpPermissionModeOverride(): Promise<{ warning?: string }> { return {}; } async interrupt(): Promise { /* not exercised here */ } streamInput(): never { throw new Error('not modeled'); } stopTask(): never { throw new Error('not modeled'); } @@ -67,6 +69,7 @@ class ImmediatelyDoneQuery implements Query { async [Symbol.asyncDispose](): Promise { /* not exercised here */ } setMaxThinkingTokens(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } + reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } supportedModels(): never { throw new Error('not modeled'); } supportedAgents(): never { throw new Error('not modeled'); } @@ -130,6 +133,52 @@ class RecordingWarmQuery extends FakeWarmQuery { } } +/** A {@link Query}-shaped stub whose async stream the test ends on demand. */ +type IControllableQuery = Query & { + /** Ends the stream (models a dispose-driven close of the underlying query). */ + end(): void; + /** How many times the consumer loop has pulled from this query's iterator. */ + readonly nextCallCount: number; +}; + +/** + * Builds a {@link Query} whose async iterator blocks (modelling a live turn) + * until {@link IControllableQuery.end}, and records how many times the consumer + * loop pulled from it. Lets a test hold the consumer loop on one query while a + * rebind swaps in the next, then observe whether the new query gets drained. + */ +function makeControllableQuery(): IControllableQuery { + let ended = false; + let wake: (() => void) | undefined; + const q = Object.assign(new ImmediatelyDoneQuery(), { + nextCallCount: 0, + end(): void { ended = true; wake?.(); wake = undefined; }, + [Symbol.asyncIterator]() { return this; }, + async next(this: { nextCallCount: number }): Promise> { + this.nextCallCount++; + while (!ended) { + await new Promise(resolve => { wake = resolve; }); + } + return { done: true, value: undefined }; + }, + async return() { return { done: true, value: undefined }; }, + async throw(err: unknown) { throw err; }, + }); + return q as unknown as IControllableQuery; +} + +/** {@link WarmQuery} that hands out {@link makeControllableQuery} instances and records them. */ +class ControllableWarmQuery extends FakeWarmQuery { + readonly queries: IControllableQuery[] = []; + + override query(_prompt: string | AsyncIterable): Query { + this.queryCallCount++; + const q = makeControllableQuery(); + this.queries.push(q); + return q; + } +} + // ===== Harness ===== interface IPipelineHarness { @@ -162,6 +211,7 @@ function createPipeline( ClaudeSdkPipeline, 'sess-1', URI.parse('claude:/sess-1'), + URI.parse(buildDefaultChatUri('claude:/sess-1')), warm, controller, dbRef, @@ -233,6 +283,7 @@ suite('ClaudeSdkPipeline', () => { ClaudeSdkPipeline, 'sess-2', URI.parse('claude:/sess-2'), + URI.parse(buildDefaultChatUri('claude:/sess-2')), warm, controller, dbRef, @@ -368,6 +419,42 @@ suite('ClaudeSdkPipeline', () => { assert.strictEqual(built[0].controller.signal.aborted, true, 'fresh controller cancelled before being installed'); assert.strictEqual(pipeline.isAborted, true); }); + + test('a rebind hands the consumer loop off to the new query so the post-rebind turn is not lost', async () => { + // Regression: a rebind swaps in a fresh `_query` while the consumer + // loop is still draining the OLD one. The post-rebind `send` queues + // its prompt while the old loop is still marked running, so + // `_ensureConsumerLoop` no-ops. If the old loop then just stopped, + // nothing would ever read the new query and `send` would hang + // ("Restore Checkpoint then send" never responds). + const warm1 = new ControllableWarmQuery(); + const { pipeline } = createPipeline(disposables, warm1); + + // Bind Q1 and start the consumer loop draining it. No result is + // pushed, so this send never resolves — we only need the live loop. + pipeline.send(makePrompt('p1'), 'turn-1').catch(() => { /* unwound on teardown */ }); + await flushMicrotasks(); + const q1 = warm1.queries[0]; + assert.ok(q1.nextCallCount > 0, 'consumer loop drains Q1'); + + // Rebind to a fresh warm/Q2 while Q1's loop is still parked. + const warm2 = new ControllableWarmQuery(); + pipeline.attachRematerializer(async () => ({ warm: warm2, abortController: new AbortController() })); + await pipeline.rebindForRestart(); + const q2 = warm2.queries[0]; + assert.strictEqual(q2.nextCallCount, 0, 'new query not drained yet — the old loop is still running'); + + // The old query's stream now ends (as a real dispose would). The + // loop must hand off to Q2 rather than stopping. + q1.end(); + await flushMicrotasks(); + + assert.ok(q2.nextCallCount > 0, 'consumer loop handed off to the new query after the old one ended'); + + // Clean teardown: let the re-armed loop unwind before dispose. + q2.end(); + await flushMicrotasks(); + }); }); suite('seedCurrentConfig', () => { @@ -431,6 +518,29 @@ suite('ClaudeSdkPipeline', () => { await pipeline.setEffort(undefined); assert.deepStrictEqual(warm.flagSettings, []); }); + + test('setEffort while awaiting rebind (post-abort) is buffered, not pushed to the dead query, then replayed on rebind', async () => { + // After an abort the `_query` handle is intentionally retained (it is + // what teardown awaits) but the stream is dead; `_needsRebind` is the + // health signal. setEffort must NOT steer that dead query — it should + // buffer the value and let `_replayCurrentConfig` push it onto the + // freshly-bound query after the rebind. + const { pipeline, warm } = await seededHighThenBind(disposables); + pipeline.abort(); + warm.flagSettings.length = 0; // isolate: ignore anything from the dead query + await pipeline.setEffort('low'); + assert.deepStrictEqual(warm.flagSettings, [], 'effort must not be pushed while needsRebind'); + + let warm2!: RecordingWarmQuery; + pipeline.attachRematerializer(async () => { + const ctl = new AbortController(); + warm2 = new RecordingWarmQuery(ctl.signal); + return { warm: warm2, abortController: ctl }; + }); + pipeline.send(makePrompt('p2'), 'turn-B').catch(() => { /* stream ends without result */ }); + await flushMicrotasks(); + assert.deepStrictEqual(warm2.flagSettings, [{ effortLevel: 'low' }], 'buffered effort replayed on the rebound query'); + }); }); suite('dispose', () => { diff --git a/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts b/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts index 80f719769c4d0..aa061764671f9 100644 --- a/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts @@ -125,7 +125,6 @@ suite('ClaudeSessionMetadataStore', () => { const projected = store.project(sdkInfo, { customizationDirectory: URI.file('/custom'), - model: { id: 'claude-opus-4-6' }, }); assert.deepStrictEqual({ @@ -135,7 +134,6 @@ suite('ClaudeSessionMetadataStore', () => { summary: projected.summary, workingDirectory: projected.workingDirectory?.toString(), customizationDirectory: projected.customizationDirectory?.toString(), - model: projected.model, }, { session: 'claude:/abc', startTime: 1000, @@ -143,7 +141,6 @@ suite('ClaudeSessionMetadataStore', () => { summary: 'custom', workingDirectory: URI.file('/repo').toString(), customizationDirectory: URI.file('/custom').toString(), - model: { id: 'claude-opus-4-6' }, }); }); @@ -157,12 +154,10 @@ suite('ClaudeSessionMetadataStore', () => { summary: projected.summary, workingDirectory: projected.workingDirectory, customizationDirectory: projected.customizationDirectory, - model: projected.model, }, { summary: 'fallback', workingDirectory: undefined, customizationDirectory: undefined, - model: undefined, }); }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts index 329dfedd3b96f..378cd8319a087 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import type { GetSessionMessagesOptions, GetSubagentMessagesOptions, ListSubagentsOptions, Options, SDKSessionInfo, SessionMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { GetSessionMessagesOptions, GetSubagentMessagesOptions, ListSubagentsOptions, Options, Query, SDKSessionInfo, SDKUserMessage, SessionMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -42,8 +42,10 @@ class FakeSdkService implements IClaudeAgentSdkService { getSubagentMessagesCalls: { sessionId: string; agentId: string }[] = []; async listSessions(): Promise { return []; } + async canLoadWithoutDownload(): Promise { return true; } async getSessionInfo(_id: string): Promise { return undefined; } async startup(_p: { options: Options; initializeTimeoutMs?: number }): Promise { throw new Error('not used'); } + async query(_params: { prompt: string | AsyncIterable; options?: Options }): Promise { throw new Error('not used'); } async getSessionMessages(sessionId: string, options?: GetSessionMessagesOptions): Promise { this.getSessionMessagesCalls.push({ sessionId, options }); if (this.getSessionMessagesRejection) { throw this.getSessionMessagesRejection; } @@ -59,6 +61,8 @@ class FakeSdkService implements IClaudeAgentSdkService { if (this.getSubagentMessagesRejection) { throw this.getSubagentMessagesRejection; } return this.subagentMessages.get(`${sessionId}::${agentId}`) ?? []; } + async forkSession(): Promise { throw new Error('not implemented in test fake'); } + async deleteSession(): Promise { throw new Error('not implemented in test fake'); } async createSdkMcpServer(): Promise { throw new Error('not implemented in test fake'); } async tool(): Promise { throw new Error('not implemented in test fake'); } } diff --git a/src/vs/platform/agentHost/test/node/claudeToolDenial.test.ts b/src/vs/platform/agentHost/test/node/claudeToolDenial.test.ts new file mode 100644 index 0000000000000..6037092ad0cd7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/claudeToolDenial.test.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + CLAUDE_PLAN_DECLINED_MESSAGE, + CLAUDE_QUESTION_CANCELLED_MESSAGE, + CLAUDE_USER_DECLINED_MESSAGE, + claudeToolDenialCode, +} from '../../node/claude/claudeToolDenial.js'; + +suite('claudeToolDenial', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('claudeToolDenialCode classifies every known deny message and nothing else', () => { + // Each known deny `message` returned from `canUseTool` maps to its + // cancellation code; any other (genuine tool-error) message stays + // unclassified so telemetry reports `error` rather than `userCancelled`. + const classified = { + userDeclined: claudeToolDenialCode(CLAUDE_USER_DECLINED_MESSAGE), + planDeclined: claudeToolDenialCode(CLAUDE_PLAN_DECLINED_MESSAGE), + questionCancelled: claudeToolDenialCode(CLAUDE_QUESTION_CANCELLED_MESSAGE), + genuineError: claudeToolDenialCode('permission denied'), + empty: claudeToolDenialCode(''), + }; + assert.deepStrictEqual(classified, { + userDeclined: 'denied', + planDeclined: 'denied', + questionCancelled: 'cancelled', + genuineError: undefined, + empty: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts b/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts index 4b6a5d9f840ff..ef18fa3bbcc00 100644 --- a/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts +++ b/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts @@ -19,53 +19,53 @@ suite('SessionClientToolsDiff', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('fresh diff: state empty, no difference', () => { + test('fresh diff: merged empty, no difference', () => { const diff = disposables.add(new SessionClientToolsDiff()); - assert.deepStrictEqual(diff.model.state.get(), { tools: undefined, clientId: undefined }); + assert.deepStrictEqual(diff.model.merged.get(), []); assert.strictEqual(diff.hasDifference, false); }); - test('setTools(undefined → []) does NOT flip dirty (undefined ≡ [])', () => { + test('setTools(c1, []) does NOT flip dirty (undefined ≡ [])', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([]); + diff.model.setTools('c1', []); assert.strictEqual(diff.hasDifference, false); }); test('setTools with a real snapshot flips dirty', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool()]); + diff.model.setTools('c1', [tool()]); assert.strictEqual(diff.hasDifference, true); }); - test('consume() returns the current state and clears dirty', () => { + test('consume() returns the merged tools and clears dirty', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool()], 'c1'); - const state = diff.consume(); - assert.deepStrictEqual(state, { tools: [tool()], clientId: 'c1' }); + diff.model.setTools('c1', [tool()]); + const merged = diff.consume(); + assert.deepStrictEqual(merged, [tool()]); assert.strictEqual(diff.hasDifference, false); }); test('setTools with a structurally-equal snapshot does NOT re-flip dirty after consume', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool()]); + diff.model.setTools('c1', [tool()]); diff.consume(); - diff.model.setTools([{ name: 'echo', description: 'echoes', inputSchema: { type: 'object', properties: { msg: { type: 'string' } }, required: ['msg'] } }]); + diff.model.setTools('c1', [{ name: 'echo', description: 'echoes', inputSchema: { type: 'object', properties: { msg: { type: 'string' } }, required: ['msg'] } }]); assert.strictEqual(diff.hasDifference, false); }); test('C6: setTools racing async work after consume re-flips dirty via autorun', async () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool({ name: 'original' })]); - const state = diff.consume(); - assert.deepStrictEqual(state.tools, [tool({ name: 'original' })]); + diff.model.setTools('c1', [tool({ name: 'original' })]); + const merged = diff.consume(); + assert.deepStrictEqual(merged, [tool({ name: 'original' })]); await Promise.resolve(); - diff.model.setTools([tool({ name: 'racer' })]); + diff.model.setTools('c1', [tool({ name: 'racer' })]); assert.strictEqual(diff.hasDifference, true); }); test('markDirty re-flips after a failed downstream build', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool()]); + diff.model.setTools('c1', [tool()]); diff.consume(); assert.strictEqual(diff.hasDifference, false); diff.markDirty(); @@ -74,53 +74,77 @@ suite('SessionClientToolsDiff', () => { test('hasDifference detects rename / description / inputSchema; ignores title', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool({ name: 'a' })]); + diff.model.setTools('c1', [tool({ name: 'a' })]); diff.consume(); - diff.model.setTools([tool({ name: 'b' })]); + diff.model.setTools('c1', [tool({ name: 'b' })]); assert.strictEqual(diff.hasDifference, true); diff.consume(); - diff.model.setTools([tool({ name: 'b', description: 'new' })]); + diff.model.setTools('c1', [tool({ name: 'b', description: 'new' })]); assert.strictEqual(diff.hasDifference, true); diff.consume(); - diff.model.setTools([tool({ name: 'b', description: 'new', inputSchema: { type: 'object', properties: { msg: { type: 'number' } }, required: ['msg'] } })]); + diff.model.setTools('c1', [tool({ name: 'b', description: 'new', inputSchema: { type: 'object', properties: { msg: { type: 'number' } }, required: ['msg'] } })]); assert.strictEqual(diff.hasDifference, true); diff.consume(); - diff.model.setTools([tool({ name: 'b', description: 'new', inputSchema: { type: 'object', properties: { msg: { type: 'number' } }, required: ['msg'] }, title: 'X' })]); + diff.model.setTools('c1', [tool({ name: 'b', description: 'new', inputSchema: { type: 'object', properties: { msg: { type: 'number' } }, required: ['msg'] }, title: 'X' })]); assert.strictEqual(diff.hasDifference, false, 'title is outside the diff scope'); }); test('order-insensitive: reordering tools does not flip dirty', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool({ name: 'a' }), tool({ name: 'b' })]); + diff.model.setTools('c1', [tool({ name: 'a' }), tool({ name: 'b' })]); diff.consume(); - diff.model.setTools([tool({ name: 'b' }), tool({ name: 'a' })]); + diff.model.setTools('c1', [tool({ name: 'b' }), tool({ name: 'a' })]); assert.strictEqual(diff.hasDifference, false); }); - test('setTools writes clientId only when supplied', () => { + test('ownerOf reflects which client contributed a tool; getTools returns a client slice', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool()], 'c1'); - assert.strictEqual(diff.model.state.get().clientId, 'c1'); - diff.model.setTools([tool({ name: 'echo2' })]); - assert.strictEqual(diff.model.state.get().clientId, 'c1', 'omitted clientId leaves previous value'); - diff.model.setTools([tool()], 'c2'); - assert.strictEqual(diff.model.state.get().clientId, 'c2'); + diff.model.setTools('c1', [tool({ name: 'a' })]); + diff.model.setTools('c2', [tool({ name: 'b' })]); + assert.strictEqual(diff.model.ownerOf('a'), 'c1'); + assert.strictEqual(diff.model.ownerOf('b'), 'c2'); + assert.strictEqual(diff.model.ownerOf('missing'), undefined); + assert.deepStrictEqual(diff.model.getTools('c1'), [tool({ name: 'a' })]); + assert.deepStrictEqual(diff.model.getTools('c2'), [tool({ name: 'b' })]); }); - test('clientId change alone does NOT flip dirty (same tools, new window)', () => { + test('merged unions multiple clients and dedupes by name (first client wins)', () => { const diff = disposables.add(new SessionClientToolsDiff()); - diff.model.setTools([tool()], 'c1'); + diff.model.setTools('c1', [tool({ name: 'shared', description: 'from c1' }), tool({ name: 'a' })]); + diff.model.setTools('c2', [tool({ name: 'shared', description: 'from c2' }), tool({ name: 'b' })]); + assert.deepStrictEqual(diff.model.merged.get().map(t => t.name), ['shared', 'a', 'b']); + assert.strictEqual(diff.model.ownerOf('shared'), 'c1', 'first-inserted client wins the shared name'); + }); + + test('ownerOf prefers the requested client when it provides the shared tool', () => { + const diff = disposables.add(new SessionClientToolsDiff()); + diff.model.setTools('c1', [tool({ name: 'shared', description: 'from c1' })]); + diff.model.setTools('c2', [tool({ name: 'shared', description: 'from c2' })]); + assert.deepStrictEqual({ + defaultOwner: diff.model.ownerOf('shared'), + preferredOwner: diff.model.ownerOf('shared', 'c2'), + missingPreferredOwner: diff.model.ownerOf('shared', 'missing'), + }, { + defaultOwner: 'c1', + preferredOwner: 'c2', + missingPreferredOwner: 'c1', + }); + }); + + test('removeClient drops that client and re-flips dirty when the merged set changes', () => { + const diff = disposables.add(new SessionClientToolsDiff()); + diff.model.setTools('c1', [tool({ name: 'a' })]); + diff.model.setTools('c2', [tool({ name: 'b' })]); diff.consume(); assert.strictEqual(diff.hasDifference, false); - // A window reload re-pushes identical tools under a new clientId. The - // observable still updates clientId, but no SDK yield-restart is - // required — only structural tool changes flip the dirty bit. - diff.model.setTools([tool()], 'c2'); - assert.strictEqual(diff.hasDifference, false); - assert.strictEqual(diff.model.state.get().clientId, 'c2'); + + diff.model.removeClient('c2'); + assert.strictEqual(diff.hasDifference, true); + assert.deepStrictEqual(diff.model.merged.get().map(t => t.name), ['a']); + assert.strictEqual(diff.model.ownerOf('b'), undefined); }); }); 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 a17f1fba090b4..ddf3819b8213c 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -8,6 +8,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { createCodexSessionMapState, extractUserInputText, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, resetCodexTurnMapState, turnStateFromStatus } from '../../../node/codex/codexMapAppServerEvents.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState } from '../../../common/state/sessionState.js'; +import { ActiveClientToolSet } from '../../../node/activeClientState.js'; suite('codexMapAppServerEvents', () => { @@ -393,9 +394,118 @@ suite('codexMapAppServerEvents', () => { }); }); - test('dynamicToolCall item carries a Client contributor when toolsClientId is set', () => { + test('mcpToolCall start carries an MCP contributor when the server has a customization', () => { const state = createCodexSessionMapState(); - state.toolsClientId = 'win-7'; + state.mcpCustomizationIds.set('github', 'cust-gh'); + const startActions = mapItemStarted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_c', server: 'github', tool: 'search', + status: 'inProgress', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const start = startActions[0]; + if (start.type !== ActionType.ChatToolCallStart) { + throw new Error('expected a ChatToolCallStart action'); + } + assert.deepStrictEqual(start.contributor, { kind: ToolCallContributorKind.MCP, customizationId: 'cust-gh' }); + }); + + test('mcpToolCall start carries no contributor when the server has no customization', () => { + const state = createCodexSessionMapState(); + // mcpCustomizationIds is empty: the agent has not applied an MCP + // inventory yet, so the start must not stamp a (bogus) MCP contributor — + // the tool then reports the default `agentHost` source. + const startActions = mapItemStarted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_n', server: 'github', tool: 'search', + status: 'inProgress', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const start = startActions[0]; + if (start.type !== ActionType.ChatToolCallStart) { + throw new Error('expected a ChatToolCallStart action'); + } + assert.strictEqual(start.contributor, undefined); + }); + + test('a host-declined commandExecution reports result.error.code = denied', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { + type: 'commandExecution', id: 'cmd_d', + command: 'rm file', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'inProgress' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const entry = state.itemToToolCall.get('cmd_d'); + if (!entry) { + throw new Error('expected a tracked tool call'); + } + // The host declined the approval (recorded by respondToPermissionRequest). + state.declinedToolCalls.add(entry.toolCallId); + const actions = mapItemCompleted(state, { + item: { + type: 'commandExecution', id: 'cmd_d', + command: 'rm file', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'failed' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: 1, + } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + const complete = actions[0]; + if (complete.type !== ActionType.ChatToolCallComplete) { + throw new Error('expected a ChatToolCallComplete action'); + } + assert.strictEqual(complete.result.success, false); + assert.strictEqual(complete.result.error?.code, 'denied'); + }); + + test('a host-declined mcpToolCall reports result.error.code = denied', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_d', server: 'github', tool: 'search', + status: 'inProgress', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const entry = state.itemToToolCall.get('mcp_d'); + if (!entry) { + throw new Error('expected a tracked tool call'); + } + // The host declined the approval (recorded by respondToPermissionRequest). + // The decline is drained once in the shared completion prologue, so a + // non-command tool type is classified as a denial just like a command. + state.declinedToolCalls.add(entry.toolCallId); + const actions = mapItemCompleted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_d', server: 'github', tool: 'search', + status: 'failed', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: 1, + } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + const complete = actions[0]; + if (complete.type !== ActionType.ChatToolCallComplete) { + throw new Error('expected a ChatToolCallComplete action'); + } + assert.strictEqual(complete.result.success, false); + assert.strictEqual(complete.result.error?.code, 'denied'); + }); + + test('dynamicToolCall item carries a Client contributor when a client owns the tool', () => { + const toolSet = new ActiveClientToolSet(); + toolSet.set('win-7', [{ name: 'get_magic_word' }]); + const state = createCodexSessionMapState(new Set(), toolSet); const startActions = mapItemStarted(state, { item: { type: 'dynamicToolCall', id: 'dyn_2', namespace: null, tool: 'get_magic_word', arguments: {}, status: 'inProgress', contentItems: null, success: null, durationMs: null } as never, threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, @@ -416,8 +526,9 @@ suite('codexMapAppServerEvents', () => { // A server tool is registered under its bare name and executes // in-process, so it must not carry a Client contributor even when a // workbench client owns the (other) client tools. - const state = createCodexSessionMapState(new Set(['addComment'])); - state.toolsClientId = 'win-7'; + const toolSet = new ActiveClientToolSet(); + toolSet.set('win-7', [{ name: 'get_magic_word' }]); + const state = createCodexSessionMapState(new Set(['addComment']), toolSet); const startActions = mapItemStarted(state, { item: { type: 'dynamicToolCall', id: 'dyn_3', namespace: null, tool: 'addComment', arguments: {}, status: 'inProgress', contentItems: null, success: null, durationMs: null } as never, threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, @@ -553,12 +664,14 @@ suite('codexMapAppServerEvents', () => { state.itemToPartId.set('i1', 'p1'); state.itemToToolCall.set('i2', { toolCallId: 'tc', turnId: 'turn_a', toolName: 'shell', output: '' }); state.itemToReasoningPartId.set('i3', 'r1'); + state.declinedToolCalls.add('tc-stale'); resetCodexTurnMapState(state); assert.deepStrictEqual({ currentTurnId: state.currentTurnId, parts: state.itemToPartId.size, toolCalls: state.itemToToolCall.size, reasoning: state.itemToReasoningPartId.size, - }, { currentTurnId: 'turn_a', parts: 0, toolCalls: 0, reasoning: 0 }); + declined: state.declinedToolCalls.size, + }, { currentTurnId: 'turn_a', parts: 0, toolCalls: 0, reasoning: 0, declined: 0 }); }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts index db3a5a1c282b5..db22310833673 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPackagePaths.test.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { join } from '../../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { codexBinaryTriple, codexPackageSuffix } from '../../../node/codex/codexAgent.js'; +import { codexBinaryTriple, codexPackageSuffix, resolveCodexDevSdkRoot } from '../../../node/codex/codexAgent.js'; suite('codex package paths', () => { @@ -84,4 +85,21 @@ suite('codex package paths', () => { assert.strictEqual(codexBinaryTriple(''), undefined); }); }); + + suite('resolveCodexDevSdkRoot', () => { + + test('returns the directory containing node_modules when @openai/codex resolves', async () => { + // `require.resolve('@openai/codex/package.json')` yields + // `/node_modules/@openai/codex/package.json`; the helper walks + // four segments up to recover `` — the dir `_startConnection` + // joins `node_modules/@openai/codex-` onto. + const root = join('home', 'me', 'vscode'); + const pkgJson = join(root, 'node_modules', '@openai', 'codex', 'package.json'); + assert.strictEqual(await resolveCodexDevSdkRoot(() => pkgJson), root); + }); + + test('returns undefined when resolution throws (e.g. built product without the devDependency)', async () => { + assert.strictEqual(await resolveCodexDevSdkRoot(() => { throw new Error('Cannot find module'); }), undefined); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts new file mode 100644 index 0000000000000..815888944571d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as http from 'http'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { + type ICopilotApiService, + type ICopilotApiServiceRequestOptions, +} from '../../../node/shared/copilotApiService.js'; +import { CodexProxyService } from '../../../node/codex/codexProxyService.js'; + +// #region Test fakes + +interface IResponsesCall { + githubToken: string; + body: string; + options: ICopilotApiServiceRequestOptions | undefined; +} + +class FakeCopilotApiService implements ICopilotApiService { + declare readonly _serviceBrand: undefined; + + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + + readonly responsesCalls: IResponsesCall[] = []; + + messages(): never { + throw new Error('messages not used by Codex proxy tests'); + } + + async countTokens(): Promise { + throw new Error('countTokens not used by Codex proxy tests'); + } + + async models(): Promise { + throw new Error('models not used by Codex proxy tests'); + } + + async responses(githubToken: string, body: string, options?: ICopilotApiServiceRequestOptions): Promise { + this.responsesCalls.push({ githubToken, body, options }); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('event: response.completed\ndata: {}\n\n')); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + } + + async utilityChatCompletion(): Promise { + throw new Error('utilityChatCompletion not used by Codex proxy tests'); + } +} + +// #endregion + +// #region HTTP helpers + +let _httpModule: typeof http | undefined; +async function getHttp(): Promise { + if (!_httpModule) { + _httpModule = await import('http'); + } + return _httpModule; +} + +function postResponses(url: string, init: { headers?: Record; body?: string }): Promise<{ status: number; body: string }> { + return getHttp().then(httpMod => new Promise((resolve, reject) => { + const u = new URL(url); + const req = httpMod.request({ + hostname: u.hostname, + port: u.port, + path: u.pathname + u.search, + method: 'POST', + headers: init.headers, + }, res => { + const chunks: Buffer[] = []; + res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + res.on('error', reject); + }); + req.on('error', reject); + if (init.body !== undefined) { + req.write(init.body); + } + req.end(); + })); +} + +// #endregion + +const TOKEN = 'gh-test-token'; + +suite('CodexProxyService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + async function withProxy(fn: (handle: { baseUrl: string; nonce: string }, fake: FakeCopilotApiService) => Promise): Promise { + const fake = new FakeCopilotApiService(); + const service = new CodexProxyService(new NullLogService(), fake); + const handle = await service.start(TOKEN); + try { + await fn(handle, fake); + } finally { + handle.dispose(); + service.dispose(); + } + } + + test('forwards transformed user-agent to CAPI responses', async () => { + await withProxy(async (handle, fake) => { + await postResponses(`${handle.baseUrl}/v1/responses`, { + headers: { 'Authorization': `Bearer ${handle.nonce}`, 'User-Agent': 'codex/1.2.3' }, + body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }), + }); + assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], 'vscode_codex/1.2.3'); + }); + }); + + test('keeps the suffix when transforming a multi-segment user-agent', async () => { + await withProxy(async (handle, fake) => { + await postResponses(`${handle.baseUrl}/v1/responses`, { + headers: { 'Authorization': `Bearer ${handle.nonce}`, 'User-Agent': 'OpenAI/Python/1.0' }, + body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }), + }); + assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], 'vscode_codex/Python/1.0'); + }); + }); + + test('omits User-Agent when the inbound request has none', async () => { + await withProxy(async (handle, fake) => { + await postResponses(`${handle.baseUrl}/v1/responses`, { + headers: { 'Authorization': `Bearer ${handle.nonce}` }, + body: JSON.stringify({ model: 'gpt-5', stream: true, input: [] }), + }); + assert.strictEqual(fake.responsesCalls.at(-1)?.options?.headers?.['User-Agent'], undefined); + }); + }); +}); 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 14491ffb9da51..9dcd3c373f307 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { CodexSessionConfigKey, collaborationModeKind, narrowAdditionalDirectories, narrowApprovalPolicy, narrowBoolean, narrowPersonality, narrowReasoningEffort, narrowReasoningSummary, narrowSandboxMode, narrowWebSearchMode } from '../../../node/codex/codexSessionConfigKeys.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; @@ -24,6 +25,7 @@ function createAgent(disposables: Pick): CodexAgent { instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined }); instantiationService.stub(IAgentConfigurationService, { _serviceBrand: undefined }); instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined }); + instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(ILogService, new NullLogService()); return disposables.add(instantiationService.createInstance(CodexAgent)); } diff --git a/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts b/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts index ad5cef2cea940..b20c7d823b777 100644 --- a/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts +++ b/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts @@ -100,6 +100,32 @@ suite('CommandAutoApprover', () => { assert.strictEqual(approver.shouldAutoApprove('node index.js'), 'noMatch'); }); + test('respects forwarded terminal auto-approve rule config', () => { + assert.deepStrictEqual([ + approver.shouldAutoApprove('echo hello', { autoApproveRules: { echo: false } }), + approver.shouldAutoApprove('python script.py', { autoApproveRules: { python: true } }), + approver.shouldAutoApprove('echo hello', { autoApproveRules: { echo: null } }), + approver.shouldAutoApprove('npm run build', { autoApproveRules: { '/^npm run build$/': { approve: true, matchCommandLine: true } } }), + approver.shouldAutoApprove('echo hello', { autoApproveRules: { echo: true, '/^echo hello$/': { approve: false, matchCommandLine: true } } }), + ], [ + 'denied', + 'approved', + 'noMatch', + 'approved', + 'denied', + ]); + }); + + test('uses forwarded terminal auto-approve rules instead of fallback defaults', () => { + assert.deepStrictEqual([ + approver.shouldAutoApprove('echo hello', { autoApproveRules: {} }), + approver.shouldAutoApprove('rm file.txt', { autoApproveRules: {} }), + ], [ + 'noMatch', + 'noMatch', + ]); + }); + // Transient env vars test('denies transient environment variable assignments', () => { assert.strictEqual(approver.shouldAutoApprove('FOO=bar some-command'), 'denied'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1802e1acbb595..f9fd29a369b55 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, CopilotSession, ModelInfo, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +import type { CopilotClient, CopilotSession, ModelInfo, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import * as fs from 'fs/promises'; import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; @@ -30,29 +30,92 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentCreateChatForkSource, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; -import { CustomizationType, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +import { CustomizationType, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; +import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, CopilotSessionEntry, getCopilotWorktreeName, getCopilotWorktreesRoot, migrateEnablementKeys, rebaseUnder } from '../../node/copilot/copilotAgent.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; import { ShellManager } from '../../node/copilot/copilotShellTools.js'; +import { registerPendingEditContentProvider } from '../../node/copilot/pendingEditContentStore.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; -import { ActiveClientState } from '../../node/activeClientState.js'; +import { ActiveClientToolSet } from '../../node/activeClientState.js'; +import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +/** + * Test helpers for the single `_sessions` container. All chats (default + peers) + * live inside the owning session's {@link CopilotSessionEntry}, keyed by chat URI + * string; the default chat is the entry's `defaultChat`. These wrap that + * structure so tests can inject/observe fakes without reaching into private + * container internals. + */ +function sessionsMap(agent: CopilotAgent): Map { + return (agent as unknown as { _sessions: Map })._sessions; +} + +function defaultChatUri(session: URI): URI { + return URI.parse(buildDefaultChatUri(session)); +} + +/** Inject (or replace) a session's default-chat stub. */ +function setDefaultSessionStub(agent: CopilotAgent, sessionId: string, stub: unknown): void { + const sessions = sessionsMap(agent); + const defaultChatKey = buildDefaultChatUri(AgentSession.uri('copilotcli', sessionId).toString()); + let entry = sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + sessions.set(sessionId, entry); + } + entry.setDefaultChat(defaultChatKey, new CopilotSessionEntry(stub as CopilotAgentSession)); +} + +/** Inject a peer-chat stub into its owning session's entry (creating the entry if needed). */ +function setPeerChatStub(agent: CopilotAgent, chatUri: URI, stub: unknown): void { + const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); + const sessions = sessionsMap(agent); + let entry = sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + sessions.set(sessionId, entry); + } + entry.registerPeerChat(chatUri.toString(), new CopilotSessionEntry(stub as CopilotAgentSession)); +} + +/** Resolve a peer-chat stub from its owning session's entry. */ +function getPeerChatStub(agent: CopilotAgent, chatUri: URI): CopilotAgentSession | undefined { + const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); + return sessionsMap(agent).get(sessionId)?.getPeerChat(chatUri.toString()); +} + +/** True when a peer chat is tracked in its owning session's entry. */ +function hasPeerChatStub(agent: CopilotAgent, chatUri: URI): boolean { + const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); + return sessionsMap(agent).get(sessionId)?.hasPeerChat(chatUri.toString()) ?? false; +} + +/** Total number of peer chats tracked across all sessions. */ +function peerChatCount(agent: CopilotAgent): number { + let count = 0; + for (const entry of sessionsMap(agent).values()) { + count += entry.peerChatKeys().length; + } + return count; +} + class TestAgentPluginManager implements IAgentPluginManager { declare readonly _serviceBrand: undefined; @@ -67,13 +130,13 @@ class TestAgentHostGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; repositoryRoot: URI | undefined = undefined; + headCommit: string | undefined = '0'.repeat(40); addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; existingBranches = new Set(); dirtyWorkingDirectories = new Set(); - async isInsideWorkTree(): Promise { return false; } async getCurrentBranch(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } async getBranches(): Promise { return []; } @@ -107,7 +170,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async commitTree(): Promise { return undefined; } async updateRef(): Promise { } async deleteRefs(): Promise { } - async revParse(): Promise { return undefined; } + async revParse(_repositoryRoot: URI, expression: string): Promise { + return expression === 'HEAD' ? this.headCommit : undefined; + } async computeFileDiffsBetweenRefs(): Promise { return undefined; } } @@ -148,6 +213,7 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -194,7 +260,7 @@ interface ITestCopilotModelInfo { readonly name: string; readonly capabilities?: { readonly supports?: { readonly vision?: boolean }; - readonly limits?: { readonly max_context_window_tokens?: number }; + readonly limits?: { readonly max_context_window_tokens?: number; readonly max_output_tokens?: number; readonly max_prompt_tokens?: number }; }; readonly policy?: { readonly state?: NonNullable['state'] }; readonly billing?: ModelInfo['billing'] & { @@ -231,7 +297,12 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { }, limits: { max_context_window_tokens: model.capabilities?.limits?.max_context_window_tokens ?? 0, - }, + // `max_output_tokens` is present on the RPC `models.list` shape the + // agent reads but absent from the SDK's `ModelInfo` limits type, so + // widen here to let fixtures exercise the real value. + max_output_tokens: model.capabilities?.limits?.max_output_tokens, + max_prompt_tokens: model.capabilities?.limits?.max_prompt_tokens, + } as ModelInfo['capabilities']['limits'], }, ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), ...(model.billing ? { billing: model.billing } : {}), @@ -299,9 +370,38 @@ interface IFakeAgentSession { class MockCopilotSession { readonly sessionId = 'test-session-1'; + private readonly _handlers = new Set(); + private readonly _typedHandlers = new Map) => void>>(); + + on(_handler: SessionEventHandler): () => void; + on(_eventType: K, _handler: TypedSessionEventHandler): () => void; + on(eventTypeOrHandler: K | SessionEventHandler, handler?: TypedSessionEventHandler): () => void { + if (typeof eventTypeOrHandler === 'function') { + this._handlers.add(eventTypeOrHandler); + return () => this._handlers.delete(eventTypeOrHandler); + } + if (!handler) { + throw new Error(`Missing handler for ${eventTypeOrHandler}`); + } + let handlers = this._typedHandlers.get(eventTypeOrHandler); + if (!handlers) { + handlers = new Set(); + this._typedHandlers.set(eventTypeOrHandler, handlers); + } + const typedHandler = handler as (event: SessionEventPayload) => void; + handlers.add(typedHandler); + return () => handlers.delete(typedHandler); + } - on(_eventType: K, _handler: TypedSessionEventHandler): () => void { - return () => { }; + emit(event: SessionEventPayload): void { + const sessionEvent = event as SessionEvent; + for (const handler of this._handlers) { + handler(sessionEvent); + } + const typedEvent = event as SessionEventPayload; + for (const handler of this._typedHandlers.get(event.type) ?? []) { + handler(typedEvent); + } } async send(): Promise { return ''; } @@ -341,8 +441,11 @@ class ResumePathCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, @IAgentHostCompletions completions: IAgentHostCompletions, + @INativeEnvironmentService environmentService: INativeEnvironmentService, + @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, + @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); + super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, environmentService, byokBridgeRegistry, NullTelemetryService, copilotApiService); this._enablePlanModeOnClient(this._copilotClient as CopilotClient); } @@ -355,6 +458,10 @@ class TestableCopilotAgent extends CopilotAgent { private readonly _fakeSessions = new Map(); readonly resumeCalls: string[] = []; + // Keep model-refresh retries effectively instant in tests. + protected override readonly _modelRefreshBaseDelayMs = 1; + protected override readonly _modelRefreshMaxDelayMs = 2; + constructor( private readonly _copilotClient: ITestCopilotClient, @ILogService logService: ILogService, @@ -364,8 +471,11 @@ class TestableCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, @IAgentHostCompletions completions: IAgentHostCompletions, + @INativeEnvironmentService environmentService: INativeEnvironmentService, + @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, + @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); + super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, environmentService, byokBridgeRegistry, NullTelemetryService, copilotApiService); this._enablePlanModeOnClient(this._copilotClient as CopilotClient); } @@ -398,7 +508,7 @@ class TestableCopilotAgent extends CopilotAgent { emitInitialMarkdown: (content: string) => { emitter.fire({ kind: 'action', - session: sessionUri, + resource: sessionUri, action: { type: ActionType.ChatResponsePart, turnId, @@ -415,7 +525,7 @@ class TestableCopilotAgent extends CopilotAgent { } } -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { +function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; userHome?: URI }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { const services = new ServiceCollection(); const logService = new NullLogService(); const fileService = options?.fileService ?? disposables.add(new FileService(logService)); @@ -435,6 +545,7 @@ function createTestAgentContext(disposables: Pick, optio flush: async () => undefined, }); services.set(IAgentHostCompletions, disposables.add(new AgentHostCompletions(logService))); + services.set(IByokLmBridgeRegistry, new ByokLmBridgeRegistry()); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); services.set(ICopilotApiService, copilotApiService); services.set(ICopilotBranchNameGenerator, new CopilotBranchNameGenerator(copilotApiService, logService)); @@ -442,7 +553,7 @@ function createTestAgentContext(disposables: Pick, optio if (options?.environmentServiceRegistration !== 'none') { const environmentService = { _serviceBrand: undefined, - userHome: URI.file('/mock-home'), + userHome: options?.userHome ?? URI.from({ scheme: Schemas.inMemory, path: '/mock-home' }), } as INativeEnvironmentService; services.set(INativeEnvironmentService, environmentService); } @@ -454,38 +565,41 @@ function createTestAgentContext(disposables: Pick, optio return { agent, instantiationService, configurationService: configService, fileService }; } -function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; copilotApiService?: ICopilotApiService }): CopilotAgent { +function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; copilotApiService?: ICopilotApiService; userHome?: URI }): CopilotAgent { return createTestAgentContext(disposables, options).agent; } type CopilotCreateSessionOptions = Parameters[0]; -function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService): { readonly session: CopilotAgentSession; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { +function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot }): { readonly session: CopilotAgentSession; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined); let createOptions: CopilotCreateSessionOptions | undefined; + const mockSession = options?.mockSession ?? new MockCopilotSession(); const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client: { createSession: async options => { createOptions = options; - return new MockCopilotSession() as unknown as CopilotSession; + return mockSession as unknown as CopilotSession; }, - resumeSession: async () => new MockCopilotSession() as unknown as CopilotSession, + resumeSession: async () => mockSession as unknown as CopilotSession, }, - activeClientState: new ActiveClientState(), + activeClientToolSet: options?.activeClientToolSet ?? new ActiveClientToolSet(), sessionId: 'test-session-1', workingDirectory: undefined, resolvedAgentName: undefined, - snapshot: { tools: [], plugins: [], mcpServers: {} }, + snapshot: options?.snapshot ?? { tools: [], plugins: [], mcpServers: {} }, shellManager, githubToken: 'token', model: undefined, }; - const session = (agent as unknown as { - _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined) => CopilotAgentSession; - })._createAgentSession(launchPlan, undefined); - return { session, createOptions: () => createOptions }; + const agentInternals = (agent as unknown as { + _getOrCreateActiveClient: (session: URI, directory: URI | undefined) => unknown; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown) => CopilotAgentSession; + }); + const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, undefined); + return { session: agentInternals._createAgentSession(launchPlan, undefined, activeClient), createOptions: () => createOptions }; } function withoutUndefinedProperties(metadata: IAgentSessionMetadata): Record { @@ -527,13 +641,63 @@ suite('CopilotAgent', () => { assert.deepStrictEqual(agent.getDescriptor(), { provider: 'copilotcli', displayName: 'Copilot', - description: 'Copilot SDK agent running in a dedicated process', + description: 'Copilot SDK agent running in the local agent host process', + capabilities: { multipleChats: { fork: true } }, }); } finally { await disposeAgent(agent); } }); + suite('spawned chat channel', () => { + function fireSignal(agent: CopilotAgent, signal: AgentSignal): void { + (agent as unknown as { _onDidSessionProgress: { fire(s: AgentSignal): void } })._onDidSessionProgress.fire(signal); + } + + test('mirrors subagent_started onto onDidSpawnChat; subagent_completed leaves the chat live', async () => { + const agent = createTestAgent(disposables); + const spawned: IAgentSpawnChatEvent[] = []; + disposables.add(agent.onDidSpawnChat(e => spawned.push(e))); + try { + const sessionUri = AgentSession.uri('copilotcli', 'spawn-session'); + const parentChat = buildDefaultChatUri(sessionUri.toString()); + const toolCallId = 'tool-42'; + const expectedChat = buildSubagentChatUri(parseRequiredSessionUriFromChatUri(parentChat), toolCallId); + + fireSignal(agent, { + kind: 'subagent_started', + chat: URI.parse(parentChat), + toolCallId, + agentName: 'researcher', + agentDisplayName: 'Researcher', + agentDescription: 'Looks things up', + }); + // Unrelated signals must not produce spawn events. + fireSignal(agent, { kind: 'action', resource: sessionUri, action: { type: ActionType.SessionTitleChanged, title: 'x' } }); + // A completed subagent chat stays live (removed only on session teardown). + fireSignal(agent, { kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId }); + + assert.deepStrictEqual({ + spawned: spawned.map(e => ({ + session: e.session.toString(), + chat: e.chat.toString(), + parent: e.parent ? { chat: e.parent.chat.toString(), toolCallId: e.parent.toolCallId } : undefined, + title: e.title, + })), + }, { + spawned: [{ + session: sessionUri.toString(), + chat: expectedChat, + parent: { chat: parentChat, toolCallId }, + title: 'Researcher', + }], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + test('uses the Copilot CLI sibling worktrees root convention', () => { assert.strictEqual( getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath, @@ -552,15 +716,29 @@ suite('CopilotAgent', () => { token: copilotApiService.utilityCalls[0]?.token, promptIncludesUserText: copilotApiService.utilityCalls[0]?.request.messages.some(message => message.content.includes('Add agent host config')), }, { - generated: 'agents/add-agent-host-config-12345678', - fallback: 'agents/add-agent-host-config-12345678', + generated: 'agents/add-agent-host-config', + fallback: 'agents/add-agent-host-config', token: 'token', promptIncludesUserText: true, }); }); + test('appends a short session-id suffix when the branch name already exists', async () => { + const copilotApiService = new TestCopilotApiService(); + copilotApiService.response = 'add-agent-host-config'; + const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); + + assert.deepStrictEqual({ + unique: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchExists: async () => false }), + collision: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchExists: async name => name === 'agents/add-agent-host-config' }), + }, { + unique: 'agents/add-agent-host-config', + collision: 'agents/add-agent-host-config-12345678', + }); + }); + test('uses Git extension branch-derived worktree folder names', () => { - assert.strictEqual(getCopilotWorktreeName('agents/add-agent-host-config-12345678'), 'agents-add-agent-host-config-12345678'); + assert.strictEqual(getCopilotWorktreeName('agents/add-agent-host-config-12345678'), 'add-agent-host-config-12345678'); }); test('keeps generated branch names short', async () => { @@ -570,7 +748,7 @@ suite('CopilotAgent', () => { assert.strictEqual( (await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' })).length, - 'agents/'.length + 48 + '-12345678'.length, + 'agents/'.length + 48, ); }); @@ -615,7 +793,7 @@ suite('CopilotAgent', () => { assert.strictEqual( await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), - 'agents/add-agent-host-config-12345678', + 'agents/add-agent-host-config', ); }); @@ -626,7 +804,7 @@ suite('CopilotAgent', () => { assert.strictEqual( await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), - 'agents/add-agent-host-config-12345678', + 'agents/add-agent-host-config', ); }); @@ -739,9 +917,125 @@ suite('CopilotAgent', () => { } }); - test('createSession falls back to an empty temp directory when workingDirectory is omitted', async () => { - const agent = createTestAgent(disposables); - let createdWorkingDirectory: URI | undefined; + test('retries refreshing models after a transient failure', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + client.modelListErrors.push(new Error('429 "too many requests"')); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const models = await waitForState(agent.models, m => m.length > 0); + + assert.deepStrictEqual({ + modelNames: models.map(m => m.name), + requestCount: client.modelListRequests.length, + }, { + modelNames: ['GPT-4o'], + requestCount: 2, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('stops refreshing models after the maximum number of attempts', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + for (let i = 0; i < 10; i++) { + client.modelListErrors.push(new Error('429 "too many requests"')); + } + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + for (let i = 0; i < 500 && client.modelListRequests.length < 5; i++) { + await new Promise(resolve => setTimeout(resolve, 1)); + } + // Give any erroneous extra retry a chance to fire before asserting. + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.deepStrictEqual({ + requestCount: client.modelListRequests.length, + models: agent.models.get(), + }, { + requestCount: 5, + models: [], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('keeps the previously loaded models when a later refresh fails', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token-a'); + await waitForState(agent.models, m => m.length > 0); + + // A refresh triggered by the next token change fails on every attempt. + for (let i = 0; i < 10; i++) { + client.modelListErrors.push(new Error('429 "too many requests"')); + } + const requestsBefore = client.modelListRequests.length; + await agent.authenticate('https://api.github.com', 'token-b'); + for (let i = 0; i < 500 && client.modelListRequests.length < requestsBefore + 5; i++) { + await new Promise(resolve => setTimeout(resolve, 1)); + } + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.deepStrictEqual({ + modelNames: agent.models.get().map(m => m.name), + retriedRequests: client.modelListRequests.length - requestsBefore, + }, { + modelNames: ['GPT-4o'], + retriedRequests: 5, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('does not refresh models or restart the client after shutdown', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, m => m.length > 0); + await agent.shutdown(); + + const startsAfterShutdown = client.startCallCount; + const requestsAfterShutdown = client.modelListRequests.length; + + // Simulate a queued model-refresh retry timer firing after shutdown. + // It must bail out rather than call `_ensureClient()` and spawn a + // fresh SDK client for an agent that is already torn down. + await (agent as unknown as { _refreshModels(attempt?: number): Promise })._refreshModels(1); + + assert.deepStrictEqual({ + starts: client.startCallCount, + requests: client.modelListRequests.length, + }, { + starts: startsAfterShutdown, + requests: requestsAfterShutdown, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('createSession infers workspace-less from an omitted workingDirectory and uses a stable scratch dir', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); + const agent = createTestAgent(disposables, { userHome }); try { await agent.authenticate('https://api.github.com', 'token'); @@ -751,18 +1045,64 @@ suite('CopilotAgent', () => { assert.strictEqual(result.provisional, true); assert.ok(result.workingDirectory); - createdWorkingDirectory = result.workingDirectory; - assert.strictEqual(createdWorkingDirectory.scheme, Schemas.file); - assert.strictEqual(createdWorkingDirectory.fsPath.toLowerCase().startsWith(os.tmpdir().toLowerCase()), true); - assert.deepStrictEqual(await fs.readdir(createdWorkingDirectory.fsPath), []); + const expected = URI.joinPath(userHome, '.copilot', 'chats', 'temp-fallback'); + assert.strictEqual(result.workingDirectory.scheme, Schemas.file); + assert.strictEqual(result.workingDirectory.fsPath, expected.fsPath); + assert.deepStrictEqual(await fs.readdir(result.workingDirectory.fsPath), []); + // Tagged workspace-less purely from inference (no input flag). + const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get('temp-fallback'); + assert.strictEqual(provisional?.workspaceless, true); } finally { - if (createdWorkingDirectory) { - await fs.rm(createdWorkingDirectory.fsPath, { recursive: true, force: true }); - } + await fs.rm(userHome.fsPath, { recursive: true, force: true }); await disposeAgent(agent); } }).timeout(30_000); + suite('quick chat scratch directory', () => { + test('resume recreates a reaped quick chat scratch dir (ensure-exists on restore)', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); + const sessionId = 'qc-resume'; + const session = AgentSession.uri('copilotcli', sessionId); + const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', scratchDir.toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + const client = new TestCopilotClient([sdkSession(sessionId, scratchDir.fsPath)]); + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); + const internals = agent as unknown as { _resumeSession: (id: string) => Promise }; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await assert.rejects(() => fs.access(scratchDir.fsPath)); + // The stubbed SDK can't finish initializing the resumed session, but + // the scratch dir is ensured before that point. + await internals._resumeSession(sessionId).catch(() => undefined); + await fs.access(scratchDir.fsPath); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }).timeout(30_000); + + test('disposeSession cleans up the quick chat scratch dir', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); + const agent = createTestAgent(disposables, { userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'qc-dispose'); + const result = await agent.createSession({ session }); + const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', 'qc-dispose'); + await fs.access(scratchDir.fsPath); + await agent.disposeSession(result.session); + await assert.rejects(() => fs.access(scratchDir.fsPath)); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }).timeout(30_000); + }); + suite('restart on startup config change', () => { class StopCountingClient extends TestCopilotClient { @@ -798,8 +1138,7 @@ suite('CopilotAgent', () => { await agent.listSessions(); let disposed = false; - const sessions = (agent as unknown as { _sessions: { set(k: string, v: { dispose(): void }): void } })._sessions; - sessions.set('active', { dispose() { disposed = true; } }); + setDefaultSessionStub(agent, 'active', { dispose() { disposed = true; } }); configurationService.updateRootConfig({ [AgentHostConfigKey.RubberDuck]: true }); await Promise.resolve(); @@ -834,7 +1173,7 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', billing: { multiplier: 1.5 }, - capabilities: { limits: { max_context_window_tokens: 128000 }, supports: { vision: true } }, + capabilities: { limits: { max_context_window_tokens: 128000, max_output_tokens: 16000, max_prompt_tokens: 112000 }, supports: { vision: true } }, }]), }); try { @@ -846,6 +1185,8 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, + maxOutputTokens: 16000, + maxPromptTokens: 112000, supportsVision: true, configSchema: undefined, policyState: undefined, @@ -883,9 +1224,9 @@ suite('CopilotAgent', () => { multiplierNumeric: 1, inputCost: 3, cacheCost: 1, - cacheWriteCost: 1, outputCost: 15, longContextInputCost: 6, + longContextCacheCost: 1, longContextOutputCost: 22.5, priceCategory: 'medium', }); @@ -911,13 +1252,13 @@ suite('CopilotAgent', () => { const schema = models[0].configSchema; assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); - assert.strictEqual(schema?.properties.contextTier, undefined); + assert.strictEqual(schema?.properties.contextSize, undefined); } finally { await disposeAgent(agent); } }); - test('configSchema emits a contextTier property when long_context tier exceeds default', async () => { + test('configSchema emits a numeric contextSize property when long_context tier exceeds default', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', @@ -936,16 +1277,17 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); - const contextTier = models[0].configSchema?.properties.contextTier; - assert.deepStrictEqual(contextTier?.enum, ['default', 'long_context']); - assert.strictEqual(contextTier?.default, 'default'); - assert.deepStrictEqual(contextTier?.enumLabels, ['200K', '1M']); + const contextSize = models[0].configSchema?.properties.contextSize; + assert.strictEqual(contextSize?.type, 'number'); + assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); + assert.strictEqual(contextSize?.default, 200_000); + assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); } finally { await disposeAgent(agent); } }); - test('configSchema omits contextTier when long_context tier is missing or not larger', async () => { + test('configSchema omits contextSize when long_context tier is missing or not larger', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [ { @@ -974,6 +1316,119 @@ suite('CopilotAgent', () => { } }); + test('configSchema shows only long context option when long_context tier has no surcharge', async () => { + const agent = createTestAgent(disposables, { + copilotClient: new TestCopilotClient([], [{ + id: 'free-long-context', + name: 'Free Long Context', + capabilities: { limits: { max_context_window_tokens: 200_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000 }, + }, + }, + }]), + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const models = await waitForState(agent.models, models => models.length > 0); + + const contextSize = models[0].configSchema?.properties?.contextSize; + assert.strictEqual(contextSize?.type, 'number'); + assert.deepStrictEqual(contextSize?.enum, [1_000_000]); + assert.strictEqual(contextSize?.default, 1_000_000); + assert.deepStrictEqual(contextSize?.enumLabels, ['1M']); + } finally { + await disposeAgent(agent); + } + }); + + suite('contextSize to contextTier mapping', () => { + const longContextModel: ITestCopilotModelInfo = { + id: 'claude-sonnet', + name: 'Claude Sonnet', + capabilities: { limits: { max_context_window_tokens: 200_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000, inputPrice: 2 }, + }, + }, + }; + + async function captureSessionConfig(model: ModelSelection | undefined, models: readonly ITestCopilotModelInfo[]): Promise { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([], models); + let capturedConfig: CopilotCreateSessionOptions | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, m => m.length > 0); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'ctx-session'), + workingDirectory: URI.file('/workspace'), + ...(model ? { model } : {}), + }); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); + return capturedConfig; + } finally { + await disposeAgent(agent); + } + } + + test('maps the largest numeric context size to long_context', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '1000000' } }, [longContextModel]); + assert.ok(config, 'SDK createSession should be called during materialization'); + assert.strictEqual(config.contextTier, 'long_context'); + }); + + test('maps the default numeric context size to default', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '200000' } }, [longContextModel]); + assert.ok(config); + assert.strictEqual(config.contextTier, 'default'); + }); + + test('drops a numeric context size the model does not offer', async () => { + const config = await captureSessionConfig( + { id: 'no-context-picker', config: { contextSize: '1000000' } }, + [{ id: 'no-context-picker', name: 'No Picker' }], + ); + assert.ok(config); + assert.strictEqual(config.contextTier, undefined); + }); + + test('passes through a legacy resolved tier string under the deprecated contextTier key', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: 'long_context' } }, [longContextModel]); + assert.ok(config); + assert.strictEqual(config.contextTier, 'long_context'); + }); + + test('uses long_context when model has no surcharge and no explicit selection', async () => { + const freeLongContextModel: ITestCopilotModelInfo = { + id: 'free-long-ctx', + name: 'Free Long Ctx', + capabilities: { limits: { max_context_window_tokens: 200_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000 }, + }, + }, + }; + const config = await captureSessionConfig({ id: 'free-long-ctx' }, [freeLongContextModel]); + assert.ok(config); + assert.strictEqual(config.contextTier, 'long_context'); + }); + }); + test('agent-created sessions can resolve session-state paths via INativeEnvironmentService', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const { agent, instantiationService } = createTestAgentContext(disposables, { @@ -1007,6 +1462,96 @@ suite('CopilotAgent', () => { } }); + test('client tool call contributor prefers the message sender when it provides the tool', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, instantiationService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); + const actions: (SessionAction | ChatAction)[] = []; + disposables.add(agent.onDidSessionProgress(signal => { + if (signal.kind === 'action') { + actions.push(signal.action); + } + })); + const activeClientToolSet = new ActiveClientToolSet(); + const sharedTool: ToolDefinition = { name: 'shared', description: 'Shared tool', inputSchema: { type: 'object', properties: {} } }; + activeClientToolSet.set('client-A', [sharedTool]); + activeClientToolSet.set('client-B', [sharedTool]); + const mockSession = new MockCopilotSession(); + const createdSession = createAgentSessionThroughAgent(agent, instantiationService, { + mockSession, + activeClientToolSet, + snapshot: { tools: activeClientToolSet.merged(), plugins: [], mcpServers: {} }, + }); + const agentSession = disposables.add(createdSession.session); + try { + await agentSession.initializeSession(); + agentSession.resetTurnState('turn-1', 'client-B'); + + mockSession.emit({ + type: 'tool.execution_start', + data: { toolCallId: 'tool-1', toolName: 'shared', arguments: {} }, + } as SessionEventPayload<'tool.execution_start'>); + + const toolStart = actions.find(action => action.type === ActionType.ChatToolCallStart); + assert.deepStrictEqual(toolStart?.type === ActionType.ChatToolCallStart ? toolStart.contributor : undefined, { + kind: ToolCallContributorKind.Client, + clientId: 'client-B', + }); + } finally { + await disposeAgent(agent); + } + }); + + test('client tool completion unblocks a pending permission request', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, instantiationService, fileService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); + disposables.add(registerPendingEditContentProvider(fileService)); + const createdSession = createAgentSessionThroughAgent(agent, instantiationService); + const agentSession = disposables.add(createdSession.session); + const pendingEditContentUri = new DeferredPromise(); + disposables.add(agent.onDidSessionProgress(signal => { + if (signal.kind === 'pending_confirmation') { + const uri = signal.state.edits?.items[0]?.after?.content.uri; + if (uri) { + pendingEditContentUri.complete(URI.parse(uri)); + } + } + })); + try { + await agentSession.initializeSession(); + const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; + assert.ok(onPermissionRequest); + + const permissionRequestResult = onPermissionRequest({ + kind: 'write', + toolCallId: 'tool-1', + canOfferSessionApproval: false, + diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+after', + fileName: URI.file('/workspace/file.txt').fsPath, + intention: 'write file', + newFileContents: 'after', + }, { sessionId: 'test-session-1' }); + const editContentUri = await pendingEditContentUri.p; + + agentSession.handleClientToolCallComplete('tool-1', { + success: false, + pastTenseMessage: 'Client tool failed', + content: [{ type: ToolResultContentType.Text, text: 'failed before approval' }], + error: { message: 'failed before approval' }, + }); + await timeout(0); + + assert.deepStrictEqual({ + permissionResult: await permissionRequestResult, + pendingEditContentExists: await fileService.exists(editContentUri), + }, { + permissionResult: { kind: 'approve-once' }, + pendingEditContentExists: false, + }); + } finally { + await disposeAgent(agent); + } + }); + test('listSessions only returns sessions with a database', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const ownedSession = AgentSession.uri('copilotcli', 'owned'); @@ -1047,6 +1592,34 @@ suite('CopilotAgent', () => { } }); + test('listSessions does not itself re-emit the workspaceless tag (AgentService overlays it centrally)', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 'quick'); + const db = sessionDataService.openDatabase(session); + // A committed quick chat persists a scratch cwd AND the AH-owned + // workspace-less marker. The marker is surfaced onto `_meta` by + // `AgentService.listSessions` (see agentService.test.ts), not by the agent + // itself — the agent only reads it for the resume system prompt / cleanup. + await db.object.setMetadata('copilot.workingDirectory', URI.file('/scratch/quick').toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('quick')]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + assert.deepStrictEqual((await agent.listSessions()).map(withoutUndefinedProperties), [{ + session, + startTime: 1000, + modifiedTime: 2000, + summary: 'SDK quick', + workingDirectory: URI.file('/scratch/quick'), + }]); + } finally { + await disposeAgent(agent); + } + }); + test('getSessionMetadata reads one SDK session and stored metadata without listing sessions', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const session = AgentSession.uri('copilotcli', 'target'); @@ -1075,10 +1648,36 @@ suite('CopilotAgent', () => { } }); - test('getSessionMetadata only returns sessions with a database', async () => { + test('getSessionMetadata preserves legacy customizationDirectory without inferring workingDirectory', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); - const session = AgentSession.uri('copilotcli', 'external'); - const client = new TestCopilotClient([sdkSession('external', '/workspace')]); + const session = AgentSession.uri('copilotcli', 'legacy-customization-directory'); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.customizationDirectory', URI.file('/legacy-workspace').toString()); + db.dispose(); + + const client = new TestCopilotClient([sdkSession('legacy-customization-directory')]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + const metadata = await agent.getSessionMetadata(session); + assert.ok(metadata); + assert.deepStrictEqual(withoutUndefinedProperties(metadata), { + session, + startTime: 1000, + modifiedTime: 2000, + summary: 'SDK legacy-customization-directory', + customizationDirectory: URI.file('/legacy-workspace'), + }); + } finally { + await disposeAgent(agent); + } + }); + + test('getSessionMetadata only returns sessions with a database', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 'external'); + const client = new TestCopilotClient([sdkSession('external', '/workspace')]); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); try { await agent.authenticate('https://api.github.com', 'token'); @@ -1205,7 +1804,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'sync-customizations-test'); - await agent.setClientCustomizations(session, 'client-1', [{ type: CustomizationType.Plugin, id: customizationId(pluginDir.toString()), uri: pluginDir.toString(), name: 'Plugin A', enabled: true }]); + agent.getOrCreateActiveClient(session, { clientId: 'client-1' }).customizations = [{ type: CustomizationType.Plugin, id: customizationId(pluginDir.toString()), uri: pluginDir.toString(), name: 'Plugin A', enabled: true }]; // Wait for the deferred resolution chain in PluginController.sync. await new Promise(r => setTimeout(r, 50)); @@ -1276,12 +1875,28 @@ suite('CopilotAgent', () => { const customizations = await agent.getSessionCustomizations(session); const discoveredDirectories = customizations.filter(customization => customization.type === CustomizationType.Directory); - assert.strictEqual(discoveredDirectories.length, 3); - assert.deepStrictEqual(discoveredDirectories.map(customization => customization.uri).sort(), [ + // All discovery roots are returned, even if empty or non-existing + // Workspace root is included because AGENTS.md was created + assert.strictEqual(discoveredDirectories.length, 14); + const expectedUris = [ + // workspace roots workspace.toString(), URI.joinPath(workspace, '.github', 'agents').toString(), + URI.joinPath(workspace, '.agents', 'agents').toString(), + URI.joinPath(workspace, '.claude', 'agents').toString(), + URI.joinPath(workspace, '.github', 'skills').toString(), + URI.joinPath(workspace, '.agents', 'skills').toString(), + URI.joinPath(workspace, '.claude', 'skills').toString(), URI.joinPath(workspace, '.github', 'instructions').toString(), - ].sort()); + URI.joinPath(workspace, '.github', 'hooks').toString(), + // user home roots + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/agents' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.agents/skills' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/skills' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/instructions' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/hooks' }).toString(), + ]; + assert.deepStrictEqual(discoveredDirectories.map(customization => customization.uri).sort(), expectedUris.sort()); const agentDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(workspace, '.github', 'agents').toString()); assert.ok(agentDirectory); @@ -1345,16 +1960,24 @@ suite('CopilotAgent', () => { }); const before = await agent.getSessionCustomizations(session); - assert.deepStrictEqual(before.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), [agentsRoot.toString()]); + const beforeDirs = before.filter(customization => customization.type === CustomizationType.Directory); + const agentsDirBefore = beforeDirs.find(d => d.uri === agentsRoot.toString()); + assert.ok(agentsDirBefore); + assert.strictEqual(agentsDirBefore!.children!.length, 1); // has the helper agent file await fileService.del(agentsRoot, { recursive: true }); let after = await agent.getSessionCustomizations(session); - for (let i = 0; i < 20 && after.filter(customization => customization.type === CustomizationType.Directory).length > 0; i++) { + let afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); + for (let i = 0; i < 20 && afterDirs.some(d => d.uri === agentsRoot.toString() && (d.children?.length ?? 0) > 0); i++) { await new Promise(resolve => setTimeout(resolve, 50)); after = await agent.getSessionCustomizations(session); + afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); } - assert.deepStrictEqual(after.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), []); + // agentsRoot still appears in discovery (as an empty directory) since it's a discovery root + const agentsDirAfter = afterDirs.find(d => d.uri === agentsRoot.toString()); + assert.ok(agentsDirAfter); + assert.strictEqual(agentsDirAfter.children?.length ?? 0, 0); // files are cleared } finally { await disposeAgent(agent); } @@ -1414,7 +2037,25 @@ suite('CopilotAgent', () => { } const after = await agent.getSessionCustomizations(session); - assert.deepStrictEqual(after.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), [agentsRoot.toString()]); + const afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); + // All discovery roots are discovered (workspace root only if it has AGENTS.md) + const expectedUris = [ + URI.joinPath(workspace, '.github', 'agents').toString(), + URI.joinPath(workspace, '.agents', 'agents').toString(), + URI.joinPath(workspace, '.claude', 'agents').toString(), + URI.joinPath(workspace, '.github', 'skills').toString(), + URI.joinPath(workspace, '.agents', 'skills').toString(), + URI.joinPath(workspace, '.claude', 'skills').toString(), + URI.joinPath(workspace, '.github', 'instructions').toString(), + URI.joinPath(workspace, '.github', 'hooks').toString(), + // user home roots + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/agents' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.agents/skills' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/skills' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/instructions' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/hooks' }).toString(), + ]; + assert.deepStrictEqual(afterDirs.map(customization => customization.uri).sort(), expectedUris.sort()); } finally { await disposeAgent(agent); } @@ -1527,6 +2168,30 @@ suite('CopilotAgent', () => { } }); + test('sendMessage on the default chat materializes the parent provisional session', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + let capturedConfig: CopilotCreateSessionOptions | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'prov-default-chat'), + workingDirectory: URI.file('/workspace'), + }); + + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); + + assert.strictEqual(capturedConfig?.sessionId, 'prov-default-chat'); + } finally { + await disposeAgent(agent); + } + }); + test('disposeSession on provisional session does not touch SDK or worktree', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); @@ -1628,7 +2293,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); assert.ok(capturedConfig, 'SDK createSession should be called during provisional materialization'); const systemMessage = capturedConfig.systemMessage; @@ -1665,7 +2330,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); assert.strictEqual(capturedConfig?.gitHubToken, 'gh-token-abc', 'createSession should receive the GitHub token at session level so the SDK can resolve a per-session GitHub identity'); @@ -1694,7 +2359,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); assert.deepStrictEqual(capturedConfig?.tools?.map(tool => tool.name), []); } finally { @@ -1718,8 +2383,7 @@ suite('CopilotAgent', () => { }, dispose() { }, }; - const sessions = (agent as unknown as { _sessions: Map })._sessions; - sessions.set(sessionId, stub); + setDefaultSessionStub(agent, sessionId, stub); return { calls }; } @@ -1727,10 +2391,11 @@ suite('CopilotAgent', () => { const agent = createTestAgent(disposables); try { const sessionUri = AgentSession.uri('copilotcli', 'session-top'); + const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); const result: ToolCallResult = { success: true, pastTenseMessage: 'did it' }; - agent.onClientToolCallComplete(sessionUri, 'tc-top', result); + agent.onClientToolCallComplete(sessionUri, defaultChat, 'tc-top', result); assert.deepStrictEqual(calls, [{ toolCallId: 'tc-top', result }]); } finally { @@ -1738,54 +2403,13 @@ suite('CopilotAgent', () => { } }); - test('routes a subagent session URI to its parent session entry', async () => { - // Regression: client-tool completions for tools running inside a - // subagent are dispatched against the subagent session URI by - // the renderer. The agent must resolve that to the parent - // session entry — only the parent owns the SDK session and the - // pending deferred for the tool call. - const agent = createTestAgent(disposables); - try { - const parentUri = AgentSession.uri('copilotcli', 'session-parent'); - const { calls } = installStubSession(agent, AgentSession.id(parentUri)); - - const subagentUri = URI.parse(buildSubagentSessionUri(parentUri.toString(), 'tc-parent')); - const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent tool done' }; - agent.onClientToolCallComplete(subagentUri, 'tc-inner', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); - } finally { - await disposeAgent(agent); - } - }); - - test('routes a nested subagent session URI (depth > 1) to the root session entry', async () => { - // Regression for depth > 1: a nested subagent URI like - // `copilot:/root/subagent/tc1/subagent/tc2` must walk all the way - // to the root session entry in `_sessions`, not stop at the - // intermediate parent `copilot:/root/subagent/tc1`. - const agent = createTestAgent(disposables); - try { - const rootUri = AgentSession.uri('copilotcli', 'session-root'); - const { calls } = installStubSession(agent, AgentSession.id(rootUri)); - - const subagentUri = URI.parse(buildSubagentSessionUri(rootUri.toString(), 'tc-parent')); - const nestedUri = URI.parse(buildSubagentSessionUri(subagentUri.toString(), 'tc-nested')); - const result: ToolCallResult = { success: true, pastTenseMessage: 'nested done' }; - agent.onClientToolCallComplete(nestedUri, 'tc-inner', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); - } finally { - await disposeAgent(agent); - } - }); - test('is a no-op when no session entry exists for the resolved id', async () => { const agent = createTestAgent(disposables); try { const sessionUri = AgentSession.uri('copilotcli', 'session-missing'); + const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); // No stub installed — the call should be silently ignored. - agent.onClientToolCallComplete(sessionUri, 'tc-x', { success: true, pastTenseMessage: 'noop' }); + agent.onClientToolCallComplete(sessionUri, defaultChat, 'tc-x', { success: true, pastTenseMessage: 'noop' }); } finally { await disposeAgent(agent); } @@ -1793,9 +2417,9 @@ suite('CopilotAgent', () => { test('routes a peer chat URI to its chat-session entry', async () => { // Client-tool completions for tools running inside an additional - // (non-default) chat are dispatched against the chat channel URI. - // The agent must resolve that to the `_chatSessions` entry, which - // is keyed by the chat URI string rather than a session id. + // (non-default) chat carry both the owning session URI and the + // chat channel URI. The agent must route by the chat URI to the peer + // chat hosted on the owning session's entry. const agent = createTestAgent(disposables); try { const sessionUri = AgentSession.uri('copilotcli', 'session-with-peer'); @@ -1805,21 +2429,39 @@ suite('CopilotAgent', () => { handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, dispose() { }, }; - (agent as unknown as { _chatSessions: Map })._chatSessions.set(chatUri.toString(), stub); + setPeerChatStub(agent, chatUri, stub); const result: ToolCallResult = { success: true, pastTenseMessage: 'peer done' }; - agent.onClientToolCallComplete(chatUri, 'tc-peer', result); + agent.onClientToolCallComplete(sessionUri, chatUri, 'tc-peer', result); assert.deepStrictEqual(calls, [{ toolCallId: 'tc-peer', result }]); } finally { await disposeAgent(agent); } }); + test('routes the default chat URI to the session entry, not a chat-session', async () => { + // The default chat is not a peer chat; passing its chat URI must + // still resolve via `_sessions` by the owning session id. This is + // the regression that previously hung the agent. + const agent = createTestAgent(disposables); + try { + const sessionUri = AgentSession.uri('copilotcli', 'session-default'); + const defaultChatUri = URI.parse(buildDefaultChatUri(sessionUri)); + const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); + + const result: ToolCallResult = { success: true, pastTenseMessage: 'default done' }; + agent.onClientToolCallComplete(sessionUri, defaultChatUri, 'tc-default', result); + + assert.deepStrictEqual(calls, [{ toolCallId: 'tc-default', result }]); + } finally { + await disposeAgent(agent); + } + }); }); suite('peer chat routing and lifecycle', () => { - /** Installs a stub peer chat into `_chatSessions` keyed by the chat URI. */ + /** Installs a stub peer chat into the owning session's entry, keyed by the chat URI. */ function installStubChat(agent: CopilotAgent, chatUri: URI, options?: { permissionOwner?: string; inputOwner?: string }) { const events: string[] = []; let disposed = false; @@ -1841,7 +2483,7 @@ suite('CopilotAgent', () => { handleClientToolCallComplete() { }, dispose() { disposed = true; }, }; - (agent as unknown as { _chatSessions: Map })._chatSessions.set(chatUri.toString(), stub); + setPeerChatStub(agent, chatUri, stub); return { events, isDisposed: () => disposed }; } @@ -1892,61 +2534,13 @@ suite('CopilotAgent', () => { await agent.disposeSession(result.session); assert.strictEqual(chat.isDisposed(), true, 'peer chat should be disposed with its parent session'); - const chatSessions = (agent as unknown as { _chatSessions: Map })._chatSessions; - assert.strictEqual(chatSessions.has(chatUri.toString()), false, 'peer chat entry should be removed'); - } finally { - await disposeAgent(agent); - } - }); - - test('getChats returns the persisted peer chat catalog', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - try { - const session = AgentSession.uri('copilotcli', 'session-getchats'); - const db = sessionDataService.openDatabase(session); - await db.object.setMetadata('copilot.chats', JSON.stringify({ - 'peer-a': { sdkSessionId: 'sdk-a' }, - 'peer-b': { sdkSessionId: 'sdk-b' }, - })); - - const chats = await agent.getChats(session); - - assert.deepStrictEqual( - chats.map(c => c.toString()).sort(), - [buildChatUri(session, 'peer-a'), buildChatUri(session, 'peer-b')].sort(), - ); - } finally { - await disposeAgent(agent); - } - }); - - test('getChats drops corrupted or invalid persisted entries', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - try { - const session = AgentSession.uri('copilotcli', 'session-getchats-invalid'); - const db = sessionDataService.openDatabase(session); - await db.object.setMetadata('copilot.chats', JSON.stringify({ - 'peer-ok': { sdkSessionId: 'sdk-ok' }, - 'peer-null': null, - 'peer-missing-id': { model: { id: 'm' } }, - 'peer-nonstring-id': { sdkSessionId: 42 }, - 'peer-empty-id': { sdkSessionId: '' }, - })); - - const chats = await agent.getChats(session); - - assert.deepStrictEqual( - chats.map(c => c.toString()), - [buildChatUri(session, 'peer-ok')], - ); + assert.strictEqual(hasPeerChatStub(agent, chatUri), false, 'peer chat entry should be removed'); } finally { await disposeAgent(agent); } }); - test('disposeChat removes the persisted entry and deletes its SDK conversation', async () => { + test('disposeChat deletes the SDK chat (via legacy fallback) and drops the live backing without rewriting copilot.chats', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); @@ -1954,20 +2548,29 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'session-dispose-chat'); const db = sessionDataService.openDatabase(session); + // A legacy session whose backing still lives in copilot.chats. await db.object.setMetadata('copilot.chats', JSON.stringify({ 'peer-a': { sdkSessionId: 'sdk-a' }, })); const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as { _chatBackings: Map }; + // Materialize the backing first, mirroring the orchestrator's + // restore handing back the persisted providerData. + await agent.materializeChat(chatUri, JSON.stringify({ sdkSessionId: 'sdk-a' })); - await agent.disposeChat(session, chatUri); + await agent.chats.disposeChat(chatUri); const remaining = await db.object.getMetadata('copilot.chats'); assert.deepStrictEqual({ - remaining: remaining ? JSON.parse(remaining) : {}, + backingCleared: internals._chatBackings.has(chatUri.toString()), deleted: client.deletedSessionIds, + // The agent no longer owns the durable catalog, so it leaves + // the legacy blob untouched (orchestrator drops the entry). + legacyUntouched: remaining ? JSON.parse(remaining) : {}, }, { - remaining: {}, + backingCleared: false, deleted: ['sdk-a'], + legacyUntouched: { 'peer-a': { sdkSessionId: 'sdk-a' } }, }); } finally { await disposeAgent(agent); @@ -1975,176 +2578,892 @@ suite('CopilotAgent', () => { }); }); - // Regression for the #319516 incident: a window reload reconnects with a - // NEW clientId but an identical tool list. The cached SDK session's - // staleness check (`ActiveClient.requiresRestart`) must NOT treat a - // clientId-only change as a config change — otherwise either the session - // is needlessly restarted, or (the actual bug) the cached session is - // reused while the live clientId is never updated, so subsequent client - // tool calls are stamped with the dead window's id and hang forever. - suite('client tool refresh on reload (#319516)', () => { - /** Minimal structural view of the agent's private per-session ActiveClient. */ - type TestActiveClient = { - readonly state: { readonly clientId: string | undefined }; - snapshot(): Promise; - requiresRestart(snap: IActiveClientSnapshot): Promise; + suite('peer chat create / fork / model+agent / restore round-trip', () => { + + /** Internal surface the multi-chat tests reach into to stub the SDK/agent-session seam. */ + type ChatInternals = { + _chatBackings: Map; + _sessions: Map; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, channelUri?: URI) => CopilotAgentSession; + _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string, targetDbDir: URI) => Promise; + _resolveAgentName: (sessionUri: URI, snapshot: IActiveClientSnapshot, agent: AgentSelection) => Promise; }; - function getActiveClient(agent: CopilotAgent, session: URI): TestActiveClient { - const activeClients = (agent as unknown as { _activeClients: { get(s: URI): TestActiveClient | undefined } })._activeClients; - const activeClient = activeClients.get(session); - assert.ok(activeClient, 'expected an ActiveClient to exist after setClientTools'); - return activeClient; + interface IFakeChatRecorder { + initialized: boolean; + disposed: boolean; + readonly remapCalls: ReadonlyMap[]; + readonly sends: { prompt: string; turnId: string | undefined; mode: unknown; senderClientId: string | undefined }[]; + readonly resets: { turnId: string; senderClientId: string | undefined }[]; + readonly modelCalls: { id: string }[]; + readonly agentCalls: (string | undefined)[]; } - const tools: ToolDefinition[] = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }]; + /** + * Builds a fake {@link CopilotAgentSession} that records the calls + * `createChat`/`sendMessage`/`changeModel`/`changeAgent` route to a peer + * chat, so tests can drive the real agent methods while stubbing only the + * SDK-backed chat. The `_createAgentSession` seam returns this. + */ + function makeFakeChatSession(sessionUri: URI, sdkSessionId: string, getMessages?: () => Promise, owned?: IDisposable): { rec: IFakeChatRecorder; fake: CopilotAgentSession } { + const rec: IFakeChatRecorder = { + initialized: false, + disposed: false, + remapCalls: [], + sends: [], + resets: [], + modelCalls: [], + agentCalls: [], + }; + const fake = { + sessionUri, + sessionId: sdkSessionId, + appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, + async initializeSession(): Promise { rec.initialized = true; }, + async remapTurnIds(mapping: ReadonlyMap): Promise { rec.remapCalls.push(mapping); }, + async send(prompt: string, _attachments: unknown, turnId: string | undefined, mode: unknown, senderClientId: string | undefined): Promise { + rec.sends.push({ prompt, turnId, mode, senderClientId }); + }, + resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, + async setModel(id: string): Promise { rec.modelCalls.push({ id }); }, + async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, + handleClientToolCallComplete(): void { }, + async getNextTurnEventId(): Promise { return undefined; }, + getMessages: getMessages ?? (async () => []), + dispose(): void { rec.disposed = true; owned?.dispose(); }, + } as unknown as CopilotAgentSession; + return { rec, fake }; + } - test('clientId-only change (reload) does NOT require a restart and updates the live clientId', async () => { - const agent = createTestAgent(disposables); + test('createChat materializes a peer chat, records its backing, and returns providerData (no copilot.chats write)', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); try { - const session = AgentSession.uri('copilotcli', 'reload-session'); + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'create-peer'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); - // Window A registers its tools; this is the snapshot the SDK - // session would be created with. - agent.setClientTools(session, 'client-A', tools); - const activeClient = getActiveClient(agent, session); - const appliedSnapshot = await activeClient.snapshot(); - assert.strictEqual(activeClient.state.clientId, 'client-A'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as ChatInternals; + let captured: CopilotSessionLaunchPlan | undefined; + let capturedChannel: URI | undefined; + let rec: IFakeChatRecorder | undefined; + internals._createAgentSession = (launchPlan, _dir, _ac, channelUri) => { + captured = launchPlan; + capturedChannel = channelUri; + const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); + rec = built.rec; + return built.fake; + }; - // Window A reloads: window B reconnects with a new clientId but - // the identical tool list. - agent.setClientTools(session, 'client-B', [...tools]); + const model: ModelSelection = { id: 'gpt-x' }; + const result = await agent.chats.createChat(chatUri, { model }); - // Root-cause assertions: the cached SDK session must be reused - // (no restart) AND the live clientId must now be window B's, so - // the next client tool call is stamped with a live owner. - assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), false); - assert.strictEqual(activeClient.state.clientId, 'client-B'); + const db = sessionDataService.openDatabase(session); + const raw = await db.object.getMetadata('copilot.chats'); + assert.deepStrictEqual({ + tracked: hasPeerChatStub(agent, chatUri), + initialized: rec?.initialized, + channel: capturedChannel?.toString(), + kind: captured?.kind, + backing: internals._chatBackings.get(chatUri.toString()), + providerData: result ? JSON.parse(result.providerData!) : undefined, + // The orchestrator now owns the durable catalog; the agent no + // longer writes its private `copilot.chats` metadata. + legacyCatalogWritten: raw !== undefined, + }, { + tracked: true, + initialized: true, + channel: chatUri.toString(), + kind: 'create', + backing: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, + providerData: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, + legacyCatalogWritten: false, + }); } finally { await disposeAgent(agent); } }); - test('a structural tool change still requires a restart', async () => { - const agent = createTestAgent(disposables); + test('createChat is a no-op for the default chat URI', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); try { - const session = AgentSession.uri('copilotcli', 'tools-change-session'); - - agent.setClientTools(session, 'client-A', tools); - const activeClient = getActiveClient(agent, session); - const appliedSnapshot = await activeClient.snapshot(); + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'create-default'); + const internals = agent as unknown as ChatInternals; + internals._createAgentSession = () => { throw new Error('_createAgentSession must not be called for the default chat'); }; - // A genuinely different tool set (added tool) must restart so the - // SDK session is rebuilt with the new tools. - agent.setClientTools(session, 'client-A', [...tools, { name: 'second_tool', description: 'another', inputSchema: { type: 'object', properties: {} } }]); + await agent.chats.createChat(URI.parse(buildDefaultChatUri(session)), {}); - assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), true); + assert.deepStrictEqual({ + tracked: peerChatCount(agent), + }, { + tracked: 0, + }); } finally { await disposeAgent(agent); } }); - }); - suite('_resumeSession dedup', () => { - // Regression: two concurrent paths (e.g. an outdated-config refresh in - // `sendMessage` and a `getSessionMessages` subscribe) each calling - // `_resumeSession(id)` used to construct two `CopilotAgentSession` - // entries for the same id; the second `_sessions.set(id, …)` on the - // underlying `DisposableMap` disposed the first one mid - // `initializeSession()`, producing 'Trying to add a disposable to a - // DisposableStore that has already been disposed' warnings and a - // half-initialised session with no event subscriptions. + test('createChat forks the source chat into a new peer chat and returns the forked chat providerData', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'fork-peer'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + + const internals = agent as unknown as ChatInternals; + // Install the default chat as the fork source so resolution stays + // in-memory (no SDK resume). + const source = makeFakeChatSession(session, 'source-sdk'); + setDefaultSessionStub(agent, AgentSession.id(session), source.fake); + + // Stub the SDK/fs fork seam: assert the inputs and hand back a + // deterministic forked chat id. + let forkArgs: { sourceEntry: unknown; turnId: string } | undefined; + internals._forkSdkChat = async (_client, sourceEntry, turnId) => { + forkArgs = { sourceEntry, turnId }; + return 'forked-sdk-id'; + }; + let captured: CopilotSessionLaunchPlan | undefined; + internals._createAgentSession = (launchPlan) => { + captured = launchPlan; + return makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager).fake; + }; - type AgentInternals = { - _resumeSession: (id: string) => Promise; - _doResumeSession: (id: string) => Promise; - }; - const makeFakeSession = () => ({ dispose: () => { } } as unknown as CopilotAgentSession); + const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); + const result = await agent.chats.fork(chatUri, { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }); - test('dedupes concurrent calls for the same sessionId', async () => { + const db = sessionDataService.openDatabase(session); + const raw = await db.object.getMetadata('copilot.chats'); + assert.deepStrictEqual({ + sourceIsDefaultSession: forkArgs?.sourceEntry === source.fake, + forkedTurnId: forkArgs?.turnId, + launchKind: captured?.kind, + launchSessionId: captured?.sessionId, + tracked: hasPeerChatStub(agent, chatUri), + backing: internals._chatBackings.get(chatUri.toString()), + providerData: result ? JSON.parse(result.providerData!) : undefined, + legacyCatalogWritten: raw !== undefined, + }, { + sourceIsDefaultSession: true, + forkedTurnId: 't1', + launchKind: 'resume', + launchSessionId: 'forked-sdk-id', + tracked: true, + backing: { sdkSessionId: 'forked-sdk-id' }, + providerData: { sdkSessionId: 'forked-sdk-id' }, + legacyCatalogWritten: false, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('sendMessage routes a turn to the targeted peer chat only', async () => { const agent = createTestAgent(disposables); - const internals = agent as unknown as AgentInternals; - const deferred = new DeferredPromise(); - let doResumeCalls = 0; - internals._doResumeSession = () => { - doResumeCalls++; - return deferred.p; - }; try { - const p1 = internals._resumeSession('s1'); - const p2 = internals._resumeSession('s1'); - assert.strictEqual(p1, p2); - assert.strictEqual(doResumeCalls, 1); + const session = AgentSession.uri('copilotcli', 'route-msg'); + const chatA = URI.parse(buildChatUri(session, 'peer-a')); + const chatB = URI.parse(buildChatUri(session, 'peer-b')); + const a = makeFakeChatSession(session, 'sdk-a'); + const b = makeFakeChatSession(session, 'sdk-b'); + setPeerChatStub(agent, chatA, a.fake); + setPeerChatStub(agent, chatB, b.fake); - const session = makeFakeSession(); - deferred.complete(session); - assert.strictEqual(await p1, session); - assert.strictEqual(await p2, session); + await agent.chats.sendMessage(chatA, 'hello-a', undefined, 'turn-a', 'client-1'); + + assert.deepStrictEqual({ + aSends: a.rec.sends.map(s => ({ prompt: s.prompt, turnId: s.turnId, senderClientId: s.senderClientId })), + aResets: a.rec.resets, + bSends: b.rec.sends, + bResets: b.rec.resets, + }, { + aSends: [{ prompt: 'hello-a', turnId: 'turn-a', senderClientId: 'client-1' }], + aResets: [{ turnId: 'turn-a', senderClientId: 'client-1' }], + bSends: [], + bResets: [], + }); } finally { await disposeAgent(agent); } }); - test('clears inflight entry after resolution so the next call re-invokes _doResumeSession', async () => { + test('sendMessage throws for a peer chat with no backing chat', async () => { const agent = createTestAgent(disposables); - const internals = agent as unknown as AgentInternals; - let doResumeCalls = 0; - internals._doResumeSession = async () => { - doResumeCalls++; - return makeFakeSession(); - }; try { - await internals._resumeSession('s1'); - await internals._resumeSession('s1'); - assert.strictEqual(doResumeCalls, 2); + const session = AgentSession.uri('copilotcli', 'route-ghost'); + const chatUri = URI.parse(buildChatUri(session, 'ghost')); + await assert.rejects( + () => agent.chats.sendMessage(chatUri, 'hi'), + /unknown chat/, + ); } finally { await disposeAgent(agent); } }); - test('clears inflight entry on rejection so the next call retries', async () => { + test('changeModel applies to the targeted peer chat only', async () => { const agent = createTestAgent(disposables); - const internals = agent as unknown as AgentInternals; - let attempt = 0; - internals._doResumeSession = async () => { - attempt++; - if (attempt === 1) { - throw new Error('first failed'); - } - return makeFakeSession(); - }; try { - await assert.rejects(() => internals._resumeSession('s1'), /first failed/); - await internals._resumeSession('s1'); - assert.strictEqual(attempt, 2); + const session = AgentSession.uri('copilotcli', 'model-route'); + const chatA = URI.parse(buildChatUri(session, 'peer-a')); + const chatB = URI.parse(buildChatUri(session, 'peer-b')); + const a = makeFakeChatSession(session, 'sdk-a'); + const b = makeFakeChatSession(session, 'sdk-b'); + setPeerChatStub(agent, chatA, a.fake); + setPeerChatStub(agent, chatB, b.fake); + + await agent.chats.changeModel(chatA, { id: 'model-x' }); + + assert.deepStrictEqual({ + aModels: a.rec.modelCalls.map(m => m.id), + bModels: b.rec.modelCalls.map(m => m.id), + }, { + aModels: ['model-x'], + bModels: [], + }); } finally { await disposeAgent(agent); } }); - test('does not dedupe across different sessionIds', async () => { + test('changeAgent resolves and applies the agent to the targeted peer chat, and clears it with undefined', async () => { const agent = createTestAgent(disposables); - const internals = agent as unknown as AgentInternals; - const ids: string[] = []; - internals._doResumeSession = async (id: string) => { - ids.push(id); - return makeFakeSession(); - }; try { - await Promise.all([ - internals._resumeSession('s1'), - internals._resumeSession('s2'), - ]); - assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); + const session = AgentSession.uri('copilotcli', 'agent-route'); + const chatA = URI.parse(buildChatUri(session, 'peer-a')); + const a = makeFakeChatSession(session, 'sdk-a'); + const internals = agent as unknown as ChatInternals; + setPeerChatStub(agent, chatA, a.fake); + internals._resolveAgentName = async (_sessionUri, _snapshot, selection) => selection.uri === 'agent://x' ? 'Resolved Agent' : undefined; + + await agent.chats.changeAgent(chatA, { uri: 'agent://x' }); + await agent.chats.changeAgent(chatA, undefined); + + assert.deepStrictEqual(a.rec.agentCalls, ['Resolved Agent', undefined]); } finally { await disposeAgent(agent); } }); - test('post-init shutdown race: disposes the session and throws CancellationError instead of registering on a disposed _sessions map', async () => { - // Without this guard an in-flight `_resumeSession` / - // `_materializeProvisional` whose `initializeSession()` - // resolves AFTER `dispose()` -> `shutdown()` -> `super.dispose()` + test('round-trips peer chats through providerData + materializeChat and resumes per-chat history after a restart', async () => { + // A single session data service is shared across the two agent + // instances to model the on-disk store surviving a process restart. + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 'restore-rt'); + const created: Record = {}; + const providerData: Record = {}; + + // ---- process #1: create two peer chats, capturing the opaque + // providerData blob the orchestrator would persist for each ---- + const agent1 = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent1.authenticate('https://api.github.com', 'token'); + await agent1.createSession({ session, workingDirectory: URI.file('/workspace') }); + const internals1 = agent1 as unknown as ChatInternals; + internals1._createAgentSession = (launchPlan, _dir, _ac, channelUri) => { + if (channelUri) { + created[channelUri.authority] = launchPlan.sessionId; + } + return makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager).fake; + }; + const peerAUri = URI.parse(buildChatUri(session, 'peer-a')); + const peerBUri = URI.parse(buildChatUri(session, 'peer-b')); + const resA = await agent1.chats.createChat(peerAUri, {}); + const resB = await agent1.chats.createChat(peerBUri, {}); + providerData['peer-a'] = resA!.providerData!; + providerData['peer-b'] = resB!.providerData!; + } finally { + await disposeAgent(agent1); + } + + // ---- process #2: fresh agent, empty in-memory state ---- + const agent2 = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent2.authenticate('https://api.github.com', 'token'); + // The orchestrator re-creates the (provisional) parent session on + // restore; this seeds the working directory the peer-chat resume + // path needs. + await agent2.createSession({ session, workingDirectory: URI.file('/workspace') }); + + const internals2 = agent2 as unknown as ChatInternals; + const peerA = URI.parse(buildChatUri(session, 'peer-a')); + const peerB = URI.parse(buildChatUri(session, 'peer-b')); + // The orchestrator hands each persisted blob back to the agent. + await agent2.materializeChat(peerA, providerData['peer-a']); + await agent2.materializeChat(peerB, providerData['peer-b']); + + const peerAHistory: readonly Turn[] = [{ id: 'turn-1' } as unknown as Turn]; + let resumed: CopilotSessionLaunchPlan | undefined; + internals2._createAgentSession = (launchPlan) => { + resumed = launchPlan; + return makeFakeChatSession(session, launchPlan.sessionId, async () => peerAHistory, launchPlan.shellManager).fake; + }; + + await agent2.chats.sendMessage(peerA, 'after restart'); + const history = await getPeerChatStub(agent2, peerA)!.getMessages(); + + assert.deepStrictEqual({ + materializedBackings: [internals2._chatBackings.get(peerA.toString()), internals2._chatBackings.get(peerB.toString())], + resumeKind: resumed?.kind, + resumeSessionId: resumed?.sessionId, + expectedSessionId: created['peer-a'], + historyLen: history.length, + tracked: hasPeerChatStub(agent2, peerA), + }, { + materializedBackings: [{ sdkSessionId: created['peer-a'] }, { sdkSessionId: created['peer-b'] }], + resumeKind: 'resume', + resumeSessionId: created['peer-a'], + expectedSessionId: created['peer-a'], + historyLen: 1, + tracked: true, + }); + } finally { + await disposeAgent(agent2); + } + }); + + test('materializeChat falls back to the legacy copilot.chats catalog when providerData is undefined', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'legacy-materialize'); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.chats', JSON.stringify({ + 'peer-a': { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, + })); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as ChatInternals; + + // undefined blob -> agent recovers the backing from its own catalog. + await agent.materializeChat(chatUri, undefined); + // A corrupt blob is dropped (no backing recorded). + const corruptUri = URI.parse(buildChatUri(session, 'peer-corrupt')); + await agent.materializeChat(corruptUri, 'not json'); + + assert.deepStrictEqual({ + legacy: internals._chatBackings.get(chatUri.toString()), + corrupt: internals._chatBackings.has(corruptUri.toString()), + }, { + legacy: { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, + corrupt: false, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('changeModel on a peer chat refreshes its backing and fires onDidChangeChatData', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'model-blob'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as ChatInternals; + setPeerChatStub(agent, chatUri, makeFakeChatSession(session, 'sdk-a').fake); + internals._chatBackings.set(chatUri.toString(), { sdkSessionId: 'sdk-a' }); + + const events: { chat: string; providerData: unknown }[] = []; + disposables.add(agent.onDidChangeChatData(e => events.push({ chat: e.chat.toString(), providerData: JSON.parse(e.providerData) }))); + + await agent.chats.changeModel(chatUri, { id: 'model-x' }); + + assert.deepStrictEqual({ + backing: internals._chatBackings.get(chatUri.toString()), + events, + }, { + backing: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } }, + events: [{ chat: chatUri.toString(), providerData: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } } }], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + + // The chat-addressed surface ({@link IAgent.chats}) is a thin adapter over + // the legacy `(session, chat?)` methods. These tests verify it resolves a + // single chat URI back to the right `(session, chat)` target — a peer + // `ahp-chat` URI keeps its own identity, a session URI maps to the + // session's default chat — and then delegates to the legacy implementation. + suite('chat surface (IAgentChats)', () => { + + type ConvInternals = { + _sessions: Map; + _provisionalSessions: Map; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, dir: URI | undefined, activeClient: unknown, channelUri?: URI) => CopilotAgentSession; + }; + + interface IFakeConvRecorder { + readonly sends: { prompt: string; turnId: string | undefined; senderClientId: string | undefined }[]; + readonly resets: { turnId: string; senderClientId: string | undefined }[]; + readonly modelCalls: string[]; + readonly agentCalls: (string | undefined)[]; + aborted: number; + disposed: boolean; + } + + /** + * Installs a recording fake {@link CopilotAgentSession} as a peer chat + * (hosted on the owning session's entry) or as a session's default chat, + * keyed as the real agent would, so the chat adapter can drive + * the real legacy methods. + */ + function installFake(agent: CopilotAgent, key: string, target: 'chat' | 'session', sessionUri: URI): IFakeConvRecorder { + const rec: IFakeConvRecorder = { sends: [], resets: [], modelCalls: [], agentCalls: [], aborted: 0, disposed: false }; + const fake = { + sessionUri, + sessionId: `sdk-${key}`, + appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, + async send(prompt: string, _attachments: unknown, turnId: string | undefined, _mode: unknown, senderClientId: string | undefined): Promise { + rec.sends.push({ prompt, turnId, senderClientId }); + }, + resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, + async setModel(id: string): Promise { rec.modelCalls.push(id); }, + async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, + async abort(): Promise { rec.aborted++; }, + async getMessages(): Promise { return [{ id: `turn-${key}` } as unknown as Turn]; }, + handleClientToolCallComplete(): void { }, + dispose(): void { rec.disposed = true; }, + } as unknown as CopilotAgentSession; + if (target === 'chat') { + setPeerChatStub(agent, URI.parse(key), fake); + } else { + setDefaultSessionStub(agent, key, fake); + } + return rec; + } + + /** + * Stubs `_createAgentSession` (the SDK-backed launch seam) so peer-chat + * creation/fork stays in-memory: it returns a minimal fake whose + * `sessionId` echoes the launch plan, which is what `createChat` records + * as the chat's backing. + */ + function stubBackingSession(agent: CopilotAgent): void { + (agent as unknown as ConvInternals)._createAgentSession = (launchPlan, _dir, _ac, channelUri) => { + return { + sessionUri: channelUri, + sessionId: launchPlan.sessionId, + appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, + async initializeSession(): Promise { }, + async remapTurnIds(): Promise { }, + async getMessages(): Promise { return []; }, + handleClientToolCallComplete(): void { }, + dispose(): void { launchPlan.shellManager?.dispose(); }, + } as unknown as CopilotAgentSession; + }; + } + + test('createSession mints a provisional session', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'scope-create'); + const result = await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const internals = agent as unknown as ConvInternals; + assert.deepStrictEqual({ + session: result.session.toString(), + provisional: internals._provisionalSessions.has(AgentSession.id(session)), + }, { + session: session.toString(), + provisional: true, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('disposeSession tears down a provisional session', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'scope-dispose'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const internals = agent as unknown as ConvInternals; + assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), true); + + await agent.disposeSession(session); + + assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), false); + } finally { + await disposeAgent(agent); + } + }); + + test('createChat creates a peer chat and returns its providerData', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'conv-create'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + + stubBackingSession(agent); + const result = await agent.chats.createChat(chatUri, { model: { id: 'gpt-x' } }); + + assert.deepStrictEqual({ + tracked: hasPeerChatStub(agent, chatUri), + hasProviderData: !!(result && result.providerData), + model: result ? (JSON.parse(result.providerData!) as { model?: ModelSelection }).model : undefined, + }, { + tracked: true, + hasProviderData: true, + model: { id: 'gpt-x' }, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('fork delegates to createChat with the fork source', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'conv-fork'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + installFake(agent, AgentSession.id(session), 'session', session); + + const forkArgs: { turnId: string }[] = []; + (agent as unknown as { _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string) => Promise })._forkSdkChat = async (_c, _s, turnId) => { + forkArgs.push({ turnId }); + return 'forked-sdk-id'; + }; + stubBackingSession(agent); + + const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); + const source: IAgentCreateChatForkSource = { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }; + const result = await agent.chats.fork(chatUri, source); + + assert.deepStrictEqual({ + forkArgs, + tracked: hasPeerChatStub(agent, chatUri), + providerData: result ? JSON.parse(result.providerData!) : undefined, + }, { + forkArgs: [{ turnId: 't1' }], + tracked: true, + providerData: { sdkSessionId: 'forked-sdk-id' }, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('sendMessage routes a peer chat URI to the peer chat', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-send-peer'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const rec = installFake(agent, chatUri.toString(), 'chat', session); + + await agent.chats.sendMessage(chatUri, 'hello-peer', undefined, 'turn-1', 'client-1'); + + assert.deepStrictEqual({ + sends: rec.sends, + resets: rec.resets, + }, { + sends: [{ prompt: 'hello-peer', turnId: 'turn-1', senderClientId: 'client-1' }], + resets: [{ turnId: 'turn-1', senderClientId: 'client-1' }], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('sendMessage routes a scope (session) URI to the default chat', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-send-default'); + const rec = installFake(agent, AgentSession.id(session), 'session', session); + + await agent.chats.sendMessage(defaultChatUri(session), 'hello-default', undefined, 'turn-d', 'client-d'); + + assert.deepStrictEqual(rec.sends, [{ prompt: 'hello-default', turnId: 'turn-d', senderClientId: 'client-d' }]); + } finally { + await disposeAgent(agent); + } + }); + + test('abort, changeModel, and changeAgent route a peer URI to the peer chat', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-ops'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const rec = installFake(agent, chatUri.toString(), 'chat', session); + (agent as unknown as { _resolveAgentName: (s: URI, snap: IActiveClientSnapshot, a: AgentSelection) => Promise })._resolveAgentName = async (_s, _snap, sel) => sel.uri === 'agent://x' ? 'Resolved Agent' : undefined; + + await agent.chats.abort(chatUri); + await agent.chats.changeModel(chatUri, { id: 'model-x' }); + await agent.chats.changeAgent(chatUri, { uri: 'agent://x' }); + await agent.chats.changeAgent(chatUri, undefined); + + assert.deepStrictEqual({ + aborted: rec.aborted, + modelCalls: rec.modelCalls, + agentCalls: rec.agentCalls, + }, { + aborted: 1, + modelCalls: ['model-x'], + agentCalls: ['Resolved Agent', undefined], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('getMessages returns the peer chat history', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-history'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + installFake(agent, chatUri.toString(), 'chat', session); + + const turns = await agent.chats.getMessages(chatUri); + + assert.deepStrictEqual(turns.map(t => t.id), [`turn-${chatUri.toString()}`]); + } finally { + await disposeAgent(agent); + } + }); + + test('disposeChat disposes the peer chat', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'conv-dispose'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const rec = installFake(agent, chatUri.toString(), 'chat', session); + + await agent.chats.disposeChat(chatUri); + + assert.deepStrictEqual({ + disposed: rec.disposed, + tracked: hasPeerChatStub(agent, chatUri), + deleted: client.deletedSessionIds, + }, { + disposed: true, + tracked: false, + deleted: ['sdk-' + chatUri.toString()], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + + // Regression for the #319516 incident: a window reload reconnects with a + // NEW clientId but an identical tool list. The cached SDK session's + // staleness check (`ActiveClient.requiresRestart`) must NOT treat a + // clientId-only change as a config change — otherwise either the session + // is needlessly restarted, or (the actual bug) the cached session is + // reused while the live clientId is never updated, so subsequent client + // tool calls are stamped with the dead window's id and hang forever. + suite('client tool refresh on reload (#319516)', () => { + /** Minimal structural view of the agent's private per-session ActiveClient. */ + type TestActiveClient = { + readonly toolSet: { ownerOf(toolName: string): string | undefined }; + snapshot(): Promise; + requiresRestart(snap: IActiveClientSnapshot): Promise; + }; + + function getActiveClient(agent: CopilotAgent, session: URI): TestActiveClient { + const activeClients = (agent as unknown as { _activeClients: { get(s: URI): TestActiveClient | undefined } })._activeClients; + const activeClient = activeClients.get(session); + assert.ok(activeClient, 'expected an ActiveClient to exist after registering client tools'); + return activeClient; + } + + const tools: ToolDefinition[] = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }]; + + test('clientId-only change (reload) does NOT require a restart and updates the live owner', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'reload-session'); + + // Window A registers its tools; this is the snapshot the SDK + // session would be created with. + agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = tools; + const activeClient = getActiveClient(agent, session); + const appliedSnapshot = await activeClient.snapshot(); + assert.strictEqual(activeClient.toolSet.ownerOf('my_tool'), 'client-A'); + + // Window A reloads: window B reconnects with a new clientId but + // the identical tool list. The reload removes A then adds B. + agent.removeActiveClient(session, 'client-A'); + agent.getOrCreateActiveClient(session, { clientId: 'client-B' }).tools = [...tools]; + + // Root-cause assertions: the cached SDK session must be reused + // (no restart) AND the live owner must now be window B's, so + // the next client tool call is stamped with a live owner. + assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), false); + assert.strictEqual(activeClient.toolSet.ownerOf('my_tool'), 'client-B'); + } finally { + await disposeAgent(agent); + } + }); + + test('a structural tool change still requires a restart', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'tools-change-session'); + + agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = tools; + const activeClient = getActiveClient(agent, session); + const appliedSnapshot = await activeClient.snapshot(); + + // A genuinely different tool set (added tool) must restart so the + // SDK session is rebuilt with the new tools. + agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = [...tools, { name: 'second_tool', description: 'another', inputSchema: { type: 'object', properties: {} } }]; + + assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), true); + } finally { + await disposeAgent(agent); + } + }); + + test('multiple active clients merge their tools and removal isolates per client', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'multi-client-session'); + + // Two clients each contribute their own tool plus a shared one. + agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = [ + { name: 'shared', description: 'from A', inputSchema: { type: 'object', properties: {} } }, + { name: 'a_tool', description: 'A only', inputSchema: { type: 'object', properties: {} } }, + ]; + agent.getOrCreateActiveClient(session, { clientId: 'client-B' }).tools = [ + { name: 'shared', description: 'from B', inputSchema: { type: 'object', properties: {} } }, + { name: 'b_tool', description: 'B only', inputSchema: { type: 'object', properties: {} } }, + ]; + const activeClient = getActiveClient(agent, session); + + // The SDK snapshot merges both clients, deduping the shared name + // in favor of the first-inserted client (A), and ownership maps + // each tool to its contributing client. + const merged = await activeClient.snapshot(); + assert.deepStrictEqual(merged.tools.map(t => t.name), ['shared', 'a_tool', 'b_tool']); + assert.strictEqual(activeClient.toolSet.ownerOf('shared'), 'client-A'); + assert.strictEqual(activeClient.toolSet.ownerOf('a_tool'), 'client-A'); + assert.strictEqual(activeClient.toolSet.ownerOf('b_tool'), 'client-B'); + + // Removing client A keeps B's contribution and hands the shared + // tool to B (now the sole provider). + agent.removeActiveClient(session, 'client-A'); + const afterRemoval = await activeClient.snapshot(); + assert.deepStrictEqual(afterRemoval.tools.map(t => t.name), ['shared', 'b_tool']); + assert.strictEqual(activeClient.toolSet.ownerOf('shared'), 'client-B'); + assert.strictEqual(activeClient.toolSet.ownerOf('a_tool'), undefined); + } finally { + await disposeAgent(agent); + } + }); + }); + + suite('_resumeSession dedup', () => { + // Regression: two concurrent paths (e.g. an outdated-config refresh in + // `sendMessage` and a `getSessionMessages` subscribe) each calling + // `_resumeSession(id)` used to construct two `CopilotAgentSession` + // entries for the same id; the second `_sessions.set(id, …)` on the + // underlying `DisposableMap` disposed the first one mid + // `initializeSession()`, producing 'Trying to add a disposable to a + // DisposableStore that has already been disposed' warnings and a + // half-initialised session with no event subscriptions. + + type AgentInternals = { + _resumeSession: (id: string) => Promise; + _doResumeSession: (id: string) => Promise; + }; + const makeFakeSession = () => ({ dispose: () => { } } as unknown as CopilotAgentSession); + + test('dedupes concurrent calls for the same sessionId', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + const deferred = new DeferredPromise(); + let doResumeCalls = 0; + internals._doResumeSession = () => { + doResumeCalls++; + return deferred.p; + }; + try { + const p1 = internals._resumeSession('s1'); + const p2 = internals._resumeSession('s1'); + assert.strictEqual(p1, p2); + assert.strictEqual(doResumeCalls, 1); + + const session = makeFakeSession(); + deferred.complete(session); + assert.strictEqual(await p1, session); + assert.strictEqual(await p2, session); + } finally { + await disposeAgent(agent); + } + }); + + test('clears inflight entry after resolution so the next call re-invokes _doResumeSession', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + let doResumeCalls = 0; + internals._doResumeSession = async () => { + doResumeCalls++; + return makeFakeSession(); + }; + try { + await internals._resumeSession('s1'); + await internals._resumeSession('s1'); + assert.strictEqual(doResumeCalls, 2); + } finally { + await disposeAgent(agent); + } + }); + + test('clears inflight entry on rejection so the next call retries', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + let attempt = 0; + internals._doResumeSession = async () => { + attempt++; + if (attempt === 1) { + throw new Error('first failed'); + } + return makeFakeSession(); + }; + try { + await assert.rejects(() => internals._resumeSession('s1'), /first failed/); + await internals._resumeSession('s1'); + assert.strictEqual(attempt, 2); + } finally { + await disposeAgent(agent); + } + }); + + test('does not dedupe across different sessionIds', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + const ids: string[] = []; + internals._doResumeSession = async (id: string) => { + ids.push(id); + return makeFakeSession(); + }; + try { + await Promise.all([ + internals._resumeSession('s1'), + internals._resumeSession('s2'), + ]); + assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); + } finally { + await disposeAgent(agent); + } + }); + + test('post-init shutdown race: disposes the session and throws CancellationError instead of registering on a disposed _sessions map', async () => { + // Without this guard an in-flight `_resumeSession` / + // `_materializeProvisional` whose `initializeSession()` + // resolves AFTER `dispose()` -> `shutdown()` -> `super.dispose()` // has run would call `_sessions.set(...)` on a disposed // DisposableMap, leaking the session and reproducing the // 'Trying to add a disposable to a DisposableStore that has @@ -2314,7 +3633,7 @@ suite('CopilotAgent', () => { // before constructing the SDK session. Verifies that the // real production code path persists branch metadata and // queues the live announcement. - const expectedBranchName = `agents/add-feature-${sessionId.substring(0, 8)}`; + const expectedBranchName = `agents/add-feature`; const workingDir = await agent.resolveWorktreeForTest({ workingDirectory: repositoryRoot, config: { isolation: 'worktree', branch: 'main' }, @@ -2332,7 +3651,7 @@ suite('CopilotAgent', () => { signals.push(s); })); - await agent.sendMessage(session, 'hello'); + await agent.chats.sendMessage(defaultChatUri(session), 'hello'); assert.strictEqual(sendCalls, 1, 'underlying SDK send must still be called'); const markdownSignals = signals.filter((s): s is IAgentActionSignal => @@ -2350,7 +3669,7 @@ suite('CopilotAgent', () => { // 3. Live path is one-shot: a second sendMessage must not re-emit. signals.length = 0; - await agent.sendMessage(session, 'follow-up'); + await agent.chats.sendMessage(defaultChatUri(session), 'follow-up'); const reemittedMarkdown = signals.filter(s => s.kind === 'action' && ( (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || @@ -2408,7 +3727,7 @@ suite('CopilotAgent', () => { disposables.add(agent.onDidSessionProgress(s => { signals.push(s); })); - await agent.sendMessage(session, 'hello'); + await agent.chats.sendMessage(defaultChatUri(session), 'hello'); const markdownSignals = signals.filter(s => s.kind === 'action' && ( (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || @@ -2425,6 +3744,39 @@ suite('CopilotAgent', () => { } }); + test('resolveSessionConfig does not offer or default to worktree isolation when the repository has no commits', async () => { + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'empty-repo-config'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + + // Repository with commits: worktree is offered and is the default. + gitService.headCommit = '0'.repeat(40); + const withCommits = await agent.resolveSessionConfig({ workingDirectory: repositoryRoot }); + assert.strictEqual(withCommits.values[SessionConfigKey.Isolation], 'worktree', 'worktree should be the default isolation when the repo has commits'); + + // Empty repository (no commits): worktree must not be offered or + // defaulted — the session should run directly in the folder. + gitService.headCommit = undefined; + const noCommits = await agent.resolveSessionConfig({ workingDirectory: repositoryRoot }); + assert.strictEqual(noCommits.values[SessionConfigKey.Isolation], 'folder', 'isolation must default to folder for a repo without commits'); + const isolationSchema = noCommits.schema.properties?.[SessionConfigKey.Isolation] as { enum?: readonly string[] } | undefined; + assert.deepStrictEqual(isolationSchema?.enum, ['folder'], 'worktree must not be offered for a repo without commits'); + } finally { + await disposeAgent(agent); + } + }); + test('onArchivedChanged removes the worktree on archive and recreates it on unarchive', async () => { const sessionId = 'archive-cleanup-session'; const session = AgentSession.uri('copilotcli', sessionId); @@ -2589,4 +3941,332 @@ suite('CopilotAgent', () => { }); }); + + suite('customization anchoring', () => { + + test('rebaseUnder rebases paths under the source dir and leaves others untouched', () => { + const original = URI.file('/Users/me/src/vscode'); + const worktree = URI.file('/Users/me/src/vscode.worktrees/agents-x'); + assert.strictEqual( + rebaseUnder(URI.file('/Users/me/src/vscode/.github/skills/sessions'), original, worktree)?.toString(), + URI.file('/Users/me/src/vscode.worktrees/agents-x/.github/skills/sessions').toString(), + 'a path under the source dir is rebased onto the target dir', + ); + assert.strictEqual( + rebaseUnder(original, original, worktree)?.toString(), + worktree.toString(), + 'the source dir itself maps to the target dir', + ); + assert.strictEqual( + rebaseUnder(URI.file('/Users/me/.copilot/skills/foo'), original, worktree), + undefined, + 'a path outside the source dir (e.g. user home) is not rebased', + ); + }); + + test('migrateEnablementKeys rebases keys under the original folder and preserves all others verbatim', () => { + const original = URI.file('/Users/me/src/vscode'); + const worktree = URI.file('/Users/me/src/vscode.worktrees/agents-x'); + const sessionsKey = URI.file('/Users/me/src/vscode/.github/skills/sessions/SKILL.md').toString(); + const userHomeKey = URI.file('/Users/me/.copilot/skills/foo/SKILL.md').toString(); + const result = migrateEnablementKeys(new Map([ + [sessionsKey, false], + [userHomeKey, false], + ['not-a-uri-opaque-id', true], + ['', true], + ]), original, worktree); + + assert.deepStrictEqual(Object.fromEntries(result), { + // under the original folder → rebased onto the worktree + [URI.file('/Users/me/src/vscode.worktrees/agents-x/.github/skills/sessions/SKILL.md').toString()]: false, + // everything else preserved verbatim (no `file:///` prefix, no collapse) + [userHomeKey]: false, + ['not-a-uri-opaque-id']: true, + ['']: true, + }); + }); + + let tmpDir: string; + + setup(async () => { + tmpDir = await fs.mkdtemp(`${os.tmpdir()}/copilot-agent-anchor-test-`); + }); + + teardown(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function materializeAndCaptureAnchor(resolvedWorkingDirectory: URI): Promise<{ anchor: URI | undefined; sdkWorkingDirectory: string | undefined; originalFolder: URI }> { + const originalFolder = URI.joinPath(URI.file(tmpDir), 'repo'); + await fs.mkdir(originalFolder.fsPath, { recursive: true }); + + const client = new TestCopilotClient([]); + let sdkWorkingDirectory: string | undefined; + client.createSession = async config => { + sdkWorkingDirectory = config.workingDirectory; + return new MockCopilotSession() as unknown as CopilotSession; + }; + + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: client, + }); + + // Stub working-directory resolution (worktree creation is covered by + // the worktree-announcement suite) and capture the customization + // anchor handed to `_createAgentSession`. + let anchor: URI | undefined; + const agentInternals = agent as unknown as { + _resolveSessionWorkingDirectory: (config: unknown, sessionId: string, prompt?: string) => Promise; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, channelUri?: URI) => CopilotAgentSession; + }; + agentInternals._resolveSessionWorkingDirectory = async () => resolvedWorkingDirectory; + const originalCreateAgentSession = agentInternals._createAgentSession; + agentInternals._createAgentSession = (launchPlan, customizationDirectory, activeClient, channelUri) => { + anchor = customizationDirectory; + return originalCreateAgentSession.call(agent, launchPlan, customizationDirectory, activeClient, channelUri); + }; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'anchor-session'), workingDirectory: originalFolder }); + assert.strictEqual(result.provisional, true); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); + } finally { + await disposeAgent(agent); + } + return { anchor, sdkWorkingDirectory, originalFolder }; + } + + test('materialization re-anchors customization discovery to the resolved worktree', async () => { + const worktree = URI.joinPath(URI.file(tmpDir), 'repo.worktrees', 'agents-x'); + const { anchor, sdkWorkingDirectory, originalFolder } = await materializeAndCaptureAnchor(worktree); + assert.strictEqual(anchor?.toString(), worktree.toString(), 'customization discovery must be anchored to the worktree, not the original folder'); + assert.notStrictEqual(anchor?.toString(), originalFolder.toString(), 'the anchor must move off the original folder'); + assert.strictEqual(sdkWorkingDirectory, worktree.fsPath, 'the SDK working directory must be the worktree'); + }); + + test('materialization without a worktree keeps the anchor on the original folder', async () => { + const originalFolder = URI.joinPath(URI.file(tmpDir), 'repo'); + const { anchor } = await materializeAndCaptureAnchor(originalFolder); + assert.strictEqual(anchor?.toString(), originalFolder.toString(), 'the anchor stays on the original folder when no worktree is created'); + }); + + test('worktree skill/instruction directories sent to the SDK resolve inside the worktree', async () => { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + const originalFolder = URI.from({ scheme: Schemas.inMemory, path: '/orig' }); + const worktree = URI.from({ scheme: Schemas.inMemory, path: '/wt' }); + + // A skill present only in the ORIGINAL folder must NOT reach the SDK; + // the worktree's own skill + instruction must. + await fileService.writeFile(URI.joinPath(originalFolder, '.github', 'skills', 'orig-skill', 'SKILL.md'), VSBuffer.fromString('---\nname: orig-skill\ndescription: from the original repo\n---\nbody')); + await fileService.writeFile(URI.joinPath(worktree, '.github', 'skills', 'wt-skill', 'SKILL.md'), VSBuffer.fromString('---\nname: wt-skill\ndescription: from the worktree\n---\nbody')); + await fileService.writeFile(URI.joinPath(worktree, '.github', 'instructions', 'wt.instructions.md'), VSBuffer.fromString('---\napplyTo: "**/*.ts"\ndescription: worktree instruction\n---\nbody')); + + const client = new TestCopilotClient([]); + let capturedConfig: Parameters[0] | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + + const { agent } = createTestAgentContext(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: client, + fileService, + }); + + // Stub worktree resolution; the active-client claim anchors the + // provisional plugin controller to the ORIGINAL folder first, so this + // exercises the re-anchor at materialization. + const agentInternals = agent as unknown as { + _resolveSessionWorkingDirectory: (config: unknown, sessionId: string, prompt?: string) => Promise; + }; + agentInternals._resolveSessionWorkingDirectory = async () => worktree; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'wt-dirs-session'), + workingDirectory: originalFolder, + activeClient: { clientId: 'c1', tools: [] }, + }); + assert.strictEqual(result.provisional, true); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); + } finally { + await disposeAgent(agent); + } + + assert.ok(capturedConfig, 'the SDK createSession must run during materialization'); + assert.deepStrictEqual( + { + workingDirectory: capturedConfig.workingDirectory, + skillDirectories: capturedConfig.skillDirectories, + instructionDirectories: capturedConfig.instructionDirectories, + }, + { + workingDirectory: worktree.fsPath, + skillDirectories: [URI.joinPath(worktree, '.github', 'skills', 'wt-skill').fsPath], + instructionDirectories: [URI.joinPath(worktree, '.github', 'instructions').fsPath], + }, + 'skill/instruction directories sent to the SDK must resolve inside the worktree, never the original folder', + ); + }); + + }); + + suite('custom agent worktree translation', () => { + + // The new methods under test are private; reach in the same way the + // surrounding suites do (e.g. `customization anchoring`). + type AgentInternals = { + _getAlternativeAgentForWorktree(provisional: unknown, workingDirectory: URI | undefined): AgentSelection | undefined; + _resolveAgentWhenMaterializing(provisional: unknown, snapshot: IActiveClientSnapshot, workingDirectory: URI | undefined): Promise<{ agent: AgentSelection; name: string } | undefined>; + _resolveAgentName(sessionUri: URI, snapshot: IActiveClientSnapshot, agent: AgentSelection): Promise; + _resolveSessionWorkingDirectory(config: unknown, sessionId: string, prompt?: string): Promise; + _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, channelUri?: URI): CopilotAgentSession; + _readSessionMetadata(session: URI): Promise<{ agent?: AgentSelection }>; + }; + + const repo = URI.file('/repo'); + const worktree = URI.joinPath(URI.file('/repo.worktrees'), 'agents-x'); + const repoAgentUri = URI.joinPath(repo, '.github', 'agents', 'agent.md').toString(); + const worktreeAgentUri = URI.joinPath(worktree, '.github', 'agents', 'agent.md').toString(); + const emptySnapshot: IActiveClientSnapshot = { tools: [], plugins: [], mcpServers: {} }; + + function provisional(workingDirectory: URI | undefined, agent: AgentSelection | undefined): unknown { + const sessionUri = AgentSession.uri('copilotcli', 'prov-agent'); + return { sessionId: AgentSession.id(sessionUri), sessionUri, workingDirectory, model: undefined, agent, project: undefined }; + } + + test('_getAlternativeAgentForWorktree rewrites a repo agent path onto the worktree', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + assert.deepStrictEqual( + internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), worktree), + { uri: worktreeAgentUri }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('_getAlternativeAgentForWorktree returns undefined when there is nothing to translate', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + const outsideRepoAgent: AgentSelection = { uri: URI.file('/home/me/.copilot/agents/agent.md').toString() }; + assert.deepStrictEqual( + { + noAgent: internals._getAlternativeAgentForWorktree(provisional(repo, undefined), worktree), + folderIsolation: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), undefined), + sameWorkingDirectory: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), repo), + agentOutsideRepo: internals._getAlternativeAgentForWorktree(provisional(repo, outsideRepoAgent), worktree), + }, + { + noAgent: undefined, + folderIsolation: undefined, + sameWorkingDirectory: undefined, + agentOutsideRepo: undefined, + }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('_resolveAgentWhenMaterializing keeps the original agent for folder isolation (no worktree)', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + internals._resolveAgentName = async (_sessionUri, _snapshot, selection) => selection.uri === repoAgentUri ? 'Repo Agent' : undefined; + // Folder isolation: the resolved working directory equals the + // user-picked folder, so there is no worktree copy to translate to + // and the originally selected agent is kept as-is. + assert.deepStrictEqual( + await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, repo), + { agent: { uri: repoAgentUri }, name: 'Repo Agent' }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('_resolveAgentWhenMaterializing returns undefined when no agent is selected or neither resolves', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + internals._resolveAgentName = async () => undefined; + assert.deepStrictEqual( + { + noAgent: await internals._resolveAgentWhenMaterializing(provisional(repo, undefined), emptySnapshot, worktree), + neitherResolves: await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, worktree), + }, + { noAgent: undefined, neitherResolves: undefined }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('materialization rewrites a repo agent to its worktree copy and persists it (no resolution stubbing)', async () => { + // End-to-end through real customization discovery: the same custom + // agent file exists in both the original repo and the worktree. The + // user selects the repo copy, but once the worktree is materialized + // discovery re-anchors there, so the persisted/launched agent must be + // the worktree copy — proving the translation against real resolution + // rather than a stubbed `_resolveAgentName`. + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + const repoFolder = URI.from({ scheme: Schemas.inMemory, path: '/repo' }); + const worktreeFolder = URI.from({ scheme: Schemas.inMemory, path: '/repo.worktrees/agents-x' }); + const repoAgentFile = URI.joinPath(repoFolder, '.github', 'agents', 'agent.md'); + const worktreeAgentFile = URI.joinPath(worktreeFolder, '.github', 'agents', 'agent.md'); + const agentContents = VSBuffer.fromString('---\nname: My Agent\ndescription: a custom agent\n---\nbody'); + await fileService.writeFile(repoAgentFile, agentContents); + await fileService.writeFile(worktreeAgentFile, agentContents); + + const client = new TestCopilotClient([]); + client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); + + let launchAgentName: string | undefined; + const internals = agent as unknown as AgentInternals; + internals._resolveSessionWorkingDirectory = async () => worktreeFolder; + const originalCreateAgentSession = internals._createAgentSession; + internals._createAgentSession = (launchPlan, customizationDirectory, activeClient, channelUri) => { + launchAgentName = launchPlan.resolvedAgentName; + return originalCreateAgentSession.call(agent, launchPlan, customizationDirectory, activeClient, channelUri); + }; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'agent-translate'), + workingDirectory: repoFolder, + agent: { uri: repoAgentFile.toString() }, + }); + assert.strictEqual(result.provisional, true); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); + + // `_readSessionMetadata` reads back the exact agent field the + // resume path consumes, so asserting it stands in for restore. + const stored = await internals._readSessionMetadata(result.session); + assert.deepStrictEqual( + { storedAgent: stored.agent, launchAgentName }, + { storedAgent: { uri: worktreeAgentFile.toString() }, launchAgentName: 'My Agent' }, + 'the repo agent must be rewritten to its worktree copy, both for the SDK launch and the persisted metadata the restore path reads', + ); + } finally { + await disposeAgent(agent); + } + }); + + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 7cff718360913..b989740dccdda 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; +import type { CopilotSession, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; @@ -17,22 +17,27 @@ import { IFileService } from '../../../files/common/files.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import type { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../telemetry/common/gdprTypings.js'; -import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; +import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanReview.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; +import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction } from '../../common/state/sessionActions.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { McpAuthRequiredReason, McpServerStatus } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; -import { ActiveClientState } from '../../node/activeClientState.js'; +import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { AgentHostAutoReplyEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; +import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; +import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; import { IAgentServerToolHost } from '../../common/agentServerTools.js'; @@ -50,21 +55,39 @@ class MockCopilotSession { readonly compactCalls: unknown[] = []; readonly commandListCalls: unknown[] = []; readonly commandInvokeCalls: Array<{ name: string; input?: string }> = []; - compactResult: { success: boolean; tokensRemoved: number; messagesRemoved: number } = { success: true, tokensRemoved: 0, messagesRemoved: 0 }; + compactResult: { success: boolean; tokensRemoved: number; messagesRemoved: number; contextWindow?: { currentTokens: number; tokenLimit: number; messagesLength: number } } = { success: true, tokensRemoved: 0, messagesRemoved: 0 }; compactError: unknown = undefined; - commandListResult: { commands: Array<{ name: string; kind: 'builtin' | 'skill' | 'client'; description: string; allowDuringAgentExecution: boolean }> } = { commands: [] }; + commandListResult: { + commands: Array<{ + name: string; + kind: 'builtin' | 'skill' | 'client'; + description: string; + allowDuringAgentExecution: boolean; + aliases?: string[]; + input?: { hint: string; required?: boolean; preserveMultilineInput?: boolean }; + }>; + } = { commands: [] }; commandInvokeResult: { kind: 'text'; text: string; markdown?: boolean } | { kind: 'completed'; message?: string } | { kind: 'agent-prompt'; prompt: string; displayPrompt: string; mode?: 'interactive' | 'plan' | 'autopilot' } = { kind: 'text', text: '' }; messages: SessionEvent[] = []; private readonly _handlers = new Map void>>(); + private readonly _allHandlers = new Set(); planReadResult: { exists: boolean; content: string | null; path: string | null } = { exists: false, content: null, path: null }; - on(eventType: K, handler: TypedSessionEventHandler): () => void { + on(handler: SessionEventHandler): () => void; + on(eventType: K, handler: TypedSessionEventHandler): () => void; + on(eventTypeOrHandler: K | SessionEventHandler, handler?: TypedSessionEventHandler): () => void { + if (typeof eventTypeOrHandler === 'function') { + this._allHandlers.add(eventTypeOrHandler); + return () => { this._allHandlers.delete(eventTypeOrHandler); }; + } + const eventType = eventTypeOrHandler; let set = this._handlers.get(eventType); if (!set) { set = new Set(); this._handlers.set(eventType, set); } + assert.ok(handler); set.add(handler as (event: SessionEvent) => void); return () => { set.delete(handler as (event: SessionEvent) => void); }; } @@ -78,6 +101,9 @@ class MockCopilotSession { handler(event); } } + for (const handler of this._allHandlers) { + handler(event); + } } // Stubs for methods the wrapper / session class calls @@ -131,8 +157,17 @@ class MockCopilotSession { executeSampling: async () => ({ status: 'completed' as const, result: undefined }), cancelSamplingExecution: async () => { /* no-op */ }, }, + options: { + update: async (params: { sandboxConfig?: unknown }) => { + if (params.sandboxConfig !== undefined) { + this.sandboxConfigUpdates.push(params.sandboxConfig); + } + }, + }, }; + readonly sandboxConfigUpdates: unknown[] = []; + mcpListResult: { servers: ReadonlyArray<{ name: string; status: 'connected' | 'failed' | 'pending'; error?: string }> } = { servers: [] }; mcpListError: unknown = undefined; } @@ -140,6 +175,12 @@ class MockCopilotSession { class CapturingLogService extends NullLogService { readonly errors: Array<{ first: string | Error; args: unknown[] }> = []; readonly warnings: Array<{ message: string; args: unknown[] }> = []; + readonly traces: Array<{ message: string; args: unknown[] }> = []; + + override trace(message: string, ...args: unknown[]): void { + this.traces.push({ message, args }); + super.trace(message, ...args); + } override error(message: string | Error, ...args: unknown[]): void { this.errors.push({ first: message, args }); @@ -152,32 +193,6 @@ class CapturingLogService extends NullLogService { } } -class RecordingTelemetryService implements ITelemetryService { - declare readonly _serviceBrand: undefined; - readonly telemetryLevel = TelemetryLevel.NONE; - readonly sessionId = 'someValue.sessionId'; - readonly machineId = 'someValue.machineId'; - readonly sqmId = 'someValue.sqmId'; - readonly devDeviceId = 'someValue.devDeviceId'; - readonly firstSessionDate = 'someValue.firstSessionDate'; - readonly sendErrorTelemetry = false; - readonly events: Array<{ eventName: string; data: unknown }> = []; - - publicLog(): void { } - - publicLog2> = never, T extends IGDPRProperty = never>(eventName: string, data?: StrictPropertyCheck): void { - this.events.push({ eventName, data }); - } - - publicLogError(): void { } - - publicLogError2(): void { } - - setExperimentProperty(): void { } - - setCommonProperty(): void { } -} - // ---- Helpers ---------------------------------------------------------------- /** @@ -227,7 +242,7 @@ function getInputRequest(signal: AgentSignal): ChatInputRequestedAction['request async function createAgentSession(disposables: DisposableStore, options?: { clientSnapshot?: IActiveClientSnapshot; - activeClientState?: ActiveClientState; + activeClientToolSet?: ActiveClientToolSet; environmentServiceRegistration?: 'native' | 'none'; logService?: ILogService; telemetryService?: ITelemetryService; @@ -235,12 +250,16 @@ async function createAgentSession(disposables: DisposableStore, options?: { workingDirectory?: URI; /** Per-key effective config values returned by the fake configuration service. */ configValues?: Record; + /** Per-key root config values returned by the fake configuration service's `getRootValue`. */ + rootValues?: Record; fileContents?: Record; fileReadErrors?: readonly string[]; /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ configureMockSession?: (session: MockCopilotSession) => void; /** Optional server-tool host wired into the session. */ serverToolHost?: IAgentServerToolHost; + /** Platform used to compute the SDK sandbox policy. Defaults to `'linux'` so sandbox tests are deterministic. */ + platform?: NodeJS.Platform; }): Promise<{ session: CopilotAgentSession; runtime: ICopilotSessionRuntime; @@ -284,7 +303,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { createSession: async () => mockSession as unknown as CopilotSession, resumeSession: async () => mockSession as unknown as CopilotSession, }, - activeClientState: new ActiveClientState(), + activeClientToolSet: new ActiveClientToolSet(), sessionId: 'test-session-1', workingDirectory: options?.workingDirectory, resolvedAgentName: undefined, @@ -320,6 +339,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { services.set(IDiffComputeService, createZeroDiffComputeService()); const sessionConfigUpdates: Array<{ session: string; patch: Record }> = []; const configValues = options?.configValues ?? {}; + const rootValues = options?.rootValues ?? {}; const fakeConfigurationService: IAgentConfigurationService = { _serviceBrand: undefined, onDidRootConfigChange: new Emitter().event, @@ -331,7 +351,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { getEffectiveWorkingDirectory: () => undefined, getSessionConfigValues: () => undefined, updateSessionConfig: (session, patch) => { sessionConfigUpdates.push({ session, patch }); }, - getRootValue: () => undefined, + getRootValue: ((_schema: unknown, key: string) => rootValues[key]) as IAgentConfigurationService['getRootValue'], updateRootConfig: () => { /* no-op */ }, persistRootConfig: () => { /* no-op */ }, whenIdle: async () => { /* no-op */ }, @@ -351,16 +371,18 @@ async function createAgentSession(disposables: DisposableStore, options?: { CopilotAgentSession, { sessionUri, + chatChannelUri: URI.parse(buildDefaultChatUri(sessionUri)), rawSessionId: 'test-session-1', onDidSessionProgress: progressEmitter, sessionLauncher, launchPlan, shellManager: undefined, clientSnapshot: options?.clientSnapshot, - activeClientState: options?.activeClientState, + activeClientToolSet: options?.activeClientToolSet, resolveMcpChildId: () => undefined, workingDirectory: options?.workingDirectory, serverToolHost: options?.serverToolHost, + platform: options?.platform ?? 'linux', }, )); @@ -379,6 +401,49 @@ suite('CopilotAgentSession', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + suite('CopilotSessionWrapper', () => { + test('fires unhandled events when no wrapped listener is registered', () => { + const mockSession = new MockCopilotSession(); + const wrapper = disposables.add(new CopilotSessionWrapper(mockSession as unknown as CopilotSession)); + const events: string[] = []; + disposables.add(wrapper.onUnhandledEvent(e => events.push(e.type))); + + mockSession.fire('session.compaction_start', {} as SessionEventPayload<'session.compaction_start'>['data']); + + assert.deepStrictEqual(events, ['session.compaction_start']); + }); + + test('tracks wrapped listener registrations dynamically', () => { + const mockSession = new MockCopilotSession(); + const wrapper = disposables.add(new CopilotSessionWrapper(mockSession as unknown as CopilotSession)); + const events: string[] = []; + disposables.add(wrapper.onUnhandledEvent(e => events.push(e.type))); + const handledListener = wrapper.onSessionCompactionStart(() => { }); + + mockSession.fire('session.compaction_start', {} as SessionEventPayload<'session.compaction_start'>['data']); + handledListener.dispose(); + mockSession.fire('session.compaction_start', {} as SessionEventPayload<'session.compaction_start'>['data']); + + assert.deepStrictEqual(events, ['session.compaction_start']); + }); + }); + + test('logs SDK events without wrapped handlers', async () => { + const logService = new CapturingLogService(); + const { mockSession } = await createAgentSession(disposables, { logService }); + + mockSession.fire('session.title_changed', { title: 'A new title' } as SessionEventPayload<'session.title_changed'>['data'], { + ephemeral: true, + id: 'evt-title', + timestamp: '2026-06-24T00:00:00.000Z', + }); + + assert.deepStrictEqual( + logService.traces.filter(t => t.message.includes('Unhandled SDK event')).map(t => t.message), + ['[Copilot:test-session-1] Unhandled SDK event: {"type":"session.title_changed","data":{"title":"A new title"},"id":"evt-title","timestamp":"2026-06-24T00:00:00.000Z","parentId":null,"ephemeral":true}'] + ); + }); + test('maps internal attachment URIs to Copilot SDK path fields', async () => { const fileUri = URI.file('/workspace/file.ts'); const selectionUri = URI.file('/workspace/selection.ts'); @@ -465,6 +530,25 @@ suite('CopilotAgentSession', () => { }]); }); + test('memoizes the event reconstruction across getMessages/getSubagentMessages and invalidates on log changes', async () => { + const { session, mockSession } = await createAgentSession(disposables); + let getEventsCalls = 0; + mockSession.getEvents = async () => { getEventsCalls++; return mockSession.messages; }; + + // A single resume wave reads + reconstructs the event log once, shared + // by the parent turns and every subagent lookup. + await session.getMessages(); + await session.getSubagentMessages('tc-x'); + await session.getMessages(); + assert.strictEqual(getEventsCalls, 1, 'event log should be read once for the whole resume wave'); + + // A log-mutating event drops the memo so a later read rebuilds from + // fresh events instead of serving stale turns. + mockSession.fire('assistant.turn_end', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_end'>['data']); + await session.getMessages(); + assert.strictEqual(getEventsCalls, 2, 'memo should be invalidated after the event log changes'); + }); + test('falls back to file reference when reading a symbol Resource attachment fails', async () => { const symbolUri = URI.file('/workspace/missing.ts'); const { session, mockSession } = await createAgentSession(disposables, { @@ -574,6 +658,52 @@ suite('CopilotAgentSession', () => { }]); }); + test('forwards an embedded resource with a selection as its already-sliced inline blob', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + // The handler inlines only the selected text into `data`, so the adapter forwards it verbatim (no re-slicing). + await session.send('what is the selected word?', [{ + type: MessageAttachmentKind.EmbeddedResource, + label: 'file:test.js', + displayKind: 'selection', + data: encodeBase64(VSBuffer.fromString('world')), + contentType: 'text/plain', + selection: { range: { start: { line: 1, character: 6 }, end: { line: 1, character: 11 } } }, + }]); + + assert.deepStrictEqual(mockSession.sendRequests, [{ + prompt: 'what is the selected word?', + attachments: [{ + type: 'blob', + data: encodeBase64(VSBuffer.fromString('world')), + mimeType: 'text/plain', + displayName: 'file:test.js', + }], + }]); + }); + + test('sends an embedded resource without a selection as the full blob', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('what is in this file?', [{ + type: MessageAttachmentKind.EmbeddedResource, + label: 'file:test.js', + displayKind: 'document', + data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')), + contentType: 'text/plain', + }]); + + assert.deepStrictEqual(mockSession.sendRequests, [{ + prompt: 'what is in this file?', + attachments: [{ + type: 'blob', + data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')), + mimeType: 'text/plain', + displayName: 'file:test.js', + }], + }]); + }); + test('sends paste simple attachments as text blobs', async () => { const { session, mockSession } = await createAgentSession(disposables); @@ -652,6 +782,52 @@ suite('CopilotAgentSession', () => { assert.strictEqual((turnComplete as ChatTurnCompleteAction).turnId, 'turn-compact'); }); + test('`/compact` reports post-compaction context window usage before completing the turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.compactResult = { success: true, tokensRemoved: 1200, messagesRemoved: 3, contextWindow: { currentTokens: 4500, tokenLimit: 128000, messagesLength: 7 } }; + + await session.send('/compact', undefined, 'turn-compact'); + + const actions = getActions(signals); + const usage = actions.find(a => a.type === ActionType.ChatUsage) as ChatUsageAction | undefined; + assert.ok(usage, 'expected a usage action reporting the shrunken context window'); + assert.deepStrictEqual({ turnId: usage.turnId, usage: usage.usage }, { + turnId: 'turn-compact', + usage: { inputTokens: 4500, outputTokens: 0, model: undefined }, + }); + // Usage must precede the turn completion so the reducer accepts it. + const usageIndex = actions.findIndex(a => a.type === ActionType.ChatUsage); + const completeIndex = actions.findIndex(a => a.type === ActionType.ChatTurnComplete); + assert.ok(usageIndex >= 0 && completeIndex > usageIndex, 'usage emitted before turn complete'); + }); + + test('`/compact` skips usage when the SDK omits the context window', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.compactResult = { success: true, tokensRemoved: 0, messagesRemoved: 0 }; + + await session.send('/compact', undefined, 'turn-compact'); + + const usage = getActions(signals).find(a => a.type === ActionType.ChatUsage); + assert.strictEqual(usage, undefined, 'no usage action without a context window'); + }); + + test('`/compact` carries the last-seen model id on the post-compaction usage', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + // A prior model call sets `_lastSeenModelId`, which the compaction usage + // reports so the context-usage widget can resolve the context window. + mockSession.fire('assistant.usage', { + inputTokens: 10, + outputTokens: 20, + model: 'claude-sonnet-4.6', + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + mockSession.compactResult = { success: true, tokensRemoved: 1200, messagesRemoved: 3, contextWindow: { currentTokens: 4500, tokenLimit: 128000, messagesLength: 7 } }; + + await session.send('/compact', undefined, 'turn-compact'); + + const usage = getActions(signals).reverse().find(a => a.type === ActionType.ChatUsage) as ChatUsageAction | undefined; + assert.deepStrictEqual(usage?.usage, { inputTokens: 4500, outputTokens: 0, model: 'claude-sonnet-4.6' }); + }); + test('`/compact` completes the turn even when compaction reports failure', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); mockSession.compactResult = { success: false, tokensRemoved: 0, messagesRemoved: 0 }; @@ -723,7 +899,7 @@ suite('CopilotAgentSession', () => { .filter(a => a.type === ActionType.ChatTurnComplete) .map(a => (a as ChatTurnCompleteAction).turnId), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [{ name: 'env' }], sendRequests: [], responseParts: [{ kind: ResponsePartKind.Markdown, content: '## Environment\n\nLoaded.' }], @@ -765,7 +941,7 @@ suite('CopilotAgentSession', () => { responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [], sendRequests: [{ prompt: '/env', attachments: undefined }], responseParts: [], @@ -773,6 +949,82 @@ suite('CopilotAgentSession', () => { }); }); + test('`/env` forwards trailing text as runtime command input', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { + commands: [{ + name: 'env', + kind: 'builtin', + description: 'Show loaded environment details', + allowDuringAgentExecution: true, + }], + }; + mockSession.commandInvokeResult = { kind: 'completed', message: 'done' }; + + await session.send('/env details please', undefined, 'turn-env-input'); + + const actions = getActions(signals); + assert.deepStrictEqual({ + commandListCalls: mockSession.commandListCalls, + commandInvokeCalls: mockSession.commandInvokeCalls, + sendRequests: mockSession.sendRequests, + responseParts: actions + .filter(a => a.type === ActionType.ChatResponsePart) + .map(a => { + const part = (a as ChatResponsePartAction).part; + return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; + }), + turnComplete: actions + .filter(a => a.type === ActionType.ChatTurnComplete) + .map(a => (a as ChatTurnCompleteAction).turnId), + }, { + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], + commandInvokeCalls: [{ name: 'env', input: 'details please' }], + sendRequests: [], + responseParts: [{ kind: ResponsePartKind.Markdown, content: 'done' }], + turnComplete: ['turn-env-input'], + }); + }); + + test('invokes non-local runtime slash commands via commands API', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { + commands: [{ + name: 'focus', + aliases: ['f'], + kind: 'builtin', + description: 'Focus on a scope', + allowDuringAgentExecution: true, + input: { hint: 'scope' }, + }], + }; + mockSession.commandInvokeResult = { kind: 'completed', message: 'Focus done' }; + + await session.send('/f src/vs/platform', undefined, 'turn-focus'); + + const actions = getActions(signals); + assert.deepStrictEqual({ + commandListCalls: mockSession.commandListCalls, + commandInvokeCalls: mockSession.commandInvokeCalls, + sendRequests: mockSession.sendRequests, + responseParts: actions + .filter(a => a.type === ActionType.ChatResponsePart) + .map(a => { + const part = (a as ChatResponsePartAction).part; + return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; + }), + turnComplete: actions + .filter(a => a.type === ActionType.ChatTurnComplete) + .map(a => (a as ChatTurnCompleteAction).turnId), + }, { + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], + commandInvokeCalls: [{ name: 'focus', input: 'src/vs/platform' }], + sendRequests: [], + responseParts: [{ kind: ResponsePartKind.Markdown, content: 'Focus done' }], + turnComplete: ['turn-focus'], + }); + }); + test('caches runtime slash command availability across checks', async () => { const { session, mockSession } = await createAgentSession(disposables); mockSession.commandListResult = { @@ -806,13 +1058,23 @@ suite('CopilotAgentSession', () => { }, { env: true, review: true, - skill: false, - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], + skill: true, + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], }); }); - test('`/review` forwards as a prompt-invoked command', async () => { + test('`/review` invokes runtime command when listed', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { + commands: [{ + name: 'review', + kind: 'builtin', + description: 'Run code review agent to analyze changes', + allowDuringAgentExecution: true, + input: { hint: 'scope' }, + }], + }; + mockSession.commandInvokeResult = { kind: 'completed', message: 'Review done' }; await session.send('/review focus on tests', undefined, 'turn-review'); @@ -820,19 +1082,27 @@ suite('CopilotAgentSession', () => { commandListCalls: mockSession.commandListCalls, commandInvokeCalls: mockSession.commandInvokeCalls, sendRequests: mockSession.sendRequests, - responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), - turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), + responseParts: getActions(signals) + .filter(a => a.type === ActionType.ChatResponsePart) + .map(a => { + const part = (a as ChatResponsePartAction).part; + return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; + }), + turnComplete: getActions(signals) + .filter(a => a.type === ActionType.ChatTurnComplete) + .map(a => (a as ChatTurnCompleteAction).turnId), }, { - commandListCalls: [], - commandInvokeCalls: [], - sendRequests: [{ prompt: '/review focus on tests', attachments: undefined }], - responseParts: [], - turnComplete: [], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], + commandInvokeCalls: [{ name: 'review', input: 'focus on tests' }], + sendRequests: [], + responseParts: [{ kind: ResponsePartKind.Markdown, content: 'Review done' }], + turnComplete: ['turn-review'], }); }); - test('`/security-review` forwards as a prompt-invoked command', async () => { + test('`/security-review` falls through to normal send when runtime command is unavailable', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [] }; await session.send('/security-review', undefined, 'turn-security-review'); @@ -843,7 +1113,7 @@ suite('CopilotAgentSession', () => { responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), }, { - commandListCalls: [], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [], sendRequests: [{ prompt: '/security-review', attachments: undefined }], responseParts: [], @@ -901,6 +1171,66 @@ suite('CopilotAgentSession', () => { ]); }); + test('reports the parent turn aggregate and additionally the per-subagent component', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-1'); + + // Map the subagent's agentId to its parent tool call id. + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + + // Parent agent usage (no agentId) only contributes to the parent aggregate. + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.8', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + // Subagent usage (its agentId) is reported twice: folded into the parent + // aggregate AND emitted to the subagent's child session as its component. + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + copilotUsage: { totalNanoAiu: 200_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 6, + outputTokens: 8, + copilotUsage: { totalNanoAiu: 300_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + + const usageSignals = signals.flatMap(signal => { + if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatUsage) { + return []; + } + return [{ + parentToolCallId: signal.parentToolCallId, + model: signal.action.usage.model, + totalNanoAiu: (signal.action.usage._meta as UsageInfoMeta | undefined)?.copilotUsage?.totalNanoAiu, + }]; + }); + + assert.deepStrictEqual(usageSignals, [ + // Parent-only call → parent aggregate. + { parentToolCallId: undefined, model: 'claude-opus-4.8', totalNanoAiu: 500_000_000 }, + // First subagent call → parent aggregate grows, plus the subagent component. + { parentToolCallId: undefined, model: 'gpt-5.5', totalNanoAiu: 700_000_000 }, + { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', totalNanoAiu: 200_000_000 }, + // Second subagent call → parent aggregate grows, plus the subagent component. + { parentToolCallId: undefined, model: 'gpt-5.5', totalNanoAiu: 1_000_000_000 }, + { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', totalNanoAiu: 500_000_000 }, + ]); + }); + test('forwards account quota snapshots on usage metadata', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); @@ -1354,6 +1684,125 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.kind, 'reject'); }); + test('auto-approves sandboxed-by-default shell command without prompting', async () => { + const { runtime, signals } = await createAgentSession(disposables, { + rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + }); + + const result = await runtime.handlePermissionRequest({ + kind: 'shell', + toolCallId: 'tc-sandboxed', + fullCommandText: 'cat ~/something.txt', + }); + + assert.strictEqual(result.kind, 'approve-once'); + assert.strictEqual(signals.length, 0); + }); + + test('does not auto-approve a shell command that opted out of the sandbox', async () => { + const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables, { + rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + }); + + const resultPromise = runtime.handlePermissionRequest({ + kind: 'shell', + toolCallId: 'tc-sandboxbypass', + fullCommandText: 'cat ~/something.txt', + requestSandboxBypass: true, + }); + + // Must fall through to the normal confirmation flow rather than + // auto-approving, since the command escapes the sandbox. + await waitForSignal(s => s.kind === 'pending_confirmation'); + assert.strictEqual(signals.length, 1); + assert.ok(session.respondToPermissionRequest('tc-sandboxbypass', true)); + const result = await resultPromise; + assert.strictEqual(result.kind, 'approve-once'); + }); + + test('per-request sandbox: applies the configured policy under default approvals', async () => { + const { session, mockSession } = await createAgentSession(disposables, { + rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + }); + + await session.send('hello', undefined, 'turn-1'); + + assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { + enabled: true, + allowBypass: true, + userPolicy: { filesystem: {}, network: { allowOutbound: false } }, + }); + }); + + test('per-request sandbox: disabled under session bypass approvals', async () => { + const { session, mockSession } = await createAgentSession(disposables, { + rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }); + + await session.send('hello', undefined, 'turn-1'); + + assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); + }); + + test('per-request sandbox: disabled under autopilot mode', async () => { + const { session, mockSession } = await createAgentSession(disposables, { + rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + configValues: { [SessionConfigKey.Mode]: 'autopilot' }, + }); + + await session.send('hello', undefined, 'turn-1'); + + assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); + }); + + test('per-request sandbox: disabled under global auto-approve', async () => { + const { session, mockSession } = await createAgentSession(disposables, { + rootValues: { + [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }, + [AgentHostGlobalAutoApproveEnabledConfigKey]: true, + }, + }); + + await session.send('hello', undefined, 'turn-1'); + + assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); + }); + + test('per-request sandbox: explicitly disabled on Windows', async () => { + const { session, mockSession } = await createAgentSession(disposables, { + rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + platform: 'win32', + }); + + await session.send('hello', undefined, 'turn-1'); + + assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); + }); + + test('per-request sandbox: explicitly disabled when the sandbox setting is off', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('hello', undefined, 'turn-1'); + + assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); + }); + + test('per-request sandbox: left untouched when the custom terminal tool is enabled', async () => { + const { session, mockSession } = await createAgentSession(disposables, { + rootValues: { + [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }, + [AgentHostConfigKey.EnableCustomTerminalTool]: true, + }, + }); + + await session.send('hello', undefined, 'turn-1'); + + // The host's own terminal sandbox engine handles containment, so the + // SDK sandbox config is not managed in this mode. + assert.deepStrictEqual(mockSession.sandboxConfigUpdates, []); + }); + test('pending permissions are denied on dispose', async () => { const { session, runtime } = await createAgentSession(disposables); const resultPromise = runtime.handlePermissionRequest({ @@ -1540,7 +1989,7 @@ suite('CopilotAgentSession', () => { suite('system.notification', () => { - test('translator handles supported kinds and ignores unsupported kinds', () => { + test('translator handles every notification kind and ignores empty content', () => { const base = { id: 'evt-system', parentId: null, @@ -1557,6 +2006,7 @@ suite('CopilotAgentSession', () => { }), { content: 'Shell done', messageText: '`sleep 6` completed', + startsTurn: true, }); assert.deepStrictEqual(buildCopilotSystemNotification({ @@ -1568,6 +2018,7 @@ suite('CopilotAgentSession', () => { }), { content: 'Detached done', messageText: 'Shell `detached-a` completed', + startsTurn: true, }); assert.deepStrictEqual(buildCopilotSystemNotification({ @@ -1578,13 +2029,62 @@ suite('CopilotAgentSession', () => { }, }), { content: 'Agent done', - messageText: 'Background agent completed', + messageText: 'Background agent agent-a completed', + startsTurn: true, + }); + + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: 'Agent failed', + kind: { type: 'agent_completed', agentId: 'agent-b', agentType: 'task', status: 'failed' }, + }, + }), { + content: 'Agent failed', + messageText: 'Background agent agent-b failed', + startsTurn: true, + }); + + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: '\nAgent idle\n', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, + }, + }), { + content: 'Agent idle', + messageText: 'Background agent agent-a is complete', + startsTurn: true, + }); + + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: 'Inbox message', + kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, + }, + }), { + content: 'Inbox message', + messageText: 'New inbox message from sidekick', + startsTurn: false, + }); + + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: 'Discovered instruction', + kind: { type: 'instruction_discovered', sourcePath: 'packages/billing/AGENTS.md', triggerFile: 'packages/billing/src/index.ts', triggerTool: 'view', description: 'AGENTS.md from packages/billing/' }, + }, + }), { + content: 'Discovered instruction', + messageText: 'Instruction discovered: AGENTS.md from packages/billing/', + startsTurn: false, }); assert.strictEqual(buildCopilotSystemNotification({ ...base, data: { - content: 'Agent idle', + content: ' ', kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, }, }), undefined); @@ -1605,40 +2105,81 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(turnStarted.message, { text: '`sleep 6` completed', origin: { kind: MessageKind.SystemNotification } }); }); - test('routes subsequent SDK events into the generated system turn', async () => { + test('agent idle notification routes resumed SDK events into a generated system turn', async () => { const { mockSession, signals } = await createAgentSession(disposables); mockSession.fire('system.notification', { - content: 'Shell command completed', - kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + content: '\nAgent "agent-a" has finished processing and is now idle.\n', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose', description: 'Investigate the issue' }, } as SessionEventPayload<'system.notification'>['data']); const turnStarted = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)!; mockSession.fire('assistant.message_delta', { - deltaContent: 'Reading the shell output now.', + deltaContent: 'Reading the background agent result now.', } as SessionEventPayload<'assistant.message_delta'>['data']); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - const responsePart = getActions(signals).find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown); - assert.ok(responsePart, 'expected response part for follow-up assistant delta'); - assert.strictEqual((responsePart as ChatResponsePartAction).turnId, (turnStarted as { turnId: string }).turnId); + assert.deepStrictEqual({ + message: turnStarted.message, + responseTurnId: (getActions(signals).find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown) as ChatResponsePartAction | undefined)?.turnId, + completedTurnId: (getActions(signals).find(a => a.type === ActionType.ChatTurnComplete) as ChatTurnCompleteAction | undefined)?.turnId, + }, { + message: { text: 'Background agent agent-a is complete', origin: { kind: MessageKind.SystemNotification } }, + responseTurnId: turnStarted.turnId, + completedTurnId: turnStarted.turnId, + }); }); - test('notification during an active turn appends a SystemNotification response part', async () => { + test('agent idle notification during an active turn appends a SystemNotification response part', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); session.resetTurnState('turn-active'); mockSession.fire('system.notification', { - content: 'Shell command completed', - kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + content: 'Agent "agent-a" has finished processing and is now idle.', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose' }, } as SessionEventPayload<'system.notification'>['data']); const actions = getActions(signals); - assert.strictEqual(actions.find(a => a.type === ActionType.ChatTurnStarted), undefined, 'should not create a duplicate turn'); const systemPart = actions.find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.SystemNotification) as ChatResponsePartAction | undefined; - assert.ok(systemPart, 'expected system notification response part'); - assert.strictEqual(systemPart.turnId, 'turn-active'); - assert.strictEqual(systemPart.part.kind, ResponsePartKind.SystemNotification); - assert.strictEqual(systemPart.part.content, 'Shell command completed'); + assert.deepStrictEqual({ + turnStarted: actions.find(a => a.type === ActionType.ChatTurnStarted), + turnId: systemPart?.turnId, + part: systemPart?.part, + }, { + turnStarted: undefined, + turnId: 'turn-active', + part: { + kind: ResponsePartKind.SystemNotification, + content: 'Agent "agent-a" has finished processing and is now idle.', + }, + }); + }); + + test('passive notifications render only within an active turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + mockSession.fire('system.notification', { + content: 'Inbox from sidekick', + kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, + } as SessionEventPayload<'system.notification'>['data']); + assert.deepStrictEqual(getActions(signals), []); + + session.resetTurnState('turn-active'); + mockSession.fire('system.notification', { + content: 'Inbox from sidekick', + kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, + } as SessionEventPayload<'system.notification'>['data']); + mockSession.fire('system.notification', { + content: 'Discovered instruction', + kind: { type: 'instruction_discovered', sourcePath: 'packages/billing/AGENTS.md', triggerFile: 'packages/billing/src/index.ts', triggerTool: 'view', description: 'AGENTS.md from packages/billing/' }, + } as SessionEventPayload<'system.notification'>['data']); + + assert.deepStrictEqual(getActions(signals) + .filter(action => action.type === ActionType.ChatResponsePart) + .map(action => action.part), [ + { kind: ResponsePartKind.SystemNotification, content: 'Inbox from sidekick' }, + { kind: ResponsePartKind.SystemNotification, content: 'Discovered instruction' }, + ]); }); test('generated system turn completes on session.idle', async () => { @@ -1755,123 +2296,35 @@ suite('CopilotAgentSession', () => { } }); - test('live tool_complete emits languageModelToolInvoked telemetry', async () => { - const telemetryService = new RecordingTelemetryService(); - const { mockSession } = await createAgentSession(disposables, { telemetryService }); + test('live tool_complete preserves SDK shell_exit content', async () => { + const { mockSession, signals } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', { - toolCallId: 'tc-bash-telemetry', + toolCallId: 'tc-shell-exit', toolName: 'bash', - arguments: { command: 'npm test' }, + arguments: { command: 'gti status' }, } as SessionEventPayload<'tool.execution_start'>['data']); mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-bash-telemetry', + toolCallId: 'tc-shell-exit', success: true, - result: { content: 'passed' }, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-mcp-telemetry', - toolName: 'mcp_tool', - arguments: {}, - mcpServerName: 'test-server', - mcpToolName: 'lookup', - } as SessionEventPayload<'tool.execution_start'>['data']); - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-mcp-telemetry', - success: false, - error: { code: 'denied', message: 'denied' }, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - const normalizedEvents = telemetryService.events.map(event => { - const data = event.data as { - result: string; - chatSessionId: string | undefined; - toolId: string; - toolExtensionId: string | undefined; - toolSourceKind: string; - invocationTimeMs?: number; - }; - return { - eventName: event.eventName, - data: { - ...data, - invocationTimeMs: typeof data.invocationTimeMs === 'number' && data.invocationTimeMs >= 0, - }, - }; - }); - - assert.deepStrictEqual(normalizedEvents, [ - { - eventName: 'languageModelToolInvoked', - data: { - result: 'success', - chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), - toolId: 'bash', - toolExtensionId: undefined, - toolSourceKind: 'agentHost', - invocationTimeMs: true, - }, + result: { + content: 'command not found\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 127, cwd: '/repo' }], }, - { - eventName: 'languageModelToolInvoked', - data: { - result: 'userCancelled', - chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), - toolId: 'mcp_tool', - toolExtensionId: undefined, - toolSourceKind: 'mcp', - invocationTimeMs: true, - }, - }, - ]); - }); - - test('client tool telemetry does not use clientId as toolExtensionId', async () => { - const telemetryService = new RecordingTelemetryService(); - const tools = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }] as const; - const activeClientState = new ActiveClientState(); - activeClientState.update('test-client', tools); - const { mockSession } = await createAgentSession(disposables, { - telemetryService, - clientSnapshot: { - tools, - plugins: [], - mcpServers: {}, - }, - activeClientState, - }); - - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-client-telemetry', - toolName: 'my_tool', - arguments: {}, - } as SessionEventPayload<'tool.execution_start'>['data']); - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-client-telemetry', - success: true, - result: { content: 'done' }, } as SessionEventPayload<'tool.execution_complete'>['data']); - const [event] = telemetryService.events; - assert.deepStrictEqual({ - eventName: event.eventName, - data: { - ...(event.data as object), - invocationTimeMs: typeof (event.data as { invocationTimeMs?: number }).invocationTimeMs === 'number', - }, - }, { - eventName: 'languageModelToolInvoked', - data: { - result: 'success', - chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), - toolId: 'my_tool', - toolExtensionId: undefined, - toolSourceKind: 'client', - invocationTimeMs: true, - }, - }); - + assert.strictEqual(signals.length, 3); + const completeSignal = signals[2]; + assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); + if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { + const action = completeSignal.action as ChatToolCallCompleteAction; + assert.strictEqual(action.result.success, true); + assert.deepStrictEqual(action.result.content, [ + { type: ToolResultContentType.Text, text: 'command not found\n' }, + { type: ToolResultContentType.ShellExit, shellId: '0', exitCode: 127, cwd: '/repo' }, + ]); + assert.ok(!action.result.content?.some(content => content.type === ToolResultContentType.Terminal)); + } }); test('live task_complete emits root markdown instead of a tool call', async () => { @@ -2028,28 +2481,30 @@ suite('CopilotAgentSession', () => { assert.strictEqual(signals.length, 0); }); - test('report_intent surfaces as session activity', async () => { + test('assistant.intent surfaces as session activity', async () => { const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-intent-1', - toolName: 'report_intent', - arguments: { intent: 'Reading repo docs' }, - } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('assistant.intent', { intent: 'Reading repo docs' }); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - assert.strictEqual(signals.length, 1); - const signal = signals[0]; - assert.ok(isAction(signal, ActionType.SessionActivityChanged)); - if (isAction(signal, ActionType.SessionActivityChanged)) { - assert.strictEqual((signal.action as { activity: string | undefined }).activity, 'Reading repo docs'); - } + assert.deepStrictEqual(signals + .filter(signal => isAction(signal, ActionType.SessionActivityChanged)) + .map(signal => (signal.action as { activity: string | undefined }).activity), [ + 'Reading repo docs', + undefined, + ]); + }); - // Going idle clears the activity. - mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - const clearSignal = signals.find((s, i) => i > 0 && isAction(s, ActionType.SessionActivityChanged)); - assert.ok(clearSignal, 'expected activity to be cleared on idle'); - if (clearSignal && isAction(clearSignal, ActionType.SessionActivityChanged)) { - assert.strictEqual((clearSignal.action as { activity: string | undefined }).activity, undefined); - } + test('assistant.intent clears session activity', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + mockSession.fire('assistant.intent', { intent: 'Reading repo docs' }); + mockSession.fire('assistant.intent', { intent: '' }); + + assert.deepStrictEqual(signals + .filter(signal => isAction(signal, ActionType.SessionActivityChanged)) + .map(signal => (signal.action as { activity: string | undefined }).activity), [ + 'Reading repo docs', + undefined, + ]); }); test('tool_complete event produces past-tense message', async () => { @@ -2779,6 +3234,24 @@ suite('CopilotAgentSession', () => { assert.strictEqual(signals.length, 1); assert.ok(isAction(signals[0], ActionType.ChatInputRequested)); }); + + test('auto-reply auto-answers a question without firing a progress event', async () => { + // `chat.autoReply` is forwarded as the autoReplyEnabled root config. + // Even in interactive mode it must short-circuit like autopilot. + const { runtime, signals } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.Mode]: 'interactive' }, + rootValues: { [AgentHostAutoReplyEnabledConfigKey]: true }, + }); + + const result = await runtime.handleUserInputRequest( + { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, + { sessionId: 'test-session-1' } + ); + + assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); + assert.strictEqual(result.wasFreeform, true); + assert.strictEqual(signals.length, 0); + }); }); // ---- elicitation handling ---- @@ -3064,11 +3537,11 @@ suite('CopilotAgentSession', () => { mcpServers: {}, }; - /** Builds a live ActiveClientState seeded with the given owning clientId and the snapshot's tools. */ - const activeClientStateWith = (clientId: string): ActiveClientState => { - const state = new ActiveClientState(); - state.update(clientId, snapshot.tools); - return state; + /** Builds a live ActiveClientToolSet seeded with the given owning clientId and the snapshot's tools. */ + const activeClientToolSetWith = (clientId: string): ActiveClientToolSet => { + const toolSet = new ActiveClientToolSet(); + toolSet.set(clientId, snapshot.tools); + return toolSet; }; test('client tool started with no connected client fails immediately', async () => { @@ -3103,7 +3576,7 @@ suite('CopilotAgentSession', () => { test('client tool handler waits for completion without emitting tool_ready', async () => { - const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState: activeClientStateWith('test-client') }); + const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet: activeClientToolSetWith('test-client') }); // SDK emits tool.execution_start — tool_start fires immediately mockSession.fire('tool.execution_start', { @@ -3141,9 +3614,9 @@ suite('CopilotAgentSession', () => { }); test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { - const activeClientState = new ActiveClientState(); - activeClientState.update('client-perm', snapshot.tools); - const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState }); + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('client-perm', snapshot.tools); + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); // SDK emits tool.execution_start — tool_start fires immediately mockSession.fire('tool.execution_start', { @@ -3200,7 +3673,7 @@ suite('CopilotAgentSession', () => { // ChatToolCallReady to the subagent session and emits a // stray ready against the parent session (no preceding // ChatToolCallStart). - const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState: activeClientStateWith('test-client') }); + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet: activeClientToolSetWith('test-client') }); mockSession.fire('subagent.started', { toolCallId: 'tc-parent-subagent', @@ -3448,10 +3921,10 @@ suite('CopilotAgentSession', () => { } }); - test('client tool start stamps the LIVE clientId from the shared ActiveClientState', async () => { - const activeClientState = new ActiveClientState(); - activeClientState.update('client-A', snapshot.tools); - const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState }); + test('client tool start stamps the owning clientId from the shared ActiveClientToolSet', async () => { + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('client-A', snapshot.tools); + const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); mockSession.fire('tool.execution_start', { toolCallId: 'tc-live-1', @@ -3459,8 +3932,10 @@ suite('CopilotAgentSession', () => { arguments: {}, } as SessionEventPayload<'tool.execution_start'>['data']); - // A window reload re-pushes the same tools under a new clientId. - activeClientState.update('client-B', snapshot.tools); + // A window reload removes the old client and re-pushes the same + // tools under a new clientId. + activeClientToolSet.delete('client-A'); + activeClientToolSet.set('client-B', snapshot.tools); mockSession.fire('tool.execution_start', { toolCallId: 'tc-live-2', toolName: 'my_tool', @@ -3542,7 +4017,7 @@ suite('CopilotAgentSession', () => { const tools = runtime.createServerSdkTools(); const result = await invokeClientToolHandler(tools[0], 'tc-server-tool', { foo: 'bar' }); - const sessionUri = AgentSession.uri('copilot', 'test-session-1').toString(); + const sessionUri = buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')); assert.deepStrictEqual(serverToolHost.executions, [{ sessionUri, toolName: tools[0].name, rawArgs: { foo: 'bar' } }]); assert.strictEqual(result.resultType, 'success'); assert.strictEqual(result.textResultForLlm, 'listed 2 comments'); @@ -3621,7 +4096,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(mockSession.sendRequests.length, 1); }); - test('handleExitPlanModeRequest produces a single-select input request with options and recommended', async () => { + test('handleExitPlanModeRequest produces a plan-review input request with fallback question', async () => { const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables); session.resetTurnState('turn-plan'); @@ -3632,9 +4107,37 @@ suite('CopilotAgentSession', () => { const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); const request = getInputRequest(signal); - // The plan summary and "View full plan" link are emitted as a - // markdown response part before the input request, so the - // client renders them inline above the question. + const planReview = (request as ChatInputRequestWithPlanReview).planReview; + assert.deepStrictEqual(planReview, { + title: 'Review Plan', + content: '## Plan summary', + canProvideFeedback: true, + answerQuestionId: request.questions?.[0].id, + planUri: URI.file('/sessions/abc/plan.md').toString(), + actions: [ + { + id: 'autopilot', + label: 'Implement with Autopilot', + description: 'Auto-approve all tool calls and continue until done.', + default: true, + permissionLevel: 'autopilot', + }, + { + id: 'interactive', + label: 'Implement Plan', + description: 'Implement the plan, asking for input and approval for each action.', + }, + { + id: 'exit_only', + label: 'Approve Plan Only', + description: 'Approve the plan without executing it. I will implement it myself.', + }, + ], + }); + + // The summary is now carried by the plan-review payload so the + // renderer can dock the richer plan review widget without duplicating + // the content as a separate markdown response part. const deltaContent = signals.flatMap(s => { if (s.kind !== 'action') { return []; } if (s.action.type === ActionType.ChatResponsePart) { @@ -3646,8 +4149,7 @@ suite('CopilotAgentSession', () => { } return []; }).join(''); - assert.ok(deltaContent.includes('Plan summary'), `expected delta to include plan summary; got: ${deltaContent}`); - assert.ok(deltaContent.includes('plan.md'), 'delta should include a link to the plan file'); + assert.strictEqual(deltaContent, ''); const question = request.questions?.[0]; assert.strictEqual(question?.kind, ChatInputQuestionKind.SingleSelect); @@ -3901,6 +4403,18 @@ suite('CopilotAgentSession', () => { assert.strictEqual(sessionConfigUpdates.length, 0); }); + test('session.mode_changed from a subagent does not update the session config', async () => { + // Sub-agents (e.g. a `task` tool sub-agent running in plan mode) + // emit `session.mode_changed` carrying an `agentId`. These reflect + // the sub-agent's internal mode, not the root session's, and must + // not flip the shared session mode picker (e.g. to Plan) mid-turn. + const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); + + mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'plan' } as SessionEventPayload<'session.mode_changed'>['data'], { agentId: 'subagent-1' }); + + assert.strictEqual(sessionConfigUpdates.length, 0); + }); + // ---- no automatic plan → implementation handoff ------------------- test('handleExitPlanModeRequest always surfaces the plan-review UI, even in autopilot mode', async () => { @@ -3936,6 +4450,66 @@ suite('CopilotAgentSession', () => { suite('MCP server inventory', () => { + test('MCP auth request publishes authRequired state and resolves with authenticate token', async () => { + const { session, runtime, waitForSignal } = await createAgentSession(disposables); + + const authPromise = runtime.handleMcpAuthRequest({ + requestId: 'auth-1', + serverName: 'github', + serverUrl: 'https://mcp.example.com', + reason: 'upscope', + resourceMetadata: JSON.stringify({ + resource: 'https://mcp.example.com', + resource_name: 'Example MCP', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['repo'], + }), + wwwAuthenticateParams: { scope: 'repo issues:write', error: 'insufficient_scope' }, + }, { sessionId: 'test-session-1' }); + + const signal = await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; + const action = signal.action as Extract; + + assert.deepStrictEqual(action.customization, { + type: 'mcpServer', + id: 'mcp-top-level:copilot:test-session-1:github', + uri: 'mcp-top-level:copilot:test-session-1:github', + name: 'github', + enabled: true, + state: { + kind: McpServerStatus.AuthRequired, + reason: McpAuthRequiredReason.InsufficientScope, + resource: { + resource: 'https://mcp.example.com', + resource_name: 'Example MCP', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['repo'], + }, + requiredScopes: ['repo', 'issues:write'], + description: 'insufficient_scope', + }, + channel: undefined, + mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, + }); + + assert.strictEqual(await session.resolveMcpAuthentication({ resource: 'https://mcp.example.com', scopes: ['repo', 'issues:write'], token: 'token-1' }), true); + assert.deepStrictEqual(await authPromise, { kind: 'token', accessToken: 'token-1' }); + }); + + test('needs-auth status remains starting when no auth request details are available', async () => { + const { mockSession, waitForSignal } = await createAgentSession(disposables); + + mockSession.fire('session.mcp_server_status_changed', { + serverName: 'github', + status: 'needs-auth', + } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); + + const signal = await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; + const action = signal.action as Extract; + assert.strictEqual(action.customization.type, 'mcpServer'); + assert.deepStrictEqual(action.customization.state, { kind: McpServerStatus.Starting }); + }); + test('seeds inventory from rpc.mcp.list at subscription time', async () => { const { signals, waitForSignal } = await createAgentSession(disposables, { configureMockSession: m => { diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 00c8f4a005f9b..37a24eea2ac1a 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -12,11 +12,9 @@ import { projectFromCopilotContext, projectFromRepository, resolveGitProject } f class TestAgentHostGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; - insideWorkTree = true; repositoryRoot: URI | undefined; worktreeRoots: URI[] = []; - async isInsideWorkTree(): Promise { return this.insideWorkTree; } async getCurrentBranch(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } async getBranches(): Promise { return []; } @@ -53,6 +51,7 @@ suite('Copilot Git Project', () => { }); test('resolves a repository project from a worktree working directory', async () => { + gitService.repositoryRoot = URI.file('/workspace/worktree-checkout'); gitService.worktreeRoots = [URI.file('/workspace/source-repo')]; const project = await resolveGitProject(URI.file('/workspace/worktree-checkout'), gitService); @@ -81,14 +80,10 @@ suite('Copilot Git Project', () => { }); test('returns undefined outside a git working tree', async () => { - gitService.insideWorkTree = false; - assert.strictEqual(await resolveGitProject(URI.file('/workspace/plain-folder'), gitService), undefined); }); test('falls back to repository context when no git project is available', async () => { - gitService.insideWorkTree = false; - const project = await projectFromCopilotContext({ repository: 'microsoft/vscode' }, gitService); assert.deepStrictEqual({ diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts new file mode 100644 index 0000000000000..5e35c67f22e0a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import type { IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService, IByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; +import { CopilotSessionLauncher, resolveByokSessionConfig } from '../../node/copilot/copilotSessionLauncher.js'; + +/** + * Covers the BYOK provider/model synthesis the launcher feeds into + * `createSession` / `resumeSession`. The first four tests pin the gating and + * graceful-degradation branches plus the exact SDK config shape using a real + * {@link ByokLmBridgeRegistry} and a counting proxy thunk (no real proxy). The + * last test wires the synthesized config straight into a live + * {@link ByokLmProxyService} and POSTs at it, proving the launcher's output is + * consumable end-to-end: provider `baseUrl` + `Bearer .` + + * `model = id` route through the proxy to the renderer bridge. + */ +suite('resolveByokSessionConfig', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + const log = new NullLogService(); + + /** Minimal bridge connection: a scripted `listModels` and an unused `chat`. */ + function connectionOf(listModels: () => Promise) { + return { chat: async (): Promise => ({ content: '' }), listModels }; + } + + /** A fake proxy handle plus a `startProxy` thunk that records its call count. */ + function countingProxy() { + let starts = 0; + const handle: IByokLmProxyHandle = { + baseUrl: 'http://127.0.0.1:1', + nonce: 'NONCE', + providerBaseUrl: vendor => `http://127.0.0.1:1/v/${vendor}`, + dispose: () => { }, + }; + return { + get starts() { return starts; }, + startProxy: async () => { starts++; return handle; }, + }; + } + + test('returns empty and never starts the proxy when no bridge is active', async () => { + const registry = new ByokLmBridgeRegistry(); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('returns empty and never starts the proxy when the bridge reports no models', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => [])); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('returns empty and never starts the proxy when enumeration fails', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => { throw new Error('renderer gone'); })); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('synthesizes deduped providers and per-model config from the active bridge', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => [ + { vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 200000 }, + { vendor: 'acme', id: 'gpt', name: undefined, maxContextWindowTokens: undefined }, + { vendor: 'globex', id: 'llama', name: 'Globex Llama' }, + ])); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.strictEqual(proxy.starts, 1); + assert.deepStrictEqual(config, { + providers: [ + { name: 'acme', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/acme', bearerToken: 'NONCE.sess-1' }, + { name: 'globex', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/globex', bearerToken: 'NONCE.sess-1' }, + ], + models: [ + { id: 'claude', provider: 'acme', name: 'Acme Claude', maxContextWindowTokens: 200000 }, + { id: 'gpt', provider: 'acme' }, + { id: 'llama', provider: 'globex', name: 'Globex Llama' }, + ], + }); + }); + + test('synthesized provider config routes through a live proxy to the bridge', async () => { + const registry = new ByokLmBridgeRegistry(); + let captured: IByokLmChatRequest | undefined; + const registration = registry.register('client-1', { + chat: async (request) => { captured = request; return { content: 'hello from byok' }; }, + listModels: async () => [{ vendor: 'acme', id: 'claude' }], + }); + const service = new ByokLmProxyService(log, registry); + let handle: IByokLmProxyHandle | undefined; + + const config = await resolveByokSessionConfig(sessionId, registry, async () => (handle = await service.start()), log); + const provider = config.providers![0]; + const model = config.models![0]; + try { + const response = await fetch(`${provider.baseUrl}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.bearerToken}` }, + body: JSON.stringify({ model: model.id, messages: [{ role: 'user', content: 'hi' }] }), + }); + assert.strictEqual(response.status, 200); + const text = await response.text(); + assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); + } finally { + handle?.dispose(); + registration.dispose(); + service.dispose(); + } + assert.strictEqual(captured?.vendor, 'acme'); + assert.strictEqual(captured?.modelId, 'claude'); + }); +}); + +/** + * Covers the launcher's lazy memoization and disposal of the shared BYOK proxy + * handle: concurrent launches share one bind, and + * {@link CopilotSessionLauncher.disposeByokProxyHandle} (called by the agent + * after the runtime subprocess stops) releases it so the next launch mints a + * fresh nonce. + */ +suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + + /** Minimal bridge connection: a scripted `listModels` and an unused `chat`. */ + function connectionOf(listModels: () => Promise) { + return { chat: async (): Promise => ({ content: '' }), listModels }; + } + + /** A fake proxy service whose handles carry a unique nonce per `start()`. */ + function fakeProxyService() { + let starts = 0; + let disposes = 0; + const service: IByokLmProxyService = { + _serviceBrand: undefined, + start: async (): Promise => { + const nonce = `NONCE-${++starts}`; + return { + baseUrl: 'http://127.0.0.1:1', + nonce, + providerBaseUrl: vendor => `http://127.0.0.1:1/v/${vendor}`, + dispose: () => { disposes++; }, + }; + }, + dispose: () => { }, + }; + return { service, get starts() { return starts; }, get disposes() { return disposes; } }; + } + + function createLauncher(store: DisposableStore, proxy: IByokLmProxyService, registry: IByokLmBridgeRegistry): CopilotSessionLauncher { + const services = new ServiceCollection(); + services.set(ILogService, new NullLogService()); + services.set(IByokLmProxyService, proxy); + services.set(IByokLmBridgeRegistry, registry); + // The launcher's other dependencies are unused by the BYOK path and + // resolve to `undefined` under the non-strict InstantiationService. + const instantiationService = store.add(new InstantiationService(services)); + return instantiationService.createInstance(CopilotSessionLauncher); + } + + test('memoizes the handle, and disposeByokProxyHandle releases it so the next launch mints a fresh nonce', async () => { + const store = new DisposableStore(); + const proxy = fakeProxyService(); + const registry = new ByokLmBridgeRegistry(); + store.add(registry.register('client-1', connectionOf(async () => [{ vendor: 'acme', id: 'claude' }]))); + const launcher = createLauncher(store, proxy.service, registry); + const resolve = () => (launcher as unknown as { _resolveByokSessionConfig(id: string): Promise<{ providers?: { bearerToken: string }[] }> })._resolveByokSessionConfig(sessionId); + + const first = await resolve(); + const second = await resolve(); + assert.strictEqual(proxy.starts, 1, 'subsequent launches share the memoized bind'); + assert.strictEqual(first.providers![0].bearerToken, second.providers![0].bearerToken, 'the shared bind reuses one nonce'); + + await launcher.disposeByokProxyHandle(); + await launcher.disposeByokProxyHandle(); + assert.strictEqual(proxy.disposes, 1, 'the handle is released exactly once and disposal is idempotent'); + + const third = await resolve(); + assert.strictEqual(proxy.starts, 2, 'a fresh bind is minted after disposal'); + assert.notStrictEqual(third.providers![0].bearerToken, first.providers![0].bearerToken, 'the fresh bind carries a new nonce'); + + store.dispose(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 5f6c455d9de0b..113dd7061d44e 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -9,7 +9,7 @@ import { DeferredPromise } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import * as platform from '../../../../base/common/platform.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IEnvironmentService } from '../../../environment/common/environment.js'; @@ -40,11 +40,13 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { commandDetectionSupported = false; readonly commandFinishedListenerRegistered = new DeferredPromise(); private readonly _onCommandFinished = new Emitter(); + private readonly _onData = new Emitter(); private readonly _onExit = new Emitter(); private readonly _onClaimChanged = new Emitter(); private readonly _onDidSendText = new Emitter(); readonly onDidSendText = this._onDidSendText.event; private readonly _altBufferPromises: DeferredPromise[] = []; + private _content: string | undefined; async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { this.created.push({ params, options: { ...options, shell: options?.shell ?? this.defaultShell } }); @@ -57,7 +59,7 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { this.writeInput(uri, formatTerminalText(data, options)); this._onDidSendText.fire(); } - onData(): IDisposable { return Disposable.None; } + onData(_uri: string, cb: (data: string) => void): IDisposable { return this._onData.event(cb); } onExit(_uri: string, cb: (exitCode: number) => void): IDisposable { return this._onExit.event(cb); } onClaimChanged(_uri: string, cb: (claim: TerminalClaim) => void): IDisposable { return this._onClaimChanged.event(cb); } onCommandFinished(_uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable { @@ -77,7 +79,7 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { }); return deferred.p; } - getContent(): string | undefined { return undefined; } + getContent(): string | undefined { return this._content; } getClaim(): TerminalClaim | undefined { return undefined; } hasTerminal(uri: string): boolean { return this.existingTerminalUris.has(uri); } getExitCode(): number | undefined { return undefined; } @@ -87,8 +89,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise { return this.defaultShell; } fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } + fireData(data: string): void { this._onData.fire(data); } fireExit(exitCode: number): void { this._onExit.fire(exitCode); } fireClaimChanged(claim: TerminalClaim): void { this._onClaimChanged.fire(claim); } + setContent(content: string | undefined): void { this._content = content; } fireDidEnterAltBuffer(): void { for (const promise of [...this._altBufferPromises]) { promise.complete(); @@ -386,6 +390,41 @@ suite('CopilotShellTools', () => { assert.match(terminalManager.writes[1].data, /^echo "<<>>"\r$/); }); + test('primary shell tool ignores echoed sentinel command text', async () => { + const { instantiationService, terminalManager } = createServices(); + + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', timeout: 1000 }, + }; + const resultPromise = bashTool.handler!({ command: 'echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', timeout: 1000 }, invocation) as Promise; + await waitForSentTexts(terminalManager, 2); + + const sentinelMatch = terminalManager.writes[1].data.match(/<<>/); + assert.ok(sentinelMatch, 'sentinel marker should be present'); + const sentinelId = sentinelMatch[1]; + const content = [ + ' echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', + 'MOCKED_AGENT_HOST_SANDBOX_RESPONSE', + `echo "<<>>"`, + `<<>>`, + ].join('\r\n'); + terminalManager.setContent(content); + terminalManager.fireData(content); + + const result = await resultPromise; + assert.strictEqual(result.resultType, 'success'); + assert.match(result.textResultForLlm, /Exit code: 0/); + assert.match(result.textResultForLlm, /MOCKED_AGENT_HOST_SANDBOX_RESPONSE/); + }); + test('primary shell tool forces bracketed paste with shell integration', async () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 70d60420a2287..adfffb933e6ee 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -6,9 +6,10 @@ import assert from 'assert'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; import { CompletionItemKind } from '../../common/state/protocol/commands.js'; -import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; -import { CopilotSlashCommandCompletionProvider, parseLeadingSlashCommand } from '../../node/copilot/copilotSlashCommandCompletionProvider.js'; +import { Customization, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageAttachmentKind, type PluginCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; +import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandInfo, parseLeadingSlashCommand } from '../../node/copilot/copilotSlashCommandCompletionProvider.js'; suite('CopilotSlashCommandCompletionProvider', () => { @@ -16,100 +17,97 @@ suite('CopilotSlashCommandCompletionProvider', () => { suite('parseLeadingSlashCommand', () => { test('matches lone /plan', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/plan'), { command: 'plan', rest: '' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/plan'), { command: 'plan', rest: '', rawRest: '' }); }); test('matches lone /compact', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/compact'), { command: 'compact', rest: '' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/compact'), { command: 'compact', rest: '', rawRest: '' }); }); test('matches lone /research', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/research'), { command: 'research', rest: '' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/research'), { command: 'research', rest: '', rawRest: '' }); }); test('captures trailing text after a space for /research', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/research How does React work?'), { command: 'research', rest: 'How does React work?' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/research How does React work?'), { command: 'research', rest: 'How does React work?', rawRest: 'How does React work?' }); }); test('matches lone /rubber-duck', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck'), { command: 'rubber-duck', rest: '' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck'), { command: 'rubber-duck', rest: '', rawRest: '' }); }); test('matches lone /env', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/env'), { command: 'env', rest: '' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/env'), { command: 'env', rest: '', rawRest: '' }); }); test('matches lone /review', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/review'), { command: 'review', rest: '' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/review'), { command: 'review', rest: '', rawRest: '' }); }); test('matches lone /security-review', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/security-review'), { command: 'security-review', rest: '' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/security-review'), { command: 'security-review', rest: '', rawRest: '' }); }); test('captures trailing text after a space for /rubber-duck', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck review my approach'), { command: 'rubber-duck', rest: 'review my approach' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck review my approach'), { command: 'rubber-duck', rest: 'review my approach', rawRest: 'review my approach' }); }); test('captures trailing text after a space for /env', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/env ignored input'), { command: 'env', rest: 'ignored input' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/env ignored input'), { command: 'env', rest: 'ignored input', rawRest: 'ignored input' }); }); test('captures trailing text after a space for /review', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/review focus on tests'), { command: 'review', rest: 'focus on tests' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/review focus on tests'), { command: 'review', rest: 'focus on tests', rawRest: 'focus on tests' }); }); test('captures trailing text after a space for /security-review', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/security-review focus on auth'), { command: 'security-review', rest: 'focus on auth' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/security-review focus on auth'), { command: 'security-review', rest: 'focus on auth', rawRest: 'focus on auth' }); }); - test('rejects /rubber-duck-extra (no separator)', () => { - assert.strictEqual(parseLeadingSlashCommand('/rubber-duck-extra'), undefined); + test('parses arbitrary slash command tokens', () => { + assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck-extra'), { command: 'rubber-duck-extra', rest: '', rawRest: '' }); }); - test('rejects /env-extra (no separator)', () => { - assert.strictEqual(parseLeadingSlashCommand('/env-extra'), undefined); + test('preserves multiline command input as rawRest', () => { + assert.deepStrictEqual(parseLeadingSlashCommand('/foo first line\nsecond line'), { command: 'foo', rest: 'first line\nsecond line', rawRest: 'first line\nsecond line' }); }); - test('rejects /review-extra (no separator)', () => { - assert.strictEqual(parseLeadingSlashCommand('/review-extra'), undefined); - }); - - test('rejects /security-review-extra (no separator)', () => { - assert.strictEqual(parseLeadingSlashCommand('/security-review-extra'), undefined); - }); - - test('rejects /rubber alone (incomplete command)', () => { - assert.strictEqual(parseLeadingSlashCommand('/rubber'), undefined); + test('trims rest while retaining rawRest', () => { + assert.deepStrictEqual(parseLeadingSlashCommand('/foo padded '), { command: 'foo', rest: 'padded', rawRest: 'padded ' }); }); test('captures trailing text after a space', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/plan build a hello world'), { command: 'plan', rest: 'build a hello world' }); + assert.deepStrictEqual(parseLeadingSlashCommand('/plan build a hello world'), { command: 'plan', rest: 'build a hello world', rawRest: 'build a hello world' }); }); test('captures trailing text after a space for /compact', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/compact some text'), { command: 'compact', rest: 'some text' }); - }); - - test('rejects /compact-hello (no separator)', () => { - assert.strictEqual(parseLeadingSlashCommand('/compact-hello world'), undefined); - }); - - test('rejects /plans (longer command)', () => { - assert.strictEqual(parseLeadingSlashCommand('/plans'), undefined); + assert.deepStrictEqual(parseLeadingSlashCommand('/compact some text'), { command: 'compact', rest: 'some text', rawRest: 'some text' }); }); test('rejects leading whitespace', () => { assert.strictEqual(parseLeadingSlashCommand(' /compact'), undefined); }); - test('case-sensitive', () => { - assert.strictEqual(parseLeadingSlashCommand('/PLAN'), undefined); + test('accepts uppercase command tokens', () => { + assert.deepStrictEqual(parseLeadingSlashCommand('/PLAN'), { command: 'PLAN', rest: '', rawRest: '' }); }); }); suite('provideCompletionItems', () => { - const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async () => true }); + const runtimeCommands = [ + { name: 'plan', description: 'Runtime plan', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'task' } }, + { name: 'compact', description: 'Runtime compact', kind: 'builtin' as const, allowDuringAgentExecution: true }, + { name: 'research', description: 'Runtime research', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'query' } }, + { name: 'rubber-duck', description: 'Runtime rubber-duck', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'review prompt' } }, + { name: 'env', description: 'Runtime env', kind: 'builtin' as const, allowDuringAgentExecution: true }, + { name: 'review', description: 'Runtime review', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'scope' } }, + { name: 'security-review', description: 'Runtime security review', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'scope' } }, + ]; + const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => runtimeCommands, + getSessionCustomizations: async () => [], + }); const session = 'copilotcli:/abc'; async function run(text: string, offset = text.length) { @@ -128,32 +126,40 @@ suite('CopilotSlashCommandCompletionProvider', () => { test('returns all items for lone "/"', async () => { const items = await run('/'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/plan task ', '/compact ', '/research ', '/research query ', '/rubber-duck ', '/env ', '/review ', '/security-review ', '/rubber-duck review prompt ', '/security-review scope ', '/review scope '].sort()); }); test('filters to /plan when "/p" typed', async () => { const items = await run('/p'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/plan task ']); }); test('filters to /compact when "/c" typed', async () => { const items = await run('/c'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/compact']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/compact ']); }); test('filters to /env when "/e" typed and runtime command exists', async () => { const items = await run('/e'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/env']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/env ']); }); test('filters to /research and /rubber-duck when "/r" typed', async () => { const items = await run('/r'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/research ', '/rubber-duck ', '/review ']); + assert.deepStrictEqual(items.map(i => i.insertText), [ + '/research ', + '/research query ', + '/review ', + '/review scope ', + '/rubber-duck ', + '/rubber-duck review prompt ' + ].sort()); }); test('filters to /security-review when "/s" typed', async () => { const items = await run('/s'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/security-review ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/security-review ', + '/security-review scope ']); }); test('returns nothing when /word does not match any command prefix', async () => { @@ -174,7 +180,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { test('range covers only the leading slash word', async () => { const items = await run('/p extra text', 2); - assert.strictEqual(items.length, 1); + assert.strictEqual(items.length, 2); assert.strictEqual(items[0].rangeStart, 0); assert.strictEqual(items[0].rangeEnd, 2); }); @@ -182,114 +188,433 @@ suite('CopilotSlashCommandCompletionProvider', () => { test('attachment is Simple with command + description meta', async () => { const items = await run('/'); assert.deepStrictEqual(items.map(item => ({ type: item.attachment?.type, meta: item.attachment?._meta })), [ + { + type: 'simple', + meta: { + command: 'compact', + description: 'Runtime compact' + } + }, + { + type: 'simple', + meta: { + command: 'env', + description: 'Runtime env' + } + }, { type: MessageAttachmentKind.Simple, meta: { command: 'plan', - description: 'Create an implementation plan before coding', + description: 'Runtime plan', }, }, { type: MessageAttachmentKind.Simple, meta: { - command: 'compact', - description: 'Free up context by compacting the conversation history', + command: 'plan', + description: 'Runtime plan', }, }, { type: MessageAttachmentKind.Simple, meta: { command: 'research', - description: 'Run deep research on a topic using search and web sources', + description: 'Runtime research', }, }, { type: MessageAttachmentKind.Simple, meta: { - command: 'rubber-duck', - description: 'Get an independent critique of the current approach', + command: 'research', + description: 'Runtime research', }, }, { type: MessageAttachmentKind.Simple, meta: { - command: 'env', - description: 'Show loaded environment details', + command: 'review', + description: 'Runtime review', }, }, { type: MessageAttachmentKind.Simple, meta: { command: 'review', - description: 'Run code review agent to analyze changes', + description: 'Runtime review', }, }, + { + type: 'simple', + meta: { + command: 'rubber-duck', + description: 'Runtime rubber-duck' + } + }, + { + type: 'simple', + meta: { + command: 'rubber-duck', + description: 'Runtime rubber-duck' + } + }, + { + type: 'simple', + meta: { + command: 'security-review', + description: 'Runtime security review' + } + }, { type: MessageAttachmentKind.Simple, meta: { command: 'security-review', - description: 'Analyze staged and unstaged changes for security vulnerabilities', + description: 'Runtime security review', }, }, ]); }); - test('omits /compact when session has no history', async () => { - const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => false, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async () => true }); + test('omits /rubber-duck when not enabled', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => false, + getRuntimeSlashCommands: async () => runtimeCommands, + getSessionCustomizations: async () => [], + }); + const items = await gated.provideCompletionItems({ + kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, + }, CancellationToken.None); + assert.deepStrictEqual(items.map(i => i.insertText), [ + '/compact ', + '/env ', + '/plan ', + '/plan task ', + '/research ', + '/research query ', + '/review ', + '/review scope ', + '/security-review ', + '/security-review scope ' + ].sort()); + }); + + test('returns no completion items when runtime command list is empty', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => [], + getSessionCustomizations: async () => [], + }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, }, CancellationToken.None); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); + assert.deepStrictEqual(items, []); }); - test('omits /rubber-duck when not enabled', async () => { - const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => false, hasRuntimeSlashCommand: async () => true }); + test('filters out runtime commands omitted from the catalog', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => runtimeCommands.filter(command => command.name !== 'env'), + getSessionCustomizations: async () => [], + }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, }, CancellationToken.None); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/env', '/review ', '/security-review ']); + assert.deepStrictEqual(items.map(i => i.insertText), [ + '/compact ', + '/plan ', + '/plan task ', + '/research ', + '/research query ', + '/review ', + '/review scope ', + '/rubber-duck ', + '/rubber-duck review prompt ', + '/security-review ', + '/security-review scope ', + ].sort()); + }); + + test('includes runtime SDK commands in completion results', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => [{ + name: 'focus', + description: 'Focus on specific files', + kind: 'builtin', + allowDuringAgentExecution: true, + input: { hint: 'scope' }, + }], + getSessionCustomizations: async () => [], + }); + const items = await gated.provideCompletionItems({ + kind: CompletionItemKind.UserMessage, channel: session, text: '/f', offset: 2, + }, CancellationToken.None); + assert.deepStrictEqual(items.map(i => i.insertText), ['/focus ', '/focus scope ']); }); - test('omits /env when runtime command is unavailable', async () => { - const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async (_id, command) => command !== 'env' }); + test('keeps runtime commands that also have local send-time handling', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => [ + { name: 'plan', description: 'runtime plan', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'task' } }, + { name: 'compact', description: 'runtime compact', kind: 'builtin', allowDuringAgentExecution: true }, + { name: 'runtime-only', description: 'runtime only', kind: 'client', allowDuringAgentExecution: true }, + ], + getSessionCustomizations: async () => [], + }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, }, CancellationToken.None); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/review ', '/security-review ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact ', '/runtime-only ', '/plan task '].sort()); + }); + + test('uses runtime input metadata to determine trailing space insertion', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => [ + { name: 'no-input', description: 'No input', kind: 'builtin', allowDuringAgentExecution: true }, + { name: 'needs-input', description: 'Needs input', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'value' } }, + ], + getSessionCustomizations: async () => [], + }); + const withInput = await gated.provideCompletionItems({ + kind: CompletionItemKind.UserMessage, channel: session, text: '/n', offset: 2, + }, CancellationToken.None); + assert.deepStrictEqual(withInput.map(i => i.insertText), ['/no-input ', '/needs-input ', '/needs-input value '].sort()); }); - test('keeps prompt-invoked commands when runtime commands are unavailable', async () => { - const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async (_id, command) => command === 'env' }); + test('expands an enumerated hint into one item per option (with brackets)', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => [ + { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '[on|off]' } }, + ], + getSessionCustomizations: async () => [], + }); const items = await gated.provideCompletionItems({ - kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, + kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, }, CancellationToken.None); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); + // An enumerated hint expands into the bare command plus one item per option. + assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle ', '/toggle on ', '/toggle off '].sort()); }); - test('passes raw session id (no scheme/slash) to hasHistory', async () => { - let seen: string | undefined; + test('expands an enumerated hint into one item per option (without brackets)', async () => { const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { - hasHistory: (id: string) => { seen = id; return true; }, isRubberDuckEnabled: () => true, - hasRuntimeSlashCommand: async () => true, + getRuntimeSlashCommands: async () => [ + { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'on|off' } }, + ], + getSessionCustomizations: async () => [], }); - await gated.provideCompletionItems({ - kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/', offset: 1, + const items = await gated.provideCompletionItems({ + kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, }, CancellationToken.None); - assert.strictEqual(seen, 'abc'); + // An enumerated hint expands into the bare command plus one item per option. + assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle ', '/toggle on ', '/toggle off '].sort()); }); - test('passes raw session id to runtime command availability', async () => { + test('expands an enumerated hint into one item per option (requires input)', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => [ + { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { required: true, hint: '[on|off]' } }, + ], + getSessionCustomizations: async () => [], + }); + const items = await gated.provideCompletionItems({ + kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, + }, CancellationToken.None); + // An enumerated hint expands into the bare command plus one item per option. + assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle on ', '/toggle off '].sort()); + }); + + test('passes raw session id to runtime command listing', async () => { let seen: string | undefined; const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { - hasHistory: () => true, isRubberDuckEnabled: () => true, - hasRuntimeSlashCommand: async (id: string) => { seen = id; return true; }, + getRuntimeSlashCommands: async (id: string) => { + seen = id; + return [{ name: 'focus', kind: 'builtin', description: 'Focus', allowDuringAgentExecution: true }]; + }, + getSessionCustomizations: async () => [], }); await gated.provideCompletionItems({ - kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/e', offset: 2, + kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/f', offset: 2, }, CancellationToken.None); assert.strictEqual(seen, 'abc'); }); }); + + suite('runtime skill completions', () => { + const session = 'copilotcli:/abc'; + + function skill(name: string, description?: string): SkillCustomization { + return { + type: CustomizationType.Skill, + id: `file:///skills/${name}/SKILL.md`, + uri: `file:///skills/${name}/SKILL.md`, + name, + ...(description !== undefined ? { description } : {}), + }; + } + + function plugin(name: string, children?: readonly SkillCustomization[], enabled = true): PluginCustomization { + return { + type: CustomizationType.Plugin, + id: `file:///plugins/${name}`, + uri: `file:///plugins/${name}`, + name, + enabled, + load: { kind: CustomizationLoadStatus.Loaded }, + ...(children ? { children: [...children] } : {}), + }; + } + + function syncedPlugin(name: string, children?: readonly SkillCustomization[]): PluginCustomization { + return { + ...plugin(name, children), + id: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/${name}`, + uri: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/${name}`, + }; + } + + function createProvider(runtimeCommands: readonly ICopilotRuntimeSlashCommandInfo[], customizations: readonly Customization[] = []): CopilotSlashCommandCompletionProvider { + return new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => runtimeCommands, + getSessionCustomizations: async () => customizations, + }); + } + + async function run(provider: CopilotSlashCommandCompletionProvider, text: string, offset = text.length) { + return provider.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text, offset }, CancellationToken.None); + } + + test('includes runtime skills that are not known local skills', async () => { + const provider = createProvider([ + { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('excludes runtime skills that match a known plugin skill (with plugin prefix)', async () => { + const provider = createProvider( + [{ name: 'my-plugin:my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('my-plugin', [skill('my-skill')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items, []); + }); + + test('excludes runtime skills that match a known plugin skill with the same name (no prefix)', async () => { + const provider = createProvider( + [{ name: 'monitor-pr', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('monitor-pr', [skill('monitor-pr')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items, []); + }); + + test('excludes runtime skills that match a known synced plugin skill (no prefix)', async () => { + const provider = createProvider( + [{ name: 'monitor-pr', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [syncedPlugin('skills-bundle', [skill('monitor-pr')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items, []); + }); + + test('includes runtime skills whose name differs from the prefixed known skill candidate', async () => { + // A non-synced plugin skill is known as `my-plugin:my-skill`, so a bare `my-skill` runtime skill is still surfaced. + const provider = createProvider( + [{ name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('my-plugin', [skill('my-skill')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('treats skills inside disabled containers as unknown', async () => { + const provider = createProvider( + [{ name: 'my-plugin:my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('my-plugin', [skill('my-skill')], false)], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-plugin:my-skill ']); + }); + + test('ignores mcp server containers when computing known skills', async () => { + const mcpServer: Customization = { + type: CustomizationType.McpServer, + id: 'file:///mcp/my-skill', + uri: 'file:///mcp/my-skill', + name: 'my-skill', + enabled: true, + state: { kind: McpServerStatus.Ready }, + }; + const provider = createProvider( + [{ name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [mcpServer], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('appends the skill prompt hint to the description', async () => { + const provider = createProvider([ + { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true, input: { hint: 'do stuff' } }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, type: item.attachment?.type, meta: item.attachment?._meta })), [ + { + insertText: '/my-skill ', + type: MessageAttachmentKind.Simple, + meta: { + command: 'my-skill', + description: 'Runtime skill \n(Prompt: do stuff)', + }, + }, + ]); + }); + + test('does not expand a skill hint into option items', async () => { + const provider = createProvider([ + { name: 'toggle-skill', description: 'Toggle skill', kind: 'skill', allowDuringAgentExecution: true, input: { hint: '[on|off]' } }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle-skill ']); + }); + + test('surfaces runtime skills alongside builtins for a leading slash', async () => { + const provider = createProvider([ + { name: 'plan', description: 'Runtime plan', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'task' } }, + { name: 'alpha-skill', description: 'Alpha skill', kind: 'skill', allowDuringAgentExecution: true }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/plan task ', '/alpha-skill '].sort()); + }); + + test('returns only runtime skills for an in-message slash token', async () => { + const provider = createProvider([ + { name: 'plan', description: 'Runtime plan', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'task' } }, + { name: 'runtime-only', description: 'Client command', kind: 'client', allowDuringAgentExecution: true }, + { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, + ]); + const items = await run(provider, 'use /'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('excludes known skills even for an in-message slash token', async () => { + const provider = createProvider( + [ + { name: 'my-plugin:my-skill', description: 'Known skill', kind: 'skill', allowDuringAgentExecution: true }, + { name: 'other-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, + ], + [plugin('my-plugin', [skill('my-skill')])], + ); + const items = await run(provider, 'use /'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/other-skill ']); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts index 4e64f8744e180..bf4b1615bda54 100644 --- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, SessionEvent } from '@github/copilot-sdk'; +import type { Attachment, SessionEvent, ToolExecutionCompleteContent } from '@github/copilot-sdk'; // ============================================================================= // Minimal session-event shapes for tests @@ -37,7 +37,11 @@ export interface ISessionEventToolComplete { data: { toolCallId: string; success: boolean; - result?: { content?: string }; + result?: { + /** `content` is result text; `contents` are typed SDK result blocks such as `shell_exit`. */ + content?: string; + contents?: ToolExecutionCompleteContent[]; + }; error?: { message: string; code?: string }; isUserRequested?: boolean; toolTelemetry?: unknown; @@ -72,6 +76,8 @@ export interface ISessionEventMessage { export interface ISessionEventSkillInvoked { type: 'skill.invoked'; id?: string; + /** Envelope-level sub-agent instance id. */ + agentId?: string; data: { name: string; path?: string; @@ -91,6 +97,15 @@ export interface ISessionEventSubagentStarted { }; } +export interface ISessionEventAbort { + type: 'abort'; + /** Envelope-level sub-agent instance id. */ + agentId?: string; + data: { + reason: string; + }; +} + /** Minimal event shape for session history mapping. */ export type ISessionEvent = | ISessionEventToolStart @@ -98,6 +113,7 @@ export type ISessionEvent = | ISessionEventMessage | ISessionEventSubagentStarted | ISessionEventSkillInvoked + | ISessionEventAbort | { type: string; data?: unknown }; /** diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index 08bcbf9317bf5..db94d9d86825f 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -66,6 +66,11 @@ suite('copilotToolDisplay — friendly tool names', () => { ['tool_search_tool_regex', 'Search Tools'], ['parallel_validation', 'Validate Changes'], ['codeql_checker', 'CodeQL Security Scan'], + ['addComment', 'Add Comment'], + ['listComments', 'List Comments'], + ['deleteComments', 'Delete Comments'], + ['resolveComments', 'Resolve Comments'], + ['viewUnreviewedComments', 'View Comments'], ]; for (const [toolName, displayName] of cases) { @@ -191,6 +196,33 @@ suite('getPermissionDisplay — cd-prefix stripping', () => { assert.strictEqual(display.toolInput, 'dir'); }); + test('confirmation title reflects sandbox bypass for shell requests', () => { + const sandboxed = getPermissionDisplay({ + kind: 'shell', + fullCommandText: 'npm test', + } as ITypedPermissionRequest, wd); + const bypass = getPermissionDisplay({ + kind: 'shell', + fullCommandText: 'npm test', + requestSandboxBypass: true, + } as ITypedPermissionRequest, wd); + + assert.notStrictEqual(bypass.confirmationTitle, sandboxed.confirmationTitle); + assert.ok(/sandbox/i.test(bypass.confirmationTitle), `expected title to mention the sandbox, got: ${bypass.confirmationTitle}`); + }); + + test('confirmation title reflects sandbox bypass for custom-tool shell requests', () => { + const bypass = getPermissionDisplay({ + kind: 'custom-tool', + toolName: 'bash', + args: { command: 'echo hi' }, + requestSandboxBypass: true, + } as ITypedPermissionRequest, wd); + + assert.strictEqual(bypass.permissionKind, 'shell'); + assert.ok(/sandbox/i.test(bypass.confirmationTitle), `expected title to mention the sandbox, got: ${bypass.confirmationTitle}`); + }); + }); suite('getPermissionDisplay — read permission display', () => { @@ -416,6 +448,34 @@ suite('copilotToolDisplay — write_/read_ shell tools', () => { }); }); + suite('feedback comment tools (delegated to the shared server-tool group)', () => { + + function text(msg: ReturnType | ReturnType): string { + return typeof msg === 'string' ? msg : msg.markdown; + } + + // Exhaustive per-tool/count coverage lives in serverToolGroups.test.ts. + // These smoke checks only assert that the Copilot display functions + // delegate to the shared group instead of falling through to the + // generic `Using/Used ""` fallback. + test('Copilot display delegates to the shared group', () => { + const listResult = JSON.stringify({ comments: [{ id: 'a' }, { id: 'b' }] }); + assert.deepStrictEqual({ + displayName: getToolDisplayName('listComments'), + invoke: text(getInvocationMessage('listComments', 'List Comments', undefined)), + past: text(getPastTenseMessage('listComments', 'List Comments', undefined, true, listResult)), + }, { + displayName: 'List Comments', + invoke: 'Checking comments', + past: 'Checked 2 comments', + }); + }); + + test('failed feedback tool still uses the generic failure message', () => { + assert.strictEqual(text(getPastTenseMessage('listComments', 'List Comments', undefined, false)), '"List Comments" failed'); + }); + }); + suite('getToolInputString', () => { test('write_bash extracts command field', () => { diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts new file mode 100644 index 0000000000000..c51568a5f8865 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CustomizationType } from '../../../common/state/protocol/state.js'; +import { buildClaudeBuiltinSkillsContainer, buildSdkBuiltinSkillsContainer } from '../../../node/claude/customizations/claudeBuiltinCommands.js'; + +/** Black-box copy of the (intentionally unexported) built-in URI scheme. */ +const AGENT_BUILTIN_SCHEME = 'agent-builtin'; + +suite('claudeBuiltinCommands', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('builds a read-only Built-in skills container with agent-builtin children', () => { + const container = buildClaudeBuiltinSkillsContainer(new Set()); + assert.ok(container, 'expected a built-in container'); + + // Container is a non-writable Skill directory on the agent-builtin scheme. + assert.strictEqual(container.type, CustomizationType.Directory); + assert.strictEqual(container.contents, CustomizationType.Skill); + assert.strictEqual(container.writable, false); + assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_SCHEME); + + const children = container.children ?? []; + assert.ok(children.length > 0, 'expected built-in skills'); + for (const child of children) { + const uri = URI.parse(child.uri); + assert.strictEqual(child.type, CustomizationType.Skill); + assert.strictEqual(uri.scheme, AGENT_BUILTIN_SCHEME, `child ${child.name} should use the agent-builtin scheme`); + assert.strictEqual(uri.path, `/skill/${child.name}`, `child ${child.name} should be a /skill/ path`); + assert.ok(child.description && child.description.length > 0, `child ${child.name} should have a description`); + } + + // Names are unique. + const names = children.map(c => c.name); + assert.strictEqual(new Set(names).size, names.length, 'built-in command names should be unique'); + }); + + test('curated container excludes built-ins that collide with a discovered disk skill', () => { + const all = buildClaudeBuiltinSkillsContainer(new Set()); + const allNames = (all?.children ?? []).map(c => c.name); + assert.ok(allNames.includes('init'), 'precondition: `init` is a curated built-in'); + + // A disk skill named `init` must suppress the curated built-in of the + // same name (the editable file wins), so it is never duplicated. + const filtered = buildClaudeBuiltinSkillsContainer(new Set(['init'])); + const filteredNames = (filtered?.children ?? []).map(c => c.name); + assert.ok(!filteredNames.includes('init'), '`init` should be excluded when on disk'); + assert.strictEqual(filteredNames.length, allNames.length - 1); + }); + + test('SDK container surfaces only commands not discovered on disk, carrying SDK descriptions', () => { + const commands = [ + { name: 'init', description: 'Initialize the project.', argumentHint: '' }, + { name: 'my-skill', description: 'A user skill on disk.', argumentHint: '' }, + { name: 'compact', description: 'Compact the context.', argumentHint: '' }, + ]; + const container = buildSdkBuiltinSkillsContainer(commands, new Set(['my-skill'])); + assert.ok(container, 'expected a built-in container'); + + assert.strictEqual(container.contents, CustomizationType.Skill); + assert.strictEqual(container.writable, false); + assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_SCHEME); + + const children = container.children ?? []; + // The on-disk skill is excluded; the two genuine runtime built-ins remain. + const summary = children.map(child => { + assert.strictEqual(child.type, CustomizationType.Skill); + const uri = URI.parse(child.uri); + assert.strictEqual(uri.scheme, AGENT_BUILTIN_SCHEME); + assert.strictEqual(uri.path, `/skill/${child.name}`, `child ${child.name} should be a /skill/ path`); + return { name: child.name, description: child.description }; + }); + assert.deepStrictEqual(summary, [ + { name: 'init', description: 'Initialize the project.' }, + { name: 'compact', description: 'Compact the context.' }, + ]); + }); + + test('SDK container is undefined when every command is a known disk skill', () => { + const commands = [{ name: 'a', description: 'A', argumentHint: '' }]; + assert.strictEqual(buildSdkBuiltinSkillsContainer(commands, new Set(['a'])), undefined); + }); + + test('SDK container dedupes commands by name, keeping the first', () => { + const commands = [ + { name: 'dup', description: 'first', argumentHint: '' }, + { name: 'dup', description: 'second', argumentHint: '' }, + ]; + const container = buildSdkBuiltinSkillsContainer(commands, new Set()); + assert.strictEqual(container?.children?.length, 1); + const child = container?.children?.[0]; + assert.ok(child); + assert.strictEqual(child.type, CustomizationType.Skill); + assert.strictEqual(child.description, 'first'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeCustomizationTestUtils.ts b/src/vs/platform/agentHost/test/node/customizations/claudeCustomizationTestUtils.ts new file mode 100644 index 0000000000000..c0b22b023d425 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeCustomizationTestUtils.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { FileService } from '../../../../files/common/fileService.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../log/common/log.js'; + +/** The in-memory project (workspace) root the discovery tests scan. */ +export const claudeTestWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); + +/** The in-memory user-home root the discovery tests scan. */ +export const claudeTestUserHome = URI.from({ scheme: Schemas.inMemory, path: '/home' }); + +/** + * Creates a {@link FileService} backed by an in-memory provider for the + * `inmemory` scheme, registering both with `disposables` for cleanup. + */ +export function createInMemoryFileService(disposables: DisposableStore): IFileService { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + return fileService; +} + +/** Writes `content` to the in-memory `path` and returns its URI. */ +export async function seedFile(fileService: IFileService, path: string, content = ''): Promise { + const uri = URI.from({ scheme: Schemas.inMemory, path }); + await fileService.writeFile(uri, VSBuffer.fromString(content)); + return uri; +} diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts deleted file mode 100644 index 2c2153e5cf85a..0000000000000 --- a/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts +++ /dev/null @@ -1,169 +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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { TestInstantiationService } from '../../../../instantiation/test/common/instantiationServiceMock.js'; -import { FileService } from '../../../../files/common/fileService.js'; -import { IFileService } from '../../../../files/common/files.js'; -import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../../log/common/log.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../../common/agentPluginManager.js'; -import { CustomizationLoadStatus, CustomizationType, type ClientPluginCustomization, type Customization } from '../../../common/state/sessionState.js'; -import type { ISdkResolvedCustomizations } from '../../../node/claude/claudeSdkPipeline.js'; -import { ClaudeSdkCustomizationBundler } from '../../../node/claude/customizations/claudeSdkCustomizationBundler.js'; - -suite('ClaudeSdkCustomizationBundler', () => { - - const disposables = new DisposableStore(); - let fileService: FileService; - let bundler: ClaudeSdkCustomizationBundler; - const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); - const workingDir = URI.file('/work'); - - setup(() => { - fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - - const inst = disposables.add(new TestInstantiationService()); - inst.stub(IFileService, fileService); - inst.stub(IAgentPluginManager, { - basePath, - syncCustomizations: async (_clientId: string, _refs: readonly ClientPluginCustomization[]): Promise => [], - } satisfies Partial as unknown as IAgentPluginManager); - bundler = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, workingDir)); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - function snapshot(overrides: Partial = {}): ISdkResolvedCustomizations { - return { - commands: [], - agents: [], - mcpServers: [], - ...overrides, - }; - } - - test('returns undefined when SDK snapshot has no commands or agents', async () => { - const result = await bundler.bundle(snapshot()); - assert.strictEqual(result, undefined); - }); - - test('writes manifest, agent files, and skill subdirs for a snapshot with agents and commands', async () => { - const result = await bundler.bundle(snapshot({ - agents: [{ name: 'planner', description: 'Plans things', model: 'claude' }], - commands: [{ name: 'doit', description: 'Does it', argumentHint: '' }], - })); - - assert.ok(result, 'should produce a bundle'); - const rootUri = URI.parse(result!.uri); - const manifest = await fileService.readFile(URI.joinPath(rootUri, '.plugin', 'plugin.json')); - const manifestJson = JSON.parse(manifest.value.toString()); - assert.strictEqual(manifestJson.name, 'claude-discovered'); - const agentFile = await fileService.readFile(URI.joinPath(rootUri, 'agents', 'planner.md')); - assert.match(agentFile.value.toString(), /name: "planner"/); - assert.match(agentFile.value.toString(), /description: "Plans things"/); - const skillFile = await fileService.readFile(URI.joinPath(rootUri, 'skills', 'doit', 'SKILL.md')); - assert.match(skillFile.value.toString(), /name: "doit"/); - assert.match(skillFile.value.toString(), /Usage: ``/); - }); - - test('children include agent customizations sourced from the SDK snapshot with on-disk file URIs', async () => { - const result = await bundler.bundle(snapshot({ - agents: [ - { name: 'a1', description: 'one', model: 'm' }, - { name: 'a2', description: 'two', model: 'm' }, - ], - })); - const agents = result!.children!.filter(c => c.type === CustomizationType.Agent); - assert.deepStrictEqual(agents.map(a => a.name), ['a1', 'a2']); - assert.ok(agents[0].uri.endsWith('/agents/a1.md'), `expected on-disk path, got ${agents[0].uri}`); - assert.ok(agents[1].uri.endsWith('/agents/a2.md')); - }); - - test('repeated bundle with same snapshot is nonce-stable and does not rewrite', async () => { - const r1 = await bundler.bundle(snapshot({ - agents: [{ name: 'p', description: 'd', model: 'm' }], - })); - const rootUri = URI.parse(r1!.uri); - const agentUri = URI.joinPath(rootUri, 'agents', 'p.md'); - const stat1 = await fileService.stat(agentUri); - - const r2 = await bundler.bundle(snapshot({ - agents: [{ name: 'p', description: 'd', model: 'm' }], - })); - assert.strictEqual(r1!.id, r2!.id); - const stat2 = await fileService.stat(agentUri); - assert.strictEqual(stat1.mtime, stat2.mtime, 'unchanged snapshot must not rewrite the on-disk tree'); - }); - - test('changed snapshot deletes prior bundle tree before writing the new one', async () => { - await bundler.bundle(snapshot({ - agents: [{ name: 'old', description: 'd', model: 'm' }], - })); - const result = await bundler.bundle(snapshot({ - agents: [{ name: 'new', description: 'd', model: 'm' }], - })); - const rootUri = URI.parse(result!.uri); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'new.md'))); - assert.ok(!(await fileService.exists(URI.joinPath(rootUri, 'agents', 'old.md'))), 'previous agent file should be deleted'); - }); - - test('sanitises agent and command names — invalid chars replaced, length capped, empty falls back to "unnamed"', async () => { - const longName = 'a'.repeat(200); - const result = await bundler.bundle(snapshot({ - agents: [ - { name: 'has spaces & slashes/here', description: 'd', model: 'm' }, - { name: longName, description: 'd', model: 'm' }, - { name: '!!!', description: 'd', model: 'm' }, - ], - })); - const rootUri = URI.parse(result!.uri); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'has_spaces___slashes_here.md'))); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', `${'a'.repeat(128)}.md`))); - assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', '___.md'))); - }); - - test('discoverable bundles for different working directories namespace by hash so they do not collide', async () => { - const inst = disposables.add(new TestInstantiationService()); - inst.stub(IFileService, fileService); - inst.stub(IAgentPluginManager, { - basePath, - } satisfies Partial as unknown as IAgentPluginManager); - const other = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, URI.file('/other-work'))); - - const a = await bundler.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); - const b = await other.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); - assert.notStrictEqual(a!.uri, b!.uri); - }); - - test('returned Customization carries the expected shape (load Loaded, enabled true, name)', async () => { - const result = await bundler.bundle(snapshot({ - agents: [{ name: 'a', description: 'd', model: 'm' }], - commands: [{ name: 'c', description: 'd', argumentHint: '' }], - })); - assert.deepStrictEqual({ - enabled: result!.enabled, - loadKind: result!.load?.kind, - type: result!.type, - }, { - enabled: true, - loadKind: CustomizationLoadStatus.Loaded, - type: CustomizationType.Plugin, - }); - assert.ok(typeof result!.name === 'string' && result!.name.length > 0); - }); - - // Smoke: ensure return type compiles against Customization - function _typeCheck(): Customization | undefined { - return undefined; - } - void _typeCheck; -}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts index 1c7791b42d0a5..0f3ce0d81db72 100644 --- a/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts @@ -39,14 +39,14 @@ suite('SessionClientCustomizationsDiff', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); let fires = 0; disposables.add(diff.onDidChange(() => fires++)); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); assert.strictEqual(diff.hasDifference, true); assert.strictEqual(fires, 1); }); test('enabledPluginPaths excludes entries without pluginDir', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([ + diff.model.setSyncedCustomizations('c1', [ synced('https://a', { dir: '/p/a' }), synced('https://b'), ]); @@ -55,7 +55,7 @@ suite('SessionClientCustomizationsDiff', () => { test('setEnabled(false) removes from enabled paths and flips dirty exactly when value changes', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); diff.consume(); assert.strictEqual(diff.hasDifference, false); @@ -74,13 +74,13 @@ suite('SessionClientCustomizationsDiff', () => { test('default enablement is true (absent entry counts as enabled)', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); assert.strictEqual(diff.model.enabledPluginPaths.get().length, 1); }); test('setEnabled(true) is a no-op for default-enabled entries', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); diff.consume(); let fires = 0; disposables.add(diff.onDidChange(() => fires++)); @@ -91,7 +91,7 @@ suite('SessionClientCustomizationsDiff', () => { test('consume returns current paths and clears dirty', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); const paths = diff.consume(); assert.strictEqual(paths.length, 1); assert.strictEqual(diff.hasDifference, false); @@ -99,7 +99,7 @@ suite('SessionClientCustomizationsDiff', () => { test('markDirty re-flips after failed downstream reload', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); diff.consume(); assert.strictEqual(diff.hasDifference, false); diff.markDirty(); @@ -108,18 +108,18 @@ suite('SessionClientCustomizationsDiff', () => { test('structurally-equivalent re-send is deduped (no fire, no dirty)', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); diff.consume(); let fires = 0; disposables.add(diff.onDidChange(() => fires++)); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); assert.strictEqual(fires, 0); assert.strictEqual(diff.hasDifference, false); }); test('toggling enablement of customization without pluginDir still flips dirty (no-restart optimisation intentionally given up: rebind is cheap and correctness > efficiency)', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a')]); + diff.model.setSyncedCustomizations('c1', [synced('https://a')]); diff.consume(); diff.model.setEnabled(customizationId('https://a'), false); assert.strictEqual(diff.hasDifference, true); @@ -127,20 +127,37 @@ suite('SessionClientCustomizationsDiff', () => { test('nonce change at same URI / pluginDir flips dirty', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', nonce: 'v1' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a', nonce: 'v1' })]); diff.consume(); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', nonce: 'v2' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a', nonce: 'v2' })]); assert.strictEqual(diff.hasDifference, true); }); test('name change at same URI flips dirty (state observable fires for workbench refetch)', () => { const diff = disposables.add(new SessionClientCustomizationsDiff()); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', name: 'A' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a', name: 'A' })]); diff.consume(); let fires = 0; disposables.add(diff.onDidChange(() => fires++)); - diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', name: 'A renamed' })]); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a', name: 'A renamed' })]); assert.strictEqual(fires, 1); assert.strictEqual(diff.hasDifference, true); }); + + test('merges multiple clients and dedupes by id (first client wins); removeClient drops a client', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations('c1', [synced('https://a', { dir: '/p/a' })]); + diff.model.setSyncedCustomizations('c2', [synced('https://b', { dir: '/p/b' })]); + assert.deepStrictEqual( + diff.model.enabledPluginPaths.get().map(u => u.fsPath), + [URI.file('/p/a').fsPath, URI.file('/p/b').fsPath], + ); + + diff.model.removeClient('c1'); + assert.deepStrictEqual( + diff.model.enabledPluginPaths.get().map(u => u.fsPath), + [URI.file('/p/b').fsPath], + ); + }); }); + diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts new file mode 100644 index 0000000000000..d5910532bf2ed --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts @@ -0,0 +1,399 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { CustomizationType, McpServerStatus, type AgentSelection, type DirectoryCustomization, type HookCustomization, type McpServerCustomization } from '../../../common/state/protocol/state.js'; +import { customizationId, type PluginCustomization } from '../../../common/state/sessionState.js'; +import type { ISdkResolvedCustomizations } from '../../../node/claude/claudeSdkPipeline.js'; +import { ClaudeCustomizationWatcher, buildDiscoveredCustomizations, mapDiscoveredCustomizations, resolveClaudeAgentName } from '../../../node/claude/customizations/claudeSessionCustomizationDiscovery.js'; +import { CLAUDE_BUILTIN_AGENTS } from '../../../node/claude/customizations/claudeBuiltinCommands.js'; +import { toParsedAgent, toParsedSkill, type IParsedRule } from '../../../../agentPlugins/common/pluginParsers.js'; +import type { IResolvedNativePlugin } from '../../../node/claude/customizations/scan/claudeNativePluginScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from './claudeCustomizationTestUtils.js'; + +suite('claudeSessionCustomizationDiscovery', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + /** Builds a resolved native plugin with optional bundled skills / agents. */ + const nativePlugin = (id: string, rootPath: string, parts: { skills?: string[]; agents?: string[] } = {}): IResolvedNativePlugin => { + const root = URI.from({ scheme: Schemas.inMemory, path: rootPath }); + return { + id, + root, + parsed: { + hooks: [], + mcpServers: [], + instructions: [], + skills: (parts.skills ?? []).map(name => toParsedSkill({ uri: URI.joinPath(root, 'skills', name, 'SKILL.md'), name })), + agents: (parts.agents ?? []).map(name => toParsedAgent({ uri: URI.joinPath(root, 'agents', `${name}.md`), name })), + }, + }; + }; + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('mapDiscoveredCustomizations', () => { + test('maps discovered entries into per-scope Directory containers with real child URIs + top-level MCP', () => { + const wsAgentUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/agents/wa.md' }); + const wsSkillUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/skills/ws/SKILL.md' }); + const userAgentUri = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/ua.md' }); + const discovered = [ + toParsedAgent({ uri: wsAgentUri, name: 'wa', description: 'WA' }), + toParsedSkill({ uri: wsSkillUri, name: 'ws', description: 'WS' }), + toParsedAgent({ uri: userAgentUri, name: 'ua', description: 'UA' }), + ]; + const mcp: McpServerCustomization[] = [{ type: CustomizationType.McpServer, id: 'mcp-id', uri: 'inmemory:/x', name: 'srv', enabled: true, state: { kind: McpServerStatus.Starting } }]; + + const result = mapDiscoveredCustomizations(discovered, mcp, [], [], workspace, userHome); + + const dirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + // Workspace containers first (agents, skills), then user — each rooted at + // the real `/.claude/` dir so the workbench can label scope. + assert.deepStrictEqual( + dirs.map(d => ({ uri: d.uri, contents: d.contents, children: d.children?.map(c => ({ name: c.name, uri: c.uri })) })), + [ + { uri: URI.joinPath(workspace, '.claude', 'agents').toString(), contents: CustomizationType.Agent, children: [{ name: 'wa', uri: wsAgentUri.toString() }] }, + { uri: URI.joinPath(workspace, '.claude', 'skills').toString(), contents: CustomizationType.Skill, children: [{ name: 'ws', uri: wsSkillUri.toString() }] }, + { uri: URI.joinPath(userHome, '.claude', 'agents').toString(), contents: CustomizationType.Agent, children: [{ name: 'ua', uri: userAgentUri.toString() }] }, + ], + ); + + const server = result.find(c => c.type === CustomizationType.McpServer) as McpServerCustomization; + assert.strictEqual(server.name, 'srv'); + }); + + test('maps rules into per-scope Directory containers rooted at `.claude/rules`', () => { + const wsRuleUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/CLAUDE.md' }); + const userRuleUri = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/rules/g.md' }); + const rule = (uri: URI, name: string): IParsedRule => ({ + uri, + name, + customization: { type: CustomizationType.Rule, id: customizationId(uri.toString()), uri: uri.toString(), name, alwaysApply: true }, + }); + const discovered = [rule(wsRuleUri, 'CLAUDE.md'), rule(userRuleUri, 'g')]; + + const result = mapDiscoveredCustomizations(discovered, [], [], [], workspace, userHome); + + const dirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + assert.deepStrictEqual( + dirs.map(d => ({ uri: d.uri, contents: d.contents, children: d.children?.map(c => ({ name: c.name, uri: c.uri })) })), + [ + { uri: URI.joinPath(workspace, '.claude', 'rules').toString(), contents: CustomizationType.Rule, children: [{ name: 'CLAUDE.md', uri: wsRuleUri.toString() }] }, + { uri: URI.joinPath(userHome, '.claude', 'rules').toString(), contents: CustomizationType.Rule, children: [{ name: 'g', uri: userRuleUri.toString() }] }, + ], + ); + }); + + test('maps hooks into per-scope Directory containers carrying the real settings-file URI', () => { + const wsHookUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/settings.json' }); + const userHookUri = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/settings.json' }); + const hook = (uri: URI): HookCustomization => ({ type: CustomizationType.Hook, id: customizationId(uri.toString()), uri: uri.toString(), name: 'settings.json' }); + + const result = mapDiscoveredCustomizations([], [], [hook(wsHookUri), hook(userHookUri)], [], workspace, userHome); + + const dirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + assert.deepStrictEqual( + dirs.map(d => ({ uri: d.uri, contents: d.contents, children: d.children?.map(c => ({ name: c.name, uri: c.uri })) })), + [ + { uri: URI.joinPath(workspace, '.claude', 'hooks').toString(), contents: CustomizationType.Hook, children: [{ name: 'settings.json', uri: wsHookUri.toString() }] }, + { uri: URI.joinPath(userHome, '.claude', 'hooks').toString(), contents: CustomizationType.Hook, children: [{ name: 'settings.json', uri: userHookUri.toString() }] }, + ], + ); + }); + + test('maps a native plugin into a top-level Plugin container carrying its bundled children', () => { + const plugin = nativePlugin('tg@m', '/home/.claude/plugins/cache/m/tg/1.0.0', { skills: ['send'], agents: ['helper'] }); + + const result = mapDiscoveredCustomizations([], [], [], [plugin], workspace, userHome); + + const plugins = result.filter(c => c.type === CustomizationType.Plugin) as PluginCustomization[]; + assert.deepStrictEqual( + plugins.map(p => ({ uri: p.uri, name: p.name, children: p.children?.map(c => c.name).sort() })), + [{ uri: plugin.root.toString(), name: 'tg@m', children: ['helper', 'send'] }], + ); + }); + }); + + suite('buildDiscoveredCustomizations', () => { + test('post-materialize filter keeps SDK-known disk entries, hides unknown, adds non-editable fallback', () => { + const knownAgent = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/known.md' }); + const hiddenAgent = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/hidden.md' }); + const diskSkill = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/skills/kskill/SKILL.md' }); + const discovered = [ + toParsedAgent({ uri: knownAgent, name: 'known', description: 'K' }), + toParsedAgent({ uri: hiddenAgent, name: 'hidden' }), + toParsedSkill({ uri: diskSkill, name: 'kskill' }), + ]; + const diskMcp: McpServerCustomization = { type: CustomizationType.McpServer, id: 'disk-mcp', uri: 'inmemory:/settings.json', name: 'diskmcp', enabled: true, state: { kind: McpServerStatus.Starting } }; + const sdk: ISdkResolvedCustomizations = { + agents: [{ name: 'known', description: 'K' }, { name: 'sdkonly', description: 'S' }, { name: 'general-purpose', description: 'default' }], + commands: [{ name: 'kskill', description: '', argumentHint: '' }, { name: 'sdkcmd', description: 'C', argumentHint: '' }], + mcpServers: [{ name: 'diskmcp', status: 'connected' }, { name: 'sdkmcp', status: 'failed' }], + plugins: [], + }; + + const result = buildDiscoveredCustomizations(discovered, [diskMcp], [], [], workspace, userHome, sdk); + + // Children are split into per-scope containers; aggregate across them. + const dirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + const agentChildren = dirs.filter(d => d.contents === CustomizationType.Agent).flatMap(d => d.children ?? []); + // Editable disk-skill directories only (exclude the read-only Built-in container). + const skillChildren = dirs.filter(d => d.contents === CustomizationType.Skill && URI.parse(d.uri).scheme !== 'agent-builtin').flatMap(d => d.children ?? []); + const mcps = result.filter(c => c.type === CustomizationType.McpServer) as McpServerCustomization[]; + + // known agent kept with its real URI; hidden dropped; sdkonly added + // non-editable; general-purpose default agent hidden. + assert.deepStrictEqual(agentChildren.map(c => ({ name: c.name, uri: c.uri })).sort((a, b) => a.name.localeCompare(b.name)), [ + { name: 'known', uri: knownAgent.toString() }, + { name: 'sdkonly', uri: 'claude-internal:/agent/sdkonly' }, + ]); + // disk skill kept (real URI); the SDK-only command `sdkcmd` is NOT mixed + // in among the editable disk skills (it surfaces in the Built-in container). + assert.deepStrictEqual(skillChildren.map(c => ({ name: c.name, uri: c.uri })), [ + { name: 'kskill', uri: diskSkill.toString() }, + ]); + // disk MCP kept + enriched to Ready (connected); SDK-only MCP added. + assert.deepStrictEqual(mcps.map(m => ({ name: m.name, state: m.state.kind })).sort((a, b) => a.name.localeCompare(b.name)), [ + { name: 'diskmcp', state: McpServerStatus.Ready }, + { name: 'sdkmcp', state: McpServerStatus.Starting }, + ]); + }); + + test('SDK-only commands surface read-only in the Built-in container, not among editable disk skills', () => { + const diskSkill = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/skills/real/SKILL.md' }); + const discovered = [toParsedSkill({ uri: diskSkill, name: 'real' })]; + const sdk: ISdkResolvedCustomizations = { + agents: [], + // `real` is a loaded disk skill; `init` / `loop` are SDK-only + // built-in slash commands with no editable file. + commands: [ + { name: 'real', description: '', argumentHint: '' }, + { name: 'init', description: 'Initialize', argumentHint: '' }, + { name: 'loop', description: 'Loop', argumentHint: '' }, + ], + mcpServers: [], + plugins: [], + }; + + const result = buildDiscoveredCustomizations(discovered, [], [], [], workspace, userHome, sdk); + const skillDirs = result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]; + + // Editable disk-skill directories contain only the real on-disk skill. + const editableSkills = skillDirs + .filter(d => d.contents === CustomizationType.Skill && URI.parse(d.uri).scheme !== 'agent-builtin') + .flatMap(d => d.children ?? []); + assert.deepStrictEqual(editableSkills.map(c => ({ name: c.name, uri: c.uri })), [{ name: 'real', uri: diskSkill.toString() }]); + + // The SDK-only commands appear in the read-only Built-in container. + const builtin = skillDirs.find(d => URI.parse(d.uri).scheme === 'agent-builtin'); + assert.deepStrictEqual((builtin?.children ?? []).map(c => c.name), ['init', 'loop']); + }); + + test('rules survive the post-materialize SDK filter (no SDK counterpart)', () => { + const ruleUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/CLAUDE.md' }); + const discovered: IParsedRule[] = [{ + uri: ruleUri, + name: 'CLAUDE.md', + customization: { type: CustomizationType.Rule, id: customizationId(ruleUri.toString()), uri: ruleUri.toString(), name: 'CLAUDE.md', alwaysApply: true }, + }]; + const sdk: ISdkResolvedCustomizations = { agents: [], commands: [], mcpServers: [], plugins: [] }; + + const result = buildDiscoveredCustomizations(discovered, [], [], [], workspace, userHome, sdk); + + const ruleChildren = (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .filter(d => d.contents === CustomizationType.Rule) + .flatMap(d => d.children ?? []); + assert.deepStrictEqual(ruleChildren.map(c => ({ name: c.name, uri: c.uri })), [{ name: 'CLAUDE.md', uri: ruleUri.toString() }]); + }); + + test('hooks survive the post-materialize SDK filter (no SDK counterpart)', () => { + const hookUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/settings.json' }); + const hooks: HookCustomization[] = [{ type: CustomizationType.Hook, id: customizationId(hookUri.toString()), uri: hookUri.toString(), name: 'settings.json' }]; + const sdk: ISdkResolvedCustomizations = { agents: [], commands: [], mcpServers: [], plugins: [] }; + + const result = buildDiscoveredCustomizations([], [], hooks, [], workspace, userHome, sdk); + + const hookChildren = (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .filter(d => d.contents === CustomizationType.Hook) + .flatMap(d => d.children ?? []); + assert.deepStrictEqual(hookChildren.map(c => ({ name: c.name, uri: c.uri })), [{ name: 'settings.json', uri: hookUri.toString() }]); + }); + + test('post-materialize keeps native plugins the live session loaded, hides the rest (matched by root path)', () => { + const loaded = nativePlugin('loaded@m', '/home/.claude/plugins/cache/m/loaded/1.0.0', { skills: ['x'] }); + const unloaded = nativePlugin('unloaded@m', '/home/.claude/plugins/cache/m/unloaded/1.0.0', { skills: ['y'] }); + const sdk: ISdkResolvedCustomizations = { agents: [], commands: [], mcpServers: [], plugins: [{ name: 'loaded', path: loaded.root.fsPath }] }; + + const result = buildDiscoveredCustomizations([], [], [], [loaded, unloaded], workspace, userHome, sdk); + + const plugins = result.filter(c => c.type === CustomizationType.Plugin) as PluginCustomization[]; + assert.deepStrictEqual(plugins.map(p => p.name), ['loaded@m']); + }); + + test('post-materialize matches a plugin by SDK `source` (id) even when the reported path is not the resolved root', () => { + // The SDK reports a workspace-`local`-scoped plugin with a path that + // is NOT its cache root (observed: `///`). + // `source` (the `@` id) is the reliable key. + const local = nativePlugin('github-inbox@team-kit', '/home/.claude/plugins/cache/team-kit/github-inbox/1.0.0', { agents: ['inbox'] }); + const sdk: ISdkResolvedCustomizations = { + agents: [], commands: [], mcpServers: [], + plugins: [{ name: 'github-inbox', path: '/some/bogus/team-kit/github-inbox/', source: 'github-inbox@team-kit' }], + }; + + const result = buildDiscoveredCustomizations([], [], [], [local], workspace, userHome, sdk); + + const plugins = result.filter(c => c.type === CustomizationType.Plugin) as PluginCustomization[]; + assert.deepStrictEqual(plugins.map(p => p.name), ['github-inbox@team-kit']); + }); + + test('pre-materialize shows all native plugins (no live session to filter against)', () => { + const a = nativePlugin('a@m', '/home/.claude/plugins/cache/m/a/1.0.0'); + const b = nativePlugin('b@m', '/home/.claude/plugins/cache/m/b/1.0.0'); + + const result = buildDiscoveredCustomizations([], [], [], [a, b], workspace, userHome, undefined); + + const plugins = result.filter(c => c.type === CustomizationType.Plugin) as PluginCustomization[]; + assert.deepStrictEqual(plugins.map(p => p.name).sort(), ['a@m', 'b@m']); + }); + + test('a surfaced plugin\'s components do not also leak as standalone SDK fallbacks (bare + namespaced names suppressed)', () => { + // The live SDK reports a loaded plugin's components as agents + // (namespaced `:`) and commands (usually bare). Both + // forms must be suppressed from the standalone `claude-internal:` + // agents / Built-in skills — they belong under the plugin container. + const plugin = nativePlugin('inbox@team-kit', '/home/.claude/plugins/cache/team-kit/inbox/1.0.0', { agents: ['Investigator'], skills: ['do-thing'] }); + const sdk: ISdkResolvedCustomizations = { + agents: [{ name: 'inbox:Investigator', description: 'p' }, { name: 'standalone-agent', description: 's' }], + commands: [{ name: 'do-thing', description: 'p', argumentHint: '' }, { name: 'standalone-cmd', description: 's', argumentHint: '' }], + mcpServers: [], + plugins: [{ name: 'inbox', path: '/bogus', source: 'inbox@team-kit' }], + }; + + const result = buildDiscoveredCustomizations([], [], [], [plugin], workspace, userHome, sdk); + + // Plugin surfaced as its own container. + assert.deepStrictEqual((result.filter(c => c.type === CustomizationType.Plugin) as PluginCustomization[]).map(p => p.name), ['inbox@team-kit']); + // Standalone (per-scope Directory) entries: plugin components absent, non-plugin SDK entries present. + const standaloneNames = result.flatMap(c => c.type === CustomizationType.Directory ? (c.children ?? []).map(ch => ch.name) : []); + assert.strictEqual(standaloneNames.includes('inbox:Investigator'), false, 'namespaced plugin agent not standalone'); + assert.strictEqual(standaloneNames.includes('do-thing'), false, 'bare plugin skill not in Built-in'); + assert.deepStrictEqual([standaloneNames.includes('standalone-agent'), standaloneNames.includes('standalone-cmd')], [true, true], 'non-plugin SDK entries still surface'); + }); + + test('pre-materialize seeds curated built-in agents (claude-internal; general-purpose hidden; disk name wins)', () => { + // A disk agent named 'Explore' must shadow the curated built-in of the + // same name (the editable file wins). + const diskExplore = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/Explore.md' }); + const discovered = [toParsedAgent({ uri: diskExplore, name: 'Explore', description: 'mine' })]; + + const result = buildDiscoveredCustomizations(discovered, [], [], [], workspace, userHome, undefined); + + const agentChildren = (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .filter(d => d.contents === CustomizationType.Agent) + .flatMap(d => d.children ?? []); + + // Disk Explore kept (real URI); every curated built-in EXCEPT + // general-purpose (hidden) and the disk-shadowed Explore is added as a + // non-editable `claude-internal:` entry. + const expected = [ + { name: 'Explore', uri: diskExplore.toString() }, + ...CLAUDE_BUILTIN_AGENTS + .map(a => a.name) + .filter(n => n !== 'general-purpose' && n !== 'Explore') + .map(n => ({ name: n, uri: `claude-internal:/agent/${n}` })), + ].sort((a, b) => a.name.localeCompare(b.name)); + + assert.deepStrictEqual( + agentChildren.map(c => ({ name: c.name, uri: c.uri })).sort((a, b) => a.name.localeCompare(b.name)), + expected, + ); + }); + }); + + suite('ClaudeCustomizationWatcher', () => { + // Debounce wide enough that a burst of edits reliably lands in a single + // window even on a loaded CI machine; `settle` then waits a clear + // multiple of it so the one debounced fire is always counted before we + // assert. The burst itself is issued concurrently so the change events + // cluster inside one window rather than racing the debounce. + const debounceMs = 20; + const settle = () => timeout(debounceMs * 6); + + test('fires once (debounced) for changes under watched roots and ignores unrelated edits', async () => { + const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), debounceMs)); + let fires = 0; + disposables.add(watcher.onDidChange(() => { fires++; })); + + // An unrelated edit in the workspace root must NOT trigger a refresh. + await seed('/workspace/unrelated.txt', 'x'); + await settle(); + assert.strictEqual(fires, 0); + + // A burst of edits across the watched roots collapses to a single fire: + // a user-scope agent, a project-scope skill, and the sibling `.mcp.json`. + await Promise.all([ + seed('/home/.claude/agents/a.md', 'a'), + seed('/workspace/.claude/skills/s/SKILL.md', 's'), + seed('/workspace/.mcp.json', '{}'), + ]); + await settle(); + assert.strictEqual(fires, 1); + }); + + test('fires for a root-level CLAUDE.md / CLAUDE.local.md edit', async () => { + const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), debounceMs)); + let fires = 0; + disposables.add(watcher.onDidChange(() => { fires++; })); + + await Promise.all([ + seed('/workspace/CLAUDE.md', '# memory'), + seed('/workspace/CLAUDE.local.md', '# personal'), + ]); + await settle(); + assert.strictEqual(fires, 1); + }); + }); + + suite('resolveClaudeAgentName', () => { + const log = new NullLogService(); + const sel = (uri: string): AgentSelection => ({ uri }); + + test('no selection → undefined', async () => { + assert.strictEqual(await resolveClaudeAgentName(undefined, fileService, log, 'sid'), undefined); + }); + + test('claude-internal URI decodes the name (inverse of nonEditableUri)', async () => { + assert.strictEqual(await resolveClaudeAgentName(sel('claude-internal:/agent/sdkonly'), fileService, log, 'sid'), 'sdkonly'); + assert.strictEqual(await resolveClaudeAgentName(sel('claude-internal:/agent/two%20words'), fileService, log, 'sid'), 'two words'); + }); + + test('file URI resolves the frontmatter name (not the filename)', async () => { + const file = await seed('/home/.claude/agents/foo.md', '---\nname: my-real-agent\ndescription: d\n---\nbody'); + assert.strictEqual(await resolveClaudeAgentName(sel(file.toString()), fileService, log, 'sid'), 'my-real-agent'); + }); + + test('unreadable file URI falls back to the basename (minus .md)', async () => { + const missing = URI.from({ scheme: Schemas.inMemory, path: '/home/.claude/agents/missing.md' }); + assert.strictEqual(await resolveClaudeAgentName(sel(missing.toString()), fileService, log, 'sid'), 'missing'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts deleted file mode 100644 index dc334735297ea..0000000000000 --- a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts +++ /dev/null @@ -1,78 +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 { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; -import { CustomizationLoadStatus, CustomizationType, customizationId, type Customization } from '../../../common/state/sessionState.js'; -import { projectSessionCustomizations } from '../../../node/claude/customizations/claudeSessionCustomizationsProjector.js'; - -function client(uri: string, enabled = true): ISyncedCustomization { - return { - customization: { - type: CustomizationType.Plugin, - id: customizationId(uri), - uri, - name: uri, - enabled, - load: { kind: CustomizationLoadStatus.Loaded }, - }, - }; -} - -function discoveredBundle(uri: string): Customization { - return { - type: CustomizationType.Plugin, - id: customizationId(uri), - uri, - name: 'VS Code Synced Data', - enabled: true, - load: { kind: CustomizationLoadStatus.Loaded }, - }; -} - -suite('projectSessionCustomizations', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('returns only client-pushed entries when no discovery bundle', () => { - const result = projectSessionCustomizations([client('https://a')], new Map(), undefined); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri.toString(), 'https://a'); - assert.strictEqual(result[0].enabled, true); - }); - - test('overlays enablement map (keyed by id) on client-pushed entries', () => { - const result = projectSessionCustomizations( - [client('https://a'), client('https://b')], - new Map([[customizationId('https://a'), false]]), - undefined, - ); - assert.strictEqual(result.find(c => c.uri.toString() === 'https://a')?.enabled, false); - assert.strictEqual(result.find(c => c.uri.toString() === 'https://b')?.enabled, true); - }); - - test('appends the discovery bundle verbatim', () => { - const bundleUri = URI.file('/tmp/host-discovery/x').toString(); - const result = projectSessionCustomizations( - [client('https://a')], - new Map(), - discoveredBundle(bundleUri), - ); - assert.strictEqual(result.length, 2); - assert.strictEqual(result[1].uri.toString(), bundleUri); - assert.strictEqual(result[1].enabled, true); - }); - - test('discovery bundle enablement is not overlaid from the map', () => { - const bundleUri = URI.file('/tmp/host-discovery/x').toString(); - const result = projectSessionCustomizations( - [], - new Map([[customizationId(bundleUri), false]]), - discoveredBundle(bundleUri), - ); - assert.strictEqual(result[0].enabled, true); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeAgentSkillScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeAgentSkillScan.test.ts new file mode 100644 index 0000000000000..6bdc9f0344cd8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeAgentSkillScan.test.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 assert from 'assert'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { CustomizationType } from '../../../../common/state/protocol/state.js'; +import { scanClaudeDiskCustomizations } from '../../../../node/claude/customizations/scan/claudeAgentSkillScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeAgentSkillScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('scans agents, skills, and commands (commands folded into skills) with real URIs', async () => { + const agent = await seed('/home/.claude/agents/a.md', '---\nname: a-agent\ndescription: Agent A\n---\nbody'); + const skill = await seed('/workspace/.claude/skills/s/SKILL.md', '---\nname: s-skill\ndescription: Skill S\n---\nbody'); + // Slash commands are a variant of skills (spec §3) — discovered as Skill kind. + const command = await seed('/workspace/.claude/commands/c.md', '---\nname: c-cmd\ndescription: Command C\n---\nbody'); + + const discovered = await scanClaudeDiskCustomizations(workspace, userHome, fileService); + const actual = discovered + .map(d => ({ type: d.customization.type, uri: d.uri.toString(), name: d.name, description: d.description })) + .sort((a, b) => a.uri.localeCompare(b.uri)); + + assert.deepStrictEqual(actual, [ + { type: CustomizationType.Agent, uri: agent.toString(), name: 'a-agent', description: 'Agent A' }, + { type: CustomizationType.Skill, uri: command.toString(), name: 'c-cmd', description: 'Command C' }, + { type: CustomizationType.Skill, uri: skill.toString(), name: 's-skill', description: 'Skill S' }, + ].sort((a, b) => a.uri.localeCompare(b.uri))); + }); + + test('a skill wins over a same-named command (spec §3 priority)', async () => { + const skill = await seed('/workspace/.claude/skills/dup/SKILL.md', '---\nname: dup\ndescription: The skill\n---\nbody'); + await seed('/workspace/.claude/commands/dup.md', '---\nname: dup\ndescription: The command\n---\nbody'); + + const discovered = await scanClaudeDiskCustomizations(workspace, userHome, fileService); + + assert.deepStrictEqual( + discovered.map(d => ({ type: d.customization.type, uri: d.uri.toString(), name: d.name, description: d.description })), + [{ type: CustomizationType.Skill, uri: skill.toString(), name: 'dup', description: 'The skill' }], + ); + }); + + test('project scope shadows user scope on name clash', async () => { + const projectAgent = await seed('/workspace/.claude/agents/dup.md', '---\nname: dup\ndescription: project\n---\nbody'); + await seed('/home/.claude/agents/dup.md', '---\nname: dup\ndescription: user\n---\nbody'); + + const discovered = await scanClaudeDiskCustomizations(workspace, userHome, fileService); + const dup = discovered.filter(d => d.name === 'dup'); + + assert.strictEqual(dup.length, 1); + assert.strictEqual(dup[0].uri.toString(), projectAgent.toString()); + assert.strictEqual(dup[0].description, 'project'); + }); + + test('a `.claude/skills/` dir that is a native plugin is not surfaced as a standalone skill (PB-8)', async () => { + // A plain on-disk skill is surfaced normally. + const realSkill = await seed('/workspace/.claude/skills/real/SKILL.md', '---\nname: real\ndescription: Real\n---\nbody'); + // A `@skills-dir` plugin dir holds a plugin manifest (and may also carry a + // top-level SKILL.md) — it belongs to its PluginCustomization, not the + // standalone skill list. + await seed('/workspace/.claude/skills/tg/.claude-plugin/plugin.json', JSON.stringify({ name: 'tg' })); + await seed('/workspace/.claude/skills/tg/SKILL.md', '---\nname: tg\ndescription: plugin\n---\nbody'); + + const discovered = await scanClaudeDiskCustomizations(workspace, userHome, fileService); + + assert.deepStrictEqual( + discovered.filter(d => d.customization.type === CustomizationType.Skill).map(d => ({ name: d.name, uri: d.uri.toString() })), + [{ name: 'real', uri: realSkill.toString() }], + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeHookScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeHookScan.test.ts new file mode 100644 index 0000000000000..8d1fb880d9f84 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeHookScan.test.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { CustomizationType } from '../../../../common/state/protocol/state.js'; +import { scanClaudeHooks } from '../../../../node/claude/customizations/scan/claudeHookScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeHookScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('surfaces one hook entry per settings file that declares hooks, with the real settings URI', async () => { + const project = await seed('/workspace/.claude/settings.json', JSON.stringify({ + model: 'claude-x', + hooks: { PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo hi' }] }] }, + })); + const user = await seed('/home/.claude/settings.json', JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo start' }] }] }, + })); + + const hooks = await scanClaudeHooks(workspace, userHome, fileService); + + assert.deepStrictEqual( + hooks.map(h => ({ type: h.type, uri: h.uri })), + [ + { type: CustomizationType.Hook, uri: project.toString() }, + { type: CustomizationType.Hook, uri: user.toString() }, + ], + ); + }); + + test('reads settings.local.json (project scope) ahead of user settings', async () => { + const local = await seed('/workspace/.claude/settings.local.json', JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo stop' }] }] }, + })); + + const hooks = await scanClaudeHooks(workspace, userHome, fileService); + + assert.deepStrictEqual(hooks.map(h => h.uri), [local.toString()]); + }); + + test('a scope with disableAllHooks contributes nothing', async () => { + await seed('/workspace/.claude/settings.json', JSON.stringify({ + disableAllHooks: true, + hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] }, + })); + + const hooks = await scanClaudeHooks(workspace, userHome, fileService); + + assert.deepStrictEqual(hooks, []); + }); + + test('a settings file without a hooks block yields no entry', async () => { + await seed('/workspace/.claude/settings.json', JSON.stringify({ model: 'x', permissions: { allow: [] } })); + + const hooks = await scanClaudeHooks(workspace, userHome, fileService); + + assert.deepStrictEqual(hooks, []); + }); + + test('with no workspace (undefined cwd) only the user settings file is read', async () => { + const user = await seed('/home/.claude/settings.json', JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo start' }] }] }, + })); + // A project hook that must NOT be surfaced when there is no workspace. + await seed('/workspace/.claude/settings.json', JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo stop' }] }] }, + })); + + const hooks = await scanClaudeHooks(undefined, userHome, fileService); + + assert.deepStrictEqual(hooks.map(h => h.uri), [user.toString()]); + }); + + test('a settings file shared by workspace and user scope (cwd === userHome) is surfaced once', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ + hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] }, + })); + + // When the user opens their home directory as the workspace, the + // project and user scopes resolve to the same settings file — it must + // not surface twice. + const hooks = await scanClaudeHooks(userHome, userHome, fileService); + + assert.strictEqual(hooks.length, 1); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeMcpScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeMcpScan.test.ts new file mode 100644 index 0000000000000..870a6043ba125 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeMcpScan.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { CustomizationType, McpServerStatus } from '../../../../common/state/protocol/state.js'; +import { scanClaudeMcpServers } from '../../../../node/claude/customizations/scan/claudeMcpScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeMcpScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('scans MCP servers from settings.json with real URIs, ignoring unrelated settings keys', async () => { + const settings = await seed('/workspace/.claude/settings.json', JSON.stringify({ + model: 'claude-x', + permissions: { allow: [] }, + mcpServers: { srv: { command: 'node', args: ['s.js'] } }, + })); + + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + + assert.deepStrictEqual( + servers.map(s => ({ type: s.type, uri: s.uri, name: s.name, enabled: s.enabled, state: s.state })), + [{ type: CustomizationType.McpServer, uri: settings.toString(), name: 'srv', enabled: true, state: { kind: McpServerStatus.Starting } }], + ); + }); + + test('scans a flat .mcp.json server map', async () => { + const mcp = await seed('/workspace/.mcp.json', JSON.stringify({ flatSrv: { command: 'x' } })); + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + assert.deepStrictEqual(servers.map(s => ({ uri: s.uri, name: s.name })), [{ uri: mcp.toString(), name: 'flatSrv' }]); + }); + + test('settings.json without an mcpServers block yields no servers', async () => { + await seed('/workspace/.claude/settings.json', JSON.stringify({ model: 'x', permissions: { allow: [] } })); + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + assert.deepStrictEqual(servers, []); + }); + + test('ignores array-valued MCP entries', async () => { + await seed('/workspace/.mcp.json', JSON.stringify({ bad: [1, 2], good: { command: 'x' } })); + const servers = await scanClaudeMcpServers(workspace, userHome, fileService); + assert.deepStrictEqual(servers.map(s => s.name), ['good']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts new file mode 100644 index 0000000000000..35f8436726695 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../log/common/log.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { scanClaudeNativePlugins } from '../../../../node/claude/customizations/scan/claudeNativePluginScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeNativePluginScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const logService = new NullLogService(); + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + const manifest = (name: string) => JSON.stringify({ name, description: `${name} plugin`, version: '1.0.0' }); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('resolves a marketplace-cache plugin (id → //) with its real root', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'telegram@official': true } })); + await seed('/home/.claude/plugins/cache/official/telegram/0.0.6/.claude-plugin/plugin.json', manifest('telegram')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual( + plugins.map(p => ({ id: p.id, root: p.root.path })), + [{ id: 'telegram@official', root: '/home/.claude/plugins/cache/official/telegram/0.0.6' }], + ); + }); + + test('resolves an Open-Plugins-format plugin (.plugin/plugin.json), not just the Claude layout', async () => { + // Marketplace plugins can ship the Open Plugins manifest layout + // (`.plugin/plugin.json`) rather than `.claude-plugin/plugin.json`; the + // runtime still loads them, so the scanner must surface them too. + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'github-inbox@team-kit': true } })); + await seed('/home/.claude/plugins/cache/team-kit/github-inbox/1.0.0/.plugin/plugin.json', manifest('github-inbox')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual( + plugins.map(p => ({ id: p.id, root: p.root.path })), + [{ id: 'github-inbox@team-kit', root: '/home/.claude/plugins/cache/team-kit/github-inbox/1.0.0' }], + ); + }); + + test('picks the newest-mtime version dir when several are cached', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'p@m': true } })); + await seed('/home/.claude/plugins/cache/m/p/0.0.1/.claude-plugin/plugin.json', manifest('p')); + // Seeded second, so it has the newer mtime in the in-memory provider. + await seed('/home/.claude/plugins/cache/m/p/0.0.2/.claude-plugin/plugin.json', manifest('p')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual(plugins.map(p => p.root.path), ['/home/.claude/plugins/cache/m/p/0.0.2']); + }); + + test('breaks an mtime tie by numeric version order (`0.0.10` beats `0.0.9`)', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'p@m': true } })); + // Plain string compare would pick `0.0.9` ('9' > '1'); numeric order picks `0.0.10`. + await seed('/home/.claude/plugins/cache/m/p/0.0.9/.claude-plugin/plugin.json', manifest('p')); + await seed('/home/.claude/plugins/cache/m/p/0.0.10/.claude-plugin/plugin.json', manifest('p')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual(plugins.map(p => p.root.path), ['/home/.claude/plugins/cache/m/p/0.0.10']); + }); + + test('surfaces bundled components (skills / MCP) with real URIs', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'tg@m': true } })); + const root = '/home/.claude/plugins/cache/m/tg/1.0.0'; + await seed(`${root}/.claude-plugin/plugin.json`, manifest('tg')); + await seed(`${root}/.mcp.json`, JSON.stringify({ mcpServers: { bridge: { command: 'node' } } })); + await seed(`${root}/skills/send/SKILL.md`, '---\nname: send\ndescription: send a message\n---\nbody'); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.strictEqual(plugins.length, 1); + const p = plugins[0]; + assert.deepStrictEqual(p.parsed.mcpServers.map(m => m.name), ['bridge']); + assert.deepStrictEqual(p.parsed.skills.map(s => ({ name: s.name, uri: s.customization.uri })), [ + { name: 'send', uri: `inmemory:${root}/skills/send/SKILL.md` }, + ]); + }); + + test('local `false` disables a project-enabled plugin (precedence user < project < local)', async () => { + await seed('/workspace/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'p@m': true } })); + await seed('/workspace/.claude/settings.local.json', JSON.stringify({ enabledPlugins: { 'p@m': false } })); + await seed('/home/.claude/plugins/cache/m/p/1.0.0/.claude-plugin/plugin.json', manifest('p')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual(plugins, []); + }); + + test('treats string[] / object values as enabled, only `false` disables', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ + enabledPlugins: { 'ver@m': ['1.x'], 'obj@m': { pinned: true }, 'off@m': false }, + })); + await seed('/home/.claude/plugins/cache/m/ver/1.0.0/.claude-plugin/plugin.json', manifest('ver')); + await seed('/home/.claude/plugins/cache/m/obj/1.0.0/.claude-plugin/plugin.json', manifest('obj')); + await seed('/home/.claude/plugins/cache/m/off/1.0.0/.claude-plugin/plugin.json', manifest('off')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual(plugins.map(p => p.id), ['obj@m', 'ver@m']); + }); + + test('resolves an in-place @skills-dir plugin, preferring the workspace scope', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'mine@skills-dir': true } })); + await seed('/workspace/.claude/skills/mine/.claude-plugin/plugin.json', manifest('mine')); + await seed('/home/.claude/skills/mine/.claude-plugin/plugin.json', manifest('mine')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual(plugins.map(p => p.root.path), ['/workspace/.claude/skills/mine']); + }); + + test('fail-soft: an enabled plugin with no resolvable root is skipped, not thrown', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'present@m': true, 'missing@m': true } })); + await seed('/home/.claude/plugins/cache/m/present/1.0.0/.claude-plugin/plugin.json', manifest('present')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual(plugins.map(p => p.id), ['present@m']); + }); + + test('no enabledPlugins block yields no plugins', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ model: 'x' })); + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + assert.deepStrictEqual(plugins, []); + }); + + test('with no workspace, only the user-home settings are read (skills-dir skips the workspace candidate)', async () => { + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'p@m': true, 'mine@skills-dir': true } })); + await seed('/home/.claude/plugins/cache/m/p/1.0.0/.claude-plugin/plugin.json', manifest('p')); + await seed('/home/.claude/skills/mine/.claude-plugin/plugin.json', manifest('mine')); + + const plugins = await scanClaudeNativePlugins(undefined, userHome, fileService, logService); + + assert.deepStrictEqual( + plugins.map(p => p.root.path).sort(), + ['/home/.claude/plugins/cache/m/p/1.0.0', '/home/.claude/skills/mine'], + ); + }); + + test('rejects ids whose segments could traverse outside the plugin roots', async () => { + // A crafted enabledPlugins key (e.g. from an untrusted workspace + // settings.local.json) must never redirect the scan via `..` / path + // separators, even if a manifest happens to exist at the target. + await seed('/workspace/.claude/settings.local.json', JSON.stringify({ + enabledPlugins: { '../../../etc@m': true, 'p@../../../etc': true, 'ok@m': true }, + })); + await seed('/home/.claude/plugins/cache/m/ok/1.0.0/.claude-plugin/plugin.json', manifest('ok')); + // Seed a manifest at a traversal target to prove it is NOT resolved. + await seed('/etc/.claude-plugin/plugin.json', manifest('evil')); + + const plugins = await scanClaudeNativePlugins(workspace, userHome, fileService, logService); + + assert.deepStrictEqual(plugins.map(p => p.id), ['ok@m']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeRuleScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeRuleScan.test.ts new file mode 100644 index 0000000000000..ff48366a7a4b0 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeRuleScan.test.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../files/common/files.js'; +import { CustomizationType } from '../../../../common/state/protocol/state.js'; +import { scanClaudeRules } from '../../../../node/claude/customizations/scan/claudeRuleScan.js'; +import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; + +suite('claudeRuleScan', () => { + + const disposables = new DisposableStore(); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('CLAUDE.md memory files (project + user) become always-on rules with the file name', async () => { + const userMem = await seed('/home/.claude/CLAUDE.md', '# user memory'); + const rootMem = await seed('/workspace/CLAUDE.md', '# project memory'); + const dotMem = await seed('/workspace/.claude/CLAUDE.md', '# project .claude memory'); + const localMem = await seed('/workspace/CLAUDE.local.md', '# personal memory'); + + const rules = await scanClaudeRules(workspace, userHome, fileService); + + assert.deepStrictEqual( + rules + .map(r => ({ uri: r.uri.toString(), name: r.name, type: r.customization.type, alwaysApply: r.customization.alwaysApply, globs: r.customization.globs })) + .sort((a, b) => a.uri.localeCompare(b.uri)), + [ + { uri: dotMem.toString(), name: 'CLAUDE.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + { uri: localMem.toString(), name: 'CLAUDE.local.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + { uri: rootMem.toString(), name: 'CLAUDE.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + { uri: userMem.toString(), name: 'CLAUDE.md', type: CustomizationType.Rule, alwaysApply: true, globs: undefined }, + ].sort((a, b) => a.uri.localeCompare(b.uri)), + ); + }); + + test('.claude/rules files are path-scoped when `paths` frontmatter is present, else always-on', async () => { + const scoped = await seed('/workspace/.claude/rules/scoped.md', '---\nname: scoped-rule\ndescription: Only for src\npaths:\n - "src/**"\n - "lib/**"\n---\nbody'); + const always = await seed('/workspace/.claude/rules/always.md', '# applies everywhere'); + + const rules = await scanClaudeRules(workspace, userHome, fileService); + + assert.deepStrictEqual( + rules + .map(r => ({ uri: r.uri.toString(), name: r.name, description: r.customization.description, alwaysApply: r.customization.alwaysApply, globs: r.customization.globs })) + .sort((a, b) => a.uri.localeCompare(b.uri)), + [ + { uri: always.toString(), name: 'always', description: undefined, alwaysApply: true, globs: undefined }, + { uri: scoped.toString(), name: 'scoped-rule', description: 'Only for src', alwaysApply: false, globs: ['src/**', 'lib/**'] }, + ].sort((a, b) => a.uri.localeCompare(b.uri)), + ); + }); + + test('recurses into .claude/rules subdirectories for both scopes', async () => { + const projectNested = await seed('/workspace/.claude/rules/frontend/ui.md', '# ui rule'); + const userRule = await seed('/home/.claude/rules/global.md', '# global rule'); + + const rules = await scanClaudeRules(workspace, userHome, fileService); + + assert.deepStrictEqual( + rules.map(r => r.uri.toString()).sort(), + [projectNested.toString(), userRule.toString()].sort(), + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 392f06d80ead3..fb5fd929d064e 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -6,11 +6,11 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agentService.js'; -import { ResponsePartKind, type ResponsePart } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type ToolCallResponsePart } from '../../common/state/sessionState.js'; import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; -suite('mapSessionEvents — task_complete rendering', () => { +suite('mapSessionEvents — history replay', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -53,6 +53,27 @@ suite('mapSessionEvents — task_complete rendering', () => { ]); }); + test('fallback task_complete marks the turn complete', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'finish the task' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: 'All done.', toolRequests: [{ toolCallId: 'tc-1', name: 'task_complete', arguments: { summary: 'Finished.' } }] } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + state: turn.state, + parts: partKinds(turn.responseParts), + })), [{ + state: TurnState.Complete, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'All done.' }, + { kind: ResponsePartKind.Markdown, content: '\n\n**Task completed:** Finished.' }, + ], + }]); + }); + test('a regular tool still renders as a tool call', async () => { const events: ISessionEvent[] = [ { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, @@ -68,6 +89,255 @@ suite('mapSessionEvents — task_complete rendering', () => { { kind: ResponsePartKind.ToolCall }, ]); }); + + test('preserves SDK shell_exit content on replayed tool completion', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'echo hi' } } }, + { + type: 'tool.execution_complete', + data: { + toolCallId: 'tc-1', + success: true, + result: { + content: 'hi\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 0, cwd: '/repo', outputPreview: 'hi\n' }], + }, + }, + }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + const part = turns[0].responseParts[0] as ToolCallResponsePart; + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part.toolCall.status, ToolCallStatus.Completed); + if (part.toolCall.status !== ToolCallStatus.Completed) { return; } + assert.deepStrictEqual(part.toolCall.content, [ + { type: ToolResultContentType.Text, text: 'hi\n' }, + { type: ToolResultContentType.ShellExit, shellId: '0', exitCode: 0, cwd: '/repo', outputPreview: 'hi\n' }, + ]); + }); + + test('preserves non-zero shell_exit even when SDK tool completion succeeded', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'gti status' } } }, + { + type: 'tool.execution_complete', + data: { + toolCallId: 'tc-1', + success: true, + result: { + content: 'command not found\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 127, cwd: '/repo' }], + }, + }, + }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + const part = turns[0].responseParts[0] as ToolCallResponsePart; + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part.toolCall.status, ToolCallStatus.Completed); + if (part.toolCall.status !== ToolCallStatus.Completed) { return; } + assert.strictEqual(part.toolCall.success, true); + assert.ok(part.toolCall.content?.some(content => content.type === ToolResultContentType.ShellExit && content.exitCode === 127)); + assert.ok(!part.toolCall.content?.some(content => content.type === ToolResultContentType.Terminal)); + }); + + test('restores best-effort model, fallback agent, and attachments onto user messages', async () => { + const events: ISessionEvent[] = [ + { type: 'session.model_change', data: { newModel: 'opus-4.7' } }, + { type: 'subagent.selected', data: { agentName: 'reviewer', agentDisplayName: 'Reviewer', tools: null } }, + { + type: 'user.message', + data: { + interactionId: 'm1', + content: 'hi', + attachments: [{ + type: 'file', + path: '/tmp/example.ts', + displayName: 'example.ts', + }], + } + }, + { type: 'assistant.message', data: { messageId: 'm2', content: 'hello' } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events), { + model: { id: 'fallback-model' }, + agent: { uri: 'fallback-agent' }, + }); + + assert.deepStrictEqual({ + model: turns[0].message.model, + agent: turns[0].message.agent, + attachments: turns[0].message.attachments?.map(a => ({ + type: a.type, + uri: a.type === MessageAttachmentKind.Resource ? a.uri : undefined, + label: a.label, + })), + }, { + model: { id: 'opus-4.7' }, + agent: { uri: 'fallback-agent' }, + attachments: [{ + type: MessageAttachmentKind.Resource, + uri: 'file:///tmp/example.ts', + label: 'example.ts', + }], + }); + }); + + test('uses top-level user messages as turn boundaries', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event-1', data: { interactionId: 'interaction-1', content: 'Investigate this issue' } }, + { type: 'assistant.message', id: 'initial-round', data: { interactionId: 'interaction-1', content: 'I found a likely cause.', toolRequests: [] } }, + { type: 'assistant.message', id: 'tool-round', data: { interactionId: 'interaction-2', content: 'I will verify it.', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'echo investigating' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true, result: { content: 'investigating\n' } } }, + { type: 'assistant.message', id: 'empty-round', data: { interactionId: 'interaction-2', content: '', toolRequests: [], reasoningOpaque: 'opaque-reasoning' } }, + { type: 'assistant.message', id: 'final-round', data: { interactionId: 'interaction-2', content: 'Investigation complete.', toolRequests: [] } }, + { type: 'user.message', id: 'user-event-2', data: { interactionId: 'interaction-3', content: 'Thanks' } }, + { type: 'assistant.message', id: 'acknowledgement', data: { interactionId: 'interaction-3', content: 'You are welcome.', toolRequests: [] } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message.text, + state: turn.state, + parts: partKinds(turn.responseParts), + })), [ + { + id: 'user-event-1', + message: 'Investigate this issue', + state: TurnState.Complete, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'I found a likely cause.' }, + { kind: ResponsePartKind.Markdown, content: 'I will verify it.' }, + { kind: ResponsePartKind.ToolCall }, + { kind: ResponsePartKind.Markdown, content: 'Investigation complete.' }, + ], + }, + { + id: 'user-event-2', + message: 'Thanks', + state: TurnState.Complete, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'You are welcome.' }, + ], + }, + ]); + }); + + test('synthetic user messages do not start a new turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event-1', data: { interactionId: 'interaction-1', content: 'Use the skill' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'I will use it.', toolRequests: [] } }, + { type: 'user.message', id: 'synthetic-event', data: { interactionId: 'interaction-2', content: 'Injected skill content', source: 'skill' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-2', content: 'The skill is complete.', toolRequests: [] } }, + { type: 'user.message', id: 'user-event-2', data: { interactionId: 'interaction-3', content: 'Thanks' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-3', content: 'You are welcome.', toolRequests: [] } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message.text, + parts: partKinds(turn.responseParts), + })), [ + { + id: 'user-event-1', + message: 'Use the skill', + parts: [ + { kind: ResponsePartKind.Markdown, content: 'I will use it.' }, + { kind: ResponsePartKind.Markdown, content: 'The skill is complete.' }, + ], + }, + { + id: 'user-event-2', + message: 'Thanks', + parts: [ + { kind: ResponsePartKind.Markdown, content: 'You are welcome.' }, + ], + }, + ]); + }); + + test('terminal empty assistant message completes a tool-only turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Close out the todos' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'todo' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'todo', arguments: { status: 'done' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: '', toolRequests: [] } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message.text, + state: turn.state, + parts: partKinds(turn.responseParts), + })), [{ + id: 'user-event', + message: 'Close out the todos', + state: TurnState.Complete, + parts: [ + { kind: ResponsePartKind.ToolCall }, + ], + }]); + }); + + test('tool-only turn without a terminal assistant message remains cancelled', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Run the command' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'echo done' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true, result: { content: 'done\n' } } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + state: turn.state, + parts: partKinds(turn.responseParts), + })), [{ + state: TurnState.Cancelled, + parts: [ + { kind: ResponsePartKind.ToolCall }, + ], + }]); + }); + + test('abort remains terminal for the turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'interaction-1', content: 'Wait for the task' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'The task is complete.', toolRequests: [] } }, + { type: 'abort', data: { reason: 'user initiated' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-2', content: 'Late completion.', toolRequests: [] } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + state: turn.state, + parts: partKinds(turn.responseParts), + })), [{ + state: TurnState.Cancelled, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'The task is complete.' }, + { kind: ResponsePartKind.Markdown, content: 'Late completion.' }, + ], + }]); + }); }); suite('mapSessionEvents — subagent routing', () => { @@ -121,4 +391,87 @@ suite('mapSessionEvents — subagent routing', () => { { kind: ResponsePartKind.Markdown, content: 'Subagent is done.' }, ]); }); + + test('routes subagent skill events into the subagent transcript', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'spawn a subagent' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-task', name: 'task' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-task', toolName: 'task', arguments: { description: 'explore', agentName: 'explore' } } }, + { type: 'subagent.started', agentId: 'agent-1', data: { toolCallId: 'tc-task', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' } }, + { type: 'skill.invoked', agentId: 'agent-1', data: { name: 'research', path: '/skills/research' } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-task', success: true } }, + { type: 'assistant.message', data: { messageId: 'm3', content: 'The subagent finished.' } }, + ]; + + const { turns, subagentTurnsByToolCallId } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual({ + parentState: turns[0].state, + parentParts: partKinds(turns[0].responseParts), + subagentParts: partKinds(subagentTurnsByToolCallId.get('tc-task')?.[0].responseParts ?? []), + }, { + parentState: TurnState.Complete, + parentParts: [ + { kind: ResponsePartKind.ToolCall }, + { kind: ResponsePartKind.Markdown, content: 'The subagent finished.' }, + ], + subagentParts: [ + { kind: ResponsePartKind.ToolCall }, + ], + }); + }); + + test('subagent abort marks the subagent turn cancelled', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'spawn a subagent' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-task', name: 'task' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-task', toolName: 'task', arguments: { description: 'explore', agentName: 'explore' } } }, + { type: 'subagent.started', agentId: 'agent-1', data: { toolCallId: 'tc-task', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' } }, + { type: 'assistant.message', agentId: 'agent-1', data: { messageId: 'm3', content: 'Partial result.' } }, + { type: 'abort', agentId: 'agent-1', data: { reason: 'user initiated' } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-task', success: false } }, + { type: 'assistant.message', data: { messageId: 'm4', content: 'The subagent was cancelled.' } }, + ]; + + const { turns, subagentTurnsByToolCallId } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + const subagentTurn = subagentTurnsByToolCallId.get('tc-task')?.[0]; + + assert.deepStrictEqual({ + parentState: turns[0].state, + subagentState: subagentTurn?.state, + subagentParts: partKinds(subagentTurn?.responseParts ?? []), + }, { + parentState: TurnState.Complete, + subagentState: TurnState.Cancelled, + subagentParts: [ + { kind: ResponsePartKind.Markdown, content: 'Partial result.' }, + ], + }); + }); + + test('subagent abort before its first response remains cancelled', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'spawn a subagent' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-task', name: 'task' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-task', toolName: 'task', arguments: { description: 'explore', agentName: 'explore' } } }, + { type: 'subagent.started', agentId: 'agent-1', data: { toolCallId: 'tc-task', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' } }, + { type: 'abort', agentId: 'agent-1', data: { reason: 'user initiated' } }, + { type: 'assistant.message', agentId: 'agent-1', data: { messageId: 'm3', content: 'Late partial result.' } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-task', success: false } }, + { type: 'assistant.message', data: { messageId: 'm4', content: 'The subagent was cancelled.' } }, + ]; + + const { subagentTurnsByToolCallId } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + const subagentTurn = subagentTurnsByToolCallId.get('tc-task')?.[0]; + + assert.deepStrictEqual({ + state: subagentTurn?.state, + parts: partKinds(subagentTurn?.responseParts ?? []), + }, { + state: TurnState.Cancelled, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'Late partial result.' }, + ], + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 5daeb348755ab..73b2df4840d39 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -9,12 +9,12 @@ import { observableValue } from '../../../../base/common/observable.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentProvider, type AgentSignal, type IAgent, type IAgentActionSignal, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; +import { AgentSession, type AgentProvider, type AgentSignal, type IActiveClient, type IAgent, type IAgentActionSignal, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js'; -import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection } from '../../common/state/protocol/state.js'; +import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { hasKey } from '../../../../base/common/types.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -32,6 +32,14 @@ function mockProject(provider: AgentProvider) { return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` }; } +interface IMockSendMessageCall { + readonly session: URI; + readonly prompt: string; + readonly attachments?: readonly MessageAttachment[]; + readonly chat?: URI; + readonly senderClientId?: string; +} + /** * General-purpose mock agent for unit tests. Tracks all method calls * for assertion and exposes {@link fireProgress} to inject progress events. @@ -39,6 +47,8 @@ function mockProject(provider: AgentProvider) { export class MockAgent implements IAgent { private readonly _onDidSessionProgress = new Emitter(); readonly onDidSessionProgress = this._onDidSessionProgress.event; + private readonly _onDidSendMessage = new Emitter(); + readonly onDidSendMessage = this._onDidSendMessage.event; private readonly _models = observableValue(this, []); readonly models = this._models; @@ -48,8 +58,8 @@ export class MockAgent implements IAgent { private readonly _activeTurnIds = new Map(); - readonly sendMessageCalls: { session: URI; prompt: string; attachments?: readonly MessageAttachment[]; chat?: URI }[] = []; - readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = []; + readonly sendMessageCalls: IMockSendMessageCall[] = []; + readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[]; chat?: URI }[] = []; readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; @@ -57,6 +67,9 @@ export class MockAgent implements IAgent { readonly changeAgentCalls: { session: URI; agent: AgentSelection | undefined; chat?: URI }[] = []; readonly authenticateCalls: { resource: string; token: string }[] = []; readonly setClientCustomizationsCalls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; + readonly setClientToolsCalls: { clientId: string; tools: readonly ToolDefinition[] }[] = []; + readonly removeActiveClientCalls: { clientId: string }[] = []; + readonly clientToolCallCompleteCalls: { session: URI; chat: URI; toolCallId: string; result: ToolCallResult }[] = []; readonly setCustomizationEnabledCalls: { id: string; enabled: boolean }[] = []; /** Configurable return value for getCustomizations. */ customizations: Customization[] = []; @@ -121,17 +134,17 @@ export class MockAgent implements IAgent { return { items: [] }; } - async sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise { - // Only record `chat` when defined so existing single-chat assertions - // that compare against `{ session, prompt, attachments }` still match. - this.sendMessageCalls.push(chat ? { session, prompt, attachments, chat } : { session, prompt, attachments }); + async sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise { + const call = { session, prompt, attachments, chat, ...(senderClientId ? { senderClientId } : {}) }; + this.sendMessageCalls.push(call); + this._onDidSendMessage.fire(call); if (turnId) { this._activeTurnIds.set(uriKey(session), turnId); } } - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void { - this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages }); + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], chat?: URI): void { + this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages, chat }); } async getSessionMessages(session: URI): Promise { @@ -167,6 +180,63 @@ export class MockAgent implements IAgent { this.changeAgentCalls.push({ session, agent, chat }); } + /** + * Create an additional (peer) chat. The base mock is single-chat and + * rejects; multi-chat test subclasses override this. + */ + async createChat(_session: URI, _chat: URI, _options?: IAgentCreateChatOptions): Promise { + throw new Error(`Agent ${this.id} does not support multiple chats`); + } + + /** Dispose an additional (peer) chat. Overridden by multi-chat subclasses. */ + async disposeChat(_session: URI, _chat: URI): Promise { } + + /** + * Map an already-resolved chat URI to the `(session, chat)` pair the + * mock records calls against (mirroring the real agents). + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Mock agent chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat: URI.parse(chat.toString()) }; + } + + readonly chats: IAgentChats = { + createChat: (chatUri: URI, options?: IAgentCreateChatOptions): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.createChat(session, chat, options); + }, + fork: (chatUri: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.createChat(session, chat, { ...options, fork: source }); + }, + disposeChat: (chatUri: URI): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.disposeChat(session, chat); + }, + sendMessage: (chatUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.sendMessage(session, chat, prompt, attachments, turnId, senderClientId); + }, + abort: (chat: URI): Promise => { + const { session } = this._resolveChatTarget(chat); + return this.abortSession(session); + }, + changeModel: (chatUri: URI, model: ModelSelection): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.changeModel(session, model, chat); + }, + changeAgent: (chatUri: URI, agent: AgentSelection | undefined): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.changeAgent(session, agent, chat); + }, + getMessages: (chat: URI): Promise => { + return this.getSessionMessages(chat); + }, + }; + async authenticate(resource: string, token: string): Promise { this.authenticateCalls.push({ resource, token }); return true; @@ -176,7 +246,7 @@ export class MockAgent implements IAgent { return this.customizations; } - async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { + syncClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): ISyncedCustomization[] { this.setClientCustomizationsCalls.push({ clientId, customizations }); const results: ISyncedCustomization[] = customizations.map(c => ({ customization: { @@ -186,7 +256,7 @@ export class MockAgent implements IAgent { })); this._onDidSessionProgress.fire({ kind: 'action', - session, + resource: session, action: { type: ActionType.SessionCustomizationsChanged, customizations: results.map(result => result.customization), @@ -199,9 +269,33 @@ export class MockAgent implements IAgent { this.setCustomizationEnabledCalls.push({ id, enabled }); } - setClientTools(): void { } + getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient { + const self = this; + let tools: readonly ToolDefinition[] = []; + let customizations: readonly ClientPluginCustomization[] = []; + return { + clientId: client.clientId, + displayName: client.displayName, + get tools() { return tools; }, + set tools(value: readonly ToolDefinition[]) { + tools = value; + self.setClientToolsCalls.push({ clientId: client.clientId, tools: value }); + }, + get customizations() { return customizations; }, + set customizations(value: readonly ClientPluginCustomization[]) { + customizations = value; + self.syncClientCustomizations(session, client.clientId, [...value]); + }, + }; + } - onClientToolCallComplete(): void { } + removeActiveClient(_session: URI, clientId: string): void { + this.removeActiveClientCalls.push({ clientId }); + } + + onClientToolCallComplete(session: URI, chat: URI, toolCallId: string, result: ToolCallResult): void { + this.clientToolCallCompleteCalls.push({ session, chat, toolCallId, result }); + } async shutdown(): Promise { } @@ -228,6 +322,7 @@ export class MockAgent implements IAgent { dispose(): void { this._onDidSessionProgress.dispose(); + this._onDidSendMessage.dispose(); this._onDidCustomizationsChange.dispose(); } } @@ -368,31 +463,32 @@ export class ScriptedMockAgent implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async sendMessage(session: URI, prompt: string, _attachments?: readonly MessageAttachment[], turnId?: string): Promise { + async sendMessage(session: URI, chat: URI, prompt: string, _attachments?: readonly MessageAttachment[], turnId?: string): Promise { if (turnId) { this._activeTurnIds.set(uriKey(session), turnId); + this._activeTurnIds.set(uriKey(chat), turnId); } - const { sessionStr, turnId: tid } = this._ctx(session); + const { sessionStr, turnId: tid } = this._ctx(chat); switch (prompt) { case 'hello': this._fireSequence([ - _markdown(session, sessionStr, tid, 'Hello, world!'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Hello, world!'), + _idle(chat, sessionStr, tid), ]); break; case 'use-tool': this._fireSequence([ - ..._toolStart(session, sessionStr, tid, 'tc-1', 'echo_tool', 'Echo Tool', 'Running echo tool...'), - _toolComplete(session, sessionStr, tid, 'tc-1', { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true }), - _markdown(session, sessionStr, tid, 'Tool done.'), - _idle(session, sessionStr, tid), + ..._toolStart(chat, sessionStr, tid, 'tc-1', 'echo_tool', 'Echo Tool', 'Running echo tool...'), + _toolComplete(chat, sessionStr, tid, 'tc-1', { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true }), + _markdown(chat, sessionStr, tid, 'Tool done.'), + _idle(chat, sessionStr, tid), ]); break; case 'error': this._fireSequence([ - _error(session, sessionStr, tid, 'test_error', 'Something went wrong'), + _error(chat, sessionStr, tid, 'test_error', 'Something went wrong'), ]); break; @@ -400,17 +496,17 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start to create the tool, then pending_confirmation to request confirmation (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-perm-1', 'shell', 'Shell', 'Run a test command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-perm-1', 'shell', 'Shell', 'Run a test command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-perm-1', 'Run a test command', { toolInput: 'echo test', confirmationTitle: 'Run a test command' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-perm-1', 'Run a test command', { toolInput: 'echo test', confirmationTitle: 'Run a test command' })); })(); this._pendingPermissions.set('tc-perm-1', (approved) => { if (approved) { this._fireSequence([ - _markdown(session, sessionStr, tid, 'Allowed.'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Allowed.'), + _idle(chat, sessionStr, tid), ]); } }); @@ -421,16 +517,16 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with write permission for a regular file (should be auto-approved) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-write-1', 'create', 'Create File', 'Create file')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-write-1', 'create', 'Create File', 'Create file')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' })); // Auto-approved writes resolve immediately — complete the tool and turn await timeout(10); this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-write-1', { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-write-1', { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), + _idle(chat, sessionStr, tid), ]); })(); break; @@ -440,17 +536,17 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with write permission for .env (should be blocked) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-write-env-1', 'create', 'Create File', 'Create file')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-write-env-1', 'create', 'Create File', 'Create file')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' })); })(); this._pendingPermissions.set('tc-write-env-1', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-write-env-1', { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-write-env-1', { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), + _idle(chat, sessionStr, tid), ]); } }); @@ -461,16 +557,16 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with shell permission for an allowed command (should be auto-approved) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-1', 'bash', 'Run Command', 'Run command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-shell-1', 'bash', 'Run Command', 'Run command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-1', 'ls -la', { permissionKind: 'shell', toolInput: 'ls -la' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-shell-1', 'ls -la', { permissionKind: 'shell', toolInput: 'ls -la' })); // Auto-approved shell commands resolve immediately await timeout(10); this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-shell-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-shell-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true }), + _idle(chat, sessionStr, tid), ]); })(); break; @@ -480,17 +576,17 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + pending_confirmation with shell permission for a denied command (should require confirmation) (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-deny-1', 'bash', 'Run Command', 'Run command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-shell-deny-1', 'bash', 'Run Command', 'Run command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-deny-1', 'rm -rf /', { permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-shell-deny-1', 'rm -rf /', { permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' })); })(); this._pendingPermissions.set('tc-shell-deny-1', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-shell-deny-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-shell-deny-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true }), + _idle(chat, sessionStr, tid), ]); } }); @@ -515,31 +611,31 @@ export class ScriptedMockAgent implements IAgent { // never fires, and the session hangs. (async () => { await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-orphan-initial', 'bash', 'Run Command', 'Run command')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-orphan-initial', 'bash', 'Run Command', 'Run command')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_toolComplete(session, sessionStr, tid, 'tc-orphan-initial', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true })); + this._onDidSessionProgress.fire(_toolComplete(chat, sessionStr, tid, 'tc-orphan-initial', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true })); await timeout(5); // Complete the turn — the state manager clears the active turn. - this._onDidSessionProgress.fire(_idle(session, sessionStr, tid)); + this._onDidSessionProgress.fire(_idle(chat, sessionStr, tid)); // Hook-triggered continuation: a new tool starts with an // empty turnId and `pending_confirmation` arrives while // there is no active turn. await timeout(10); - for (const s of _toolStart(session, sessionStr, '', 'tc-orphan', 'view', 'Read', 'Read file')) { + for (const s of _toolStart(chat, sessionStr, '', 'tc-orphan', 'view', 'Read', 'Read file')) { this._onDidSessionProgress.fire(s); } await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: '/workspace/file.ts' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: '/workspace/file.ts' })); })(); this._pendingPermissions.set('tc-orphan', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-orphan', { pastTenseMessage: 'Read file', content: [{ type: ToolResultContentType.Text, text: 'contents' }], success: true }), - _markdown(session, sessionStr, tid, 'continued-after-hook'), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-orphan', { pastTenseMessage: 'Read file', content: [{ type: ToolResultContentType.Text, text: 'contents' }], success: true }), + _markdown(chat, sessionStr, tid, 'continued-after-hook'), + _idle(chat, sessionStr, tid), ]); } }); @@ -548,47 +644,47 @@ export class ScriptedMockAgent implements IAgent { case 'with-usage': this._fireSequence([ - _markdown(session, sessionStr, tid, 'Usage response.'), - _usage(session, sessionStr, tid, { inputTokens: 100, outputTokens: 50, model: 'mock-model', _meta: { cost: 0.5 } }), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Usage response.'), + _usage(chat, sessionStr, tid, { inputTokens: 100, outputTokens: 50, model: 'mock-model', _meta: { cost: 0.5 } }), + _idle(chat, sessionStr, tid), ]); break; case 'with-reasoning': { - const initialReasoning = _reasoning(session, sessionStr, tid, 'Let me think'); + const initialReasoning = _reasoning(chat, sessionStr, tid, 'Let me think'); const partId = initialReasoning.action.type === ActionType.ChatResponsePart && hasKey(initialReasoning.action.part, { id: true }) ? initialReasoning.action.part.id : ''; this._fireSequence([ initialReasoning, - _action(session, { + _action(chat, { type: ActionType.ChatReasoning, turnId: tid, partId, content: ' about this...', }), - _markdown(session, sessionStr, tid, 'Reasoned response.'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Reasoned response.'), + _idle(chat, sessionStr, tid), ]); break; } case 'with-title': this._fireSequence([ - _markdown(session, sessionStr, tid, 'Title response.'), + _markdown(chat, sessionStr, tid, 'Title response.'), _titleChanged(session, sessionStr, MOCK_AUTO_TITLE), - _idle(session, sessionStr, tid), + _idle(chat, sessionStr, tid), ]); break; case 'slow': { // Slow response for cancel testing — fires delta after a long delay const timer = setTimeout(() => { - const ctx = this._ctx(session); + const ctx = this._ctx(chat); this._fireSequence([ - _markdown(session, ctx.sessionStr, ctx.turnId, 'Slow response.'), - _idle(session, ctx.sessionStr, ctx.turnId), + _markdown(chat, ctx.sessionStr, ctx.turnId, 'Slow response.'), + _idle(chat, ctx.sessionStr, ctx.turnId), ]); }, 5000); this._pendingAborts.set(session.toString(), () => clearTimeout(timer)); @@ -603,7 +699,7 @@ export class ScriptedMockAgent implements IAgent { (async () => { await timeout(10); // Client tools don't get auto-ready — toolStart with toolClientId only emits tool_start - this._onDidSessionProgress.fire(_action(session, { + this._onDidSessionProgress.fire(_action(chat, { type: ActionType.ChatToolCallStart, turnId: tid, toolCallId: 'tc-client-1', @@ -612,14 +708,14 @@ export class ScriptedMockAgent implements IAgent { contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client-tool' }, })); await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-1', 'Running tests...', { toolInput: '{}' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-client-1', 'Running tests...', { toolInput: '{}' })); })(); // The tool stays pending — the client is responsible for dispatching toolCallComplete. // Once complete, fire a response delta and idle. this._pendingPermissions.set('tc-client-1', () => { this._fireSequence([ - _markdown(session, sessionStr, tid, 'Client tool done.'), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Client tool done.'), + _idle(chat, sessionStr, tid), ]); }); break; @@ -629,7 +725,7 @@ export class ScriptedMockAgent implements IAgent { // Fires tool_start with toolClientId followed by a permission request. (async () => { await timeout(10); - this._onDidSessionProgress.fire(_action(session, { + this._onDidSessionProgress.fire(_action(chat, { type: ActionType.ChatToolCallStart, turnId: tid, toolCallId: 'tc-client-perm-1', @@ -638,14 +734,14 @@ export class ScriptedMockAgent implements IAgent { contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client-tool' }, })); await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-perm-1', 'Run tests on project', { confirmationTitle: 'Allow Run Tests?' })); + this._onDidSessionProgress.fire(_pendingConfirmation(chat, 'tc-client-perm-1', 'Run tests on project', { confirmationTitle: 'Allow Run Tests?' })); })(); this._pendingPermissions.set('tc-client-perm-1', (approved) => { if (approved) { this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-client-perm-1', { pastTenseMessage: 'Ran tests', content: [{ type: ToolResultContentType.Text, text: 'all passed' }], success: true }), - _markdown(session, sessionStr, tid, 'Permission granted, tool done.'), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-client-perm-1', { pastTenseMessage: 'Ran tests', content: [{ type: ToolResultContentType.Text, text: 'all passed' }], success: true }), + _markdown(chat, sessionStr, tid, 'Permission granted, tool done.'), + _idle(chat, sessionStr, tid), ]); } }); @@ -658,14 +754,14 @@ export class ScriptedMockAgent implements IAgent { // child session, then an inner tool runs in the child session // (routed via `parentToolCallId`). this._fireSequence([ - ..._toolStart(session, sessionStr, tid, 'tc-task-1', 'task', 'Task', 'Spawning subagent', { toolKind: 'subagent', subagentAgentName: 'explore', subagentDescription: 'Explore' }), - { kind: 'subagent_started', session, toolCallId: 'tc-task-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Exploration helper' }, - ..._toolStart(session, sessionStr, tid, 'tc-inner-1', 'echo_tool', 'Echo Tool', 'Inner tool running...', { parentToolCallId: 'tc-task-1' }), - _toolComplete(session, sessionStr, tid, 'tc-inner-1', { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, 'tc-task-1'), - { kind: 'subagent_completed', session, toolCallId: 'tc-task-1' }, - _toolComplete(session, sessionStr, tid, 'tc-task-1', { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }), - _markdown(session, sessionStr, tid, 'Subagent finished.'), - _idle(session, sessionStr, tid), + ..._toolStart(chat, sessionStr, tid, 'tc-task-1', 'task', 'Task', 'Spawning subagent', { toolKind: 'subagent', subagentAgentName: 'explore', subagentDescription: 'Explore' }), + { kind: 'subagent_started', chat, toolCallId: 'tc-task-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Exploration helper' }, + ..._toolStart(chat, sessionStr, tid, 'tc-inner-1', 'echo_tool', 'Echo Tool', 'Inner tool running...', { parentToolCallId: 'tc-task-1' }), + _toolComplete(chat, sessionStr, tid, 'tc-inner-1', { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, 'tc-task-1'), + { kind: 'subagent_completed', chat, toolCallId: 'tc-task-1' }, + _toolComplete(chat, sessionStr, tid, 'tc-task-1', { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }), + _markdown(chat, sessionStr, tid, 'Subagent finished.'), + _idle(chat, sessionStr, tid), ]); break; } @@ -677,28 +773,28 @@ export class ScriptedMockAgent implements IAgent { // git-driven diff path to pick this up. Format: `terminal-edit:`. const filePath = prompt.slice('terminal-edit:'.length); void (async () => { - for (const s of _toolStart(session, sessionStr, tid, 'tc-term-edit-1', 'bash', 'Run Command', 'Edit file via shell')) { + for (const s of _toolStart(chat, sessionStr, tid, 'tc-term-edit-1', 'bash', 'Run Command', 'Edit file via shell')) { this._onDidSessionProgress.fire(s); } const fs = await import('fs/promises'); await fs.writeFile(filePath, 'edited-from-terminal\n'); this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-term-edit-1', { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), + _toolComplete(chat, sessionStr, tid, 'tc-term-edit-1', { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), + _idle(chat, sessionStr, tid), ]); })().catch(err => { // Surface failures deterministically — an unhandled rejection // would make the test suite flaky. this._fireSequence([ - _markdown(session, sessionStr, tid, 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err))), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err))), + _idle(chat, sessionStr, tid), ]); }); break; } this._fireSequence([ - _markdown(session, sessionStr, tid, 'Unknown prompt: ' + prompt), - _idle(session, sessionStr, tid), + _markdown(chat, sessionStr, tid, 'Unknown prompt: ' + prompt), + _idle(chat, sessionStr, tid), ]); break; } @@ -708,32 +804,45 @@ export class ScriptedMockAgent implements IAgent { // When steering is set, consume it on the next tick if (steeringMessage) { timeout(20).then(() => { - this._onDidSessionProgress.fire({ kind: 'steering_consumed', session, id: steeringMessage.id }); + this._onDidSessionProgress.fire({ kind: 'steering_consumed', chat: isAhpChatChannel(session.toString()) ? session : URI.parse(buildDefaultChatUri(session)), id: steeringMessage.id }); }); } } - async setClientCustomizations() { - return []; + getOrCreateActiveClient(_session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient { + let tools: readonly ToolDefinition[] = []; + let customizations: readonly ClientPluginCustomization[] = []; + return { + clientId: client.clientId, + displayName: client.displayName, + get tools() { return tools; }, + set tools(value: readonly ToolDefinition[]) { tools = value; }, + get customizations() { return customizations; }, + set customizations(value: readonly ClientPluginCustomization[]) { customizations = value; }, + }; } + removeActiveClient(): void { } + setCustomizationEnabled() { } - setClientTools(): void { } - private didCompleteToolCalls = new Set(); - onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { - const key = `${session.toString()}:${toolCallId}`; + onClientToolCallComplete(session: URI, chat: URI, toolCallId: string, result: ToolCallResult): void { + // The mock's event model is chat-channel oriented (sendMessage fires + // every turn signal on the chat URI). Emit the completion on the chat + // channel the tool was started on so the parked turn callback — which + // captured that same chat URI — resolves on the right channel. + const key = `${chat.toString()}:${toolCallId}`; if (this.didCompleteToolCalls.has(key)) { return; } this.didCompleteToolCalls.add(key); // Fire tool_complete action signal and resolve any pending callback. - const { sessionStr, turnId } = this._ctx(session); - this._onDidSessionProgress.fire(_toolComplete(session, sessionStr, turnId, toolCallId, result)); + const { sessionStr, turnId } = this._ctx(chat); + this._onDidSessionProgress.fire(_toolComplete(chat, sessionStr, turnId, toolCallId, result)); const callback = this._pendingPermissions.get(toolCallId); if (callback) { this._pendingPermissions.delete(toolCallId); @@ -746,7 +855,11 @@ export class ScriptedMockAgent implements IAgent { if (subagentInfo) { return buildSubagentTurnsFromHistory(this._preExistingMessages, subagentInfo.toolCallId, session.toString()); } - if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) { + // Restore addresses the default chat by its channel URI; normalize it + // back to the session URI (mirroring the real agents' getSessionMessages). + const parsed = parseChatUri(session); + const normalized = parsed && buildDefaultChatUri(parsed.session) === session.toString() ? URI.parse(parsed.session) : session; + if (normalized.toString() === PRE_EXISTING_SESSION_URI.toString()) { return buildTurnsFromHistory(this._preExistingMessages); } return []; @@ -768,6 +881,49 @@ export class ScriptedMockAgent implements IAgent { // Mock agent doesn't track model state } + /** + * Map an already-resolved chat URI to the `(session, chat)` pair the + * scripted mock's per-chat context is keyed by. + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Scripted mock chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat: URI.parse(chat.toString()) }; + } + + readonly chats: IAgentChats = { + createChat: (_chat: URI, _options?: IAgentCreateChatOptions): Promise => { + throw new Error('Scripted mock agent does not support multiple chats'); + }, + fork: (_chat: URI, _source: IAgentCreateChatForkSource, _options?: IAgentCreateChatOptions): Promise => { + throw new Error('Scripted mock agent does not support chat forking'); + }, + disposeChat: (_chat: URI): Promise => { + return Promise.resolve(); + }, + sendMessage: (chatUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.sendMessage(session, chat, prompt, attachments, turnId); + }, + abort: (chat: URI): Promise => { + const { session } = this._resolveChatTarget(chat); + return this.abortSession(session); + }, + changeModel: (chat: URI, model: ModelSelection): Promise => { + const { session } = this._resolveChatTarget(chat); + return this.changeModel(session, model); + }, + changeAgent: (_chat: URI, _agent: AgentSelection | undefined): Promise => { + // Scripted mock does not track agent selection. + return Promise.resolve(); + }, + getMessages: (chat: URI): Promise => { + return this.getSessionMessages(chat); + }, + }; + async truncateSession(_session: URI, _turnId?: string): Promise { // Mock agent accepts truncation without side effects } @@ -827,7 +983,7 @@ let _mockPartIdCounter = 0; /** Wraps a session action into an {@link IAgentActionSignal}. */ function _action(session: URI, action: import('../../common/state/sessionActions.js').SessionAction | import('../../common/state/sessionActions.js').ChatAction, parentToolCallId?: string): IAgentActionSignal { - return { kind: 'action', session, action, parentToolCallId }; + return { kind: 'action', resource: session, action, parentToolCallId }; } /** Creates a markdown {@link ResponsePartKind.Markdown} response part signal. */ @@ -926,7 +1082,7 @@ function _pendingConfirmation(session: URI, toolCallId: string, invocationMessag }): IAgentToolPendingConfirmationSignal { return { kind: 'pending_confirmation', - session, + chat: session, state: { status: ToolCallStatus.PendingConfirmation, toolCallId, diff --git a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts index 155c28ae2ab69..20521365a763c 100644 --- a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts @@ -20,6 +20,7 @@ import assert from 'assert'; import { ToolCallContributorKind, ToolResultContentType, type ToolCallContributor } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, getActionEnvelope, IServerHandle, @@ -82,7 +83,7 @@ suite('Protocol WebSocket — Client Tools', function () { // Complete the client tool call client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallComplete', turnId: 'turn-ct', @@ -137,7 +138,7 @@ suite('Protocol WebSocket — Client Tools', function () { // Approve the permission client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-cp', @@ -170,7 +171,7 @@ suite('Protocol WebSocket — Client Tools', function () { // tool_ready that was generated by the event mapper. client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallComplete', turnId: 'turn-ra', diff --git a/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts index 0515308523f8e..3d41be3d616ac 100644 --- a/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts @@ -26,6 +26,7 @@ import { generateUuid } from '../../../../../base/common/uuid.js'; import { MessageKind, PendingMessageKind, ChatInputResponseKind, type ChatInputRequest } from '../../../common/state/sessionState.js'; import { createRealSession, defineSharedRealSdkTests, dispatchTurn, getAcceptedAnswers, type IRealSdkProviderConfig } from './realSdkTestHelpers.js'; import { getActionEnvelope, isActionNotification, startRealServer, TestProtocolClient, type IServerHandle } from './testHelpers.js'; +import { URI } from '../../../../../base/common/uri.js'; const REAL_CODEX_ENABLED = process.env['AGENT_HOST_REAL_CODEX'] === '1'; @@ -91,7 +92,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-steer-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'steer-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'steer-client', createdSessions, URI.file(workingDirectory)); // A long, slow turn gives us a window to steer before it completes. const turnId = generateUuid(); @@ -142,7 +143,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-tool-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'tool-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'tool-client', createdSessions, URI.file(workingDirectory)); // Register a client-provided tool BEFORE the first turn so it lands in // `thread/start.dynamicTools`. @@ -150,7 +151,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); channel: session, clientSeq: 1, action: { - type: 'session/activeClientChanged', + type: 'session/activeClientSet', activeClient: { clientId: 'tool-client', tools: [{ @@ -218,7 +219,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-tool2-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'tool-client-2', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'tool-client-2', createdSessions, URI.file(workingDirectory)); // Let the background prewarm materialize a thread BEFORE any tools are // registered, so the tools must be applied via a thread restart. @@ -228,7 +229,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); channel: session, clientSeq: 1, action: { - type: 'session/activeClientChanged', + type: 'session/activeClientSet', activeClient: { clientId: 'tool-client-2', tools: [{ @@ -290,7 +291,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-servertool-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'servertool-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'servertool-client', createdSessions, URI.file(workingDirectory)); // No client tools are registered. The agent host's server tools // (feedback "comments") are wired automatically by the server and must @@ -334,7 +335,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-fileapprove-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'fileapprove-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'fileapprove-client', createdSessions, URI.file(workingDirectory)); // Read-only sandbox + on-request approval forces codex to ask before // applying any file edit (an `item/fileChange/requestApproval`). @@ -382,7 +383,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-trunc-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'trunc-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'trunc-client', createdSessions, URI.file(workingDirectory)); // Drive two quick turns to completion so the session has history. for (const [turnId, text] of [['trunc-1', 'Reply with exactly OK1.'], ['trunc-2', 'Reply with exactly OK2.']] as const) { @@ -414,7 +415,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-planmode-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'planmode-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'planmode-client', createdSessions, URI.file(workingDirectory)); // Switch the session to Plan mode via the platform-generic Agent Mode // control — codex only exposes `request_user_input` in plan collaboration diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotCustomizations.integrationTest.ts new file mode 100644 index 0000000000000..6c02d079a80e4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/copilotCustomizations.integrationTest.ts @@ -0,0 +1,659 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Real Copilot SDK customization integration tests running on a mocked LLM. + * + * agent host log file: ~/.vscode-insiders/tmp/tmp_vscode_1/ahp-customizations-home-mock-ZBucPX/Library/Application Support/Code - OSS Dev/logs/20260701T192836/agenthost-server.log + */ + +import assert from 'assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from '../../../../../base/common/path.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ActionType, SessionCustomizationsChangedAction } from '../../../common/state/sessionActions.js'; +import { CustomizationType, type DirectoryCustomization } from '../../../common/state/sessionState.js'; +import { createRealSession, dispatchTurn, IRealSdkProviderConfig } from './realSdkTestHelpers.js'; +import { getActionEnvelope, isActionNotification, IServerHandle, startRealServer, TestProtocolClient } from './testHelpers.js'; + +const COPILOT_CONFIG: IRealSdkProviderConfig = { + suiteTitle: 'Protocol WebSocket — Real Copilot SDK Mocked LLM', + provider: 'copilotcli', + scheme: 'copilotcli', + shellToolName: 'bash', + subagentToolNames: ['task'], + exitPlanModeToolName: 'exit_plan_mode', + enabled: true, + supportsWorktreeIsolation: true, + supportsSubagents: true, + supportsPlanMode: true, + githubToken: 'not-a-real-token', // The tests will use a mocked LLM, so the token doesn't need to be valid. +}; + +const SETUP_TIMEOUT_MS = 45_000; +const TEST_TIMEOUT_MS = 90_000; +const NOTIFICATION_TIMEOUT_MS = 10_000; + +suite('Protocol WebSocket — Real Copilot SDK, Mocked LLM (Copilot customizations)', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + const createdSessions: string[] = []; + const tempDirs: string[] = []; + let userHomeDir: string; + + suiteSetup(async function () { + this.timeout(SETUP_TIMEOUT_MS); + userHomeDir = await mkdtemp(`${tmpdir()}/ahp-customizations-home-mock-`); + server = await startRealServer({ mockLlm: true, homeDir: userHomeDir }); + tempDirs.push(userHomeDir); + }); + + suiteTeardown(async function () { + server?.process.kill(); + + for (const dir of tempDirs) { + try { + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { /* best-effort */ } + } + tempDirs.length = 0; + }); + + setup(async function () { + this.timeout(SETUP_TIMEOUT_MS); + client = new TestProtocolClient(server.port); + await client.connect(); + await cleanHomeFolder(); + }); + + teardown(async function () { + for (const session of createdSessions) { + try { + await client.call('disposeSession', { session }, 5000); + } catch { /* best-effort */ } + } + createdSessions.length = 0; + client.close(); + }); + + async function cleanHomeFolder() { + const foldersToClean = ['.copilot/agents', '.copilot/instructions', '.copilot/skills', '.copilot/hooks', '.agents', '.claude']; + await Promise.all([ + ...foldersToClean.map(folder => rm(join(userHomeDir, folder), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })), + ]); + } + + test('detects only directory customizations on an empty workspace via session/customizationsChanged after hello (mock LLM)', async function () { + this.timeout(TEST_TIMEOUT_MS); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-empty-mock-`); + tempDirs.push(workspaceDir); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations-empty-mock', createdSessions, URI.file(workspaceDir)); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-empty-client-mock', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations-empty-mock', 'hello', 2); + + const [customizationsNotif] = await Promise.all([ + client.waitForNotification(n => isActionNotification(n, ActionType.SessionCustomizationsChanged) && getActionEnvelope(n).channel === sessionUri, NOTIFICATION_TIMEOUT_MS), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), NOTIFICATION_TIMEOUT_MS), + ]); + + const customizationsAction = getActionEnvelope(customizationsNotif).action as SessionCustomizationsChangedAction; + const mappedCustomizations = customizationsAction.customizations.map(customization => ({ + type: customization.type, + uri: customization.uri, + children: customization.type === CustomizationType.Directory ? (customization.children ?? []).map(child => child.uri) : undefined, + })); + const expectedCustomizations = [ + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.agents', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'skills')).toString(), children: [] }, + ]; + const actualByUri = new Map(mappedCustomizations.map(customization => [customization.uri, customization])); + assert.strictEqual(actualByUri.size, expectedCustomizations.length, `expected ${expectedCustomizations.length} unique customizations, got: ${JSON.stringify(mappedCustomizations)}`); + const actualCustomizations = expectedCustomizations.map(expected => actualByUri.get(expected.uri)); + assert.deepStrictEqual(actualCustomizations, expectedCustomizations); + }); + + test('detects workspace agents, instructions, skills, and hooks via session/customizationsChanged after hello (mock LLM)', async function () { + this.timeout(TEST_TIMEOUT_MS); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-test-mock-`); + tempDirs.push(workspaceDir); + const githubDir = join(workspaceDir, '.github'); + const agentsDir = join(githubDir, 'agents'); + const instructionsDir = join(githubDir, 'instructions'); + const skillsDir = join(githubDir, 'skills', 'hello-skill'); + const hooksDir = join(githubDir, 'hooks'); + const userAgentsDir = join(userHomeDir, '.copilot', 'agents'); + const userInstructionsDir = join(userHomeDir, '.copilot', 'instructions'); + const userCopilotSkillsDir = join(userHomeDir, '.copilot', 'skills', 'copilot-hello-skill'); + const userSkillsDir = join(userHomeDir, '.agents', 'skills', 'user-hello-skill'); + const userHooksDir = join(userHomeDir, '.copilot', 'hooks'); + const userAgentFile = join(userAgentsDir, 'user-hello.agent.md'); + const userInstructionFile = join(userInstructionsDir, 'user-policy.instructions.md'); + const userCopilotSkillFile = join(userCopilotSkillsDir, 'SKILL.md'); + const userSkillFile = join(userSkillsDir, 'SKILL.md'); + const userHookFile = join(userHooksDir, 'user-pre-tool.json'); + + await Promise.all([ + mkdir(agentsDir, { recursive: true }), + mkdir(instructionsDir, { recursive: true }), + mkdir(skillsDir, { recursive: true }), + mkdir(hooksDir, { recursive: true }), + mkdir(userAgentsDir, { recursive: true }), + mkdir(userInstructionsDir, { recursive: true }), + mkdir(userCopilotSkillsDir, { recursive: true }), + mkdir(userSkillsDir, { recursive: true }), + mkdir(userHooksDir, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(agentsDir, 'hello.agent.md'), [ + '---', + 'name: Hello Agent', + 'description: Handles hello requests', + '---', + 'You are a test agent.', + ].join('\n')), + writeFile(join(instructionsDir, 'policy.instructions.md'), [ + '---', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')), + writeFile(join(skillsDir, 'SKILL.md'), [ + '---', + 'name: hello-skill', + 'description: Says hello', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(join(hooksDir, 'pre-tool.json'), JSON.stringify({ PreToolUse: [] }, undefined, 2)), + writeFile(userAgentFile, [ + '---', + 'name: User Hello Agent', + 'description: Handles user hello requests', + '---', + 'You are a user-scope test agent.', + ].join('\n')), + writeFile(userInstructionFile, [ + '---', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer concise language.', + ].join('\n')), + writeFile(userCopilotSkillFile, [ + '---', + 'name: user-copilot-skill', + 'description: Says hello from Copilot home', + '---', + 'Return a Copilot home greeting.', + ].join('\n')), + writeFile(userSkillFile, [ + '---', + 'name: user-hello-skill', + 'description: Says hello from user home', + '---', + 'Return a user-level greeting.', + ].join('\n')), + writeFile(userHookFile, JSON.stringify({ PreToolUse: [] }, undefined, 2)), + ]); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations-mock', createdSessions, URI.file(workspaceDir)); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-client-mock', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations-mock', 'hello', 2); + + const [customizationsNotif] = await Promise.all([ + client.waitForNotification(n => isActionNotification(n, ActionType.SessionCustomizationsChanged) && getActionEnvelope(n).channel === sessionUri, NOTIFICATION_TIMEOUT_MS), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), NOTIFICATION_TIMEOUT_MS), + ]); + + const customizationsAction = getActionEnvelope(customizationsNotif).action as SessionCustomizationsChangedAction; + const mappedCustomizations = customizationsAction.customizations.map(customization => ({ + type: customization.type, + uri: customization.uri, + children: customization.type === CustomizationType.Directory ? (customization.children ?? []).map(child => child.uri) : undefined, + })); + const expectedCustomizations = [ + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.agents', 'skills')).toString(), children: [URI.file(userSkillFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [URI.file(userAgentFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [URI.file(userHookFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [URI.file(userInstructionFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'skills')).toString(), children: [URI.file(userCopilotSkillFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'agents')).toString(), children: [URI.file(join(agentsDir, 'hello.agent.md')).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [URI.file(join(hooksDir, 'pre-tool.json')).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [URI.file(join(instructionsDir, 'policy.instructions.md')).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'skills')).toString(), children: [URI.file(join(skillsDir, 'SKILL.md')).toString()] }, + ]; + const actualByUri = new Map(mappedCustomizations.map(customization => [customization.uri, customization])); + assert.strictEqual(actualByUri.size, expectedCustomizations.length, `expected ${expectedCustomizations.length} unique customizations, got: ${JSON.stringify(mappedCustomizations)}`); + const actualCustomizations = expectedCustomizations.map(expected => actualByUri.get(expected.uri)); + assert.deepStrictEqual(actualCustomizations, expectedCustomizations); + }); + + test('emits session/customizationsChanged when customization files are edited, added, and removed (mock LLM)', async function () { + this.timeout(TEST_TIMEOUT_MS); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-watch-mock-`); + tempDirs.push(workspaceDir); + const githubDir = join(workspaceDir, '.github'); + const agentsDir = join(githubDir, 'agents'); + const skillsDir = join(githubDir, 'skills'); + const instructionsDir = join(githubDir, 'instructions'); + const hooksDir = join(githubDir, 'hooks'); + const homeAgentsDir = join(userHomeDir, '.copilot', 'agents'); + const homeCopilotSkillsDir = join(userHomeDir, '.copilot', 'skills'); + const homeSkillsDir = join(userHomeDir, '.agents', 'skills'); + const homeInstructionsDir = join(userHomeDir, '.copilot', 'instructions'); + const homeHooksDir = join(userHomeDir, '.copilot', 'hooks'); + const agentFile = join(agentsDir, 'hello.agent.md'); + const addedAgentFile = join(agentsDir, 'added.agent.md'); + const skillFile = join(skillsDir, 'watch-skill', 'SKILL.md'); + const addedSkillFile = join(skillsDir, 'added-skill', 'SKILL.md'); + const instructionFile = join(instructionsDir, 'watch.instructions.md'); + const addedInstructionFile = join(instructionsDir, 'added.instructions.md'); + const hookFile = join(hooksDir, 'pre-tool.json'); + const addedHookFile = join(hooksDir, 'post-tool.json'); + const homeAgentFile = join(homeAgentsDir, 'home.agent.md'); + const homeCopilotSkillFile = join(homeCopilotSkillsDir, 'nls', 'SKILL.md'); + const addedHomeAgentFile = join(homeAgentsDir, 'added-home.agent.md'); + const homeSkillFile = join(homeSkillsDir, 'home-skill', 'SKILL.md'); + const addedHomeSkillFile = join(homeSkillsDir, 'added-home-skill', 'SKILL.md'); + const homeInstructionFile = join(homeInstructionsDir, 'home.instructions.md'); + const addedHomeInstructionFile = join(homeInstructionsDir, 'added-home.instructions.md'); + const homeHookFile = join(homeHooksDir, 'home-pre-tool.json'); + const addedHomeHookFile = join(homeHooksDir, 'home-post-tool.json'); + const agentsInstructionsFile = join(workspaceDir, 'AGENTS.md'); + const workspaceAgentsDir = join(workspaceDir, '.agents', 'agents'); + const workspaceAgentsFile = join(workspaceAgentsDir, 'workspace-folder.agent.md'); + const workspaceRootUri = URI.file(workspaceDir).toString(); + + await Promise.all([ + mkdir(agentsDir, { recursive: true }), + mkdir(join(skillsDir, 'watch-skill'), { recursive: true }), + mkdir(instructionsDir, { recursive: true }), + mkdir(hooksDir, { recursive: true }), + mkdir(homeAgentsDir, { recursive: true }), + mkdir(join(homeSkillsDir, 'home-skill'), { recursive: true }), + mkdir(homeInstructionsDir, { recursive: true }), + mkdir(homeHooksDir, { recursive: true }), + ]); + await Promise.all([ + writeFile(agentFile, [ + '---', + 'name: Hello Agent', + 'description: Handles hello requests', + '---', + 'You are a test agent.', + ].join('\n')), + writeFile(skillFile, [ + '---', + 'name: watch-skill', + 'description: Watches skill changes', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(instructionFile, [ + '---', + 'name: Watch Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Be concise.', + ].join('\n')), + writeFile(hookFile, JSON.stringify({ PreToolUse: [] }, undefined, 2)), + writeFile(homeAgentFile, [ + '---', + 'name: Home Agent', + 'description: Home scoped agent', + '---', + 'You are a home test agent.', + ].join('\n')), + writeFile(homeSkillFile, [ + '---', + 'name: home-skill', + 'description: Home scoped skill', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(homeInstructionFile, [ + '---', + 'name: Home Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer home defaults.', + ].join('\n')), + writeFile(homeHookFile, JSON.stringify({ PreToolUse: [] }, undefined, 2)), + ]); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations-watch-mock', createdSessions, URI.file(workspaceDir)); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-watch-client-mock', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations-watch-mock', 'hello', 2); + + const getChildNamesAtDirectory = (action: SessionCustomizationsChangedAction, directoryUri: string, type: CustomizationType): string[] => { + const directories = action.customizations.filter((customization): customization is DirectoryCustomization => customization.type === CustomizationType.Directory); + const directory = directories.find(customization => customization.uri === directoryUri); + return (directory?.children ?? []) + .filter(child => child.type === type) + .map(child => child.name) + .sort((a, b) => a.localeCompare(b)); + }; + + const waitForDirectoryChildNames = async (directoryUri: string, type: CustomizationType, expectedNames: readonly string[], timeoutMs = NOTIFICATION_TIMEOUT_MS): Promise => { + const expectedSorted = [...expectedNames].sort((a, b) => a.localeCompare(b)); + const assertSingleCustomizationChangeNotification = (): void => { + const matchingNotifications = client.receivedNotifications().filter(notification => + isActionNotification(notification, ActionType.SessionCustomizationsChanged) && + getActionEnvelope(notification).channel === sessionUri + ); + assert.strictEqual( + matchingNotifications.length, + 1, + `expected exactly one ${ActionType.SessionCustomizationsChanged} notification for ${directoryUri}; got ${matchingNotifications.length}: ${JSON.stringify(matchingNotifications)}`, + ); + }; + + const getMatchingActionFromNotifications = (notifications: ReturnType): SessionCustomizationsChangedAction | undefined => { + for (const notification of notifications) { + if (!isActionNotification(notification, ActionType.SessionCustomizationsChanged) || getActionEnvelope(notification).channel !== sessionUri) { + continue; + } + const action = getActionEnvelope(notification).action as SessionCustomizationsChangedAction; + const names = getChildNamesAtDirectory(action, directoryUri, type); + if (JSON.stringify(names) === JSON.stringify(expectedSorted)) { + return action; + } + } + return undefined; + }; + + const existingAction = getMatchingActionFromNotifications(client.receivedNotifications()); + if (existingAction) { + assert.deepStrictEqual(getChildNamesAtDirectory(existingAction, directoryUri, type), expectedSorted); + assertSingleCustomizationChangeNotification(); + return; + } + + let notif; + try { + notif = await client.waitForNotification(n => { + if (!isActionNotification(n, ActionType.SessionCustomizationsChanged) || getActionEnvelope(n).channel !== sessionUri) { + return false; + } + const action = getActionEnvelope(n).action as SessionCustomizationsChangedAction; + const names = getChildNamesAtDirectory(action, directoryUri, type); + return JSON.stringify(names) === JSON.stringify(expectedSorted); + }, timeoutMs); + } catch (error) { + throw new Error(`Timeout waiting for customizations update. directory=${directoryUri}, type=${type}, expected=${JSON.stringify(expectedSorted)}, received=${JSON.stringify(client.receivedNotifications())}, error=${error}`); + } + const action = getActionEnvelope(notif).action as SessionCustomizationsChangedAction; + assert.deepStrictEqual(getChildNamesAtDirectory(action, directoryUri, type), expectedSorted); + assertSingleCustomizationChangeNotification(); + }; + + await Promise.all([ + waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Hello Agent']), + waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Home Agent']), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), NOTIFICATION_TIMEOUT_MS), + ]); + + client.clearReceived(); + await writeFile(agentFile, [ + '---', + 'name: Hello Agent Renamed', + 'description: Handles hello requests', + '---', + 'You are a renamed test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Hello Agent Renamed']); + + client.clearReceived(); + await writeFile(addedAgentFile, [ + '---', + 'name: Added Agent', + 'description: Added after startup', + '---', + 'You are a newly added test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Added Agent', 'Hello Agent Renamed']); + + client.clearReceived(); + await rm(addedAgentFile, { force: true }); + await waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Hello Agent Renamed']); + + client.clearReceived(); + await writeFile(agentsInstructionsFile, 'Be concise in all responses.'); + await waitForDirectoryChildNames(workspaceRootUri, CustomizationType.Rule, ['AGENTS.md']); + + client.clearReceived(); + await rm(agentsInstructionsFile, { force: true }); + await waitForDirectoryChildNames(workspaceRootUri, CustomizationType.Rule, []); + + client.clearReceived(); + await mkdir(workspaceAgentsDir, { recursive: true }); + await writeFile(workspaceAgentsFile, [ + '---', + 'name: Workspace Folder Agent', + 'description: Found in .agents/agents', + '---', + 'You are a workspace-folder test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(workspaceAgentsDir).toString(), CustomizationType.Agent, ['Workspace Folder Agent']); + + client.clearReceived(); + await writeFile(skillFile, [ + '---', + 'name: watch-skill-renamed', + 'description: Watches skill changes', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(skillsDir).toString(), CustomizationType.Skill, ['watch-skill-renamed']); + + client.clearReceived(); + await mkdir(join(skillsDir, 'added-skill'), { recursive: true }); + await writeFile(addedSkillFile, [ + '---', + 'name: added-skill', + 'description: Added after startup', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(skillsDir).toString(), CustomizationType.Skill, ['added-skill', 'watch-skill-renamed']); + + client.clearReceived(); + await rm(addedSkillFile, { force: true }); + await waitForDirectoryChildNames(URI.file(skillsDir).toString(), CustomizationType.Skill, ['watch-skill-renamed']); + + client.clearReceived(); + await writeFile(instructionFile, [ + '---', + 'name: Watch Policy Renamed', + 'applyTo:', + ' - "**/*"', + '---', + 'Be concise.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(instructionsDir).toString(), CustomizationType.Rule, ['Watch Policy Renamed']); + + client.clearReceived(); + await writeFile(addedInstructionFile, [ + '---', + 'name: Added Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(instructionsDir).toString(), CustomizationType.Rule, ['Added Policy', 'Watch Policy Renamed']); + + client.clearReceived(); + await rm(addedInstructionFile, { force: true }); + await waitForDirectoryChildNames(URI.file(instructionsDir).toString(), CustomizationType.Rule, ['Watch Policy Renamed']); + + client.clearReceived(); + await writeFile(hookFile, JSON.stringify({ PreToolUse: [{ command: 'echo changed' }] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(hooksDir).toString(), CustomizationType.Hook, ['pre-tool.json']); + + client.clearReceived(); + await writeFile(addedHookFile, JSON.stringify({ PostToolUse: [] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(hooksDir).toString(), CustomizationType.Hook, ['post-tool.json', 'pre-tool.json']); + + client.clearReceived(); + await rm(addedHookFile, { force: true }); + await waitForDirectoryChildNames(URI.file(hooksDir).toString(), CustomizationType.Hook, ['pre-tool.json']); + + client.clearReceived(); + await writeFile(homeAgentFile, [ + '---', + 'name: Home Agent Renamed', + 'description: Home scoped agent', + '---', + 'You are a renamed home test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Home Agent Renamed']); + + client.clearReceived(); + await writeFile(addedHomeAgentFile, [ + '---', + 'name: Added Home Agent', + 'description: Added after startup in home', + '---', + 'You are a newly added home test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Added Home Agent', 'Home Agent Renamed']); + + client.clearReceived(); + await rm(addedHomeAgentFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Home Agent Renamed']); + + client.clearReceived(); + await writeFile(homeSkillFile, [ + '---', + 'name: home-skill-renamed', + 'description: Home scoped skill', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeSkillsDir).toString(), CustomizationType.Skill, ['home-skill-renamed']); + + client.clearReceived(); + await mkdir(join(homeCopilotSkillsDir, 'nls'), { recursive: true }); + await writeFile(homeCopilotSkillFile, [ + '---', + 'name: nls-copilot-home-skill', + 'description: Added under ~/.copilot/skills', + '---', + 'Return localized strings.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeCopilotSkillsDir).toString(), CustomizationType.Skill, ['nls-copilot-home-skill']); + + client.clearReceived(); + await mkdir(join(homeSkillsDir, 'added-home-skill'), { recursive: true }); + await writeFile(addedHomeSkillFile, [ + '---', + 'name: added-home-skill', + 'description: Added after startup in home', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeSkillsDir).toString(), CustomizationType.Skill, ['added-home-skill', 'home-skill-renamed']); + + client.clearReceived(); + await rm(addedHomeSkillFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeSkillsDir).toString(), CustomizationType.Skill, ['home-skill-renamed']); + + client.clearReceived(); + await writeFile(homeInstructionFile, [ + '---', + 'name: Home Policy Renamed', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer home defaults.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeInstructionsDir).toString(), CustomizationType.Rule, ['Home Policy Renamed']); + + client.clearReceived(); + await writeFile(addedHomeInstructionFile, [ + '---', + 'name: Added Home Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeInstructionsDir).toString(), CustomizationType.Rule, ['Added Home Policy', 'Home Policy Renamed']); + + client.clearReceived(); + await rm(addedHomeInstructionFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeInstructionsDir).toString(), CustomizationType.Rule, ['Home Policy Renamed']); + + client.clearReceived(); + await writeFile(homeHookFile, JSON.stringify({ PreToolUse: [{ command: 'echo home-changed' }] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(homeHooksDir).toString(), CustomizationType.Hook, ['home-pre-tool.json']); + + client.clearReceived(); + await writeFile(addedHomeHookFile, JSON.stringify({ PostToolUse: [] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(homeHooksDir).toString(), CustomizationType.Hook, ['home-post-tool.json', 'home-pre-tool.json']); + + client.clearReceived(); + await rm(addedHomeHookFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeHooksDir).toString(), CustomizationType.Hook, ['home-pre-tool.json']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts index f0925c97e051f..6c09d20777e1e 100644 --- a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts @@ -58,10 +58,12 @@ defineSharedRealSdkTests(COPILOT_CONFIG); let client: TestProtocolClient; const createdSessions: string[] = []; const tempDirs: string[] = []; + let userHomeDir: string; suiteSetup(async function () { this.timeout(60_000); - server = await startRealServer(); + userHomeDir = await mkdtemp(`${tmpdir()}/ahp-customizations-home-`); + server = await startRealServer({ homeDir: userHomeDir }); }); suiteTeardown(function () { @@ -70,6 +72,9 @@ defineSharedRealSdkTests(COPILOT_CONFIG); setup(async function () { this.timeout(30_000); + if (!tempDirs.includes(userHomeDir)) { + tempDirs.push(userHomeDir); + } client = new TestProtocolClient(server.port); await client.connect(); }); @@ -93,8 +98,10 @@ defineSharedRealSdkTests(COPILOT_CONFIG); test('usage reports include Copilot cost metadata', async function () { this.timeout(120_000); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-cost-report-')); + tempDirs.push(workingDirectory); - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-usage', createdSessions, URI.file(tmpdir()).toString()); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-usage', createdSessions, URI.file(workingDirectory)); dispatchTurn(client, sessionUri, 'turn-usage', 'Reply with exactly "usage-ok" and do not use tools.', 1); const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'chat/usage'), 90_000); @@ -122,15 +129,15 @@ defineSharedRealSdkTests(COPILOT_CONFIG); test('attaches a Python file and reads its function names', async function () { this.timeout(120_000); - const tempDir = await mkdtemp(`${tmpdir()}/ahp-attachment-test-`); - tempDirs.push(tempDir); - const filePath = join(tempDir, 'calculator.py'); + const workingDirectory = await mkdtemp(`${tmpdir()}/ahp-attachment-test-`); + tempDirs.push(workingDirectory); + const filePath = join(workingDirectory, 'calculator.py'); await writeFile(filePath, [ 'def add(a, b):', '\treturn a + b', ].join('\n')); - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-attachment', createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-attachment', createdSessions, URI.file(workingDirectory)); const prompt = 'Read the attached Python file. What function names are defined in it? Reply with only the function names.'; const attachments: MessageAttachment[] = [{ type: MessageAttachmentKind.Resource, @@ -144,10 +151,13 @@ defineSharedRealSdkTests(COPILOT_CONFIG); assert.match(result.responseText, /\badd\b/i, `expected the model to identify the attached file function; got: ${JSON.stringify(result.responseText)}`); }); - test.skip('attaches a text blob and reads its function names', async function () { + test('attaches a text blob and reads its function names', async function () { this.timeout(120_000); - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-blob-attachment', createdSessions, URI.file(tmpdir()).toString()); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-text-blob-')); + tempDirs.push(workingDirectory); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-blob-attachment', createdSessions, URI.file(workingDirectory)); const prompt = 'Read the attached Python text blob. What function names are defined in it? Reply with only the function names.'; const attachments: MessageAttachment[] = [{ type: MessageAttachmentKind.Simple, @@ -167,10 +177,10 @@ defineSharedRealSdkTests(COPILOT_CONFIG); test('strips redundant `cd &&` prefix from shell tool calls', async function () { this.timeout(180_000); - const tempDir = await mkdtemp(`${tmpdir()}/ahp-cd-strip-test-`); - tempDirs.push(tempDir); - const expectedWorkingDirPath = tempDir; - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-cd-strip', createdSessions, URI.file(tempDir).toString()); + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-cd-strip-test-`); + tempDirs.push(workspaceDir); + const expectedWorkingDirPath = workspaceDir; + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-cd-strip', createdSessions, URI.file(workspaceDir)); client.clearReceived(); dispatchTurn(client, sessionUri, 'turn-cd-strip', diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdkMocked.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdkMocked.integrationTest.ts new file mode 100644 index 0000000000000..1f7d56aeca84e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdkMocked.integrationTest.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Real Copilot SDK integration tests running on a mocked LLM. + */ + +import assert from 'assert'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { URI } from '../../../../../base/common/uri.js'; +import { ResponsePartKind } from '../../../common/state/sessionState.js'; +import { createRealSession, dispatchTurn, IRealSdkProviderConfig } from './realSdkTestHelpers.js'; +import { fetchSessionWithChat, isActionNotification, IServerHandle, startRealServer, TestProtocolClient } from './testHelpers.js'; + +export const COPILOT_CONFIG: IRealSdkProviderConfig = { + suiteTitle: 'Protocol WebSocket — Real Copilot SDK Mocked LLM', + provider: 'copilotcli', + scheme: 'copilotcli', + shellToolName: 'bash', + subagentToolNames: ['task'], + exitPlanModeToolName: 'exit_plan_mode', + enabled: true, + supportsWorktreeIsolation: true, + supportsSubagents: true, + supportsPlanMode: true, + githubToken: 'not-a-real-token', // The tests will use a mocked LLM, so the token doesn't need to be valid. +}; + +suite('Protocol WebSocket — Real Copilot SDK, Mocked LLM (Copilot-specific)', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + const createdSessions: string[] = []; + const tempDirs: string[] = []; + + suiteSetup(async function () { + this.timeout(120_000); + server = await startRealServer({ mockLlm: true }); + }); + + suiteTeardown(function () { + server?.process.kill(); + }); + + setup(async function () { + this.timeout(120_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(async function () { + for (const session of createdSessions) { + try { + await client.call('disposeSession', { session }, 5000); + } catch { /* best-effort */ } + } + createdSessions.length = 0; + client.close(); + + for (const dir of tempDirs) { + try { + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { /* best-effort */ } + } + tempDirs.length = 0; + }); + + test('returns a hello response via mock LLM', async function () { + this.timeout(180_000); + + const probeToken = 'MOCK_REQUEST_PROBE_12345'; + const workspaceDir = await mkdtemp(`${tmpdir()}/test-mock-hello`); + tempDirs.push(workspaceDir); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-mock-hello', createdSessions, URI.file(workspaceDir)); + dispatchTurn(client, sessionUri, 'turn-mock-hello', `Reply with exactly: ${probeToken}`, 1); + try { + await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); + } catch (err) { + console.error(`Failed to receive chat/turnComplete notification within timeout: ${err}, receivedNotifications: ${JSON.stringify(client.receivedNotifications())}, logMessages: ${server.mockLlm?.logMessages.join('\n') ?? 'no mockllm server'}`); + throw new Error(`Failed to receive chat/turnComplete notification within timeout: ${err}, receivedNotifications: ${JSON.stringify(client.receivedNotifications())}, logMessages: ${server.mockLlm?.logMessages.join('\n') ?? 'no mockllm server'}`); + } + + assert.ok((server.mockLlm?.requestCount() ?? 0) >= 1, 'expected at least one request to the mock LLM'); + + const state = await fetchSessionWithChat(client, sessionUri); + + const turn = state.turns.find(t => t.id === 'turn-mock-hello'); + const markdownText = turn?.responseParts.map(p => p.kind === ResponsePartKind.Markdown ? p.content : '').join('\n') ?? ``; + assert.ok(markdownText.trim().length > 0, `expected non-empty assistant markdown; got: ${JSON.stringify(markdownText)}`); + assert.match(markdownText, new RegExp(`\\b${probeToken}\\b`, 'i'), `expected probe token in assistant markdown; got: ${JSON.stringify(markdownText)}`); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts index 01f7c774832a0..a617d25df49be 100644 --- a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts @@ -281,7 +281,7 @@ suite('Protocol WebSocket — Multi-Client', function () { // Client B confirms the tool call client2.notify('dispatchAction', { - channel: sessionUri, + channel: buildDefaultChatUri(sessionUri), clientSeq: 1, action: { type: 'chat/toolCallConfirmed', diff --git a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts index 352d8a3cd674e..f998c692f26c7 100644 --- a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts @@ -26,7 +26,7 @@ import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registr import { MessageKind, ResponsePartKind, ROOT_STATE_URI, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, - ChatInputResponseKind, ToolResultContentType, ToolCallConfirmationReason, ToolCallCancellationReason, buildDefaultChatUri, isSubagentSession, + ChatInputResponseKind, ToolResultContentType, ToolCallConfirmationReason, ToolCallCancellationReason, buildDefaultChatUri, buildSubagentSessionUri, parseChatUri, type MessageAttachment, type ChatInputAnswer, type ChatInputRequest, type ISessionWithDefaultChat, type SessionState, type TerminalState, type ToolResultContent, type ToolResultSubagentContent, } from '../../../common/state/sessionState.js'; @@ -127,6 +127,11 @@ export interface IRealSdkProviderConfig { * shared test prompt doesn't reliably drive it to `ExitPlanMode`. */ readonly supportsPlanMode: boolean; + + /** + * The github token to use. If not provided, the test will attempt to resolve it from the environment or `gh auth token`. + */ + readonly githubToken?: string; } // #endregion @@ -139,10 +144,10 @@ export async function createRealSession( config: IRealSdkProviderConfig, clientId: string, trackingList: string[], - workingDirectory?: string, + workingDirectory: URI, ): Promise { await c.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId }, 30_000); - await c.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); + await c.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: config.githubToken ?? resolveGitHubToken() }, 30_000); const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); // Default to `folder` isolation so the agent runs in the directory the @@ -152,7 +157,7 @@ export async function createRealSession( await c.call('createSession', { channel: sessionUri, provider: config.provider, - workingDirectory, + workingDirectory: workingDirectory.toString(), config: workingDirectory ? { isolation: 'folder' } : undefined, }, 30_000); @@ -175,7 +180,7 @@ export async function createRealSession( /** Dispatch a turn with the given user message text. */ export function dispatchTurn(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { c.dispatch({ - channel: session, + channel: buildDefaultChatUri(session), clientSeq, action: { type: ActionType.ChatTurnStarted, @@ -188,7 +193,7 @@ export function dispatchTurn(c: TestProtocolClient, session: string, turnId: str /** Dispatch a turn with the given user message text and attachments. */ export function dispatchTurnWithAttachments(c: TestProtocolClient, session: string, turnId: string, text: string, attachments: readonly MessageAttachment[], clientSeq: number): void { c.dispatch({ - channel: session, + channel: buildDefaultChatUri(session), clientSeq, action: { type: ActionType.ChatTurnStarted, @@ -315,7 +320,7 @@ async function driveTurn(c: TestProtocolClient, session: string, turnId: string, if (!action.confirmed) { sawPendingConfirmation = true; c.dispatch({ - channel: session, + channel: buildDefaultChatUri(session), clientSeq: nextClientSeq++, action: { type: ActionType.ChatToolCallConfirmed, @@ -333,7 +338,7 @@ async function driveTurn(c: TestProtocolClient, session: string, turnId: string, sawInputRequest = true; const action = getActionEnvelope(notification).action as ChatInputRequestedAction; c.dispatch({ - channel: session, + channel: buildDefaultChatUri(session), clientSeq: nextClientSeq++, action: { type: ActionType.ChatInputCompleted, @@ -552,7 +557,10 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { test('sends a simple message and receives a response', async function () { this.timeout(120_000); - const sessionUri = await createRealSession(client, config, `real-sdk-simple-${config.provider}`, createdSessions, URI.file(tmpdir()).toString()); + const workspaceDir = mkdtempSync(`${tmpdir()}/read-sdk-simple`); + tempDirs.push(workspaceDir); + + const sessionUri = await createRealSession(client, config, `real-sdk-simple-${config.provider}`, createdSessions, URI.file(workspaceDir)); dispatchTurn(client, sessionUri, 'turn-1', 'Say exactly "hello" and nothing else', 1); const complete = await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); @@ -626,7 +634,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const tempDir = mkdtempSync(`${tmpdir()}/ahp-perm-test-`); tempDirs.push(tempDir); - const sessionUri = await createRealSession(client, config, `real-sdk-permission-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-permission-${config.provider}`, createdSessions, URI.file(tempDir)); dispatchTurn(client, sessionUri, 'turn-perm', 'Run the shell command: echo "hello from test"', 1); // Validate the permission flow by driving toward the first signal @@ -658,7 +666,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { } const action = getActionEnvelope(next).action as { toolCallId: string }; client.dispatch({ - channel: sessionUri, + channel: buildDefaultChatUri(sessionUri), clientSeq: nextSeq++, action: { type: ActionType.ChatToolCallConfirmed, @@ -678,7 +686,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const tempDir = mkdtempSync(`${tmpdir()}/ahp-plan-test-`); tempDirs.push(tempDir); - const sessionUri = await createRealSession(client, config, `real-sdk-plan-mode-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-plan-mode-${config.provider}`, createdSessions, URI.file(tempDir)); client.dispatch({ channel: sessionUri, @@ -717,14 +725,16 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { assert.strictEqual(extraSessionNotificationsAfterFollowup.length, 0, 'sending another message should stay on the same session instead of forking'); const resubscribeResult = await client.call('subscribe', { channel: sessionUri }); - const finalSnapshot = resubscribeResult.snapshot!.state as SessionState; - assert.strictEqual(finalSnapshot.summary.resource, sessionUri, 'follow-up turn should keep the original session resource'); + assert.strictEqual(resubscribeResult.snapshot!.resource, sessionUri, 'follow-up turn should keep the original session resource'); }); test('can abort a running turn', async function () { this.timeout(120_000); - const sessionUri = await createRealSession(client, config, `real-sdk-abort-${config.provider}`, createdSessions, URI.file(tmpdir()).toString()); + const tempDir = mkdtempSync(`${tmpdir()}/ahp-abort-`); + tempDirs.push(tempDir); + + const sessionUri = await createRealSession(client, config, `real-sdk-abort-${config.provider}`, createdSessions, URI.file(tempDir)); dispatchTurn(client, sessionUri, 'turn-abort', 'Write a very long essay about the history of computing', 1); await client.waitForNotification( @@ -759,7 +769,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const subscribeResult = await client.call('subscribe', { channel: sessionUri }); const sessionState = subscribeResult.snapshot!.state as SessionState; - assert.strictEqual(sessionState.summary.workingDirectory, workingDirUri, + assert.strictEqual(sessionState.workingDirectory, workingDirUri, `subscribe snapshot summary should carry the requested working directory`); }); @@ -805,7 +815,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { channel: sessionUri, clientSeq: 1, action: { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: `real-sdk-worktree-${config.provider}`, displayName: 'Test Client', @@ -857,7 +867,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { confirmed?: string }; if (!toolReadyAction.confirmed) { client.dispatch({ - channel: addedSummary.resource, + channel: buildDefaultChatUri(addedSummary.resource), clientSeq: 4, action: { type: ActionType.ChatToolCallConfirmed, @@ -898,7 +908,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { writeFileSync(`${tempDir}/file-a.txt`, 'alpha'); writeFileSync(`${tempDir}/file-b.txt`, 'beta'); - const sessionUri = await createRealSession(client, config, `real-sdk-subagent-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-subagent-${config.provider}`, createdSessions, URI.file(tempDir)); const sessionChatUri = buildDefaultChatUri(sessionUri); let approvalsActive = true; @@ -953,15 +963,15 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const parentContent = (getActionEnvelope(subagentContentNotif).action as { content: readonly ToolResultContent[] }).content; const subagentRef = parentContent.find((c): c is ToolResultSubagentContent => c.type === ToolResultContentType.Subagent)!; - const subagentSessionUri = subagentRef.resource as unknown as string; - assert.ok(typeof subagentSessionUri === 'string' && isSubagentSession(subagentSessionUri), - `subagent session URI should be subagent-shaped, got: ${JSON.stringify(subagentSessionUri)}`); - const subagentChatUri = buildDefaultChatUri(subagentSessionUri); + const subagentChatUri = subagentRef.resource as unknown as string; + const parsedSubagentChat = parseChatUri(subagentChatUri); + assert.ok( + parsedSubagentChat?.session === sessionUri && parsedSubagentChat.chatId.startsWith('subagent/'), + `subagent resource should be a subagent chat of the parent session, got: ${JSON.stringify(subagentChatUri)}`, + ); - await client.call('subscribe', { channel: subagentSessionUri }); // The subagent's conversation contents (its inner tool calls) are - // emitted on its own default chat channel; subscribe so we observe - // them separately from the parent. + // emitted on the chat channel carried by the tool result. await client.call('subscribe', { channel: subagentChatUri }); await client.waitForNotification(n => { @@ -998,12 +1008,14 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { writeFileSync(`${tempDir}/file-a.txt`, 'alpha'); writeFileSync(`${tempDir}/file-b.txt`, 'beta'); - const sessionUri = await createRealSession(client, config, `real-sdk-subagent-replay-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-subagent-replay-${config.provider}`, createdSessions, URI.file(tempDir)); + const sessionChatUri = buildDefaultChatUri(sessionUri); - // A unique marker the sub-agent — and only the sub-agent — is asked to - // emit, so we can detect whether its assistant message leaks into the - // parent transcript when the session is rebuilt from the event log. - const sentinel = `SUBAGENT_ONLY_MARKER_${generateUuid().replace(/-/g, '')}`; + // A unique phrase that only the subagent is asked to emit in an + // intermediate assistant message, so replay can detect whether + // subagent assistant text leaks upward without depending on the + // parent agent's final summary behavior. + const sentinel = `subagent replay note ${generateUuid().replace(/-/g, '').slice(0, 10)}`; let approvalsActive = true; let approvalSeq = 2000; @@ -1024,13 +1036,14 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { processedSeqs.add(envelope.serverSeq); const action = envelope.action as { turnId: string; toolCallId: string; confirmed?: string }; if (!action.confirmed) { - client.notify('dispatchAction', { + client.dispatch({ channel: envelope.channel, clientSeq: ++approvalSeq, action: { - type: 'chat/toolCallConfirmed', + type: ActionType.ChatToolCallConfirmed, turnId: action.turnId, toolCallId: action.toolCallId, approved: true, + confirmed: ToolCallConfirmationReason.UserAction, }, }); } @@ -1041,8 +1054,9 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { dispatchTurn(client, sessionUri, 'turn-sa-replay', `Use the \`${config.subagentToolNames[0]}\` tool to spawn a subagent to list the files in the current working directory. ` + - `Instruct the subagent to finish its response with the exact marker text ${sentinel} on its own line. ` + - `You, the main agent, must NOT write the marker text ${sentinel} yourself — only the subagent may emit it.`, + `Instruct the subagent to begin its response with this sentence on its own line: ${sentinel}. ` + + 'Then the subagent should list the files. ' + + 'After the subagent completes, you, the main agent, must reply exactly "SUBAGENT_DONE" and must not repeat that sentence.', 1); const subagentContentNotif = await client.waitForNotification(n => { @@ -1051,34 +1065,42 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { } const envelope = getActionEnvelope(n); const action = envelope.action as { content: readonly ToolResultContent[] }; - return envelope.channel === sessionUri && action.content.some(c => c.type === ToolResultContentType.Subagent); + return envelope.channel === sessionChatUri && action.content.some(c => c.type === ToolResultContentType.Subagent); }, 120_000); const parentContent = (getActionEnvelope(subagentContentNotif).action as { content: readonly ToolResultContent[] }).content; const subagentRef = parentContent.find((c): c is ToolResultSubagentContent => c.type === ToolResultContentType.Subagent)!; - const subagentSessionUri = subagentRef.resource as unknown as string; - assert.ok(typeof subagentSessionUri === 'string' && isSubagentSession(subagentSessionUri), - `subagent session URI should be subagent-shaped, got: ${JSON.stringify(subagentSessionUri)}`); + const subagentChatUri = subagentRef.resource as unknown as string; + const parsedSubagentChat = parseChatUri(subagentChatUri); + assert.ok( + parsedSubagentChat?.session === sessionUri && parsedSubagentChat.chatId.startsWith('subagent/'), + `subagent resource should be a subagent chat of the parent session, got: ${JSON.stringify(subagentChatUri)}`, + ); + const subagentToolCallId = parsedSubagentChat.chatId.slice('subagent/'.length); + const replaySubagentSessionUri = buildSubagentSessionUri(sessionUri, subagentToolCallId); - await client.call('subscribe', { channel: subagentSessionUri }); + await client.call('subscribe', { channel: subagentChatUri }); await client.waitForNotification(n => - isActionNotification(n, 'chat/turnComplete') && getActionEnvelope(n).channel === sessionUri, 150_000); + isActionNotification(n, 'chat/turnComplete') && getActionEnvelope(n).channel === sessionChatUri, 150_000); approvalsActive = false; await approvalLoop; - // Force a reopen: drop every subscription on the session and its - // sub-agent so the agent host evicts the cached, live-built state, then - // re-fetch — which rebuilds the turns from the persisted SDK event log - // through `mapSessionEvents` (the path the regression lived in). The - // parent-session unsubscribe is sent last so it triggers eviction. - for (const channel of [buildDefaultChatUri(subagentSessionUri), subagentSessionUri, buildDefaultChatUri(sessionUri), sessionUri]) { + // Force a reopen: drop the subagent chat and parent-session + // subscriptions so the agent host evicts the cached, live-built state, + // then re-fetch — which rebuilds the turns from the persisted SDK event + // log through `mapSessionEvents` (the path the regression lived in). + // The parent-session unsubscribe is sent last so it triggers eviction. + for (const channel of [subagentChatUri, buildDefaultChatUri(sessionUri), sessionUri]) { client.notify('unsubscribe', { channel }); } const reopenedParent = await fetchSessionWithChat(client, sessionUri); - const reopenedSubagent = await fetchSessionWithChat(client, subagentSessionUri); + // Persisted SDK replay still restores subagents through their derived + // session resource, while the live path exposes the dedicated chat + // resource above. + const reopenedSubagent = await fetchSessionWithChat(client, replaySubagentSessionUri); const assistantText = (turns: ISessionWithDefaultChat['turns']): string => turns.map(t => t.responseParts.map(p => p.kind === ResponsePartKind.Markdown ? p.content : '').join('')).join('\n'); @@ -1086,15 +1108,15 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const subagentText = assistantText(reopenedSubagent.turns); const parentText = assistantText(reopenedParent.turns); - // Precondition: the sub-agent emitted the marker and it is routed to the + // Precondition: the sub-agent emitted the phrase and it is routed to the // sub-agent transcript on the replay path. assert.ok(subagentText.includes(sentinel), - `sub-agent transcript should contain the marker after reopen; got: ${JSON.stringify(subagentText).slice(0, 500)}`); + `sub-agent transcript should contain the phrase after reopen; got: ${JSON.stringify(subagentText).slice(0, 500)}`); // The regression: the sub-agent's assistant.message must NOT leak into // the parent transcript when the session is reopened. assert.ok(!parentText.includes(sentinel), - `parent transcript must NOT contain the sub-agent's marker after reopen ` + + `parent transcript must NOT contain the sub-agent's phrase after reopen ` + `(replay path leaked sub-agent assistant.message into parent turns); ` + `parent text: ${JSON.stringify(parentText).slice(0, 800)}`); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts index a5b86e2d04d37..9583cabf10866 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { ChangesetFileSetAction, SessionAddedParams } from '../../../common/state/sessionActions.js'; +import type { ChangesetContentChangedAction, SessionAddedParams } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import { dispatchTurnStarted, @@ -101,19 +101,30 @@ const hasGit = (() => { const editedFile = join(tmpRoot, 'from-terminal.txt'); dispatchTurnStarted(client, sessionUri, 'turn-1', `terminal-edit:${editedFile}`, 1); - // Wait for a `changeset/fileSet` action targeting the edited file. - // On macOS, git's `--show-toplevel` resolves symlinks (/var → - // /private/var) so the URI may differ in prefix; match by basename. - const fileSetNotif = await client.waitForNotification(n => { - if (!isActionNotification(n, 'changeset/fileSet')) { + // Wait for a `changeset/contentChanged` action whose file list + // includes the edited file. On macOS, git's `--show-toplevel` + // resolves symlinks (/var → /private/var) so the URI may differ in + // prefix; match by basename. + const fileUri = (edit: ChangesetContentChangedAction['files'][number]['edit']) => + edit.after?.uri ?? edit.before?.uri; + const matchesEditedFile = (action: ChangesetContentChangedAction) => + action.files.some(f => { + const u = fileUri(f.edit); + return typeof u === 'string' && u.endsWith('/from-terminal.txt'); + }); + const contentChangedNotif = await client.waitForNotification(n => { + if (!isActionNotification(n, 'changeset/contentChanged')) { return false; } - const action = getActionEnvelope(n).action as ChangesetFileSetAction; - const u = action.file.edit.after?.uri ?? action.file.edit.before?.uri; - return typeof u === 'string' && u.endsWith('/from-terminal.txt'); + return matchesEditedFile(getActionEnvelope(n).action as ChangesetContentChangedAction); }, 10_000); - const action = getActionEnvelope(fileSetNotif).action as ChangesetFileSetAction; - assert.ok(action.file.edit.after, 'expected after-side for newly added file'); - assert.ok(!action.file.edit.before, 'newly added file should have no before-side'); + const action = getActionEnvelope(contentChangedNotif).action as ChangesetContentChangedAction; + const file = action.files.find(f => { + const u = fileUri(f.edit); + return typeof u === 'string' && u.endsWith('/from-terminal.txt'); + }); + assert.ok(file, 'expected the edited file in the changeset content'); + assert.ok(file.edit.after, 'expected after-side for newly added file'); + assert.ok(!file.edit.before, 'newly added file should have no before-side'); }); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index 6cc94772ad932..4a3136a81e3db 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -6,13 +6,14 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { IModelChangedAction, IResponsePartAction, SessionAddedParams, ITitleChangedAction } from '../../../common/state/sessionActions.js'; +import { ActionType, type IResponsePartAction, type ITurnStartedAction, type SessionAddedParams, type ITitleChangedAction } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import type { ListSessionsResult } from '../../../common/state/sessionProtocol.js'; import { MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; import { MOCK_AUTO_TITLE } from '../mockAgent.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, fetchSessionWithChat, getActionEnvelope, @@ -69,7 +70,7 @@ suite('Protocol WebSocket — Session Features', function () { const snapshot = await client.call('subscribe', { channel: sessionUri }); const state = snapshot.snapshot!.state as ISessionWithDefaultChat; - assert.strictEqual(state.summary.title, 'My Custom Title'); + assert.strictEqual(state.title, 'My Custom Title'); }); test('agent-generated titleChanged is broadcast', async function () { @@ -94,7 +95,7 @@ suite('Protocol WebSocket — Session Features', function () { const snapshot = await client.call('subscribe', { channel: sessionUri }); const state = snapshot.snapshot!.state as ISessionWithDefaultChat; - assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE); + assert.strictEqual(state.title, MOCK_AUTO_TITLE); }); test('first turn immediately sets title to user message', async function () { @@ -104,7 +105,7 @@ suite('Protocol WebSocket — Session Features', function () { // Verify the session starts with the default placeholder title const before = await client.call('subscribe', { channel: sessionUri }); - assert.strictEqual((before.snapshot!.state as ISessionWithDefaultChat).summary.title, ''); + assert.strictEqual((before.snapshot!.state as ISessionWithDefaultChat).title, ''); // Send first turn — side effects should dispatch an immediate titleChanged // with the user's message text before the agent produces its own title. @@ -136,7 +137,13 @@ suite('Protocol WebSocket — Session Features', function () { }, }); - await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/titleChanged')) { + return false; + } + const action = getActionEnvelope(n).action as ITitleChangedAction; + return action.title === 'Persisted Title'; + }); // Poll listSessions until the persisted title appears (async DB write) let session: { title: string } | undefined; @@ -154,47 +161,28 @@ suite('Protocol WebSocket — Session Features', function () { // ---- Session model -------------------------------------------------------- - test('session model flows through create, subscribe, listSessions, and modelChanged', async function () { + test('message model flows through turn dispatch and subscribe', async function () { this.timeout(10_000); - await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: 'test-model-summary' }); - - const sessionUri = nextSessionUri(); - await client.call('createSession', { channel: sessionUri, provider: 'mock', model: { id: 'mock-model' } }); - - const addedNotif = await client.waitForNotification(n => - n.method === 'root/sessionAdded' - ); - const addedSession = addedNotif.params as SessionAddedParams; - assert.deepStrictEqual(addedSession.summary.model, { id: 'mock-model' }); - const createdSessionUri = addedSession.summary.resource; - - const initialSnapshot = await client.call('subscribe', { channel: createdSessionUri }); - const initialState = initialSnapshot.snapshot!.state as ISessionWithDefaultChat; - assert.deepStrictEqual(initialState.summary.model, { id: 'mock-model' }); - - const initialList = await client.call('listSessions', { channel: ROOT_STATE_URI }); - assert.deepStrictEqual(initialList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model' }); - - client.notify('dispatchAction', { - channel: createdSessionUri, + const sessionUri = await createAndSubscribeSession(client, 'test-message-model'); + client.dispatch({ + channel: defaultChatChannel(sessionUri), clientSeq: 1, action: { - type: 'session/modelChanged', - model: { id: 'mock-model-2' }, + type: ActionType.ChatTurnStarted, + turnId: 'turn-model', + message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'mock-model' } }, }, }); - const modelNotif = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged')); - const modelAction = getActionEnvelope(modelNotif).action as IModelChangedAction; - assert.deepStrictEqual(modelAction.model, { id: 'mock-model-2' }); + const turnStartedNotif = await client.waitForNotification(n => isActionNotification(n, 'chat/turnStarted')); + const turnStartedAction = getActionEnvelope(turnStartedNotif).action as ITurnStartedAction; + assert.deepStrictEqual(turnStartedAction.message.model, { id: 'mock-model' }); - const updatedSnapshot = await client.call('subscribe', { channel: createdSessionUri }); - const updatedState = updatedSnapshot.snapshot!.state as ISessionWithDefaultChat; - assert.deepStrictEqual(updatedState.summary.model, { id: 'mock-model-2' }); + await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete')); - const updatedList = await client.call('listSessions', { channel: ROOT_STATE_URI }); - assert.deepStrictEqual(updatedList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model-2' }); + const state = await fetchSessionWithChat(client, sessionUri); + assert.deepStrictEqual(state.turns.at(-1)?.message.model, { id: 'mock-model' }); }); // ---- Reasoning events ------------------------------------------------------ @@ -247,7 +235,7 @@ suite('Protocol WebSocket — Session Features', function () { // Queue a message when the session is idle — server should immediately consume it client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 1, action: { type: 'chat/pendingMessageSet', @@ -283,7 +271,7 @@ suite('Protocol WebSocket — Session Features', function () { // Queue a message while the turn is in progress client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/pendingMessageSet', @@ -329,7 +317,7 @@ suite('Protocol WebSocket — Session Features', function () { // Set a steering message while the turn is in progress client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/pendingMessageSet', @@ -378,7 +366,7 @@ suite('Protocol WebSocket — Session Features', function () { // Truncate: keep only turn-t1 client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 3, action: { type: 'chat/truncated', turnId: 'turn-t1' }, }); @@ -402,7 +390,7 @@ suite('Protocol WebSocket — Session Features', function () { // Truncate all (no turnId) client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/truncated' }, }); @@ -429,7 +417,7 @@ suite('Protocol WebSocket — Session Features', function () { // Truncate to turn-tr1 client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 3, action: { type: 'chat/truncated', turnId: 'turn-tr1' }, }); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts index 2d4bd0e1beb87..b9fe7b823fc42 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts @@ -162,8 +162,8 @@ suite('Protocol WebSocket — Session Lifecycle', function () { // Verify the flags are reflected in the subscribed session state const snapshot = await client.call('subscribe', { channel: sessionUri }); const state = snapshot.snapshot!.state as ISessionWithDefaultChat; - assert.ok(state.summary.status & SessionStatus.IsArchived, 'IsArchived flag should be set in snapshot'); - assert.ok(state.summary.status & SessionStatus.IsRead, 'IsRead flag should be set in snapshot'); + assert.ok(state.status & SessionStatus.IsArchived, 'IsArchived flag should be set in snapshot'); + assert.ok(state.status & SessionStatus.IsRead, 'IsRead flag should be set in snapshot'); // Poll listSessions until the persisted flags appear (async DB write) client.close(); diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts index b05adf675043b..50e14a47640fd 100644 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { ChildProcess, fork } from 'child_process'; +import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { WebSocket } from 'ws'; import { URI } from '../../../../../base/common/uri.js'; import { SubscribeResult, type DispatchActionParams } from '../../../common/state/protocol/commands.js'; import { ActionType, type ActionEnvelope } from '../../../common/state/sessionActions.js'; import type { SessionAddedParams } from '../../../common/state/protocol/notifications.js'; -import { MessageKind, buildDefaultChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type SessionState } from '../../../common/state/sessionState.js'; +import { MessageKind, buildDefaultChatUri, mergeSessionWithDefaultChat, parseDefaultChatUri, type ChatState, type ISessionWithDefaultChat, type SessionState } from '../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; +import { AgentHostCodexAgentEnabledEnvVar } from '../../../common/agentService.js'; import { isJsonRpcNotification, isJsonRpcResponse, @@ -33,7 +35,7 @@ export class TestProtocolClient { private _nextId = 1; private readonly _pendingCalls = new Map(); private readonly _notifications: AhpNotification[] = []; - private readonly _notifWaiters: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void }[] = []; + private readonly _notifWaiters: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void; dispose: () => void }[] = []; constructor(port: number) { this._ws = new WebSocket(`ws://127.0.0.1:${port}`); @@ -67,13 +69,8 @@ export class TestProtocolClient { } } else if (isJsonRpcNotification(msg)) { const notif = msg; - for (let i = this._notifWaiters.length - 1; i >= 0; i--) { - if (this._notifWaiters[i].predicate(notif)) { - const waiter = this._notifWaiters.splice(i, 1)[0]; - waiter.resolve(notif); - } - } this._notifications.push(notif); + this._flushNotificationWaiters(); } } @@ -120,22 +117,44 @@ export class TestProtocolClient { } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); - if (idx >= 0) { - this._notifWaiters.splice(idx, 1); - } - reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`)); - }, timeoutMs); - - this._notifWaiters.push({ + const waiter = { predicate, - resolve: n => { clearTimeout(timer); resolve(n); }, + resolve, reject, - }); + dispose: () => clearTimeout(timer), + }; + const timer = setTimeout(() => { + this._removeNotificationWaiter(waiter); + const received = this._notifications.map(n => { + const action = n.method === 'action' ? (n.params as ActionEnvelope).action.type : undefined; + return action ? `${n.method}:${action}` : n.method; + }).join(', '); + reject(new Error(`Timeout waiting for notification (${timeoutMs}ms). Received: ${received}`)); + }, timeoutMs); + this._notifWaiters.push(waiter); + this._flushNotificationWaiters(); }); } + private _flushNotificationWaiters(): void { + for (let i = this._notifWaiters.length - 1; i >= 0; i--) { + const waiter = this._notifWaiters[i]; + const match = this._notifications.find(waiter.predicate); + if (match) { + this._notifWaiters.splice(i, 1); + waiter.dispose(); + waiter.resolve(match); + } + } + } + + private _removeNotificationWaiter(waiter: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void; dispose: () => void }): void { + const idx = this._notifWaiters.indexOf(waiter); + if (idx >= 0) { + this._notifWaiters.splice(idx, 1); + } + } + /** Return all received notifications matching a predicate. */ receivedNotifications(predicate?: (n: AhpNotification) => boolean): AhpNotification[] { return predicate ? this._notifications.filter(predicate) : [...this._notifications]; @@ -168,6 +187,7 @@ export class TestProtocolClient { close(): void { for (const w of this._notifWaiters) { + w.dispose(); w.reject(new Error('Client closed')); } this._notifWaiters.length = 0; @@ -188,6 +208,51 @@ export class TestProtocolClient { export interface IServerHandle { process: ChildProcess; port: number; + /** Present when the server was started with a mock LLM; exposes request count for assertions. */ + mockLlm?: IMockLlmServerHandleWithLog; +} + +interface IMockLlmServerHandle { + readonly url: string; + requestCount(): number; + getRequests?(): readonly unknown[]; + close(): Promise; +} + +interface IMockLlmServerHandleWithLog extends IMockLlmServerHandle { + logMessages: string[]; +} + +interface IMockLlmServerModule { + startServer(port: number, options?: { logger?: (msg: string) => void; verbose?: boolean; captureRequests?: boolean }): Promise; + registerScenario(id: string, definition: unknown): void; +} + +function buildCopilotChatToken(mockUrl: string, copilotPlan: 'free' | 'pro' = 'free'): string { + return Buffer.from(JSON.stringify({ + token: 'smoketest-fake-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_in: 1800, + sku: copilotPlan === 'pro' ? 'individual_subscription_copilot' : 'free_limited_copilot', + individual: true, + isNoAuthUser: true, + copilot_plan: copilotPlan, + organization_login_list: [], + endpoints: { api: mockUrl, proxy: mockUrl }, + })).toString('base64'); +} + +async function startMockLlmServer(): Promise { + const mockServerPath = fileURLToPath(new URL('../../../../../../../scripts/chat-simulation/common/mock-llm-server.ts', import.meta.url)); + const nodeRequire = createRequire(import.meta.url); + const mockModule = nodeRequire(mockServerPath) as IMockLlmServerModule; + mockModule.registerScenario('text-only', { + type: 'multi-turn', + turns: [{ kind: 'echo-last-message' }], + }); + const messages: string[] = []; + const serverHandle = await mockModule.startServer(0, { logger: msg => messages.push(msg), verbose: true, captureRequests: true }); + return { ...serverHandle, logMessages: messages }; } export async function startServer(options?: { readonly quiet?: boolean; readonly userDataDir?: string; readonly env?: NodeJS.ProcessEnv }): Promise { @@ -236,10 +301,11 @@ export async function startServer(options?: { readonly quiet?: boolean; readonly } /** - * Start the agent host server with the real Copilot SDK agent (no mock agent). + * Start the agent host server with the Copilot SDK agent with either a real or mocked LLM. * The server is started with logging enabled so the CopilotAgent is registered. */ -export async function startRealServer(options?: { readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string }): Promise { +export async function startRealServer(options?: { readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly mockLlm?: boolean; readonly homeDir?: string; readonly env?: NodeJS.ProcessEnv }): Promise { + const mockLlmServer = options?.mockLlm ? await startMockLlmServer() : undefined; return new Promise((resolve, reject) => { const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url)); const args = ['--port', '0', '--without-connection-token']; @@ -249,12 +315,64 @@ export async function startRealServer(options?: { readonly claudeSdkRoot?: strin if (options?.codexSdkRoot) { args.push('--codex-sdk-root', options.codexSdkRoot); } - const child = fork(serverPath, args, { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + const childEnv = { + ...process.env, + ...(options?.env ?? {}), + ...(options?.homeDir ? { + HOME: options.homeDir, + USERPROFILE: options.homeDir, + } : {}), + // Codex defaults to disabled; opt it in for the real-SDK suite when a + // codex SDK root is supplied so the provider actually registers. + ...(options?.codexSdkRoot ? { [AgentHostCodexAgentEnabledEnvVar]: 'true' } : {}), + ...(mockLlmServer ? { + GITHUB_PAT: 'smoketest-fake-pat', + IS_SCENARIO_AUTOMATION: '1', + // Real-SDK Copilot tests run against responses-capable models + // (e.g. gpt-5.3-codex) that are "pro"-gated in the mock /models + // fixture, so mint a pro-plan token for this harness. + VSCODE_COPILOT_CHAT_TOKEN: buildCopilotChatToken(mockLlmServer.url, 'pro'), + // Route the Copilot SDK's GitHub API calls (token refresh, model + // discovery, etc.) at the mock instead of api.github.com, which + // would 401 with the fake token. + COPILOT_DEBUG_GITHUB_API_URL: mockLlmServer.url, + COPILOT_API_URL: mockLlmServer.url, + GITHUB_COPILOT_API_TOKEN: 'smoketest-fake-agent-host-token', + // Route the agent host's shared CAPI client (used by the Codex / + // agent-host harnesses for model discovery + requests) at the mock + // instead of api.github.com, which would 401 with the fake token. + VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE: mockLlmServer.url, + } : {}), + }; + let child: ChildProcess; + try { + child = fork(serverPath, args, { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: childEnv, + }); + } catch (err) { + void mockLlmServer?.close(); + throw err; + } + let mockClosed = false; + const closeMockServer = async (): Promise => { + if (mockClosed || !mockLlmServer) { + return; + } + mockClosed = true; + try { + await mockLlmServer.close(); + } catch { + // best effort + } + }; + child.on('exit', () => { + void closeMockServer(); }); const timer = setTimeout(() => { child.kill(); + void closeMockServer(); reject(new Error('Real server startup timed out')); }, 30_000); @@ -263,7 +381,7 @@ export async function startRealServer(options?: { readonly claudeSdkRoot?: strin const match = text.match(/READY:(\d+)/); if (match) { clearTimeout(timer); - resolve({ process: child, port: parseInt(match[1], 10) }); + resolve({ process: child, port: parseInt(match[1], 10), mockLlm: mockLlmServer }); } }); @@ -276,11 +394,13 @@ export async function startRealServer(options?: { readonly claudeSdkRoot?: strin child.on('error', err => { clearTimeout(timer); + void closeMockServer(); reject(err); }); child.on('exit', code => { clearTimeout(timer); + void closeMockServer(); reject(new Error(`Real server exited prematurely with code ${code}`)); }); }); @@ -294,6 +414,10 @@ export function nextSessionUri(): string { return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }).toString(); } +export function defaultChatChannel(sessionUri: string): string { + return buildDefaultChatUri(sessionUri); +} + export function isActionNotification(n: AhpNotification, actionType: string): boolean { if (n.method !== 'action') { return false; @@ -330,7 +454,7 @@ export async function createAndSubscribeSession(c: TestProtocolClient, clientId: export function dispatchTurnStarted(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { c.dispatch({ - channel: session, + channel: defaultChatChannel(session), clientSeq, action: { type: ActionType.ChatTurnStarted, @@ -348,8 +472,10 @@ export function dispatchTurnStarted(c: TestProtocolClient, session: string, turn * requires merging the session snapshot with its default chat snapshot. */ export async function fetchSessionWithChat(c: TestProtocolClient, sessionUri: string): Promise { - const sessionSnap = await c.call('subscribe', { channel: sessionUri }); - const chatSnap = await c.call('subscribe', { channel: buildDefaultChatUri(sessionUri) }); + const owningSession = parseDefaultChatUri(sessionUri) ?? sessionUri; + const chatUri = parseDefaultChatUri(sessionUri) ? sessionUri : buildDefaultChatUri(sessionUri); + const sessionSnap = await c.call('subscribe', { channel: owningSession }); + const chatSnap = await c.call('subscribe', { channel: chatUri }); return mergeSessionWithDefaultChat( sessionSnap.snapshot!.state as SessionState, chatSnap.snapshot?.state as ChatState | undefined, diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts index c271f10757ef4..d239e142775d0 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts @@ -8,6 +8,7 @@ import type { IResponsePartAction } from '../../../common/state/sessionActions.j import { ResponsePartKind, type MarkdownResponsePart } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, getActionEnvelope, IServerHandle, @@ -55,7 +56,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { // Confirm the tool call client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-perm', @@ -116,7 +117,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { // Confirm it manually to let the turn complete client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-deny', @@ -173,7 +174,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { // Confirm it manually to let the turn complete client.notify('dispatchAction', { clientSeq: 2, - channel: sessionUri, + channel: defaultChatChannel(sessionUri), action: { type: 'chat/toolCallConfirmed', turnId: 'turn-shell-deny', 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 76beec0879c83..71a9280401b70 100644 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -7,9 +7,10 @@ import assert from 'assert'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; import type { FetchTurnsResult, ListSessionsResult } from '../../../common/state/sessionProtocol.js'; -import { ResponsePartKind, ROOT_STATE_URI, buildSubagentSessionUri, isSubagentSession, type MarkdownResponsePart, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; +import { ResponsePartKind, ROOT_STATE_URI, buildSubagentChatUri, isSubagentSession, type MarkdownResponsePart, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, + defaultChatChannel, dispatchTurnStarted, fetchSessionWithChat, getActionEnvelope, @@ -94,7 +95,7 @@ suite('Protocol WebSocket — Turn Execution', function () { dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1); client.notify('dispatchAction', { - channel: sessionUri, + channel: defaultChatChannel(sessionUri), clientSeq: 2, action: { type: 'chat/turnCancelled', turnId: 'turn-cancel' }, }); @@ -170,7 +171,7 @@ suite('Protocol WebSocket — Turn Execution', function () { const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt'); const initialSnapshot = await client.call('subscribe', { channel: sessionUri }); - const initialModifiedAt = (initialSnapshot.snapshot!.state as ISessionWithDefaultChat).summary.modifiedAt; + const initialModifiedAt = Date.parse((initialSnapshot.snapshot!.state as ISessionWithDefaultChat).chats[0].modifiedAt); await new Promise(resolve => setTimeout(resolve, 50)); @@ -178,7 +179,7 @@ suite('Protocol WebSocket — Turn Execution', function () { await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete')); const updatedSnapshot = await client.call('subscribe', { channel: sessionUri }); - const updatedModifiedAt = (updatedSnapshot.snapshot!.state as ISessionWithDefaultChat).summary.modifiedAt; + const updatedModifiedAt = Date.parse((updatedSnapshot.snapshot!.state as ISessionWithDefaultChat).chats[0].modifiedAt); assert.ok(updatedModifiedAt >= initialModifiedAt); }); @@ -193,7 +194,7 @@ suite('Protocol WebSocket — Turn Execution', function () { // Subscribe to the child subagent session — its URI is derived from // the parent session URI + parent toolCallId. - const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1'); + const childUri = buildSubagentChatUri(sessionUri, 'tc-task-1'); const parentState = await fetchSessionWithChat(client, sessionUri); const childState = await fetchSessionWithChat(client, childUri); @@ -223,7 +224,7 @@ suite('Protocol WebSocket — Turn Execution', function () { await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete')); // Sanity: the subagent child session is live (subscribing succeeds). - const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1'); + const childUri = buildSubagentChatUri(sessionUri, 'tc-task-1'); const childSnapshot = await client.call('subscribe', { channel: childUri }); assert.ok(childSnapshot.snapshot, 'subagent child session should be live'); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index c37134136cf9a..896750c35fd0c 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -13,7 +13,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../../common/state/sessionActions.js'; +import { ActionType, 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, 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, type SessionSummary } from '../../common/state/sessionState.js'; @@ -114,8 +114,8 @@ class MockAgentService implements IAgentService { provider: config?.provider ?? 'copilot', title: '', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///created-project', displayName: 'Created Project' }, workingDirectory: config?.workingDirectory?.toString(), }); @@ -238,6 +238,7 @@ suite('ProtocolServerHandler', () => { let logService: CountingLogService; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); + const defaultChatUri = buildDefaultChatUri(sessionUri); function makeSessionSummary(resource?: string): SessionSummary { return { @@ -245,8 +246,8 @@ suite('ProtocolServerHandler', () => { provider: 'copilot', title: 'Test', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, }; } @@ -439,11 +440,11 @@ suite('ProtocolServerHandler', () => { // Chat actions are emitted on the derived default-chat channel, so the // client must subscribe to it (as the real UI bridge does) to see echoes. - const transport = connectClient('client-1', [sessionUri, buildDefaultChatUri(sessionUri)]); + const transport = connectClient('client-1', [sessionUri, defaultChatUri]); transport.sent.length = 0; transport.simulateMessage(notification('dispatchAction', { - channel: sessionUri, + channel: defaultChatUri, clientSeq: 1, action: { type: ActionType.ChatTurnStarted, @@ -958,23 +959,23 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(transport.sent.length, 0); }); - test('client disconnect clears active client and fails owned tool calls after grace period', () => { + test('client disconnect retains active client during grace, then removes it and fails owned tool calls after grace period', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-tools', tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -982,7 +983,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -994,13 +995,18 @@ suite('ProtocolServerHandler', () => { const transport = connectClient('client-tools', [sessionUri]); transport.simulateClose(); - assert.strictEqual(stateManager.getSessionState(sessionUri)?.activeClient, undefined); + // The active client is retained during the grace window so a quick + // reconnect can keep its slot. + assert.deepStrictEqual(stateManager.getSessionState(sessionUri)?.activeClients.map(c => c.clientId), ['client-tools']); let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running); await new Promise(r => setTimeout(r, 30_001)); + // After the grace window the active client is removed and its + // pending tool call is failed. + assert.deepStrictEqual(stateManager.getSessionState(sessionUri)?.activeClients, []); part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { @@ -1020,18 +1026,18 @@ suite('ProtocolServerHandler', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-tools', tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1068,18 +1074,18 @@ suite('ProtocolServerHandler', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-tools', tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1110,18 +1116,18 @@ suite('ProtocolServerHandler', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-tools', tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1159,18 +1165,18 @@ suite('ProtocolServerHandler', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-tools', tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1178,7 +1184,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1217,18 +1223,18 @@ suite('ProtocolServerHandler', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-tools', tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1236,7 +1242,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1269,18 +1275,18 @@ suite('ProtocolServerHandler', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-tools', tools: [{ name: 'runTask', description: 'Runs a task' }] }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1288,7 +1294,7 @@ suite('ProtocolServerHandler', () => { displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1300,7 +1306,7 @@ suite('ProtocolServerHandler', () => { const transport = connectClient('client-tools', [sessionUri]); transport.simulateClose(); stateManager.dispatchServerAction(sessionUri, { - type: ActionType.SessionActiveClientChanged, + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-replacement', tools: [{ name: 'runTask', description: 'Runs a task' }] @@ -1327,7 +1333,8 @@ suite('ProtocolServerHandler', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - stateManager.dispatchServerAction(sessionUri, { + const chatUri = buildDefaultChatUri(sessionUri); + stateManager.dispatchServerAction(chatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, @@ -1335,7 +1342,7 @@ suite('ProtocolServerHandler', () => { // Tool call stamped for a clientId that never connected (e.g. a // stale stamp from a long-dead window). No disconnect event ever // fires for it; the issuance-time orphan check must arm the timeout. - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(chatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1366,12 +1373,12 @@ suite('ProtocolServerHandler', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1395,13 +1402,13 @@ suite('ProtocolServerHandler', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); // First orphaned tool call (owner never connected) arms the grace timer. - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-1', @@ -1416,7 +1423,7 @@ suite('ProtocolServerHandler', () => { // deadline — otherwise the first call could be kept alive // indefinitely. await new Promise(r => setTimeout(r, 20_000)); - stateManager.dispatchServerAction(sessionUri, { + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tool-2', @@ -1436,6 +1443,167 @@ suite('ProtocolServerHandler', () => { }); }); + test('unsubscribe removes the active client and fails its owned tool calls', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + stateManager.dispatchServerAction(sessionUri, { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }] + }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'run it', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateMessage(notification('unsubscribe', { channel: sessionUri })); + + assert.deepStrictEqual(stateManager.getSessionState(sessionUri)?.activeClients, []); + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + error: 'Client client-tools disconnected before completing Run Task', + }); + + transport.simulateClose(); + }); + + test('reconnect without resubscription removes the active client and fails its owned tool calls', async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + + const transport1 = connectClient('client-tools', [sessionUri]); + const initSeq = (findResponse(transport1.sent, 1) as { result: InitializeResult }).result.serverSeq; + + stateManager.dispatchServerAction(sessionUri, { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }] + }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'run it', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + transport1.simulateClose(); + + // Reconnect, but do NOT resubscribe to the session. + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 1); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-tools', + lastSeenServerSeq: initSeq, + subscriptions: [], + })); + await reconnectRespPromise; + + assert.deepStrictEqual(stateManager.getSessionState(sessionUri)?.activeClients, []); + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + error: 'Client client-tools disconnected before completing Run Task', + }); + + transport2.simulateClose(); + }); + + test('reconnect with resubscription keeps the active client and its owned tool calls', async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + + const transport1 = connectClient('client-tools', [sessionUri]); + const initSeq = (findResponse(transport1.sent, 1) as { result: InitializeResult }).result.serverSeq; + + stateManager.dispatchServerAction(sessionUri, { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }] + }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'run it', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' }, + }); + + transport1.simulateClose(); + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 1); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-tools', + lastSeenServerSeq: initSeq, + subscriptions: [sessionUri], + })); + await reconnectRespPromise; + + assert.deepStrictEqual(stateManager.getSessionState(sessionUri)?.activeClients.map(c => c.clientId), ['client-tools']); + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Streaming); + + transport2.simulateClose(); + }); + test('handshake includes defaultDirectory from side effects', () => { const transport = connectClient('client-home'); @@ -1822,6 +1990,70 @@ suite('ProtocolServerHandler', () => { }); }); + suite('download progress channel', () => { + // Progress is emitted on the state manager (so it reaches both local + // IPC and remote WebSocket renderers through the same path as session + // notifications). This suite verifies the handler forwards each frame to + // connected clients as a `progress` notification on the root channel. + // Spun up per-test with a private state manager so the outer suite is + // unaffected. + let dlStateManager: AgentHostStateManager; + let dlServer: MockProtocolServer; + let dlAgentService: MockAgentService; + let localDisposables: DisposableStore; + + setup(() => { + localDisposables = new DisposableStore(); + dlStateManager = localDisposables.add(new AgentHostStateManager(new NullLogService())); + dlServer = localDisposables.add(new MockProtocolServer()); + dlAgentService = new MockAgentService(); + dlAgentService.setStateManager(dlStateManager); + localDisposables.add(dlAgentService); + localDisposables.add(new ProtocolServerHandler( + dlAgentService, + dlStateManager, + dlServer, + { defaultDirectory: URI.file('/home/testuser').toString() }, + localDisposables.add(new AgentHostFileSystemProvider()), + new NullLogService(), + )); + }); + + teardown(() => { + localDisposables.dispose(); + }); + + function connectDownloadClient(clientId: string): MockProtocolTransport { + const transport = new MockProtocolTransport(); + dlServer.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId, + })); + return transport; + } + + function findProgress(sent: ProtocolMessage[]): ProgressParams[] { + return sent + .filter(isJsonRpcNotification) + .filter((m): m is AhpNotification & { method: 'root/progress'; params: ProgressParams } => m.method === 'root/progress') + .map(m => m.params); + } + + test('forwards each progress frame to connected clients on the root channel', () => { + const transport = connectDownloadClient('client-dl-1'); + + dlStateManager.emitProgress({ progressToken: 't1', progress: 0, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 500, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 1000, total: 1000, message: 'Claude' }); + + const frames = findProgress(transport.sent); + assert.deepStrictEqual(frames.map(f => f.progress), [0, 500, 1000]); + assert.ok(frames.every(f => f.progressToken === 't1' && f.message === 'Claude' && f.total === 1000)); + assert.ok(frames.every(f => (f as ProgressParams & { channel: string }).channel === 'ahp-root://'), 'frames are broadcast on the root channel'); + }); + }); + suite('resource watches', () => { test('subscribe to a resource-watch channel returns the descriptor + bumps refcount; envelopes are routed', async () => { diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 2c54ef35288c2..de824401d3d6c 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -12,16 +12,12 @@ import { CustomizationType } from '../../common/state/protocol/state.js'; function makeSession(): SessionState { return { - summary: { - resource: 'copilot:/test', - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + project: { uri: 'file:///test-project', displayName: 'Test Project' }, lifecycle: SessionLifecycle.Ready, + activeClients: [], chats: [], }; } diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts index e81afe38cb49e..b7b014da8c365 100644 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts @@ -30,11 +30,7 @@ function sandbox( } const cfg: ISandboxConfigValue = {}; if (enabled !== undefined) { - if (platform === 'win32') { - cfg[AgentHostSandboxKey.WindowsEnabled] = enabled; - } else { - cfg[AgentHostSandboxKey.Enabled] = enabled; - } + cfg[AgentHostSandboxKey.Enabled] = enabled; } if (fs) { const fsKey = platform === 'win32' @@ -72,37 +68,30 @@ suite('buildSandboxConfigForSdk', () => { assert.strictEqual(buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.Off)), undefined); }); - test('enables sandbox for `on` on every platform', () => { - for (const platform of ['darwin', 'linux', 'win32'] as const) { + test('enables sandbox for `on` on non-Windows platforms', () => { + for (const platform of ['darwin', 'linux'] as const) { assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On)), { enabled: true, + allowBypass: true, userPolicy: { filesystem: {}, network: { allowOutbound: false } }, }); } }); - test('enables sandbox and outbound network for `allowNetwork` on every platform', () => { - for (const platform of ['darwin', 'linux', 'win32'] as const) { + test('enables sandbox and outbound network for `allowNetwork` on non-Windows platforms', () => { + for (const platform of ['darwin', 'linux'] as const) { assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.AllowNetwork)), { enabled: true, + allowBypass: true, userPolicy: { filesystem: {}, network: { allowOutbound: true } }, }); } }); - test('on Windows, WindowsEnabled overrides the cross-platform Enabled key', () => { - // Cross-platform value says off, but the Windows override says on - // → Windows session honors the override. - const cfg = { - [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.Off, - [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue.On, - }; - assert.deepStrictEqual(buildSandboxConfigForSdk('win32', cfg), { - enabled: true, - userPolicy: { filesystem: {}, network: { allowOutbound: false } }, - }); - // Non-Windows ignores WindowsEnabled. - assert.strictEqual(buildSandboxConfigForSdk('darwin', cfg), undefined); + test('ignores the enable settings on Windows', () => { + // The sandbox is not supported on Windows, so the enable settings are ignored. + assert.strictEqual(buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.On)), undefined); + assert.strictEqual(buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.AllowNetwork)), undefined); }); }); @@ -110,14 +99,13 @@ suite('buildSandboxConfigForSdk', () => { test('selects the OS-specific slice from the per-OS filesystem keys', () => { const cfg: ISandboxConfigValue = { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, - [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue.On, [AgentHostSandboxKey.LinuxFileSystem]: { allowWrite: ['/linux'] }, [AgentHostSandboxKey.MacFileSystem]: { allowWrite: ['/mac'] }, - [AgentHostSandboxKey.WindowsFileSystem]: { allowWrite: ['C:\\win'] }, }; assert.deepStrictEqual(buildSandboxConfigForSdk('linux', cfg)?.userPolicy.filesystem, { readwritePaths: ['/linux'] }); assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', cfg)?.userPolicy.filesystem, { readwritePaths: ['/mac'] }); - assert.deepStrictEqual(buildSandboxConfigForSdk('win32', cfg)?.userPolicy.filesystem, { readwritePaths: ['C:\\win'] }); + // Windows is ignored entirely. + assert.strictEqual(buildSandboxConfigForSdk('win32', cfg), undefined); }); test('maps each setting to the corresponding SDK list', () => { @@ -129,6 +117,7 @@ suite('buildSandboxConfigForSdk', () => { }; assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, fs)), { enabled: true, + allowBypass: true, userPolicy: { filesystem: { readwritePaths: ['/work'], @@ -143,6 +132,7 @@ suite('buildSandboxConfigForSdk', () => { test('omits filesystem lists that are empty', () => { assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, {})), { enabled: true, + allowBypass: true, userPolicy: { filesystem: {}, network: { allowOutbound: false } }, }); }); @@ -193,32 +183,20 @@ suite('buildSandboxConfigForSdk', () => { }); suite('network hosts', () => { - test('forwards allowedHosts and opens outbound even when sandbox is `on`', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'] }))?.userPolicy.network, { - allowOutbound: true, - allowedHosts: ['github.com'], - }); + test('drops host lists and keeps outbound closed when sandbox is `on` (host lists disabled on all platforms)', () => { + for (const platform of ['darwin', 'linux'] as const) { + assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy.network, { + allowOutbound: false, + }, platform); + } }); test('ignores host lists when sandbox is `allowNetwork` (allow all)', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { - allowOutbound: true, - }); - }); - - test('forwards blockedHosts and opens outbound when only blockedHosts is set (deny-list-only allows non-denied domains)', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { blockedHosts: ['evil.example'] }))?.userPolicy.network, { - allowOutbound: true, - blockedHosts: ['evil.example'], - }); - }); - - test('forwards both allowedHosts and blockedHosts together when sandbox is `on`', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { - allowOutbound: true, - allowedHosts: ['a.example'], - blockedHosts: ['b.example'], - }); + for (const platform of ['darwin', 'linux'] as const) { + assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { + allowOutbound: true, + }, platform); + } }); test('ignores empty host lists', () => { @@ -226,17 +204,5 @@ suite('buildSandboxConfigForSdk', () => { allowOutbound: false, }); }); - - test('drops host lists and keeps outbound closed on darwin (Seatbelt has no per-host filter)', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy.network, { - allowOutbound: false, - }); - }); - - test('darwin `allowNetwork` still opens outbound (host lists were already ignored)', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['github.com'] }))?.userPolicy.network, { - allowOutbound: true, - }); - }); }); }); diff --git a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts new file mode 100644 index 0000000000000..1943f9bcc902e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { StringOrMarkdown } from '../../common/state/sessionState.js'; +import { getServerToolDisplay } from '../../node/shared/serverToolGroups.js'; + +function text(value: StringOrMarkdown | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + return typeof value === 'string' ? value : value.markdown; +} + +suite('serverToolGroups display', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('feedback tools resolve to dedicated display strings', () => { + const display = (toolName: string) => { + const d = getServerToolDisplay(toolName, undefined); + return { displayName: d?.displayName, invocation: text(d?.invocationMessage) }; + }; + assert.deepStrictEqual({ + add: display('addComment'), + list: display('listComments'), + del: display('deleteComments'), + resolve: display('resolveComments'), + view: display('viewUnreviewedComments'), + }, { + add: { displayName: 'Add Comment', invocation: 'Adding comment' }, + list: { displayName: 'List Comments', invocation: 'Checking comments' }, + del: { displayName: 'Delete Comments', invocation: 'Deleting comments' }, + resolve: { displayName: 'Resolve Comments', invocation: 'Resolving comments' }, + view: { displayName: 'View Comments', invocation: 'Viewing comments' }, + }); + }); + + test('listComments past tense reflects the comment count parsed from the result', () => { + const past = (resultText?: string) => + text(getServerToolDisplay('listComments', undefined, { text: resultText, success: true })?.pastTenseMessage); + const withComments = (n: number) => JSON.stringify({ comments: Array.from({ length: n }, (_, i) => ({ id: `${i}` })) }); + assert.deepStrictEqual({ + zero: past(withComments(0)), + one: past(withComments(1)), + many: past(withComments(3)), + noResult: past(), + malformed: past('not json'), + noComments: past(JSON.stringify({ other: 1 })), + }, { + zero: 'Checked 0 comments', + one: 'Checked 1 comment', + many: 'Checked 3 comments', + noResult: 'Checked comments', + malformed: 'Checked comments', + noComments: 'Checked comments', + }); + }); + + test('non-listComments past tense ignores the result text', () => { + assert.strictEqual( + text(getServerToolDisplay('resolveComments', undefined, { text: 'anything', success: true })?.pastTenseMessage), + 'Resolved comments', + ); + }); + + test('transport-prefixed names (Claude mcp__host__) match the bare tool', () => { + assert.deepStrictEqual({ + display: getServerToolDisplay('mcp__host__listComments', undefined)?.displayName, + past: text(getServerToolDisplay('mcp__host__listComments', undefined, { text: JSON.stringify({ comments: [{ id: 'a' }, { id: 'b' }] }), success: true })?.pastTenseMessage), + }, { + display: 'List Comments', + past: 'Checked 2 comments', + }); + }); + + test('unknown tools return undefined so callers fall back to their generic display', () => { + assert.strictEqual(getServerToolDisplay('bash', { command: 'ls' }), undefined); + assert.strictEqual(getServerToolDisplay('someClientTool', undefined), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index 8697c5462667b..5a59b1ef94897 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -120,6 +120,7 @@ suite('SessionCustomizationDiscovery', () => { await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); await seed('/home/.copilot/agents/abc.agent.md', 'user agent abc'); await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); + await seed('/home/.copilot/skills/alpha/SKILL.md', 'user copilot skill'); await seed('/home/.agents/skills/aaa/SKILL.md', 'user skill aaa'); await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); @@ -162,6 +163,7 @@ suite('SessionCustomizationDiscovery', () => { await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); await seed('/workspace/.claude/CLAUDE.md', 'workspace claude instruction'); await seed('/home/.copilot/agents/user.agent.md', 'user agent'); + await seed('/home/.copilot/skills/copilot-user-skill/SKILL.md', 'user copilot skill'); await seed('/home/.agents/skills/user-skill/SKILL.md', 'user skill'); await seed('/home/.copilot/instructions/user.instructions.md', 'user instruction'); await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); @@ -192,6 +194,7 @@ suite('SessionCustomizationDiscovery', () => { assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'hooks').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'agents').toString()), false); + assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'skills').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'instructions').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'hooks').toString()), true); @@ -364,6 +367,7 @@ suite('SessionCustomizationDiscovery', () => { const wsInstr = await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); const wsHook = await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); const userAgent = await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); + const userCopilotSkill = await seed('/home/.copilot/skills/copilot-zap/SKILL.md', 'user copilot skill'); const userSkill = await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); const userHook = await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); // Noise that should not be picked up @@ -376,6 +380,7 @@ suite('SessionCustomizationDiscovery', () => { assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ { uri: userAgent, type: DiscoveredType.Agent }, + { uri: userCopilotSkill, type: DiscoveredType.Skill }, { uri: userHook, type: DiscoveredType.Hook }, { uri: userSkill, type: DiscoveredType.Skill }, { uri: wsAgent, type: DiscoveredType.Agent }, @@ -510,11 +515,34 @@ suite('SessionCustomizationDiscovery', () => { }); test('returns undefined when no files were discovered', async () => { + // Ensure workspace root exists + await fileService.createFolder(workspace); + await fileService.createFolder(userHome); + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); - const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); const directories = await discovery.scan(CancellationToken.None); - const result = await bundler.bundle(directories); - assert.strictEqual(result, undefined); + + // Even with no files, discovery should return all search root directories + // directories should never be null/undefined, should be an empty array if no directories found + assert.ok(Array.isArray(directories), `Expected directories to be an array, got ${JSON.stringify(directories)}`); + + // Since we're now discovering all roots even if they don't exist, + // we expect to find some directories (at minimum the workspace root for AGENTS.md) + if (directories.length === 0) { + // If no directories are discovered, that's okay for this test - it means discovery + // is still looking for actual files/directories. Update test expectations. + return; + } + + // All directories should be empty since no files were created + for (const dir of directories) { + assert.strictEqual(dir.files.length, 0, `Expected ${dir.uri.toString()} to have no files`); + } + + // Bundler returns undefined when directories are empty (no customizations to bundle) + const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); + await bundler.bundle(directories); + // Just verify bundling doesn't crash }); test('maps discovered files to parsed plugin preserving source URIs', async () => { diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 888ed2ea48f56..db19777b3335a 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -9,10 +9,12 @@ import * as fs from 'fs/promises'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; -import { FileEditKind } from '../../common/state/sessionState.js'; +import { FileEditKind, MessageKind } from '../../common/state/sessionState.js'; +import type { IReviewedFileRecord } from '../../common/sessionDataService.js'; import type { Database } from '@vscode/sqlite3'; import { generateUuid } from '../../../../base/common/uuid.js'; import { join } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; suite('SessionDatabase', () => { @@ -37,6 +39,13 @@ suite('SessionDatabase', () => { return inst; } + async setRawChatDraft(chat: URI, draft: string): Promise { + const rawDb = await this._ensureDb(); + await new Promise((resolve, reject) => { + rawDb.run('INSERT OR REPLACE INTO chat_drafts (chat_uri, draft) VALUES (?, ?)', [chat.toString(), draft], err => err ? reject(err) : resolve()); + }); + } + /** Extract the raw db connection; this instance becomes inert. */ async ejectDb(): Promise { const rawDb = await this._ensureDb(); @@ -567,6 +576,128 @@ suite('SessionDatabase', () => { }); }); + suite('chat drafts', () => { + const chat = URI.parse('ahp-chat://default/Y29waWxvdDovLy9zZXNzaW9uLTE'); + + test('setChatDraft and getChatDraft round-trip', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const draft = { + text: 'draft', + origin: { kind: MessageKind.User }, + model: { id: 'opus' }, + agent: { uri: 'agent://reviewer' }, + }; + + await db.setChatDraft(chat, draft); + + assert.deepStrictEqual(await db.getChatDraft(chat), draft); + }); + + test('setChatDraft undefined clears a draft', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const draft = { text: 'draft', origin: { kind: MessageKind.User } }; + + await db.setChatDraft(chat, draft); + await db.setChatDraft(chat, undefined); + + assert.strictEqual(await db.getChatDraft(chat), undefined); + }); + + test('getChatDraft returns undefined for corrupt draft rows', async () => { + const testDb = disposables.add(await TestableSessionDatabase.open(':memory:')); + db = testDb; + + await testDb.setRawChatDraft(chat, '{'); + + assert.strictEqual(await db.getChatDraft(chat), undefined); + }); + + test('migration v6 creates chat draft tables', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const tables = await db.getAllTables(); + assert.ok(tables.includes('chat_drafts')); + }); + }); + + // ---- reviewed files ------------------------------------------------- + + suite('reviewed files', () => { + const uriA = URI.parse('file:///workspace/a.ts'); + const uriB = URI.parse('file:///workspace/b.ts'); + + const normalize = (records: readonly IReviewedFileRecord[]) => records.map(r => ({ uri: r.uri.toString(), nonce: r.nonce })); + + test('markFileReviewed and isFileReviewed discriminate by uri and nonce', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + + assert.deepStrictEqual( + await Promise.all([ + db.isFileReviewed(uriA, 'n1'), + db.isFileReviewed(uriA, 'n2'), + db.isFileReviewed(uriB, 'n1'), + ]), + [true, false, false], + ); + }); + + test('getReviewedFiles returns all entries in insertion order', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriB, 'n2'); + await db.markFileReviewed(uriA, 'n3'); + + assert.deepStrictEqual(normalize(await db.getReviewedFiles()), [ + { uri: uriA.toString(), nonce: 'n1' }, + { uri: uriB.toString(), nonce: 'n2' }, + { uri: uriA.toString(), nonce: 'n3' }, + ]); + }); + + test('getReviewedFilesForUri returns only the given uri', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriB, 'n2'); + await db.markFileReviewed(uriA, 'n3'); + + assert.deepStrictEqual(normalize(await db.getReviewedFilesForUri(uriA)), [ + { uri: uriA.toString(), nonce: 'n1' }, + { uri: uriA.toString(), nonce: 'n3' }, + ]); + }); + + test('unmarkFileReviewed removes an entry and is a no-op when absent', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.unmarkFileReviewed(uriA, 'n1'); + await db.unmarkFileReviewed(uriA, 'n1'); // no-op, must not throw + + assert.deepStrictEqual( + await Promise.all([db.isFileReviewed(uriA, 'n1'), db.getReviewedFiles()]), + [false, []], + ); + }); + + test('marking the same (uri, nonce) twice keeps a single entry', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriA, 'n1'); + + assert.deepStrictEqual(normalize(await db.getReviewedFiles()), [{ uri: uriA.toString(), nonce: 'n1' }]); + }); + + test('migration v7 creates the reviewed_files table', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const tables = await db.getAllTables(); + assert.ok(tables.includes('reviewed_files')); + }); + }); + // ---- vacuumInto ----------------------------------------------------- suite('vacuumInto', () => { diff --git a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts index 5da1e74ee80f7..289f5878be8ce 100644 --- a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileEditKind, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { encodeString, TestDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; -import { computeSessionDiffs } from '../../node/sessionDiffAggregator.js'; +import { computeSessionDiffs, computeUnionedDiffs } from '../../node/sessionDiffAggregator.js'; import { parseSessionDbUri } from '../../node/shared/fileEditTracker.js'; const TEST_SESSION_URI = 'session://test-session'; @@ -490,3 +490,100 @@ suite('computeSessionDiffs', () => { assert.deepStrictEqual(result, previousDiffs); }); }); + +suite('computeUnionedDiffs', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const PEER_CHAT_URI = 'ahp-chat://peer/encoded'; + + test('returns empty array when no source has edits', async () => { + const result = await computeUnionedDiffs( + [{ sessionUri: TEST_SESSION_URI, db: new TestSessionDatabase() }], + createTestDiffService(), + ); + assert.deepStrictEqual(result, []); + }); + + test('unions edits from the session DB and a peer chat DB', async () => { + const sessionDb = new TestSessionDatabase(); + sessionDb.addEdit({ + turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('a1'), afterContent: encodeString('a1\na2'), + }); + + const peerDb = new TestSessionDatabase(); + peerDb.addEdit({ + turnId: 'pt1', toolCallId: 'ptc1', filePath: '/b.txt', kind: FileEditKind.Create, + addedLines: undefined, removedLines: undefined, + beforeContent: undefined, afterContent: encodeString('b1\nb2\nb3'), + }); + + const result = await computeUnionedDiffs( + [ + { sessionUri: TEST_SESSION_URI, db: sessionDb }, + { sessionUri: PEER_CHAT_URI, db: peerDb }, + ], + createTestDiffService(), + ); + + assert.deepStrictEqual( + result.map(simplify).sort((x, y) => (x.uri ?? '').localeCompare(y.uri ?? '')), + [simpleDiff('/a.txt', 1, 0), simpleDiff('/b.txt', 3, 0)], + ); + + // The peer file's content URI must encode the peer chat URI so the + // resource resolver opens the peer DB, not the session DB. + const peerDiff = result.find(d => getDiffUri(d) === URI.file('/b.txt').toString())!; + const afterFields = parseSessionDbUri(peerDiff.after!.content.uri); + assert.deepStrictEqual(afterFields, { + sessionUri: PEER_CHAT_URI, + toolCallId: 'ptc1', + filePath: '/b.txt', + part: 'after', + }); + }); + + test('a file edited by multiple sources takes before from the first and after from the last source', async () => { + const sessionDb = new TestSessionDatabase(); + sessionDb.addEdit({ + turnId: 't1', toolCallId: 'tc1', filePath: '/shared.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('v1'), afterContent: encodeString('v2'), + }); + + const peerDb = new TestSessionDatabase(); + peerDb.addEdit({ + turnId: 'pt1', toolCallId: 'ptc1', filePath: '/shared.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('v2'), afterContent: encodeString('v3'), + }); + + const result = await computeUnionedDiffs( + [ + { sessionUri: TEST_SESSION_URI, db: sessionDb }, + { sessionUri: PEER_CHAT_URI, db: peerDb }, + ], + createTestDiffService(), + ); + + assert.strictEqual(result.length, 1); + const [diff] = result; + + // before snapshot from the session DB (first source) + assert.deepStrictEqual(parseSessionDbUri(diff.before!.content.uri), { + sessionUri: TEST_SESSION_URI, + toolCallId: 'tc1', + filePath: '/shared.txt', + part: 'before', + }); + // after snapshot from the peer chat DB (last source) + assert.deepStrictEqual(parseSessionDbUri(diff.after!.content.uri), { + sessionUri: PEER_CHAT_URI, + toolCallId: 'ptc1', + filePath: '/shared.txt', + part: 'after', + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index e0756c2437352..bd32a50abec14 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -12,7 +12,7 @@ import { isWindows } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformSessionSchema } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { SessionStatus, ToolCallConfirmationReason, type SessionSummary } from '../../common/state/sessionState.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -23,6 +23,7 @@ suite('SessionPermissionManager', () => { const disposables = new DisposableStore(); let manager: AgentHostStateManager; + let configService: AgentConfigurationService; let permissions: SessionPermissionManager; // Real (symlink-resolved) temp directories so that the symlink-resolution @@ -38,8 +39,8 @@ suite('SessionPermissionManager', () => { provider: 'copilot', title: 't', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, workingDirectory, }; @@ -49,6 +50,10 @@ suite('SessionPermissionManager', () => { return { toolCallId: 'tc-1', session: URI.parse(sessionUri), permissionKind: 'write', permissionPath }; } + function shellEvent(commandLine: string): IToolApprovalEvent { + return { toolCallId: 'tc-shell', session: URI.parse(sessionUri), permissionKind: 'shell', toolInput: commandLine }; + } + setup(async () => { // Prefer the CI runner temp dir (a plain long path) over `os.tmpdir()`, // which on Windows CI is an 8.3 short path (`C:\Users\RUNNER~1\...`) that @@ -64,7 +69,7 @@ suite('SessionPermissionManager', () => { outsideDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-out-'))); manager = disposables.add(new AgentHostStateManager(new NullLogService())); - const configService = disposables.add(new AgentConfigurationService(manager, new NullLogService())); + configService = disposables.add(new AgentConfigurationService(manager, new NullLogService())); permissions = disposables.add(new SessionPermissionManager(manager, configService, new NullLogService())); await permissions.initialize(); @@ -143,4 +148,86 @@ suite('SessionPermissionManager', () => { ); assert.deepStrictEqual([inside, outside], [ToolCallConfirmationReason.NotNeeded, undefined]); }); + + test('auto-approves shell commands in default permission mode when terminal auto-approve is enabled', async () => { + const result = await permissions.getAutoApproval(shellEvent('echo hello'), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded); + }); + + test('uses forwarded terminal auto-approve rules as the source of truth over fallback defaults', async () => { + configService.updateRootConfig({ [AgentHostTerminalAutoApproveRulesConfigKey]: {} }); + + const result = await permissions.getAutoApproval(shellEvent('echo hello'), sessionUri); + assert.strictEqual(result, undefined); + }); + + test('respects forwarded terminal auto-approve deny rules in default permission mode', async () => { + configService.updateRootConfig({ [AgentHostTerminalAutoApproveRulesConfigKey]: { echo: false } }); + + const result = await permissions.getAutoApproval(shellEvent('echo hello'), sessionUri); + assert.strictEqual(result, undefined); + }); + + test('respects forwarded terminal auto-approve allow rules in default permission mode', async () => { + configService.updateRootConfig({ [AgentHostTerminalAutoApproveRulesConfigKey]: { python: true } }); + + const result = await permissions.getAutoApproval(shellEvent('python script.py'), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded); + }); + + test('requires confirmation for shell commands in default permission mode when terminal auto-approve is disabled', async () => { + configService.updateRootConfig({ + [AgentHostTerminalAutoApproveEnabledConfigKey]: false, + [AgentHostTerminalAutoApproveRulesConfigKey]: { echo: true }, + }); + + const result = await permissions.getAutoApproval(shellEvent('echo hello'), sessionUri); + assert.strictEqual(result, undefined); + }); + + test('does not affect session bypass permission mode when terminal auto-approve is disabled', async () => { + configService.updateRootConfig({ + [AgentHostTerminalAutoApproveEnabledConfigKey]: false, + [AgentHostTerminalAutoApproveRulesConfigKey]: { echo: false }, + }); + manager.setSessionConfig(sessionUri, { + schema: platformSessionSchema.toProtocol(), + values: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }); + + const result = await permissions.getAutoApproval(shellEvent('echo hello'), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.Setting); + }); + + test('auto-approves any write when global auto-approve is enabled, even in default permission mode', async () => { + configService.updateRootConfig({ [AgentHostGlobalAutoApproveEnabledConfigKey]: true }); + + const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'anything.txt')), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.Setting); + }); + + test('auto-approves shell commands when global auto-approve is enabled, even with terminal auto-approve disabled', async () => { + configService.updateRootConfig({ + [AgentHostGlobalAutoApproveEnabledConfigKey]: true, + [AgentHostTerminalAutoApproveEnabledConfigKey]: false, + }); + + // A command that would otherwise require confirmation (terminal + // auto-approve disabled) is approved because global auto-approve is a + // superset that short-circuits before the per-kind checks. + const result = await permissions.getAutoApproval(shellEvent('rm -rf /tmp/whatever'), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.Setting); + }); + + test('global auto-approve is reported independently of the session permission picker', () => { + assert.strictEqual(permissions.isGlobalAutoApproveEnabled(), false); + assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), false); + + configService.updateRootConfig({ [AgentHostGlobalAutoApproveEnabledConfigKey]: true }); + + // The global setting is a superset of all settings but does not change the + // session's own approval level (the permissions picker stays at default). + assert.strictEqual(permissions.isGlobalAutoApproveEnabled(), true); + assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), false); + }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts index b060f3f895088..da81a8a17654a 100644 --- a/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts @@ -45,12 +45,12 @@ suite('AgentHostOctoKitService', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('createPullRequest posts the expected request and parses the response', async () => { - const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/42', number: 42 })); + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/42', number: 42, node_id: 'PR_node_42' })); const service = makeService(fetch); const result = await service.createPullRequest('o', 'r', 'My PR', 'Body', 'feature', 'main', false, 'gh-token', signal()); - assert.deepStrictEqual(result, { url: 'https://github.com/o/r/pull/42', number: 42 }); + assert.deepStrictEqual(result, { url: 'https://github.com/o/r/pull/42', number: 42, nodeId: 'PR_node_42' }); const cap = captured(); assert.strictEqual(cap.url, 'https://api.github.com/repos/o/r/pulls'); @@ -90,7 +90,7 @@ suite('AgentHostOctoKitService', () => { }); test('findPullRequestByHeadBranch fetches the latest matching pull request', async () => { - const { fetch, captured } = capturingFetch(jsonResponse([{ html_url: 'https://github.com/o/r/pull/9', number: 9 }])); + const { fetch, captured } = capturingFetch(jsonResponse([{ html_url: 'https://github.com/o/r/pull/9', number: 9, node_id: 'PR_node_9' }])); const service = makeService(fetch); const result = await service.findPullRequestByHeadBranch('o', 'r', 'feature/test', 'tok', signal()); @@ -100,7 +100,7 @@ suite('AgentHostOctoKitService', () => { url: captured().url, method: captured().init?.method, }, { - result: { url: 'https://github.com/o/r/pull/9', number: 9 }, + result: { url: 'https://github.com/o/r/pull/9', number: 9, nodeId: 'PR_node_9' }, url: 'https://api.github.com/repos/o/r/pulls?head=o%3Afeature%2Ftest&state=all&sort=updated&direction=desc&per_page=1', method: 'GET', }); @@ -132,4 +132,34 @@ suite('AgentHostOctoKitService', () => { /Failed to create pull request for o\/r/, ); }); + + test('enablePullRequestAutoMerge posts the GraphQL mutation', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ data: { enablePullRequestAutoMerge: { pullRequest: { id: 'PR_node_42' } } } })); + const service = makeService(fetch); + + await service.enablePullRequestAutoMerge('PR_node_42', 'SQUASH', 'gh-token', signal()); + + const cap = captured(); + const headers = cap.init?.headers as Record; + assert.deepStrictEqual({ + url: cap.url, + method: cap.init?.method, + authorization: headers['Authorization'], + variables: (JSON.parse(cap.init?.body as string) as { variables: unknown }).variables, + }, { + url: 'https://api.github.com/graphql', + method: 'POST', + authorization: 'Bearer gh-token', + variables: { pullRequestId: 'PR_node_42', mergeMethod: 'SQUASH' }, + }); + }); + + test('enablePullRequestAutoMerge throws when GraphQL returns errors', async () => { + const service = makeService(capturingFetch(jsonResponse({ errors: [{ message: 'Pull request is in clean status' }] })).fetch); + + await assert.rejects( + () => service.enablePullRequestAutoMerge('PR_node_42', 'MERGE', 'tok', signal()), + /GitHub GraphQL request failed: Pull request is in clean status/, + ); + }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts index 710ccf6b2a261..4558221cf7ba3 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts @@ -587,18 +587,23 @@ suite('CopilotApiService', () => { assert.strictEqual(headers['OpenAI-Intent'], 'messages-proxy'); }); - test('sends a derived Copilot-Integration-Id header by default', async () => { + test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => { const { fetch: fetchFn, captured } = routingFetch( () => anthropicResponse([{ type: 'text', text: 'ok' }]), ); const service = createService(fetchFn); - // @vscode/copilot-api derives the integration id from the license / - // SKU / build state and sends it on every request. + // Default (no opt-in): @vscode/copilot-api derives and sends the header. await service.messages('gh-tok', baseRequest); - const headers = captured().init?.headers as Record; + const withHeader = captured().init?.headers as Record; + + // Opt-in: the header is omitted entirely so CAPI authorizes against + // the token's real entitlement instead of the derived integration id. + await service.messages('gh-tok', baseRequest, { suppressIntegrationId: true }); + const suppressed = captured().init?.headers as Record; - assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present'); + assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default'); + assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in'); }); }); @@ -1585,16 +1590,21 @@ suite('CopilotApiService', () => { assert.strictEqual(capturedHeaders?.['Authorization'], 'Bearer gh-tok'); }); - test('sends a derived Copilot-Integration-Id header by default', async () => { + test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => { const { fetch: fetchFn, captured } = routingFetch(() => modelsResponse([])); const service = createService(fetchFn); - // @vscode/copilot-api derives the integration id from the license / - // SKU / build state and sends it on every request. + // Default (no opt-in): @vscode/copilot-api derives and sends the header. await service.models('gh-tok'); - const headers = captured().init?.headers as Record; + const withHeader = captured().init?.headers as Record; + + // Opt-in: the header is omitted entirely so CAPI authorizes against + // the token's real entitlement instead of the derived integration id. + await service.models('gh-tok', { suppressIntegrationId: true }); + const suppressed = captured().init?.headers as Record; - assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present'); + assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default'); + assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in'); }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts index e5f87262f7ef6..e25a9422cfc32 100644 --- a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ActionType } from '../../../common/state/protocol/common/actions.js'; -import { CustomizationType, McpServerStatus, type Customization, type McpServerState } from '../../../common/state/protocol/channels-session/state.js'; +import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization, type McpServerState } from '../../../common/state/protocol/channels-session/state.js'; import type { SessionAction } from '../../../common/state/sessionActions.js'; import { McpCustomizationController, findMcpChildId, parseMcpChannelUri, type ISdkMcpServer } from '../../../node/shared/mcpCustomizationController.js'; @@ -28,6 +28,17 @@ function server(name: string, state: McpServerState): ISdkMcpServer { function ready(): McpServerState { return { kind: McpServerStatus.Ready }; } function starting(): McpServerState { return { kind: McpServerStatus.Starting }; } function stopped(): McpServerState { return { kind: McpServerStatus.Stopped }; } +function authRequired(): McpServerState { + return { + kind: McpServerStatus.AuthRequired, + reason: McpAuthRequiredReason.Required, + resource: { + resource: 'https://mcp.example.com', + authorization_servers: ['https://auth.example.com'], + }, + requiredScopes: ['repo'], + }; +} function errored(message: string): McpServerState { return { kind: McpServerStatus.Error, error: { errorType: 'test-error', message } }; } @@ -197,6 +208,22 @@ suite('McpCustomizationController', () => { ]); }); + test('runtimeStates snapshots child and top-level servers by customization id', () => { + const { controller } = harness({ customizations: PLUGIN_CUSTOMIZATIONS }); + store.add(controller); + + controller.applyOne(server('fs', ready())); + controller.applyOne(server('search', starting())); + + assert.deepStrictEqual(controller.runtimeStates.get(), new Map([ + ['mcp-child:demo:fs', { state: { kind: McpServerStatus.Ready }, channel: 'mcp://copilot/session-1/fs' }], + ['mcp-top-level:copilot:session-1:search', { state: { kind: McpServerStatus.Starting }, channel: undefined }], + ])); + + controller.remove('fs'); + assert.deepStrictEqual([...controller.runtimeStates.get().keys()], ['mcp-top-level:copilot:session-1:search']); + }); + test('top-level entry stays top-level across updates (id stable)', () => { const { controller, actions } = harness(); store.add(controller); @@ -212,6 +239,37 @@ suite('McpCustomizationController', () => { assert.deepStrictEqual(ids, [expectedId, expectedId, expectedId]); }); + test('authRequired state is preserved across coarse starting updates', () => { + const { controller, actions } = harness({ customizations: PLUGIN_CUSTOMIZATIONS }); + store.add(controller); + + const authState = authRequired(); + controller.applyOne(server('fs', authState)); + controller.applyOne(server('fs', starting())); + controller.applyOne(server('fs', ready())); + + assert.deepStrictEqual(actions, [ + { + type: ActionType.SessionMcpServerStateChanged, + id: 'mcp-child:demo:fs', + state: authState, + channel: undefined, + }, + { + type: ActionType.SessionMcpServerStateChanged, + id: 'mcp-child:demo:fs', + state: authState, + channel: undefined, + }, + { + type: ActionType.SessionMcpServerStateChanged, + id: 'mcp-child:demo:fs', + state: { kind: McpServerStatus.Ready }, + channel: 'mcp://copilot/session-1/fs', + }, + ]); + }); + test('parseMcpChannelUri round-trips the controller-minted channel URI', () => { const channel = 'mcp://copilot/session-1/fs'; assert.deepStrictEqual(parseMcpChannelUri(channel), { diff --git a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts new file mode 100644 index 0000000000000..18c6ff025a806 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SectionOverride } from '@github/copilot-sdk'; +import { resolveToolInstructionsOverride, universalToolInstructions } from '../../node/copilot/prompts/toolInstructions.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +/** Builds a `hasTool` predicate backed by the given available tool names. */ +function hasTools(...names: string[]): (name: string) => boolean { + const set = new Set(names); + return name => set.has(name); +} + +/** A gated tool-instruction line that renders `use ` when `tool` is present. */ +function lineFor(tool: string): (hasTool: (name: string) => boolean) => string | undefined { + return hasTool => hasTool(tool) ? `use ${tool}` : undefined; +} + +suite('toolInstructions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('universalToolInstructions', () => { + test('joins applicable lines in order and drops gated-out ones', () => { + assert.strictEqual(universalToolInstructions(hasTools('a', 'c'), [lineFor('a'), lineFor('b'), lineFor('c')]), 'use a\nuse c'); + }); + + test('returns undefined when no line applies (including the default registry when its lines do not apply)', () => { + assert.strictEqual(universalToolInstructions(hasTools('x'), [lineFor('a')]), undefined); + assert.strictEqual(universalToolInstructions(hasTools('a')), undefined); + }); + + test('renders the registered browser line from the default registry only when openBrowserPage + an agentic browser tool are present', () => { + assert.deepStrictEqual( + [ + universalToolInstructions(hasTools('openBrowserPage', 'readPage')), + universalToolInstructions(hasTools('openBrowserPage')), + universalToolInstructions(hasTools('readPage')), + ], + [ + 'Use the browser tools (openBrowserPage, readPage, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.', + undefined, + undefined, + ] + ); + }); + }); + + // `composeToolInstructions` is module-private; its composition/spacing + // behavior is exercised here through the public `resolveToolInstructionsOverride` + // (injecting synthetic lines via the `lines` seam). + suite('resolveToolInstructionsOverride', () => { + test('returns undefined (keep existing) when no line applies', () => { + assert.strictEqual(resolveToolInstructionsOverride(hasTools('x'), { action: 'append', content: 'A' }, [lineFor('a')]), undefined); + }); + + test('with no per-model override, appends the rendered line after the foundation section', () => { + assert.deepStrictEqual(resolveToolInstructionsOverride(hasTools('a'), undefined, [lineFor('a')]), { action: 'append', content: '\nuse a' }); + }); + + test('folds into a per-model string override, preserving action and foundation spacing', () => { + const overrides: SectionOverride[] = [ + { action: 'append', content: 'A' }, // sits after foundation → leads with a newline + { action: 'prepend', content: 'P' }, // sits before foundation → trails with a newline + { action: 'replace', content: 'OWN' },// owns the section → no padding + { action: 'replace', content: '' }, // empty replace → no spurious leading newline + ]; + assert.deepStrictEqual(overrides.map(o => resolveToolInstructionsOverride(hasTools('a'), o, [lineFor('a')])), [ + { action: 'append', content: '\nA\nuse a' }, + { action: 'prepend', content: 'P\nuse a\n' }, + { action: 'replace', content: 'OWN\nuse a' }, + { action: 'replace', content: 'use a' }, + ]); + }); + + test('preserves a remove or transform-function override untouched', () => { + const transform = (s: string) => s; + assert.deepStrictEqual(resolveToolInstructionsOverride(hasTools('a'), { action: 'remove' }, [lineFor('a')]), { action: 'remove' }); + assert.deepStrictEqual(resolveToolInstructionsOverride(hasTools('a'), { action: transform }, [lineFor('a')]), { action: transform }); + }); + }); +}); diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index caa42eec52ace..fc6d318e6c551 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -6,7 +6,6 @@ import { parse as parseJSONC } from '../../../base/common/json.js'; import { cloneAndChange, equals as objectEquals } from '../../../base/common/objects.js'; import { isAbsolute } from '../../../base/common/path.js'; -import { untildify } from '../../../base/common/labels.js'; import { basename, extname, isEqualOrParent, joinPath, normalizePath, isEqual as isURLEquals, dirname } from '../../../base/common/resources.js'; import { escapeRegExpCharacters } from '../../../base/common/strings.js'; import { hasKey, Mutable } from '../../../base/common/types.js'; @@ -139,7 +138,7 @@ export interface IPluginFormatConfig { readonly pluginRootToken: string | undefined; readonly pluginRootEnvVar: string | undefined; /** Parses hooks from a JSON object using the format's conventions. */ - parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: string): IParsedHookGroup[]; + parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: URI): IParsedHookGroup[]; } const COPILOT_FORMAT: IPluginFormatConfig = { @@ -252,7 +251,15 @@ function makeHookCustomization(hookUri: URI): HookCustomization { }; } -function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization { +/** + * Builds the protocol {@link McpServerCustomization} for an MCP server + * declared at `definitionUri` (the manifest / settings / `.mcp.json` file + * the server is defined in). The id is disambiguated by server `name` so + * multiple servers declared in one file get distinct ids, and the entry + * carries {@link DEFAULT_MCP_APP} so MCP App support is advertised + * consistently with every other MCP customization. + */ +export function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization { return { type: CustomizationType.McpServer, id: buildChildId(definitionUri, `mcp=${encodeURIComponent(name)}`), @@ -581,7 +588,7 @@ function normalizeHookCommand(raw: Record): IParsedHookCommand * Resolves a raw hook command JSON object into a {@link IParsedHookCommand}, * normalizing fields and resolving the working directory. */ -function resolveHookCommand(raw: Record, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand | undefined { +function resolveHookCommand(raw: Record, workspaceRoot: URI | undefined, userHome: URI): IParsedHookCommand | undefined { const normalized = normalizeHookCommand(raw); if (!normalized) { return undefined; @@ -590,11 +597,12 @@ function resolveHookCommand(raw: Record, workspaceRoot: URI | u let cwdUri: URI | undefined; const rawCwd = typeof raw.cwd === 'string' ? raw.cwd : undefined; if (rawCwd) { - const expanded = untildify(rawCwd, userHome); - if (isAbsolute(expanded)) { - cwdUri = URI.file(expanded); + if (rawCwd.startsWith('~/')) { + cwdUri = URI.joinPath(userHome, rawCwd.substring(2)); + } else if (isAbsolute(rawCwd)) { + cwdUri = URI.file(rawCwd); } else if (workspaceRoot) { - cwdUri = joinPath(workspaceRoot, expanded); + cwdUri = joinPath(workspaceRoot, rawCwd); } } else { cwdUri = workspaceRoot; @@ -607,7 +615,7 @@ function resolveHookCommand(raw: Record, workspaceRoot: URI | u * Extracts hook commands from an item that may be a direct command object * or a nested structure with a `matcher` (Claude format). */ -function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand[] { +function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, userHome: URI): IParsedHookCommand[] { if (!item || typeof item !== 'object') { return []; } @@ -639,12 +647,19 @@ function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, user /** * Parses hooks from a JSON object (any supported format). + * + * Handles Claude's `disableAllHooks` short-circuit, the `HOOK_TYPE_MAP` + * canonicalization, and the nested `{ matcher, hooks: [...] }` command + * form. Returns one {@link IParsedHookGroup} per recognized lifecycle + * event; all groups parsed from the same file share a single + * {@link IParsedHookGroup.customization} (keyed on `hookUri`), so callers + * that only need the file-level customization can read it off any group. */ -function parseHooksJson( +export function parseHooksJson( hookUri: URI, json: unknown, workspaceRoot: URI | undefined, - userHome: string, + userHome: URI, ): IParsedHookGroup[] { if (!json || typeof json !== 'object') { return []; @@ -699,7 +714,7 @@ export function interpolateHookPluginRoot( json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, - userHome: string, + userHome: URI, token: string, envVar: string, ): IParsedHookGroup[] { @@ -1024,7 +1039,7 @@ async function readHooks( formatConfig: IPluginFormatConfig, fileService: IFileService, workspaceRoot: URI | undefined, - userHome: string, + userHome: URI, ): Promise { for (const hookPath of paths) { const json = await readJsonFile(hookPath, fileService); @@ -1102,7 +1117,7 @@ export async function parsePlugin( pluginUri: URI, fileService: IFileService, workspaceRoot: URI | undefined, - userHome: string, + userHome: URI, boundaryUri?: URI, ): Promise { const formatConfig = await detectPluginFormat(pluginUri, fileService); @@ -1159,11 +1174,13 @@ export async function parsePlugin( }; } -function toParsedAgent(resource: INamedPluginResource): IParsedAgent { +/** Pairs an agent {@link INamedPluginResource} with its protocol-level {@link AgentCustomization}. */ +export function toParsedAgent(resource: INamedPluginResource): IParsedAgent { return { ...resource, customization: makeAgentCustomization(resource) }; } -function toParsedSkill(resource: INamedPluginResource): IParsedSkill { +/** Pairs a skill {@link INamedPluginResource} with its protocol-level {@link SkillCustomization}. */ +export function toParsedSkill(resource: INamedPluginResource): IParsedSkill { return { ...resource, customization: makeSkillCustomization(resource) }; } diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index cb6728a193332..968d76d579070 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -8,17 +8,23 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import { CustomizationType, McpServerStatus, type McpServerCustomization } from '../../../agentHost/common/state/protocol/state.js'; +import { DEFAULT_MCP_APP } from '../../../agentHost/common/state/protocol/mcpAppDefaults.js'; +import { customizationId } from '../../../agentHost/common/state/sessionState.js'; function stubMcpCustomization(): McpServerCustomization { return { type: CustomizationType.McpServer, id: 'stub', uri: 'file:///plugin', name: 'test', enabled: true, state: { kind: McpServerStatus.Starting } }; } import { IParsedHookCommand, + makeMcpServerCustomization, parseComponentPathConfig, + parseHooksJson, resolveComponentDirs, normalizeMcpServerConfiguration, shellQuotePluginRootInCommand, convertBareEnvVarsToVsCodeSyntax, + toParsedAgent, + toParsedSkill, } from '../../common/pluginParsers.js'; suite('pluginParsers', () => { @@ -330,4 +336,121 @@ suite('pluginParsers', () => { assert.strictEqual(IParsedHookCommand.isEquals(left, right), false); }); }); + + suite('toParsedAgent / toParsedSkill', () => { + + test('toParsedAgent pairs the resource with an AgentCustomization', () => { + const uri = URI.file('/home/.claude/agents/explore.md'); + const parsed = toParsedAgent({ uri, name: 'explore', description: 'Explore the codebase' }); + assert.deepStrictEqual(parsed, { + uri, + name: 'explore', + description: 'Explore the codebase', + customization: { + type: CustomizationType.Agent, + id: customizationId(uri.toString()), + uri: uri.toString(), + name: 'explore', + description: 'Explore the codebase', + }, + }); + }); + + test('toParsedSkill pairs the resource with a SkillCustomization and omits an absent description', () => { + const uri = URI.file('/home/.claude/skills/mapper/SKILL.md'); + const parsed = toParsedSkill({ uri, name: 'mapper' }); + assert.deepStrictEqual(parsed, { + uri, + name: 'mapper', + customization: { + type: CustomizationType.Skill, + id: customizationId(uri.toString()), + uri: uri.toString(), + name: 'mapper', + }, + }); + }); + }); + + suite('makeMcpServerCustomization', () => { + + test('builds a Starting server with DEFAULT_MCP_APP and a name-disambiguated id', () => { + const uri = URI.file('/workspace/.mcp.json'); + const customization = makeMcpServerCustomization(uri, 'fs server'); + assert.deepStrictEqual(customization, { + type: CustomizationType.McpServer, + id: `${customizationId(uri.toString())}#mcp=${encodeURIComponent('fs server')}`, + uri: uri.toString(), + name: 'fs server', + enabled: true, + state: { kind: McpServerStatus.Starting }, + mcpApp: DEFAULT_MCP_APP, + }); + }); + + test('two servers declared in the same file get distinct ids', () => { + const uri = URI.file('/workspace/.mcp.json'); + assert.notStrictEqual(makeMcpServerCustomization(uri, 'a').id, makeMcpServerCustomization(uri, 'b').id); + }); + }); + + // ---- parseHooksJson ------------------------------------------------- + + suite('parseHooksJson', () => { + + const hookUri = URI.file('/workspace/.claude/settings.json'); + const parse = (json: unknown) => parseHooksJson(hookUri, json, undefined, URI.file('/home')); + + test('returns [] for a non-object, a missing hooks block, or disableAllHooks', () => { + assert.deepStrictEqual(parse(undefined), []); + assert.deepStrictEqual(parse({ model: 'x' }), []); + assert.deepStrictEqual(parse({ disableAllHooks: true, hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: 'echo' }] }] } }), []); + }); + + test('canonicalizes event names (camelCase → PascalCase) and ignores unrecognized events', () => { + const groups = parse({ + hooks: { + postToolUse: [{ hooks: [{ type: 'command', command: 'echo a' }] }], + bogusEvent: [{ hooks: [{ type: 'command', command: 'echo b' }] }], + }, + }); + assert.deepStrictEqual(groups.map(g => g.type), ['PostToolUse']); + }); + + test('extracts commands from the nested matcher form and drops empty groups', () => { + const groups = parse({ + hooks: { + PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo run' }] }], + Stop: [{ matcher: 'X', hooks: [{ type: 'not-a-command' }] }], + }, + }); + assert.deepStrictEqual(groups.map(g => g.type), ['PreToolUse']); + assert.deepStrictEqual(groups[0].commands.map(c => c.command), ['echo run']); + }); + + test('extracts commands from the flat (non-nested) command form', () => { + const groups = parse({ + hooks: { PostToolUse: [{ type: 'command', command: 'echo flat' }] }, + }); + assert.deepStrictEqual(groups.map(g => g.type), ['PostToolUse']); + assert.deepStrictEqual(groups[0].commands.map(c => c.command), ['echo flat']); + }); + + test('all groups from one file share a single file-level customization', () => { + const groups = parse({ + hooks: { + PreToolUse: [{ hooks: [{ type: 'command', command: 'a' }] }], + PostToolUse: [{ hooks: [{ type: 'command', command: 'b' }] }], + }, + }); + assert.strictEqual(groups.length, 2); + assert.strictEqual(groups[0].customization, groups[1].customization); + assert.deepStrictEqual(groups[0].customization, { + type: CustomizationType.Hook, + id: customizationId(hookUri.toString()), + uri: hookUri.toString(), + name: 'settings.json', + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/browserView/common/browserChatToolReferenceNames.ts b/src/vs/platform/browserView/common/browserChatToolReferenceNames.ts similarity index 79% rename from src/vs/workbench/contrib/browserView/common/browserChatToolReferenceNames.ts rename to src/vs/platform/browserView/common/browserChatToolReferenceNames.ts index ec7138fe37c88..da7e826c04ebb 100644 --- a/src/vs/workbench/contrib/browserView/common/browserChatToolReferenceNames.ts +++ b/src/vs/platform/browserView/common/browserChatToolReferenceNames.ts @@ -5,6 +5,10 @@ /** * Tool reference names for the integrated browser tools. + * + * Lives in `platform` (rather than next to the `workbench/contrib/browserView` + * tools that produce them) so the Copilot agent host — which cannot import from + * `workbench` — can gate its browser tool instructions on the same names. */ export const BrowserChatToolReferenceName = { OpenBrowserPage: 'openBrowserPage', diff --git a/src/vs/platform/browserView/common/browserPermissions.ts b/src/vs/platform/browserView/common/browserPermissions.ts new file mode 100644 index 0000000000000..1e747f6c4f681 --- /dev/null +++ b/src/vs/platform/browserView/common/browserPermissions.ts @@ -0,0 +1,483 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { localize } from '../../../nls.js'; + +/** + * UI-agnostic, per-origin permission model for the integrated browser, modeled + * on Chromium's user-friendly Site Settings categories rather than the raw + * Electron permission strings. This module intentionally ships NO UI and no + * Electron import so it can load in both the main process (authoritative store) + * and the workbench renderer (read mirror hydrated from storage). + * + * The Electron permission string list is taken from Electron's own source + * (`shell/common/gin_converters/content_converter.cc`), the single source of + * truth for the strings passed to the permission handlers. Any + * `blink::PermissionType` Electron does not explicitly name is reported as + * `'unknown'`. + */ + +/** + * A decision a user can record for a (origin, category) pair. These are the + * only two values ever persisted; clearing a decision removes it entirely. + * - 'allow' -> grant without prompting + * - 'deny' -> reject without prompting + */ +export type PermissionDecision = 'allow' | 'deny'; + +/** + * The effective state of a (origin, category) pair: either a recorded + * {@link PermissionDecision}, or 'ask' when no decision has been recorded. + * 'ask' is only ever a default/effective value -- it is never stored. + */ +export type PermissionState = PermissionDecision | 'ask'; + +/** + * User-facing permission categories. These are deliberately coarser and more + * meaningful than Electron's raw permission strings. For example Electron's + * single `media` permission is split into `Camera` and `Microphone`, and the + * various clipboard permissions collapse into `Clipboard`. + */ +export const enum PermissionCategory { + Location = 'location', + Camera = 'camera', + Microphone = 'microphone', + Notifications = 'notifications', + Sensors = 'sensors', + Clipboard = 'clipboard', + Devices = 'devices', +} + +/** + * The kinds of hardware-device chooser flows the {@link PermissionCategory.Devices} + * category gates. Each maps to a distinct Electron device-selection event but is + * surfaced to the user through one unified request/selection flow. + */ +export type BrowserDeviceType = 'usb' | 'serial' | 'hid' | 'bluetooth'; + +/** + * A single hardware device offered to the user during a device-chooser flow. + * Only plain, user-presentable data crosses the IPC boundary; the opaque + * `deviceId` is echoed back verbatim to select the device. + */ +export interface IBrowserDeviceCandidate { + /** Opaque, device-type-specific identifier echoed back to select the device. */ + readonly deviceId: string; + /** Primary, user-facing label (e.g. product name). */ + readonly label: string; + /** Optional secondary detail (e.g. manufacturer or vendor:product ids). */ + readonly detail?: string; +} + +/** + * Static metadata describing a category and how it maps to Electron's raw + * permission strings. A UI can iterate {@link PERMISSION_CATEGORY_DESCRIPTORS} + * to render a settings list directly from this data. + */ +export interface IPermissionCategoryDescriptor { + readonly category: PermissionCategory; + /** Short, human-readable label suitable for a settings row. */ + readonly label: string; + /** One-line description of what granting this category enables. */ + readonly description: string; + /** The icon to display for this category. */ + readonly icon: ThemeIcon; + /** Electron permission strings that map to this category. */ + readonly permissions: string[]; + /** State assumed for this category when an origin has not recorded a decision. */ + readonly defaultState: PermissionState; +} + +export const PERMISSION_CATEGORY_DESCRIPTORS: Readonly> = { + [PermissionCategory.Location]: { + category: PermissionCategory.Location, + label: localize('browserPermission.location.label', "Location"), + description: localize('browserPermission.location.description', "Access this device's geographic location"), + icon: Codicon.location, + permissions: ['geolocation', 'geolocation-approximate'], + defaultState: 'ask', + }, + [PermissionCategory.Camera]: { + category: PermissionCategory.Camera, + label: localize('browserPermission.camera.label', "Camera"), + description: localize('browserPermission.camera.description', "Capture video from cameras"), + icon: Codicon.deviceCamera, + // `media` is shared with Microphone; disambiguated via mediaType/mediaTypes. + permissions: ['media'], + defaultState: 'ask', + }, + [PermissionCategory.Microphone]: { + category: PermissionCategory.Microphone, + label: localize('browserPermission.microphone.label', "Microphone"), + description: localize('browserPermission.microphone.description', "Capture audio from microphones"), + icon: Codicon.mic, + permissions: ['media'], + defaultState: 'ask', + }, + [PermissionCategory.Sensors]: { + category: PermissionCategory.Sensors, + label: localize('browserPermission.sensors.label', "Sensors"), + description: localize('browserPermission.sensors.description', "Read motion and environmental sensors"), + icon: Codicon.pulse, + permissions: ['sensors'], + defaultState: 'allow', + }, + [PermissionCategory.Clipboard]: { + category: PermissionCategory.Clipboard, + label: localize('browserPermission.clipboard.label', "Clipboard"), + description: localize('browserPermission.clipboard.description', "Read from and write to the system clipboard"), + icon: Codicon.clippy, + permissions: ['clipboard-read'], + defaultState: 'ask', + }, + [PermissionCategory.Notifications]: { + category: PermissionCategory.Notifications, + label: localize('browserPermission.notifications.label', "Notifications"), + description: localize('browserPermission.notifications.description', "Display desktop notifications"), + icon: Codicon.bell, + permissions: ['notifications'], + defaultState: 'ask', + }, + [PermissionCategory.Devices]: { + category: PermissionCategory.Devices, + label: localize('browserPermission.devices.label', "Devices"), + description: localize('browserPermission.devices.description', "Request access to USB, serial, HID, and Bluetooth devices"), + icon: Codicon.plug, + // Each device kind has its own native chooser; this decision only gates + // whether that chooser is allowed to surface. Bluetooth has no Electron + // permission string (it is gated in the chooser handler directly). + permissions: ['usb', 'serial', 'hid'], + defaultState: 'allow', + }, + /** + * Permissions not listed here are either always allowed (see + * {@link ALWAYS_ALLOWED_PERMISSIONS}) or, by default, always denied: + * + * No-op in Electron due to missing backend support + * - Smart Cards (`smart-card`) + * - NFC (`nfc`) + * - Protected Content (`mediaKeySystem`) + * - Augmented / Virtual Reality, Hand Tracking (`ar`, `vr`, `hand-tracking`) + * - Payment Handlers (`payment-handler`) + * - Background Sync (`background-sync`, `periodic-background-sync`, `background-fetch`) + * - Printing (`web-printing`) + * - App Installation (`web-app-installation`) + * - Storage Access (`storage-access`, `top-level-storage-access`) + * + * Not currently implemented (in approximate order of 'might want') + * - Local Network Access (`local-network-access`, `local-network`, `loopback-network`) + * - Screen Capture, Captured Surface Control (`display-capture`, `captured-surface-control`) + * - File Writing (`fileSystem`) + * - Open External (`openExternal`) + * - MIDI (`midi`, `midiSysex`) + * - Persistent Storage (`persistent-storage`) + * - Device Activity (`idle-detection`) + * - Audio Output (`speaker-selection`) + * - Wake Lock (`screen-wake-lock`, `system-wake-lock`) + * - Window Management (`window-management`) + * - Fonts (`local-fonts`) + * - Automatic Fullscreen (`automatic-fullscreen`) + */ +}; + +/** + * Raw Electron permission strings that are granted unconditionally, with no + * recorded state and no management control. These are low-risk capabilities + * that Chrome itself also always grants automatically. + */ +export const ALWAYS_ALLOWED_PERMISSIONS: ReadonlySet = new Set([ + 'pointerLock', + 'keyboardLock', + 'fullscreen', + 'clipboard-sanitized-write', +]); + +/** Whether a raw Electron permission string is granted unconditionally. */ +export function isAlwaysAllowedPermission(permission: string): boolean { + return ALWAYS_ALLOWED_PERMISSIONS.has(permission); +} + +/** All categories, in a stable display order. */ +export const ALL_PERMISSION_CATEGORIES: readonly PermissionCategory[] = Object.keys(PERMISSION_CATEGORY_DESCRIPTORS) as PermissionCategory[]; + +/** The default state for each permission category. */ +const DEFAULT_PERMISSION_STATES: Readonly> = Object.freeze( + Object.fromEntries(ALL_PERMISSION_CATEGORIES.map(category => [category, PERMISSION_CATEGORY_DESCRIPTORS[category].defaultState])) as Record +); + +/** + * Reverse lookup table built once from the descriptors: + * electron permission string -> categories that own it. + * `media` is intentionally omitted here because it requires `details` to + * disambiguate; it is handled explicitly in {@link electronPermissionToCategories}. + */ +const PERMISSION_TO_CATEGORIES: ReadonlyMap = (() => { + const map = new Map(); + for (const category of ALL_PERMISSION_CATEGORIES) { + for (const permission of PERMISSION_CATEGORY_DESCRIPTORS[category].permissions) { + if (permission === 'media') { + continue; + } + const existing = map.get(permission); + if (existing) { + existing.push(category); + } else { + map.set(permission, [category]); + } + } + } + return map; +})(); + +/** + * Map a raw Electron permission string (from either handler, or a device type) + * to the user-friendly category/categories it represents. + * + * Notes: + * - `media` resolves to `Camera`, `Microphone`, or both depending on the + * normalized `mediaKinds` hint extracted by the caller from Electron's + * details. With no hint it conservatively resolves to both, so the caller + * can require the strictest decision. + * - `unknown` (and anything unrecognized) resolves to an empty array. + */ +export function electronPermissionToCategories(permission: string, mediaKinds?: ReadonlyArray<'video' | 'audio'>): PermissionCategory[] { + if (permission === 'media') { + return resolveMediaCategories(mediaKinds); + } + return PERMISSION_TO_CATEGORIES.get(permission) ?? []; +} + +function resolveMediaCategories(mediaKinds?: ReadonlyArray<'video' | 'audio'>): PermissionCategory[] { + const categories = new Set(); + for (const kind of mediaKinds ?? []) { + categories.add(kind === 'video' ? PermissionCategory.Camera : PermissionCategory.Microphone); + } + // No usable hint: assume both so callers apply the strictest decision. + if (categories.size === 0) { + return [PermissionCategory.Camera, PermissionCategory.Microphone]; + } + return [...categories]; +} + +/** + * Normalize a full URL down to a stable permission key. + * + * For URLs with a real origin (http/https/etc.) this returns the origin + * (scheme + host + port), e.g. "https://example.com:8443". Host-less URLs such + * as `file:` have no meaningful origin, so they key off the scheme and full + * path instead (query and fragment removed), e.g. "file:///home/user/page.html". + * Falls back to the trimmed raw input if it cannot be parsed. + */ +export function toOriginKey(url: string | undefined | null): string { + // Trim first so leading/trailing whitespace doesn't push otherwise valid + // URLs into the catch path. Electron also reports opaque/unique origins + // (sandboxed frames, data: URLs, etc.) as the literal string "null"; that is + // not a real host, so treat it as no origin to keep category defaults from + // applying to it. + const trimmed = url?.trim(); + if (!trimmed || trimmed === 'null') { + return ''; + } + try { + const parsed = new URL(trimmed); + // Host-less schemes such as file: have no meaningful origin (it is + // reported as "null" in Node but "file://" in Chromium), so key off the + // scheme and full path instead -- query and fragment are dropped. + if (!parsed.host) { + return `${parsed.protocol}//${parsed.pathname}`; + } + return parsed.origin; + } catch { + return trimmed; + } +} + +/** A single recorded grant: which origin, which category, what decision. */ +export interface IPermissionGrant { + readonly origin: string; + readonly category: PermissionCategory; + readonly state: PermissionDecision; +} + +/** + * A (category, decision) pair for one (implied) origin; the write API payload. + * A `null` decision clears any recorded choice, falling back to the default. + */ +export interface IPermissionCategoryState { + readonly category: PermissionCategory; + readonly state: PermissionDecision | null; +} + +/** + * On-disk shape of the whole store: `{ origin: { category: decision } }`. + */ +export interface ISerializedBrowserPermissionsSnapshot { + readonly origins: Readonly>>>; +} + +const VALID_CATEGORIES = new Set(ALL_PERMISSION_CATEGORIES); + +/** + * In-memory, serializable tracker of permission state keyed by (origin, + * category). The main process owns the authoritative instance; the workbench + * keeps a read mirror hydrated from storage. Mutate with `set` / `setMany`, + * observe with `onDidChange`, persist with `serialize` / `hydrate`. + */ +export class BrowserPermissionStore extends Disposable { + + private readonly _data = new Map>(); + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + /** + * The default state assumed for a category when an origin has recorded no decision. + */ + defaultStateFor(category: PermissionCategory): PermissionState { + return PERMISSION_CATEGORY_DESCRIPTORS[category].defaultState; + } + + /** Get the recorded decision for a (origin, category) pair. */ + getDecision(origin: string, category: PermissionCategory): PermissionDecision | undefined { + return this._data.get(toOriginKey(origin))?.get(category); + } + + /** + * Resolve the effective boolean decision for a (origin, category) pair, + * applying {@link defaultStateFor} when the recorded state is 'ask'. + */ + isAllowed(origin: string, category: PermissionCategory): boolean { + return (this.getDecision(origin, category) ?? this.defaultStateFor(category)) === 'allow'; + } + + /** Set (or clear, via `null`) the decision for a (origin, category) pair. */ + set(origin: string, category: PermissionCategory, decision: PermissionDecision | null): void { + const key = toOriginKey(origin); + if (!key) { + return; + } + + if (decision === null) { + const categories = this._data.get(key); + if (!categories?.delete(category)) { + return; // nothing changed + } + if (categories.size === 0) { + this._data.delete(key); + } + } else { + let categories = this._data.get(key); + if (categories?.get(category) === decision) { + return; // nothing changed + } + if (!categories) { + categories = new Map(); + this._data.set(key, categories); + } + categories.set(category, decision); + } + + this._onDidChange.fire(); + } + + /** Set (or clear) the decision for several categories of one origin at once. */ + setMany(origin: string, grants: Iterable): void { + for (const { category, state } of grants) { + this.set(origin, category, state); + } + } + + /** Remove all recorded state for an origin. */ + clearOrigin(origin: string): void { + const key = toOriginKey(origin); + if (this._data.delete(key)) { + this._onDidChange.fire(); + } + } + + /** Remove all recorded state for every origin. */ + clear(): void { + if (this._data.size === 0) { + return; + } + this._data.clear(); + this._onDidChange.fire(); + } + + /** + * Return the full category->state map for one origin, including categories + * with no recorded decision. Ideal for rendering a + * per-site settings page. + */ + getOrigin(origin: string): Record { + const result: Record = { ...DEFAULT_PERMISSION_STATES }; + const recorded = this._data.get(toOriginKey(origin)); + if (recorded) { + for (const [category, state] of recorded) { + result[category] = state; + } + } + return result; + } + + /** All origins that have at least one recorded decision. */ + origins(): string[] { + return [...this._data.keys()]; + } + + /** Flat list of every recorded grant. */ + list(): IPermissionGrant[] { + const grants: IPermissionGrant[] = []; + for (const [origin, categories] of this._data) { + for (const [category, state] of categories) { + grants.push({ origin, category, state }); + } + } + return grants; + } + + serialize(): ISerializedBrowserPermissionsSnapshot { + const origins: Record>> = {}; + for (const [origin, categories] of this._data) { + const entry: Partial> = {}; + for (const [category, state] of categories) { + entry[category] = state; + } + origins[origin] = entry; + } + return { origins }; + } + + hydrate(snapshot: ISerializedBrowserPermissionsSnapshot | undefined): void { + this._data.clear(); + if (snapshot?.origins && typeof snapshot.origins === 'object') { + for (const [origin, categories] of Object.entries(snapshot.origins)) { + if (!categories || typeof categories !== 'object') { + continue; + } + const key = toOriginKey(origin); + if (!key) { + continue; + } + let target: Map | undefined; + for (const [category, state] of Object.entries(categories)) { + if (!VALID_CATEGORIES.has(category) || (state !== 'allow' && state !== 'deny')) { + continue; + } + if (!target) { + target = new Map(); + this._data.set(key, target); + } + target.set(category as PermissionCategory, state); + } + } + } + this._onDidChange.fire(); + } +} diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 2c2669ae6a112..92db79754a400 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -7,6 +7,7 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { localize } from '../../../nls.js'; import { ITunnelProxyInfo } from '../../tunnel/common/tunnelProxy.js'; +import { IPermissionCategoryState, ISerializedBrowserPermissionsSnapshot, IBrowserDeviceCandidate, BrowserDeviceType, PermissionCategory } from './browserPermissions.js'; const commandPrefix = 'workbench.action.browser'; export enum BrowserViewCommandId { @@ -36,6 +37,9 @@ export enum BrowserViewCommandId { // History ShowHistory = `${commandPrefix}.showHistory`, + // Permissions + ManagePermissions = `${commandPrefix}.managePermissions`, + // Chat actions AddElementToChat = `${commandPrefix}.addElementToChat`, AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, @@ -119,6 +123,14 @@ export interface IBrowserViewWindowConfiguration { * workspace folder paths. */ readonly trustedFileRoots: readonly string[]; + /** + * Whether Workspace Trust is disabled entirely + * (`security.workspace.trust.enabled: false`) for this window. When + * `true`, every `file://` request is allowed regardless of + * {@link trustedFileRoots} since there is no meaningful notion of an + * untrusted folder to enforce. + */ + readonly trustAllFiles: boolean; } export interface IBrowserViewBounds { @@ -214,6 +226,7 @@ export interface IBrowserViewCreateOptions { export interface IBrowserViewStorageKeys { readonly history?: string; readonly favicons?: string; + readonly permissions?: string; } export interface IBrowserViewState { @@ -231,6 +244,7 @@ export interface IBrowserViewState { certificateError: IBrowserViewCertificateError | undefined; storageScope: BrowserViewStorageScope; storageKeys: IBrowserViewStorageKeys; + permissions: ISerializedBrowserPermissionsSnapshot; browserZoomIndex: number; isElementSelectionActive: boolean; isRemoteSession: boolean; @@ -301,6 +315,18 @@ export interface IBrowserViewFaviconChangeEvent { favicon: string | undefined; } +export interface IBrowserViewPermissionRequestEvent { + origin: string; + category: PermissionCategory; + device?: IBrowserViewDeviceRequest; +} + +export interface IBrowserViewDeviceRequest { + requestId: string; + deviceType: BrowserDeviceType; + devices: IBrowserDeviceCandidate[]; +} + export interface IBrowserViewFindInPageOptions { recompute?: boolean; forward?: boolean; @@ -381,6 +407,8 @@ export interface IBrowserViewService { onDynamicDidChangeAreaSelectionActive(id: string): Event; onDynamicDidChangeDeviceEmulation(id: string): Event; onDynamicDidChangeRemoteStatus(id: string): Event; + onDynamicDidRequestPermission(id: string): Event; + onDynamicDidChangePermissions(id: string): Event; /** * Get all known browser views with their ownership and state information. @@ -559,6 +587,32 @@ export interface IBrowserViewService { */ deleteBrowserHistory(id: string, entryIds?: readonly number[]): Promise; + /** + * Record permission decisions for an origin in this view's session. This is + * the single write API for permissions: it is used both by the management UI + * and to answer an outstanding {@link onDynamicDidRequestPermission} prompt. + * Recording a decision for a category auto-resolves any pending request for + * that (origin, category). The only values ever stored are `'allow'` and + * `'deny'`; passing a `null` decision clears the saved choice, falling back + * to the category default. Changes are persisted immediately. + * + * @param id The browser view identifier + * @param origin The origin (URL or origin string) to record decisions for + * @param grants The per-category decisions to record + */ + setPermissions(id: string, origin: string, grants: readonly IPermissionCategoryState[]): Promise; + + /** + * Answer an in-progress hardware-device chooser raised via + * {@link onDynamicDidRequestPermission} (its `device` payload). Pass the + * chosen `deviceId`, or `null` to cancel the chooser. + * + * @param id The browser view identifier + * @param requestId The device request to answer + * @param deviceId The selected device id, or `null` to cancel + */ + selectDevice(id: string, requestId: string, deviceId: string | null): Promise; + /** * Get captured console logs for a browser view. * Console messages are automatically captured from the moment the view is created. diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index a07e9d3008fd3..7748280b41d32 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -13,18 +13,12 @@ import { IApplicationStorageMainService } from '../../storage/electron-main/stor import { BrowserViewStorageScope, IBrowserSessionOptions } from '../common/browserView.js'; import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust.js'; import { BrowserSessionHistory, IBrowserSessionHistory } from './browserSessionHistory.js'; +import { BrowserSessionPermissions, IBrowserSessionPermissions } from './browserSessionPermissions.js'; import { BrowserSessionRemote, IBrowserSessionRemote } from './browserSessionRemote.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { localize } from '../../../nls.js'; -// Same as webviews, minus clipboard-read -const allowedPermissions = new Set([ - 'pointerLock', - 'notifications', - 'clipboard-sanitized-write' -]); - /** * Holds an Electron session along with its storage scope and unique browser * context identifier. Each instance maps one-to-one to an Electron @@ -190,10 +184,13 @@ export class BrowserSession { } private static readonly _trustedFileRoots = TernarySearchTree.forPaths(!isLinux); + private static _trustAllFiles = false; + /** * Set trusted file roots for all browser sessions. */ - static setTrustedFileRoots(roots: readonly string[]): void { + static setTrustedFileRoots(roots: readonly string[], trustAllFiles: boolean): void { + BrowserSession._trustAllFiles = trustAllFiles; BrowserSession._trustedFileRoots.clear(); for (const root of roots) { if (root) { @@ -209,6 +206,7 @@ export class BrowserSession { private readonly _trust: BrowserSessionTrust; private readonly _history: BrowserSessionHistory; private readonly _remote: BrowserSessionRemote; + private readonly _permissions: BrowserSessionPermissions; /** * @deprecated Don't use this directly. Create sessions via the static factory methods. @@ -228,6 +226,7 @@ export class BrowserSession { this._trust = new BrowserSessionTrust(this); this._history = new BrowserSessionHistory(this); this._remote = new BrowserSessionRemote(this); + this._permissions = new BrowserSessionPermissions(this); this.configure(); BrowserSession.knownSessions.add(electronSession); BrowserSession._bySession.set(electronSession, this); @@ -250,6 +249,11 @@ export class BrowserSession { return this._remote; } + /** Public permissions interface owning per-origin permission state. */ + get permissions(): IBrowserSessionPermissions { + return this._permissions; + } + /** * Connect application storage to this session so that preferences * (trusted certificates, history, etc.) are persisted across restarts. @@ -259,25 +263,21 @@ export class BrowserSession { connectStorage(storage: IApplicationStorageMainService): void { this._trust.connectStorage(storage); this._history.connectStorage(storage); + this._permissions.connectStorage(storage); } /** * Apply the permission policy and preload scripts to the session. */ private configure(): void { - this.electronSession.setPermissionRequestHandler((_webContents, permission, callback) => { - return callback(allowedPermissions.has(permission)); - }); - this.electronSession.setPermissionCheckHandler((_webContents, permission, _origin) => { - return allowedPermissions.has(permission); - }); + this._permissions.configure(this.electronSession); this.electronSession.registerPreloadScript({ type: 'frame', filePath: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath }); this.electronSession.protocol.handle(Schemas.file, request => { const filePath = normalize(URI.parse(request.url).fsPath); - if (!BrowserSession._trustedFileRoots.findSubstr(filePath)) { + if (!BrowserSession._trustAllFiles && !BrowserSession._trustedFileRoots.findSubstr(filePath)) { return new Response(localize('browserSession.untrustedFile', 'Forbidden. File does not reside within a trusted folder.'), { status: 403 }); } return this.electronSession.fetch(request, { bypassCustomProtocolHandlers: true }); @@ -290,6 +290,7 @@ export class BrowserSession { async clearData(): Promise { await this._trust.clear(); this._history.delete(); + this._permissions.clear(); await this.electronSession.clearData(); } diff --git a/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts new file mode 100644 index 0000000000000..a30b3b3d5edd1 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts @@ -0,0 +1,658 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { webContents as electronWebContents } from 'electron'; +import { localize } from '../../../nls.js'; +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; +import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; +import { + BrowserDeviceType, + BrowserPermissionStore, + IBrowserDeviceCandidate, + IPermissionCategoryState, + ISerializedBrowserPermissionsSnapshot, + PermissionCategory, + electronPermissionToCategories, + isAlwaysAllowedPermission, + toOriginKey, +} from '../common/browserPermissions.js'; +import { BrowserViewStorageScope, IBrowserViewPermissionRequestEvent, IBrowserViewStorageKeys } from '../common/browserView.js'; +import type { BrowserSession } from './browserSession.js'; + +/** Time the main process waits for a prompt answer before a non-persisted deny. */ +const PROMPT_TIMEOUT_MS = 30_000; + +/** + * Fired when a permission request for an undecided category needs UI. The view + * that owns {@link webContents} should {@link claim} it and surface a prompt; + * if no listener claims it, the request is left undecided (effective deny). + */ +export interface IBrowserSessionPermissionRequest { + /** The top-level web contents the request originates from. */ + readonly webContents: Electron.WebContents; + /** The origin + category being requested. */ + readonly request: IBrowserViewPermissionRequestEvent; + /** Called by the owning view to take responsibility for prompting. */ + claim(): void; +} + +/** + * Fired when a hardware-device chooser ({@link PermissionCategory.Devices}) needs + * UI, and re-fired as the available device list changes. The owning view + * {@link claim}s it and surfaces a picker; the user's pick is reported back via + * {@link IBrowserSessionPermissions.resolveDevice}. If the originating + * webContents is destroyed or the session is disposed, the page promise is + * settled and the pending request is removed; a late + * {@link IBrowserSessionPermissions.resolveDevice} call is then a no-op. Any + * open picker on the workbench side is left open until the user dismisses it. + */ +export interface IBrowserSessionDeviceRequest { + /** The top-level web contents the request originates from. */ + readonly webContents: Electron.WebContents; + /** The origin requesting a device. */ + readonly origin: string; + /** Stable id correlating the initial request with its updates. */ + readonly requestId: string; + /** Which native chooser flow this is. */ + readonly deviceType: BrowserDeviceType; + /** The devices currently available to choose from. */ + readonly devices: IBrowserDeviceCandidate[]; + /** Called by the owning view to take responsibility for the chooser UI. */ + claim(): void; +} + +/** Internal record of an in-flight device chooser awaiting the user's pick. */ +interface IPendingDeviceRequest { + readonly requestId: string; + readonly webContents: Electron.WebContents; + readonly origin: string; + readonly deviceType: BrowserDeviceType; + devices: IBrowserDeviceCandidate[]; + settled: boolean; + /** Type-specific adapter that calls the native Electron callback. */ + invoke: (deviceId: string | null) => void; + /** Resolve the chooser with a device id, or `null` to cancel. */ + settle(deviceId: string | null): void; +} + + +export interface IBrowserSessionPermissions { + readonly storageKeys: IBrowserViewStorageKeys; + /** + * Fires when an undecided permission needs UI. Each browser view listens and + * claims the requests targeting its own web contents. + */ + readonly onDidRequestPermission: Event; + /** Fires when a hardware-device chooser needs UI, and again as its device list changes. */ + readonly onDidRequestDevice: Event; + readonly onDidChange: Event; + /** Current snapshot of all recorded decisions, mirrored to the workbench. */ + serialize(): ISerializedBrowserPermissionsSnapshot; + /** Record permission decisions for an origin and persist immediately. */ + set(origin: string, grants: readonly IPermissionCategoryState[]): void; + /** Clear all recorded permission state for this session. */ + clear(): void; + /** Funnel a per-webContents Bluetooth chooser into the unified device flow. */ + beginBluetoothRequest(webContents: Electron.WebContents, devices: Electron.BluetoothDevice[], callback: (deviceId: string) => void): void; + /** Answer a device chooser with the chosen id, or `null` to cancel. */ + resolveDevice(requestId: string, deviceId: string | null): void; +} + +interface IPendingRequest { + readonly origin: string; + readonly category: PermissionCategory; + readonly deferred: DeferredPromise; +} + +/** + * Per-{@link BrowserSession} permission state. Owns the authoritative + * {@link BrowserPermissionStore}, installs the Electron permission handlers that + * consult it, and brokers prompts for categories that have no recorded decision. + * + * Every change to the store is flushed to application storage immediately so + * decisions survive a crash right after they are made. + */ +export class BrowserSessionPermissions extends Disposable implements IBrowserSessionPermissions { + + private readonly _permissionStore = this._register(new BrowserPermissionStore()); + + /** Fires on any change to the store (set, clear, hydrate). */ + readonly onDidChange: Event = this._permissionStore.onDidChange; + + private _storage: IApplicationStorageMainService | undefined; + private _persistable = false; + + /** While set, store changes are coalesced into a single deferred flush. */ + private _batching = false; + private _batchDirty = false; + + private readonly _onDidRequestPermission = this._register(new Emitter()); + readonly onDidRequestPermission = this._onDidRequestPermission.event; + + private readonly _onDidRequestDevice = this._register(new Emitter()); + readonly onDidRequestDevice = this._onDidRequestDevice.event; + + private readonly _pending = new Set(); + private readonly _pendingDevices = new Map(); + + readonly storageKeys: IBrowserViewStorageKeys; + + constructor(session: BrowserSession) { + super(); + + this.storageKeys = session.storageScope === BrowserViewStorageScope.Ephemeral + ? {} + : { permissions: `browser.permissions.${session.id}` }; + + this._register(this._permissionStore.onDidChange(() => { + this._resolvePending(); + // During a batched `set()` defer the write so several category + // changes collapse into a single storage flush. + if (this._batching) { + this._batchDirty = true; + return; + } + if (this._persistable) { + this._flushNow(); + } + })); + + this._register(toDisposable(() => { + for (const pending of this._pending) { + pending.deferred.complete(); + } + this._pending.clear(); + // Cancel any in-flight device choosers so pages aren't left hanging. + for (const device of [...this._pendingDevices.values()]) { + device.settle(null); + } + })); + } + + /** + * Install the permission request / check / device handlers on the session. + * Backed entirely by {@link BrowserPermissionStore}; unrecorded categories + * are brokered to the owning browser view via {@link onDidRequestPermission}. + */ + configure(electronSession: Electron.Session): void { + electronSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + this._resolveRequest(webContents, permission, details).then(callback, () => callback(false)); + }); + electronSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { + if (isAlwaysAllowedPermission(permission)) { + return true; + } + // Prefer the full requesting URL so file: documents key off their + // path; `requestingUrl` is absent for cross-origin subframes, in + // which case Electron only gives us the bare origin. + const origin = toOriginKey(details.requestingUrl || requestingOrigin); + const categories = electronPermissionToCategories(permission, mediaKindsFromDetails(details)); + if (categories.length === 0) { + return false; + } + // Synchronous gate used by Blink pre-checks and `permissions.query`. + // Categories with no recorded decision fall back to their + // `defaultState` (e.g. Location / Camera are deny-by-default until + // granted, while others may allow by default). + return categories.every(category => this._permissionStore.isAllowed(origin, category)); + }); + + // Hardware-device choosers. USB / Serial / HID are gated by the handlers + // above (a `Devices` deny makes the check fail, so Chromium never fires + // these). We still re-check here, drive selection through the unified + // device-request flow, and listen for hot-plug add/remove events so an + // open picker stays in sync. Bluetooth is gated and funneled separately + // from the owning view (it is a per-webContents event). + electronSession.on('select-usb-device', (event, details, callback) => { + event.preventDefault(); + const target = this._frameTarget(details.frame); + if (!target || !this._deviceAllowed(target.origin)) { + callback(); + return; + } + this._beginDeviceRequest({ + webContents: target.webContents, + origin: target.origin, + deviceType: 'usb', + devices: details.deviceList.map(usbCandidate), + invoke: deviceId => deviceId === null ? callback() : callback(deviceId), + }); + }); + electronSession.on('usb-device-added', (_event, device, webContents) => { + this._addDevice(webContents, 'usb', usbCandidate(device)); + }); + electronSession.on('usb-device-removed', (_event, device, webContents) => { + this._removeDevice(webContents, 'usb', device.deviceId); + }); + + electronSession.on('select-serial-port', (event, portList, webContents, callback) => { + event.preventDefault(); + const origin = toOriginKey(webContents.getURL()); + if (!this._deviceAllowed(origin)) { + callback(''); + return; + } + this._beginDeviceRequest({ + webContents, + origin, + deviceType: 'serial', + devices: portList.map(serialCandidate), + invoke: deviceId => callback(deviceId ?? ''), + }); + }); + electronSession.on('serial-port-added', (_event, port, webContents) => { + this._addDevice(webContents, 'serial', serialCandidate(port)); + }); + electronSession.on('serial-port-removed', (_event, port, webContents) => { + this._removeDevice(webContents, 'serial', port.portId); + }); + + electronSession.on('select-hid-device', (event, details, callback) => { + event.preventDefault(); + const target = this._frameTarget(details.frame); + if (!target || !this._deviceAllowed(target.origin)) { + callback(null); + return; + } + this._beginDeviceRequest({ + webContents: target.webContents, + origin: target.origin, + deviceType: 'hid', + devices: details.deviceList.map(hidCandidate), + invoke: deviceId => callback(deviceId ?? null), + }); + }); + electronSession.on('hid-device-added', (_event, details) => { + const target = this._frameTarget(details.frame); + if (target) { + this._addDevice(target.webContents, 'hid', hidCandidate(details.device)); + } + }); + electronSession.on('hid-device-removed', (_event, details) => { + const target = this._frameTarget(details.frame); + if (target) { + this._removeDevice(target.webContents, 'hid', details.device.deviceId); + } + }); + } + + connectStorage(storage: IApplicationStorageMainService): void { + if (this._storage || !this.storageKeys.permissions) { + return; + } + this._storage = storage; + this._load(); + this._persistable = true; + } + + serialize(): ISerializedBrowserPermissionsSnapshot { + return this._permissionStore.serialize(); + } + + set(origin: string, grants: readonly IPermissionCategoryState[]): void { + const key = toOriginKey(origin); + for (const grant of grants) { + if (grant.state === null) { + this._resolvePendingForCategory(key, grant.category); + } + } + + // Coalesce the per-category onDidChange flushes into a single write for + // the whole batch so persisting from the management UI isn't N writes. + this._batching = true; + this._batchDirty = false; + try { + this._permissionStore.setMany(origin, grants); + } finally { + this._batching = false; + } + if (this._batchDirty && this._persistable) { + this._flushNow(); + } + } + + private _resolvePendingForCategory(origin: string, category: PermissionCategory): void { + if (!origin || this._pending.size === 0) { + return; + } + for (const pending of [...this._pending]) { + if (pending.origin === origin && pending.category === category) { + pending.deferred.complete(); + } + } + } + + clear(): void { + this._permissionStore.clear(); + } + + // -- Device choosers ------------------------------------------------- + + beginBluetoothRequest(webContents: Electron.WebContents, devices: Electron.BluetoothDevice[], callback: (deviceId: string) => void): void { + const origin = toOriginKey(webContents.getURL()); + if (!this._deviceAllowed(origin)) { + callback(''); + return; + } + const candidates = devices.map(bluetoothCandidate); + // Electron re-fires `select-bluetooth-device` for the same chooser as + // devices are discovered, each time with a fresh callback. Fold those + // into the existing request: refresh its list and supersede the callback. + const existing = this._findActiveDevice(webContents, 'bluetooth'); + if (existing) { + existing.devices = candidates; + existing.invoke = deviceId => callback(deviceId ?? ''); + this._emitDeviceRequest(existing); + return; + } + this._beginDeviceRequest({ + webContents, + origin, + deviceType: 'bluetooth', + devices: candidates, + invoke: deviceId => callback(deviceId ?? ''), + }); + } + + resolveDevice(requestId: string, deviceId: string | null): void { + this._pendingDevices.get(requestId)?.settle(deviceId); + } + + /** Begin a device chooser: register it, emit it, and cancel if unclaimed. */ + private _beginDeviceRequest(params: { + readonly webContents: Electron.WebContents; + readonly origin: string; + readonly deviceType: BrowserDeviceType; + readonly devices: IBrowserDeviceCandidate[]; + readonly invoke: (deviceId: string | null) => void; + }): void { + const requestId = generateUuid(); + const settle = (deviceId: string | null) => { + if (pending.settled) { + return; + } + pending.settled = true; + params.webContents.off('destroyed', cancel); + this._pendingDevices.delete(requestId); + pending.invoke(deviceId); + }; + const cancel = () => settle(null); + const pending: IPendingDeviceRequest = { + requestId, + webContents: params.webContents, + origin: params.origin, + deviceType: params.deviceType, + devices: params.devices, + settled: false, + invoke: params.invoke, + settle, + }; + params.webContents.on('destroyed', cancel); + this._pendingDevices.set(requestId, pending); + if (!this._emitDeviceRequest(pending)) { + // No view claimed it (e.g. background or destroyed view): cancel so + // the page's requestDevice() promise rejects rather than hangs. + cancel(); + } + } + + private _emitDeviceRequest(pending: IPendingDeviceRequest): boolean { + let claimed = false; + this._onDidRequestDevice.fire({ + webContents: pending.webContents, + origin: pending.origin, + requestId: pending.requestId, + deviceType: pending.deviceType, + devices: pending.devices, + claim: () => { claimed = true; }, + }); + return claimed; + } + + private _addDevice(webContents: Electron.WebContents, deviceType: BrowserDeviceType, candidate: IBrowserDeviceCandidate): void { + const pending = this._findActiveDevice(webContents, deviceType); + if (!pending || pending.devices.some(device => device.deviceId === candidate.deviceId)) { + return; + } + pending.devices = [...pending.devices, candidate]; + this._emitDeviceRequest(pending); + } + + private _removeDevice(webContents: Electron.WebContents, deviceType: BrowserDeviceType, deviceId: string): void { + const pending = this._findActiveDevice(webContents, deviceType); + if (!pending) { + return; + } + const next = pending.devices.filter(device => device.deviceId !== deviceId); + if (next.length === pending.devices.length) { + return; + } + pending.devices = next; + this._emitDeviceRequest(pending); + } + + private _findActiveDevice(webContents: Electron.WebContents, deviceType: BrowserDeviceType): IPendingDeviceRequest | undefined { + for (const pending of this._pendingDevices.values()) { + if (!pending.settled && pending.webContents === webContents && pending.deviceType === deviceType) { + return pending; + } + } + return undefined; + } + + /** Resolve the owning web contents and origin for a requesting frame. */ + private _frameTarget(frame: Electron.WebFrameMain | null): { webContents: Electron.WebContents; origin: string } | undefined { + if (!frame) { + return undefined; + } + const webContents = electronWebContents.fromFrame(frame); + if (!webContents) { + return undefined; + } + return { webContents, origin: toOriginKey(frame.url || webContents.getURL()) }; + } + + private _deviceAllowed(origin: string): boolean { + return !!origin && this._permissionStore.isAllowed(origin, PermissionCategory.Devices); + } + + private async _resolveRequest(webContents: Electron.WebContents | null, permission: string, details: PermissionRequestDetails | undefined): Promise { + if (isAlwaysAllowedPermission(permission)) { + return true; + } + const origin = toOriginKey(details?.requestingUrl ?? webContents?.getURL()); + const categories = electronPermissionToCategories(permission, mediaKindsFromDetails(details)); + if (categories.length === 0 || !origin) { + return false; + } + + // Fast paths that need no prompt. A category whose effective decision is + // already 'allow' -- either an explicit user grant or an allow-by-default + // category -- is granted silently. This keeps the async request handler + // consistent with the synchronous check handler (both use `isAllowed`). + // An explicit 'deny' short-circuits without prompting. + if (categories.every(category => this._permissionStore.isAllowed(origin, category))) { + return true; + } + if (categories.some(category => this._permissionStore.getDecision(origin, category) === 'deny')) { + return false; + } + + // At least one category is undecided: prompt for each undecided one. Do + // this sequentially so we never surface two modal prompts at once (e.g. + // a single `media` request maps to both Camera and Microphone). + for (const category of categories) { + if (!this._permissionStore.getDecision(origin, category)) { + await this._prompt(webContents, origin, category); + } + } + + return categories.every(category => this._permissionStore.isAllowed(origin, category)); + } + + private _prompt(webContents: Electron.WebContents | null, origin: string, category: PermissionCategory): Promise { + if (!webContents) { + // No view to ask -- leave undecided (effective deny by default state). + return Promise.resolve(); + } + // Fire synchronously: the owning view claims the request before fire() + // returns, so we know whether any UI will surface a prompt. + let claimed = false; + this._onDidRequestPermission.fire({ + webContents, + request: { origin, category }, + claim: () => { claimed = true; }, + }); + if (!claimed) { + return Promise.resolve(); + } + + const pending: IPendingRequest = { origin, category, deferred: new DeferredPromise() }; + this._pending.add(pending); + + const timer = setTimeout(() => pending.deferred.complete(), PROMPT_TIMEOUT_MS); + return pending.deferred.p.finally(() => { + clearTimeout(timer); + this._pending.delete(pending); + }); + } + + /** Resolve any pending request whose (origin, category) now has a decision. */ + private _resolvePending(): void { + if (this._pending.size === 0) { + return; + } + for (const pending of [...this._pending]) { + if (this._permissionStore.getDecision(pending.origin, pending.category)) { + pending.deferred.complete(); + } + } + } + + private _load(): void { + const storage = this._storage; + const key = this.storageKeys.permissions; + if (!storage || !key) { + return; + } + const snapshot = parseSnapshot(storage.get(key, StorageScope.APPLICATION)); + // Hydration fires onDidChange; suppress flushes so we don't rewrite what we just read. + this._persistable = false; + try { + this._permissionStore.hydrate(snapshot); + } finally { + this._persistable = true; + } + } + + private _flushNow(): void { + const storage = this._storage; + const key = this.storageKeys.permissions; + if (!storage || !key) { + return; + } + const snapshot = this._permissionStore.serialize(); + if (Object.keys(snapshot.origins).length === 0) { + storage.remove(key, StorageScope.APPLICATION); + } else { + storage.store(key, JSON.stringify(snapshot), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } +} + +function parseSnapshot(raw: string | undefined): T | undefined { + if (!raw) { + return undefined; + } + try { + const parsed = JSON.parse(raw) as T; + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +/** + * The Electron details union passed to `setPermissionRequestHandler`. All + * variants extend `PermissionRequest` (so share `requestingUrl`); only + * `MediaAccessPermissionRequest` adds `mediaTypes`. + */ +type PermissionRequestDetails = + | Electron.PermissionRequest + | Electron.FilesystemPermissionRequest + | Electron.MediaAccessPermissionRequest + | Electron.OpenExternalPermissionRequest; + +/** + * Normalize the media hint from either permission handler's Electron details + * into a `('video' | 'audio')[]`. The request handler supplies `mediaTypes` + * (an array); the check handler supplies a single `mediaType`. Returns + * `undefined` when there is no usable hint, so the mapper can assume both. + */ +function mediaKindsFromDetails(details: PermissionRequestDetails | Electron.PermissionCheckHandlerHandlerDetails | undefined): ('video' | 'audio')[] | undefined { + if (!details) { + return undefined; + } + const kinds = new Set<'video' | 'audio'>(); + // eslint-disable-next-line local/code-no-in-operator + if ('mediaTypes' in details && details.mediaTypes) { + for (const kind of details.mediaTypes) { + kinds.add(kind); + } + } + // eslint-disable-next-line local/code-no-in-operator + if ('mediaType' in details && (details.mediaType === 'video' || details.mediaType === 'audio')) { + kinds.add(details.mediaType); + } + return kinds.size ? [...kinds] : undefined; +} + +/** Format a USB/HID vendor:product pair as a `vvvv:pppp` hex string. */ +function vendorProductHex(vendorId: number | undefined, productId: number | undefined): string { + const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(4, '0'); + return `${hex(vendorId)}:${hex(productId)}`; +} + +function usbCandidate(device: Electron.USBDevice): IBrowserDeviceCandidate { + const ids = vendorProductHex(device.vendorId, device.productId); + return { + deviceId: device.deviceId, + label: device.productName || device.manufacturerName || localize('browser.device.usb', "USB Device {0}", ids), + detail: device.serialNumber ? `${ids} · ${device.serialNumber}` : ids, + }; +} + +function serialCandidate(port: Electron.SerialPort): IBrowserDeviceCandidate { + const ids = port.vendorId && port.productId ? `${port.vendorId}:${port.productId}` : undefined; + return { + deviceId: port.portId, + label: `${port.portName} (${port.displayName})`, + detail: ids, + }; +} + +function hidCandidate(device: Electron.HIDDevice): IBrowserDeviceCandidate { + const ids = vendorProductHex(device.vendorId, device.productId); + return { + deviceId: device.deviceId, + label: device.name || localize('browser.device.hid', "HID Device {0}", ids), + detail: device.serialNumber ? `${ids} · ${device.serialNumber}` : ids, + }; +} + +function bluetoothCandidate(device: Electron.BluetoothDevice): IBrowserDeviceCandidate { + return { + deviceId: device.deviceId, + label: device.deviceName || device.deviceId, + detail: device.deviceId, + }; +} diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 15d04338ca45c..b7f963076bb3d 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,7 +7,7 @@ import { screen, WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions, IBrowserViewPermissionRequestEvent } from '../common/browserView.js'; import { BrowserViewEmulator } from './browserViewEmulator.js'; import { BrowserViewInspector } from './browserViewInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; @@ -17,6 +17,7 @@ import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; import { BrowserSession } from './browserSession.js'; import { IBrowserHistoryItemHandle } from '../common/browserHistory.js'; +import { ISerializedBrowserPermissionsSnapshot, PermissionCategory } from '../common/browserPermissions.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -58,6 +59,9 @@ export class BrowserView extends Disposable { private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; + private _wantsVisibility = false; + private _hasBeenLaidOut = false; + private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; private readonly _consoleLogs: string[] = []; @@ -102,6 +106,12 @@ export class BrowserView extends Disposable { private readonly _onDidChangeRemoteStatus = this._register(new Emitter()); readonly onDidChangeRemoteStatus: Event = this._onDidChangeRemoteStatus.event; + private readonly _onDidRequestPermission = this._register(new Emitter()); + readonly onDidRequestPermission: Event = this._onDidRequestPermission.event; + + private readonly _onDidChangePermissions = this._register(new Emitter()); + readonly onDidChangePermissions: Event = this._onDidChangePermissions.event; + constructor( public readonly id: string, public readonly owner: IBrowserViewOwner, @@ -140,7 +150,9 @@ export class BrowserView extends Disposable { }); // Use a default size of 1024x768. - this._view.setBounds({ x: -10000, y: -10000, width: 1024, height: 768 }); + // Important: The bounds here must be on-screen, otherwise some OSes (like macOS) may not actually start rendering. + // We just have to be careful to not show the view until a layout has happened in the correct location. + this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 }); this._view.setBackgroundColor('#FFFFFF'); this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; @@ -219,6 +231,30 @@ export class BrowserView extends Disposable { this._register(this.session.remote.onDidStart(fireRemoteStatus)); this._register(this.session.remote.onDidStop(fireRemoteStatus)); + this._register(this.session.permissions.onDidRequestPermission(e => { + if (e.webContents === this.webContents && !this._isDisposed) { + e.claim(); + this._onDidRequestPermission.fire(e.request); + } + })); + this._register(this.session.permissions.onDidRequestDevice(e => { + if (e.webContents === this.webContents && !this._isDisposed) { + e.claim(); + this._onDidRequestPermission.fire({ + origin: e.origin, + category: PermissionCategory.Devices, + device: { + requestId: e.requestId, + deviceType: e.deviceType, + devices: e.devices, + }, + }); + } + })); + this._register(this.session.permissions.onDidChange(() => { + this._onDidChangePermissions.fire(this.session.permissions.serialize()); + })); + this.setupEventListeners(); } @@ -391,6 +427,11 @@ export class BrowserView extends Disposable { }); }); + webContents.on('select-bluetooth-device', (event, devices, callback) => { + event.preventDefault(); + this.session.permissions.beginBluetoothRequest(this.webContents, devices, callback); + }); + // Focus events webContents.on('focus', () => { this._onDidChangeFocus.fire({ focused: true }); @@ -559,7 +600,8 @@ export class BrowserView extends Disposable { lastError: this._lastError, certificateError: this.session.trust.getCertificateError(url), storageScope: this.session.storageScope, - storageKeys: this.session.history.storageKeys, + storageKeys: { ...this.session.history.storageKeys, ...this.session.permissions.storageKeys }, + permissions: this.session.permissions.serialize(), browserZoomIndex: this._browserZoomIndex, isElementSelectionActive: this.inspector.isElementSelectionActive, isRemoteSession: this.session.remote.isRemote, @@ -600,6 +642,11 @@ export class BrowserView extends Disposable { width: Math.round(bounds.width * bounds.zoomFactor), height: Math.round(bounds.height * bounds.zoomFactor) }); + + this._hasBeenLaidOut = true; + if (this._wantsVisibility && !this._view.getVisible()) { + this._view.setVisible(true); + } } setBrowserZoomIndex(zoomIndex: number): void { @@ -612,7 +659,7 @@ export class BrowserView extends Disposable { * Set the visibility of this view */ setVisible(visible: boolean): void { - if (this._view.getVisible() === visible) { + if (this._wantsVisibility === visible) { return; } @@ -621,7 +668,11 @@ export class BrowserView extends Disposable { this._currentWindow?.win?.webContents.focus(); } - this._view.setVisible(visible); + if (this._hasBeenLaidOut || !visible) { + this._view.setVisible(visible); + } + + this._wantsVisibility = visible; this._onDidChangeVisibility.fire({ visible }); } @@ -725,9 +776,29 @@ export class BrowserView extends Disposable { if (options?.awaitNextPaint) { await this._waitForNextPaint(); } - const image = await this._view.webContents.capturePage(options?.screenRect, { - stayHidden: true - }); + const image = await (async () => { + const maxAttempts = 5; + let lastError: Error | undefined; + for (let i = 0; i < maxAttempts; i++) { + try { + return await this._view.webContents.capturePage(options?.screenRect, { + stayHidden: true + }); + } catch (error) { + // `UnknownVizError` is a transient Electron error when no frame is available yet + // (e.g. offscreen scenarios where rendering has just been kicked off by `setVisible(true)`), + // so retry a few times. + if (error instanceof Error && error.message === 'UnknownVizError') { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 16)); + continue; + } else { + throw error; + } + } + } + throw new Error(`Failed to capture screenshot after ${maxAttempts} attempts`, { cause: lastError }); + })(); const buffer = format === 'png' ? image.toPNG() : image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); // Only update _lastScreenshot if capturing the full view @@ -861,6 +932,14 @@ export class BrowserView extends Disposable { await this.session.clearData(); } + /** + * Answer an in-progress hardware-device chooser. Pass the chosen device id, + * or `null` to cancel the chooser. + */ + selectDevice(requestId: string, deviceId: string | null): void { + this.session.permissions.resolveDevice(requestId, deviceId); + } + /** * Trust a certificate for a given host and reload the page. */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 30f960b6bf4e6..3934db7b5b858 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -15,6 +15,7 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; +import { IPermissionCategoryState } from '../common/browserPermissions.js'; import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserViewTelemetry.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { localize } from '../../../nls.js'; @@ -209,6 +210,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeRemoteStatus; } + onDynamicDidRequestPermission(id: string) { + return this._getBrowserView(id).onDidRequestPermission; + } + + onDynamicDidChangePermissions(id: string) { + return this._getBrowserView(id).onDidChangePermissions; + } + async getState(id: string): Promise { return this._getBrowserView(id).getState(); } @@ -301,6 +310,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa this._getBrowserView(id).session.history.delete(entryIds); } + async setPermissions(id: string, origin: string, grants: readonly IPermissionCategoryState[]): Promise { + this._getBrowserView(id).session.permissions.set(origin, grants); + } + + async selectDevice(id: string, requestId: string, deviceId: string | null): Promise { + this._getBrowserView(id).selectDevice(requestId, deviceId); + } + async clearGlobalStorage(): Promise { const browserSession = BrowserSession.getOrCreateGlobal(this.instantiationService); browserSession.connectStorage(this.applicationStorageMainService); @@ -373,12 +390,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private _recomputeTrustedFileRoots(): void { const roots = new Set(); + let trustAllFiles = false; for (const configuration of this._windowConfigurations.values()) { for (const root of configuration.trustedFileRoots) { roots.add(root); } + trustAllFiles ||= configuration.trustAllFiles; } - BrowserSession.setTrustedFileRoots([...roots]); + BrowserSession.setTrustedFileRoots([...roots], trustAllFiles); } /** diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 3ccaabe3279be..3efaac5ae7670 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -28,6 +28,7 @@ export interface IPlaywrightActionScope { const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes const SESSION_INACTIVITY_MS = 30 * 60_000; // 30 minutes +const OPEN_PAGE_NAVIGATION_TIMEOUT_MS = 30_000; /** * Narrow a raw Playwright transport payload to a {@link CDPRequest}. @@ -177,6 +178,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this.logService, this.agentNetworkFilterService, this.telemetryService, + viewId => this.startTrackingPage(viewId), ); // Keep the global tracked set in sync with group events. When a @@ -265,12 +267,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService async openPage(sessionId: string, url: string): Promise<{ pageId: string; summary: string }> { const session = await this._getOrCreateSession(sessionId); - const result = await session.openPage(url); - // The creating session's group already has the view. Use - // startTrackingPage to add it to the canonical set and - // replicate into other sessions. - await this.startTrackingPage(result.pageId); - return result; + return session.openPage(url); } async getSummary(sessionId: string, pageId: string): Promise { @@ -380,6 +377,7 @@ class PlaywrightSession extends Disposable { private readonly logService: ILogService, private readonly agentNetworkFilterService: IAgentNetworkFilterService, private readonly telemetryService: ITelemetryService, + private readonly onDidCreatePage: (viewId: string) => Promise, ) { super(); @@ -405,9 +403,18 @@ class PlaywrightSession extends Disposable { const page = await this._openContext.newPage(); const viewId = await this._onPageAdded(page); + await this.onDidCreatePage(viewId); if (url && url !== 'about:blank' && page.url() !== url) { - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: OPEN_PAGE_NAVIGATION_TIMEOUT_MS }); + } catch (error) { + if (!isNavigationTimeoutError(error)) { + throw error; + } + + throw new Error(`Navigation to ${url} timed out after ${OPEN_PAGE_NAVIGATION_TIMEOUT_MS} ms. The page (ID: ${viewId}) is open and can be reused.`); + } } const summary = await this._getSummary(viewId); @@ -760,6 +767,16 @@ class PlaywrightSession extends Disposable { } } +function isNavigationTimeoutError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return error.name === 'TimeoutError' + || /Timeout \d+ms exceeded/.test(error.message) + || /navigation timeout/i.test(error.message); +} + /** * Per-invocation state threaded through {@link PlaywrightSession.invokeFunction} * and its deferral machinery so completion telemetry can be emitted exactly once diff --git a/src/vs/platform/browserView/test/common/browserPermissions.test.ts b/src/vs/platform/browserView/test/common/browserPermissions.test.ts new file mode 100644 index 0000000000000..c08974c6cead3 --- /dev/null +++ b/src/vs/platform/browserView/test/common/browserPermissions.test.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + BrowserPermissionStore, + ISerializedBrowserPermissionsSnapshot, + PermissionCategory, + electronPermissionToCategories, + toOriginKey, +} from '../../common/browserPermissions.js'; + +suite('BrowserPermissionStore', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('get/set/clear and origin normalization', () => { + const store = new BrowserPermissionStore(); + + assert.strictEqual(store.getDecision('https://example.com', PermissionCategory.Camera), undefined); + + store.set('https://example.com/some/path?q=1', PermissionCategory.Camera, 'allow'); + // Path/query are stripped down to the origin key. + assert.strictEqual(store.getDecision('https://example.com', PermissionCategory.Camera), 'allow'); + assert.strictEqual(store.getDecision('https://example.com:443/x', PermissionCategory.Camera), 'allow'); + + // Passing null clears the recorded decision, falling back to the default. + store.set('https://example.com', PermissionCategory.Camera, null); + assert.strictEqual(store.getDecision('https://example.com', PermissionCategory.Camera), undefined); + assert.deepStrictEqual(store.origins(), []); + + store.dispose(); + }); + + test('isAllowed falls back to per-category default state when unset', () => { + const store = new BrowserPermissionStore(); + + // Categories that prompt default to 'ask' (not allowed); some categories + // (e.g. Sensors) default to 'allow'. + assert.strictEqual(store.isAllowed('https://a.com', PermissionCategory.Location), false); + assert.strictEqual(store.isAllowed('https://a.com', PermissionCategory.Sensors), true); + // Devices default to 'allow' (the chooser itself is the consent gesture). + assert.strictEqual(store.isAllowed('https://a.com', PermissionCategory.Devices), true); + + store.set('https://a.com', PermissionCategory.Location, 'allow'); + store.set('https://a.com', PermissionCategory.Sensors, 'deny'); + assert.strictEqual(store.isAllowed('https://a.com', PermissionCategory.Location), true); + assert.strictEqual(store.isAllowed('https://a.com', PermissionCategory.Sensors), false); + + store.dispose(); + }); + + test('onDidChange fires only on real changes', () => { + const store = new BrowserPermissionStore(); + let changes = 0; + const listener = store.onDidChange(() => changes++); + + store.set('https://a.com', PermissionCategory.Camera, 'allow'); + store.set('https://a.com', PermissionCategory.Camera, 'allow'); // no-op + store.setMany('https://a.com', [ + { category: PermissionCategory.Microphone, state: 'deny' }, + { category: PermissionCategory.Camera, state: 'allow' }, // no-op + ]); + store.clearOrigin('https://b.com'); // nothing recorded -> no-op + + assert.strictEqual(changes, 2); + listener.dispose(); + store.dispose(); + }); + + test('serialize/hydrate round-trips and clearOrigin/clear', () => { + const store = new BrowserPermissionStore(); + store.setMany('https://a.com', [ + { category: PermissionCategory.Camera, state: 'allow' }, + { category: PermissionCategory.Microphone, state: 'deny' }, + ]); + store.set('https://b.com', PermissionCategory.Location, 'allow'); + + const snapshot = store.serialize(); + assert.deepStrictEqual(snapshot, { + origins: { + 'https://a.com': { camera: 'allow', microphone: 'deny' }, + 'https://b.com': { location: 'allow' }, + }, + } satisfies ISerializedBrowserPermissionsSnapshot); + + const mirror = new BrowserPermissionStore(); + mirror.hydrate(snapshot); + assert.deepStrictEqual(mirror.serialize(), snapshot); + + mirror.clearOrigin('https://a.com'); + assert.deepStrictEqual(mirror.origins(), ['https://b.com']); + mirror.clear(); + assert.deepStrictEqual(mirror.serialize(), { origins: {} }); + + store.dispose(); + mirror.dispose(); + }); + + test('hydrate ignores unknown categories and bad input', () => { + const store = new BrowserPermissionStore(); + store.hydrate({ origins: { 'https://a.com': { camera: 'allow', bogus: 'allow' } } } as unknown as ISerializedBrowserPermissionsSnapshot); + assert.deepStrictEqual(store.serialize(), { origins: { 'https://a.com': { camera: 'allow' } } }); + + store.hydrate(undefined); + assert.deepStrictEqual(store.serialize(), { origins: {} }); + + store.dispose(); + }); +}); + +suite('electronPermissionToCategories', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps known permissions and media disambiguation', () => { + assert.deepStrictEqual(electronPermissionToCategories('geolocation'), [PermissionCategory.Location]); + assert.deepStrictEqual(electronPermissionToCategories('notifications'), [PermissionCategory.Notifications]); + assert.deepStrictEqual(electronPermissionToCategories('sensors'), [PermissionCategory.Sensors]); + // USB, Serial, and HID all map to the single Devices category. + assert.deepStrictEqual(electronPermissionToCategories('usb'), [PermissionCategory.Devices]); + assert.deepStrictEqual(electronPermissionToCategories('serial'), [PermissionCategory.Devices]); + assert.deepStrictEqual(electronPermissionToCategories('hid'), [PermissionCategory.Devices]); + assert.deepStrictEqual(electronPermissionToCategories('unrecognized'), []); + + // `media` resolves from hints, defaulting to both when none are given. + assert.deepStrictEqual(electronPermissionToCategories('media'), [PermissionCategory.Camera, PermissionCategory.Microphone]); + assert.deepStrictEqual(electronPermissionToCategories('media', ['video']), [PermissionCategory.Camera]); + assert.deepStrictEqual(electronPermissionToCategories('media', ['audio']), [PermissionCategory.Microphone]); + }); +}); + +suite('toOriginKey', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('normalizes urls and tolerates bad input', () => { + assert.strictEqual(toOriginKey('https://example.com/a/b?c=1#d'), 'https://example.com'); + assert.strictEqual(toOriginKey('http://host:8080/x'), 'http://host:8080'); + // file: URLs have no real origin, so they key off scheme + full path + // with query and fragment stripped. + assert.strictEqual(toOriginKey('file:///home/user/page.html?x=1#y'), 'file:///home/user/page.html'); + assert.strictEqual(toOriginKey('file:///C:/Users/me/index.html'), 'file:///C:/Users/me/index.html'); + assert.strictEqual(toOriginKey(undefined), ''); + assert.strictEqual(toOriginKey(' not a url '), 'not a url'); + // Whitespace is trimmed before parsing so valid URLs still normalize. + assert.strictEqual(toOriginKey(' https://example.com/a '), 'https://example.com'); + // Opaque origins reported as the literal "null" have no real host. + assert.strictEqual(toOriginKey('null'), ''); + assert.strictEqual(toOriginKey(' '), ''); + }); +}); diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index e26d74e2e63c1..6a8cc98d148b2 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -506,9 +506,16 @@ export class ExtensionError extends Error { readonly extension: ExtensionIdentifier; constructor(extensionIdentifier: ExtensionIdentifier, cause: Error, message?: string) { - super(`Error in extension ${ExtensionIdentifier.toKey(extensionIdentifier)}: ${message ?? cause.message}`, { cause }); + const detail = message && cause?.message ? `${message}: ${cause.message}` : (message ?? cause?.message); + super(`Error in extension ${ExtensionIdentifier.toKey(extensionIdentifier)}: ${detail}`, { cause }); this.name = 'ExtensionError'; this.extension = extensionIdentifier; + // Adopt the underlying cause's stack so error telemetry buckets by the real + // failure site inside the extension instead of the generic event-dispatch + // frames. Extension attribution relies on `this.extension`, not this stack. + if (cause?.stack) { + this.stack = cause.stack; + } } } diff --git a/src/vs/platform/files/common/remoteFileSystemProxy.ts b/src/vs/platform/files/common/remoteFileSystemProxy.ts deleted file mode 100644 index 6c6e9a475b6fc..0000000000000 --- a/src/vs/platform/files/common/remoteFileSystemProxy.ts +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Channel name used by each renderer process to register a server channel - * that exposes its file system operations. The main process routes calls - * from other renderers to the right window. - */ -export const REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME = 'remoteFileSystemProxy'; - -/** - * Channel name registered by the main process handler that receives requests - * from renderers and forwards them to the window that owns the remote - * connection matching the URI's authority. - */ -export const REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME = 'remoteFileSystemProxyHandler'; diff --git a/src/vs/platform/files/electron-browser/remoteFileSystemProxyClient.ts b/src/vs/platform/files/electron-browser/remoteFileSystemProxyClient.ts deleted file mode 100644 index 07c2236833040..0000000000000 --- a/src/vs/platform/files/electron-browser/remoteFileSystemProxyClient.ts +++ /dev/null @@ -1,134 +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 { VSBuffer } from '../../../base/common/buffer.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { Schemas } from '../../../base/common/network.js'; -import { URI } from '../../../base/common/uri.js'; -import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { - FileSystemProviderCapabilities, - FileType, - IFileChange, - IFileDeleteOptions, - IFileOverwriteOptions, - IFileService, - IFileSystemProviderWithFileReadWriteCapability, - IFileWriteOptions, - IStat, - IWatchOptions, -} from '../../files/common/files.js'; -import { ILogService } from '../../log/common/log.js'; -import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; -import { REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME } from '../common/remoteFileSystemProxy.js'; - -/** - * A read-only file system provider registered in windows that do not have a - * direct remote connection. It proxies file system operations through the main - * process to a window that owns the matching remote file system provider. - * - * This enables copy/paste and drag-and-drop of files between remote and local - * workspaces. - */ -export class RemoteFileSystemProxyClient extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { - - static register( - fileService: IFileService, - mainProcessService: IMainProcessService, - logService: ILogService, - remoteAuthority: string | undefined, - ): IDisposable { - // If this window has its own remote connection, it uses the direct - // RemoteFileSystemProviderClient. Registering the proxy here would - // create a loop (main process routes back to this same window). - if (remoteAuthority) { - return Disposable.None; - } - - const disposables = new DisposableStore(); - - // Listen for activation of the vscode-remote scheme. Since this - // window has no direct remote connection, register the proxy provider - // that routes through the main process to a window that does. - disposables.add(fileService.onWillActivateFileSystemProvider(e => { - if (e.scheme === Schemas.vscodeRemote) { - e.join((async () => { - try { - const provider = new RemoteFileSystemProxyClient(mainProcessService, logService); - disposables.add(provider); - disposables.add(fileService.registerProvider(Schemas.vscodeRemote, provider)); - logService.info('RemoteFileSystemProxyClient: Registered proxy provider for vscode-remote scheme'); - } catch (error) { - logService.error('RemoteFileSystemProxyClient: Failed to register proxy provider', error); - } - })()); - } - })); - - return disposables; - } - - private readonly channel: IChannel; - - readonly onDidChangeCapabilities: Event = Event.None; - - private readonly _onDidChangeFile = this._register(new Emitter()); - readonly onDidChangeFile: Event = this._onDidChangeFile.event; - - get capabilities(): FileSystemProviderCapabilities { - return FileSystemProviderCapabilities.FileReadWrite | - FileSystemProviderCapabilities.Readonly | - FileSystemProviderCapabilities.PathCaseSensitive; - } - - private constructor( - mainProcessService: IMainProcessService, - private readonly logService: ILogService, - ) { - super(); - - // Get the channel from the main process — the main process handler will - // route calls to the appropriate renderer window based on the URI's - // remote authority - this.channel = mainProcessService.getChannel(REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME); - } - - async stat(resource: URI): Promise { - this.logService.trace('RemoteFileSystemProxyClient#stat', resource.toString()); - return this.channel.call('stat', [resource]); - } - - async readdir(resource: URI): Promise<[string, FileType][]> { - this.logService.trace('RemoteFileSystemProxyClient#readdir', resource.toString()); - return this.channel.call('readdir', [resource]); - } - - async readFile(resource: URI): Promise { - this.logService.trace('RemoteFileSystemProxyClient#readFile', resource.toString()); - const buffer: VSBuffer = await this.channel.call('readFile', [resource]); - return buffer.buffer; - } - - async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise { - throw new Error('Remote file system proxy provider is read-only'); - } - - watch(_resource: URI, _opts: IWatchOptions): IDisposable { - return Disposable.None; - } - - async mkdir(_resource: URI): Promise { - throw new Error('Remote file system proxy provider is read-only'); - } - - async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { - throw new Error('Remote file system proxy provider is read-only'); - } - - async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { - throw new Error('Remote file system proxy provider is read-only'); - } -} diff --git a/src/vs/platform/files/electron-browser/remoteFileSystemProxyServer.ts b/src/vs/platform/files/electron-browser/remoteFileSystemProxyServer.ts deleted file mode 100644 index e7e37d6ea4c00..0000000000000 --- a/src/vs/platform/files/electron-browser/remoteFileSystemProxyServer.ts +++ /dev/null @@ -1,81 +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 { Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; -import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IFileService, IFileStatWithMetadata, FileType } from '../../files/common/files.js'; -import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; -import { REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME } from '../common/remoteFileSystemProxy.js'; - -/** - * Registered in every renderer process. Serves file system operations to other - * windows that cannot directly access this window's remote file system provider. - * The main process routes incoming requests via {@link RemoteFileSystemProxyMainHandler}. - * - * This follows the same pattern as {@link ElectronRemoteResourceLoader}. - */ -export class RemoteFileSystemProxyServer extends Disposable { - - constructor( - @IMainProcessService mainProcessService: IMainProcessService, - @IFileService private readonly fileService: IFileService, - ) { - super(); - - const channel: IServerChannel = { - listen(_: unknown, event: string): Event { - throw new Error(`Event not found: ${event}`); - }, - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - call: (_: unknown, command: string, arg?: any): Promise => { - const args = arg as unknown[]; - switch (command) { - case 'stat': return this.stat(URI.revive(args[0] as UriComponents)); - case 'readdir': return this.readdir(URI.revive(args[0] as UriComponents)); - case 'readFile': return this.readFile(URI.revive(args[0] as UriComponents)); - case 'exists': return this.exists(URI.revive(args[0] as UriComponents)); - case 'resolve': return this.resolve(URI.revive(args[0] as UriComponents), args[1] as boolean); - } - - throw new Error(`Call not found: ${command}`); - } - }; - - mainProcessService.registerChannel(REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME, channel); - } - - private async stat(uri: URI): Promise<{ type: FileType; size: number; mtime: number; ctime: number }> { - const provider = this.fileService.getProvider(uri.scheme); - if (!provider) { - throw new Error(`No provider for scheme: ${uri.scheme}`); - } - return provider.stat(uri); - } - - private async readdir(uri: URI): Promise<[string, FileType][]> { - const provider = this.fileService.getProvider(uri.scheme); - if (!provider) { - throw new Error(`No provider for scheme: ${uri.scheme}`); - } - return provider.readdir(uri); - } - - private async readFile(uri: URI): Promise { - const content = await this.fileService.readFile(uri); - return content.value; - } - - private async exists(uri: URI): Promise { - return this.fileService.exists(uri); - } - - private async resolve(uri: URI, resolveMetadata: boolean): Promise { - return this.fileService.resolve(uri, { resolveMetadata }) as Promise; - } -} diff --git a/src/vs/platform/files/electron-main/remoteFileSystemProxyMainHandler.ts b/src/vs/platform/files/electron-main/remoteFileSystemProxyMainHandler.ts deleted file mode 100644 index 0f5918a53ddaf..0000000000000 --- a/src/vs/platform/files/electron-main/remoteFileSystemProxyMainHandler.ts +++ /dev/null @@ -1,83 +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 { Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { Schemas } from '../../../base/common/network.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; -import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME } from '../common/remoteFileSystemProxy.js'; - -export interface IRemoteFileSystemProxyWindowsService { - getWindows(): readonly { readonly id: number; readonly remoteAuthority?: string }[]; -} - -export interface IRemoteFileSystemProxyIPCServer { - getChannel(channelName: string, clientFilter: (client: { ctx: string }) => boolean): IChannel; -} - -/** - * Main process channel that routes remote file system operations from one - * renderer to another. When a local window needs to read a `vscode-remote://` - * file, this handler finds the renderer window connected to the matching remote - * authority and forwards the request through its - * {@link REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME} server channel. - */ -export class RemoteFileSystemProxyMainHandler extends Disposable implements IServerChannel { - - constructor( - private readonly windowsMainService: IRemoteFileSystemProxyWindowsService, - private readonly electronIpcServer: IRemoteFileSystemProxyIPCServer, - ) { - super(); - } - - listen(_: unknown, event: string): Event { - throw new Error(`Event not found: ${event}`); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async call(_: unknown, command: string, arg?: any): Promise { - const args = arg as unknown[]; - const uri = URI.revive(args[0] as UriComponents); - - // Only handle vscode-remote:// URIs to avoid routing unrelated URIs - // that happen to have an authority (e.g. UNC paths on Windows). - if (uri.scheme !== Schemas.vscodeRemote) { - throw new Error(`Unsupported scheme: ${uri.scheme}. Only ${Schemas.vscodeRemote} URIs are supported.`); - } - - // Find a window that has a remote connection matching this URI's authority - const targetWindow = this.findWindowForAuthority(uri.authority); - if (!targetWindow) { - throw new Error(`No window found with remote authority: ${uri.authority}`); - } - - // Get the remote file system proxy channel registered by the target renderer - const targetChannel = this.getRendererChannel(targetWindow.id); - - // Forward the call to the target renderer - return targetChannel.call(command, arg); - } - - private findWindowForAuthority(authority: string): { id: number } | undefined { - const windows = this.windowsMainService.getWindows(); - for (const window of windows) { - if (window.remoteAuthority === authority) { - return { id: window.id }; - } - } - return undefined; - } - - private getRendererChannel(windowId: number): IChannel { - // Get the channel registered by the target renderer window. - // The connection context format is `window:{id}`. - return this.electronIpcServer.getChannel( - REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME, - (client) => client.ctx === `window:${windowId}`, - ); - } -} diff --git a/src/vs/platform/files/test/electron-main/remoteFileSystemProxy.test.ts b/src/vs/platform/files/test/electron-main/remoteFileSystemProxy.test.ts deleted file mode 100644 index e1ccfff90cb0c..0000000000000 --- a/src/vs/platform/files/test/electron-main/remoteFileSystemProxy.test.ts +++ /dev/null @@ -1,98 +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 { Event } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { IRemoteFileSystemProxyIPCServer, IRemoteFileSystemProxyWindowsService, RemoteFileSystemProxyMainHandler } from '../../electron-main/remoteFileSystemProxyMainHandler.js'; - -function createMockChannel(callImpl: (command: string, arg?: unknown) => Promise = async () => 'ok'): IChannel { - return { - call: callImpl as IChannel['call'], - listen: (() => Event.None) as IChannel['listen'], - }; -} - -suite('RemoteFileSystemProxyMainHandler', () => { - - const disposables = new DisposableStore(); - - teardown(() => { - disposables.clear(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('throws when no window matches the URI authority', async () => { - const windowsService: IRemoteFileSystemProxyWindowsService = { - getWindows: () => [ - { id: 1 }, - { id: 2, remoteAuthority: 'ssh-remote+myhost' }, - { id: 3, remoteAuthority: 'wsl+ubuntu' }, - ], - }; - - const mockServer: IRemoteFileSystemProxyIPCServer = { - getChannel: () => createMockChannel(), - }; - - const handler = disposables.add(new RemoteFileSystemProxyMainHandler(windowsService, mockServer)); - - await assert.rejects( - () => handler.call(undefined, 'stat', [URI.parse('vscode-remote://unknown-host/test')]), - /No window found with remote authority/ - ); - }); - - test('throws for non-vscode-remote URIs', async () => { - const windowsService: IRemoteFileSystemProxyWindowsService = { - getWindows: () => [ - { id: 1, remoteAuthority: 'ssh-remote+myhost' }, - ], - }; - - const mockServer: IRemoteFileSystemProxyIPCServer = { - getChannel: () => createMockChannel(), - }; - - const handler = disposables.add(new RemoteFileSystemProxyMainHandler(windowsService, mockServer)); - - await assert.rejects( - () => handler.call(undefined, 'stat', [URI.parse('file:///local/path')]), - /Unsupported scheme/ - ); - }); - - test('routes call to correct window based on URI authority', async () => { - let calledWindowCtx: string | undefined; - - const windowsService: IRemoteFileSystemProxyWindowsService = { - getWindows: () => [ - { id: 1 }, - { id: 2, remoteAuthority: 'ssh-remote+myhost' }, - ], - }; - - const mockServer: IRemoteFileSystemProxyIPCServer = { - getChannel: (_name: string, filter: (client: { ctx: string }) => boolean) => { - const connections = [ - { ctx: 'window:1' }, - { ctx: 'window:2' }, - ]; - calledWindowCtx = connections.find(filter)?.ctx; - return createMockChannel(); - }, - }; - - const handler = disposables.add(new RemoteFileSystemProxyMainHandler(windowsService, mockServer)); - - await handler.call(undefined, 'stat', [URI.parse('vscode-remote://ssh-remote+myhost/some/path')]); - - assert.strictEqual(calledWindowCtx, 'window:2'); - }); -}); diff --git a/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts b/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts new file mode 100644 index 0000000000000..076932c8fce92 --- /dev/null +++ b/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; +import { ILogService } from '../../log/common/log.js'; +import { INativeSystemWideKeybinding, INativeSystemWideKeybindingResult } from '../../native/common/native.js'; +import { INativeRunActionInWindowRequest } from '../../window/common/window.js'; +import { ICodeWindow } from '../../window/electron-main/window.js'; +import { IWindowsMainService } from '../../windows/electron-main/windows.js'; + +/** + * The subset of Electron's `globalShortcut` module that this service relies on. Injecting it as a + * constructor dependency (rather than importing the global directly) lets tests provide a fake. + */ +export interface IGlobalShortcutRegistry { + register(accelerator: string, callback: () => void): boolean; + unregister(accelerator: string): void; + isRegistered(accelerator: string): boolean; +} + +export const IGlobalKeybindingsMainService = createDecorator('globalKeybindingsMainService'); + +export interface IGlobalKeybindingsMainService { + + readonly _serviceBrand: undefined; + + /** + * Replaces the set of system-wide (OS global) keybindings owned by the given window with the + * provided set, reconciling the actual OS registrations. The result reports the user settings + * labels (or accelerators) that could not be registered (e.g. because the accelerator is already + * taken by the OS or another application). + */ + updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): INativeSystemWideKeybindingResult; +} + +export class GlobalKeybindingsMainService extends Disposable implements IGlobalKeybindingsMainService { + + declare readonly _serviceBrand: undefined; + + /** Per-window desired bindings, keyed by window id then by accelerator. */ + private readonly registry = new Map>(); + + /** Accelerators this service currently owns an OS registration for. */ + private readonly registeredAccelerators = new Set(); + + /** Accelerators that were desired but failed to register (e.g. already taken). */ + private readonly failedAccelerators = new Set(); + + constructor( + private readonly globalShortcut: IGlobalShortcutRegistry, + @IWindowsMainService private readonly windowsMainService: IWindowsMainService, + @ILifecycleMainService lifecycleMainService: ILifecycleMainService, + @ILogService private readonly logService: ILogService + ) { + super(); + + this._register(this.windowsMainService.onDidDestroyWindow(window => this.onDidDestroyWindow(window))); + this._register(lifecycleMainService.onWillShutdown(() => this.unregisterAll())); + this._register(toDisposable(() => this.unregisterAll())); + } + + updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): INativeSystemWideKeybindingResult { + const perWindow = new Map(); + for (const keybinding of keybindings) { + if (!this.isValid(keybinding)) { + this.logService.warn(`[GlobalKeybindings] ignoring invalid system-wide keybinding: ${JSON.stringify(keybinding)}`); + continue; + } + if (perWindow.has(keybinding.accelerator)) { + this.logService.warn(`[GlobalKeybindings] duplicate accelerator '${keybinding.accelerator}' in window ${windowId}, keeping first`); + continue; + } + perWindow.set(keybinding.accelerator, keybinding); + } + + if (perWindow.size > 0) { + this.registry.set(windowId, perWindow); + } else { + this.registry.delete(windowId); + } + + this.reconcile(); + + const failed: string[] = []; + for (const keybinding of perWindow.values()) { + if (this.failedAccelerators.has(keybinding.accelerator)) { + failed.push(keybinding.userSettingsLabel ?? keybinding.accelerator); + } + } + + return { failed }; + } + + private isValid(keybinding: INativeSystemWideKeybinding): boolean { + return typeof keybinding.accelerator === 'string' && keybinding.accelerator.length > 0 + && typeof keybinding.commandId === 'string' && keybinding.commandId.length > 0; + } + + /** + * Reconciles the OS registrations against the union of all windows' desired accelerators. + * Unregisters only accelerators this service owns; never touches shortcuts owned elsewhere. + */ + private reconcile(): void { + const desired = new Set(); + for (const perWindow of this.registry.values()) { + for (const accelerator of perWindow.keys()) { + desired.add(accelerator); + } + } + + // Unregister accelerators we own but no longer want + for (const accelerator of [...this.registeredAccelerators]) { + if (!desired.has(accelerator)) { + this.globalShortcut.unregister(accelerator); + this.registeredAccelerators.delete(accelerator); + this.failedAccelerators.delete(accelerator); + } + } + + // Register newly desired accelerators (retries previously failed ones too) + for (const accelerator of desired) { + if (this.registeredAccelerators.has(accelerator)) { + continue; + } + + let registered = false; + try { + // Register once with a stable callback that reads the CURRENT registry at fire time, + // so command/args are never captured from a stale snapshot. + registered = this.globalShortcut.register(accelerator, () => this.onTrigger(accelerator)); + } catch (error) { + this.logService.error(`[GlobalKeybindings] error registering '${accelerator}'`, error); + } + + if (registered) { + this.registeredAccelerators.add(accelerator); + this.failedAccelerators.delete(accelerator); + } else { + this.failedAccelerators.add(accelerator); + this.logService.warn(`[GlobalKeybindings] failed to register accelerator '${accelerator}' (already taken by the OS or another application)`); + } + } + } + + private onTrigger(accelerator: string): void { + const owners: number[] = []; + for (const [windowId, perWindow] of this.registry) { + if (perWindow.has(accelerator)) { + owners.push(windowId); + } + } + if (owners.length === 0) { + return; // stale registration; will be unregistered on next reconcile + } + + // Target window selection: + // 1. the focused window if it owns this accelerator (respect that window's own binding) + // 2. otherwise the deterministic winner (lowest window id) among alive owners + let target: ICodeWindow | undefined; + const focused = this.windowsMainService.getFocusedWindow(); + if (focused && this.registry.get(focused.id)?.has(accelerator)) { + target = focused; + } else { + target = owners + .map(windowId => this.windowsMainService.getWindowById(windowId)) + .filter((window): window is ICodeWindow => !!window) + .sort((a, b) => a.id - b.id) + .at(0); + } + + if (!target) { + this.logService.warn(`[GlobalKeybindings] no live window to handle accelerator '${accelerator}'`); + return; + } + + const binding = this.registry.get(target.id)?.get(accelerator); + if (!binding) { + return; + } + + this.logService.trace(`[GlobalKeybindings] trigger '${accelerator}' -> '${binding.commandId}' in window ${target.id}`); + + // We deliberately do NOT focus the routing window here. A system-wide keybinding fires while + // VS Code is typically unfocused, and force-focusing the routing window would pull it to the + // foreground even when the command opens or reveals a *different* window (e.g. + // `workbench.action.openAgentsWindow` reveals the agents window). Pulling the routing window + // forward first produces a visible flicker. Instead we let the command decide what to surface + // and focus — matching every other `vscode:runAction` sender (menubar, touchbar, mouse), none + // of which force-focus. `sendWhenReady` only needs the web contents to be ready, not focused. + const payload: INativeRunActionInWindowRequest = { + id: binding.commandId, + from: 'systemWideKeybinding', + args: binding.args === undefined ? undefined : [binding.args] + }; + target.sendWhenReady('vscode:runAction', CancellationToken.None, payload); + } + + private onDidDestroyWindow(window: ICodeWindow): void { + if (this.registry.delete(window.id)) { + this.reconcile(); + } + } + + private unregisterAll(): void { + for (const accelerator of this.registeredAccelerators) { + this.globalShortcut.unregister(accelerator); + } + this.registeredAccelerators.clear(); + this.failedAccelerators.clear(); + this.registry.clear(); + } +} diff --git a/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts b/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts new file mode 100644 index 0000000000000..99e34ee9b9cf2 --- /dev/null +++ b/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts @@ -0,0 +1,252 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { INativeSystemWideKeybinding } from '../../../native/common/native.js'; +import { ICodeWindow } from '../../../window/electron-main/window.js'; +import { IWindowsMainService } from '../../../windows/electron-main/windows.js'; +import { ILifecycleMainService } from '../../../lifecycle/electron-main/lifecycleMainService.js'; +import { GlobalKeybindingsMainService, IGlobalShortcutRegistry } from '../../electron-main/globalKeybindingsMainService.js'; + +class FakeGlobalShortcut implements IGlobalShortcutRegistry { + readonly registered = new Map void>(); + readonly failFor = new Set(); + unregisterCalls: string[] = []; + + register(accelerator: string, callback: () => void): boolean { + if (this.failFor.has(accelerator)) { + return false; + } + this.registered.set(accelerator, callback); + return true; + } + + unregister(accelerator: string): void { + this.unregisterCalls.push(accelerator); + this.registered.delete(accelerator); + } + + isRegistered(accelerator: string): boolean { + return this.registered.has(accelerator); + } + + trigger(accelerator: string): void { + const callback = this.registered.get(accelerator); + if (!callback) { + throw new Error(`accelerator '${accelerator}' is not registered`); + } + callback(); + } +} + +class FakeCodeWindow { + focusCalls = 0; + readonly sent: { channel: string; args: unknown[] }[] = []; + + constructor(readonly id: number) { } + + focus(): void { + this.focusCalls++; + } + + sendWhenReady(channel: string, _token: unknown, ...args: unknown[]): void { + this.sent.push({ channel, args }); + } +} + +class FakeWindowsMainService { + private readonly _onDidDestroyWindow: Emitter; + readonly onDidDestroyWindow; + + focused: FakeCodeWindow | undefined; + readonly windows = new Map(); + + constructor(store: DisposableStore) { + this._onDidDestroyWindow = store.add(new Emitter()); + this.onDidDestroyWindow = this._onDidDestroyWindow.event; + } + + addWindow(id: number): FakeCodeWindow { + const window = new FakeCodeWindow(id); + this.windows.set(id, window); + return window; + } + + getFocusedWindow(): ICodeWindow | undefined { + return this.focused as unknown as ICodeWindow | undefined; + } + + getWindowById(id: number): ICodeWindow | undefined { + return this.windows.get(id) as unknown as ICodeWindow | undefined; + } + + destroyWindow(window: FakeCodeWindow): void { + this.windows.delete(window.id); + this._onDidDestroyWindow.fire(window as unknown as ICodeWindow); + } +} + +class FakeLifecycleMainService { + private readonly _onWillShutdown: Emitter<{ reason: number; join(id: string, promise: Promise): void }>; + readonly onWillShutdown; + + constructor(store: DisposableStore) { + this._onWillShutdown = store.add(new Emitter()); + this.onWillShutdown = this._onWillShutdown.event; + } + + shutdown(): void { + this._onWillShutdown.fire({ reason: 1, join: () => { } }); + } +} + +function binding(accelerator: string, commandId: string, args?: unknown, userSettingsLabel?: string): INativeSystemWideKeybinding { + return { accelerator, commandId, args, userSettingsLabel }; +} + +suite('GlobalKeybindingsMainService', () => { + + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(shortcut = new FakeGlobalShortcut()) { + const store = ds.add(new DisposableStore()); + const windows = new FakeWindowsMainService(store); + const lifecycle = new FakeLifecycleMainService(store); + const service = store.add(new GlobalKeybindingsMainService( + shortcut, + windows as unknown as IWindowsMainService, + lifecycle as unknown as ILifecycleMainService, + new NullLogService() + )); + return { service, windows, lifecycle, shortcut }; + } + + test('registers desired accelerators and reports no failures', () => { + const { service, shortcut } = createService(); + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); + + assert.deepStrictEqual(failed, []); + assert.deepStrictEqual([...shortcut.registered.keys()].sort(), ['Control+Cmd+A', 'Control+Cmd+B']); + }); + + test('reports and unregisters accelerators removed on a subsequent update', () => { + const { service, shortcut } = createService(); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a')]); + + assert.deepStrictEqual([...shortcut.registered.keys()], ['Control+Cmd+A']); + assert.deepStrictEqual(shortcut.unregisterCalls, ['Control+Cmd+B']); + }); + + test('returns the labels that failed to register and retries them later', () => { + const shortcut = new FakeGlobalShortcut(); + shortcut.failFor.add('Control+Cmd+A'); + const { service } = createService(shortcut); + + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); + assert.deepStrictEqual(failed, ['ctrl+cmd+a']); + + // Now the accelerator becomes available; a re-sync should register it. + shortcut.failFor.delete('Control+Cmd+A'); + const { failed: failedAgain } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); + assert.deepStrictEqual(failedAgain, []); + assert.ok(shortcut.isRegistered('Control+Cmd+A')); + }); + + test('deduplicates accelerators within a single window payload', () => { + const { service, shortcut } = createService(); + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'first'), binding('Control+Cmd+A', 'second')]); + + assert.deepStrictEqual(failed, []); + assert.strictEqual(shortcut.registered.size, 1); + }); + + test('trigger dispatches the run-action payload without force-focusing the routing window', () => { + const { service, windows, shortcut } = createService(); + const window = windows.addWindow(1); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'workbench.action.openAgentsWindow', { foo: 'bar' })]); + + shortcut.trigger('Control+Cmd+A'); + + // The command controls what is surfaced/focused (e.g. it reveals the agents window), so the + // routing window must NOT be pulled to the foreground — doing so would flicker the wrong window. + assert.strictEqual(window.focusCalls, 0); + assert.deepStrictEqual(window.sent, [{ + channel: 'vscode:runAction', + args: [{ id: 'workbench.action.openAgentsWindow', from: 'systemWideKeybinding', args: [{ foo: 'bar' }] }] + }]); + }); + + test('trigger passes undefined args when the binding has none', () => { + const { service, windows, shortcut } = createService(); + const window = windows.addWindow(1); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'cmd')]); + + shortcut.trigger('Control+Cmd+A'); + + assert.deepStrictEqual(window.sent[0].args, [{ id: 'cmd', from: 'systemWideKeybinding', args: undefined }]); + }); + + test('conflict across windows resolves to the focused owner', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + const window2 = windows.addWindow(2); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'from-window-1')]); + service.updateKeybindings(2, [binding('Control+Cmd+A', 'from-window-2')]); + + windows.focused = window2; + shortcut.trigger('Control+Cmd+A'); + + assert.strictEqual(window1.sent.length, 0); + assert.strictEqual((window2.sent[0].args[0] as { id: string }).id, 'from-window-2'); + }); + + test('conflict without a focused owner resolves deterministically to the lowest window id', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + const window2 = windows.addWindow(2); + service.updateKeybindings(2, [binding('Control+Cmd+A', 'from-window-2')]); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'from-window-1')]); + + windows.focused = undefined; + shortcut.trigger('Control+Cmd+A'); + + assert.strictEqual(window2.sent.length, 0); + assert.strictEqual((window1.sent[0].args[0] as { id: string }).id, 'from-window-1'); + }); + + test('closing a window unregisters accelerators no other window owns', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a')]); + assert.ok(shortcut.isRegistered('Control+Cmd+A')); + + windows.destroyWindow(window1); + assert.strictEqual(shortcut.isRegistered('Control+Cmd+A'), false); + }); + + test('closing a window keeps accelerators still owned by another window', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + windows.addWindow(2); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a')]); + service.updateKeybindings(2, [binding('Control+Cmd+A', 'a')]); + + windows.destroyWindow(window1); + assert.ok(shortcut.isRegistered('Control+Cmd+A')); + }); + + test('shutdown unregisters all owned accelerators', () => { + const { service, lifecycle, shortcut } = createService(); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); + + lifecycle.shutdown(); + assert.strictEqual(shortcut.registered.size, 0); + }); +}); diff --git a/src/vs/platform/keybinding/common/keybinding.ts b/src/vs/platform/keybinding/common/keybinding.ts index 6c4baf3a1ded3..d4ce2da9b88ab 100644 --- a/src/vs/platform/keybinding/common/keybinding.ts +++ b/src/vs/platform/keybinding/common/keybinding.ts @@ -18,6 +18,12 @@ export interface IUserFriendlyKeybinding { command: string; args?: any; when?: string; + /** + * When `true`, the keybinding is registered as a system-wide (OS global) shortcut that fires + * even when VS Code does not have focus. Desktop only; ignored on web/server. Only honored for + * user `keybindings.json` entries (not extension-contributed keybindings). + */ + systemWide?: boolean; } export interface IKeyboardEvent { diff --git a/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts b/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts index 76a5e8ccb643c..135db192b1d68 100644 --- a/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts +++ b/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts @@ -19,8 +19,14 @@ export class ResolvedKeybindingItem { public readonly isDefault: boolean; public readonly extensionId: string | null; public readonly isBuiltinExtension: boolean; + /** + * Whether this keybinding was declared as a system-wide (OS global) shortcut via + * `keybindings.json`. Only ever `true` for user keybindings; defaults/extension keybindings + * are always `false`. + */ + public readonly systemWide: boolean; - constructor(resolvedKeybinding: ResolvedKeybinding | undefined, command: string | null, commandArgs: any, when: ContextKeyExpression | undefined, isDefault: boolean, extensionId: string | null, isBuiltinExtension: boolean) { + constructor(resolvedKeybinding: ResolvedKeybinding | undefined, command: string | null, commandArgs: any, when: ContextKeyExpression | undefined, isDefault: boolean, extensionId: string | null, isBuiltinExtension: boolean, systemWide: boolean = false) { this.resolvedKeybinding = resolvedKeybinding; this.chords = resolvedKeybinding ? toEmptyArrayIfContainsNull(resolvedKeybinding.getDispatchChords()) : []; if (resolvedKeybinding && this.chords.length === 0) { @@ -34,6 +40,7 @@ export class ResolvedKeybindingItem { this.isDefault = isDefault; this.extensionId = extensionId; this.isBuiltinExtension = isBuiltinExtension; + this.systemWide = systemWide; } } diff --git a/src/vs/platform/menubar/electron-main/menubar.ts b/src/vs/platform/menubar/electron-main/menubar.ts index c7a145e7dee71..a3f53de08e895 100644 --- a/src/vs/platform/menubar/electron-main/menubar.ts +++ b/src/vs/platform/menubar/electron-main/menubar.ts @@ -666,6 +666,9 @@ export class Menubar extends Disposable { case StateType.Updating: return [new MenuItem({ label: nls.localize('miInstallingUpdate', "Installing Update..."), enabled: false })]; + case StateType.Cancelling: + return [new MenuItem({ label: nls.localize('miCancellingUpdate', "Cancelling Update..."), enabled: false })]; + case StateType.Ready: return [new MenuItem({ label: this.mnemonicLabel(nls.localize('miRestartToUpdate', "Restart to &&Update")), click: () => { diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 0647066e45940..2f2bcccde3b59 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -88,6 +88,39 @@ export const enum FocusMode { Force, } +export interface INativeSystemWideKeybinding { + + /** + * The keybinding in Electron accelerator format (e.g. `Control+Cmd+A`). + * See https://www.electronjs.org/docs/latest/api/accelerator. + */ + readonly accelerator: string; + + /** + * The command to execute when the global shortcut is triggered. + */ + readonly commandId: string; + + /** + * Optional command arguments as configured in `keybindings.json`. Must be JSON-serializable. + */ + readonly args?: unknown; + + /** + * The user settings label (e.g. `ctrl+cmd+a`) for diagnostics/notifications. + */ + readonly userSettingsLabel?: string; +} + +export interface INativeSystemWideKeybindingResult { + + /** + * The user settings labels (or accelerators) that could not be registered, e.g. because the + * accelerator is already taken by the OS or another application. + */ + readonly failed: string[]; +} + export interface ICommonNativeHostService { readonly _serviceBrand: undefined; @@ -141,6 +174,13 @@ export interface ICommonNativeHostService { openAgentsWindow(options?: { folderUri?: UriComponents; sessionResource?: UriComponents }): Promise; + /** + * Registers this window's set of system-wide (OS global) keybindings with the main process, + * replacing any previously registered by this window. The shortcuts fire even when the + * application is not focused. Returns the set that could not be registered. + */ + syncSystemWideKeybindings(keybindings: INativeSystemWideKeybinding[]): Promise; + isFullScreen(options?: INativeHostOptions): Promise; toggleFullScreen(options?: INativeHostOptions): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 4135c6d145a97..13ee613905033 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -27,7 +27,8 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, IOSProperties, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { IGlobalKeybindingsMainService } from '../../globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; @@ -48,7 +49,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; -import { CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } @@ -71,7 +72,8 @@ export class NativeHostMainService extends Disposable implements INativeHostMain @IConfigurationService private readonly configurationService: IConfigurationService, @IRequestService private readonly requestService: IRequestService, @IProxyAuthService private readonly proxyAuthService: IProxyAuthService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IGlobalKeybindingsMainService private readonly globalKeybindingsMainService: IGlobalKeybindingsMainService ) { super(); @@ -275,7 +277,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain private async doOpenWindow(windowId: number | undefined, toOpen: IWindowOpenable[], options: IOpenWindowOptions = Object.create(null)): Promise { if (toOpen.length > 0) { - await this.windowsMainService.open({ + const windows = await this.windowsMainService.open({ context: OpenContext.API, contextWindowId: windowId, urisToOpen: toOpen, @@ -294,6 +296,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain forceProfile: options.forceProfile, forceTempProfile: options.forceTempProfile, }); + + // Hand off a chat session to the opened window so it restores both the + // folder and the session (e.g. the Agents window "Open in VS Code" flow). + // Only meaningful when exactly one window is opened so the session is + // not sent to an ambiguous target. + const chatSessionToOpen = options.chatSessionToOpen; + if (chatSessionToOpen && windows.length === 1) { + windows[0].sendWhenReady('vscode:openChatSession', CancellationToken.None, URI.revive(chatSessionToOpen).toString()); + } } } @@ -315,6 +326,13 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } } + async syncSystemWideKeybindings(windowId: number | undefined, keybindings: INativeSystemWideKeybinding[]): Promise { + if (typeof windowId !== 'number') { + return { failed: [] }; + } + return this.globalKeybindingsMainService.updateKeybindings(windowId, keybindings); + } + async isFullScreen(windowId: number | undefined, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); return window?.isFullScreen ?? false; diff --git a/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts b/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts index bb5855347b6f6..b2c43c051ca15 100644 --- a/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts +++ b/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts @@ -6,7 +6,6 @@ import { mkdirSync } from 'fs'; // The 'node:module' specifier is unresolvable by the Electron renderer // ESM loader (used by the unit test harness), so use the bare form. -// eslint-disable-next-line local/code-import-patterns import { createRequire } from 'module'; // eslint-disable-next-line local/code-import-patterns import type { DatabaseSync, StatementSync } from 'node:sqlite'; diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index b2b79e81456a0..a10f42b99764f 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -5,9 +5,10 @@ import { Event } from '../../../base/common/event.js'; import { IPolicyData } from '../../../base/common/defaultAccount.js'; +import { IExtraKnownMarketplaceEntry, extraKnownMarketplacesToConfigDict } from '../../../base/common/managedSettings.js'; import { IManagedSettingPolicyDefinition, IManagedSettingsPolicyDefinitions, ManagedSettingValue, ManagedSettingsData } from '../../../base/common/policy.js'; import { IStringDictionary } from '../../../base/common/collections.js'; -import { isEmptyObject } from '../../../base/common/types.js'; +import { isEmptyObject, isObject, isString } from '../../../base/common/types.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { PolicyDefinition } from './policy.js'; @@ -34,6 +35,43 @@ export const COPILOT_EXTRA_MARKETPLACES_KEY = 'extraKnownMarketplaces'; /** Managed-settings key for the strict-marketplace allowlist (carried as a JSON-encoded array of source entries; absent = no restrictions, `[]` = lockdown). */ export const COPILOT_STRICT_MARKETPLACES_KEY = 'strictKnownMarketplaces'; +/** Managed-settings key for the default chat model (carried as a plain string: `auto`, a model family name, or a full model id). */ +export const COPILOT_MODEL_KEY = 'model'; + +/** + * Enterprise OTel managed-settings keys. These are the scalar leaves of the canonical + * `telemetry` block from the cross-client managed-settings schema (see the CLI + * `ManagedTelemetrySettings`); they flatten to dot-path bag keys via + * {@link normalizeManagedSettings}, so no {@link STRUCTURED_MANAGED_SETTINGS} entry is needed. + * The `telemetry.resourceAttributes` and `telemetry.headers` map fields are structured + * ({@link STRUCTURED_MANAGED_SETTINGS} rows carry them as JSON-encoded objects under their nested + * keys); `telemetry.serviceName` is a scalar. + */ + +/** Managed-settings key for enterprise OTel enablement. */ +export const COPILOT_OTEL_ENABLED_KEY = 'telemetry.enabled'; + +/** Managed-settings key for the enterprise OTLP collector endpoint. */ +export const COPILOT_OTEL_ENDPOINT_KEY = 'telemetry.endpoint'; + +/** Managed-settings key for the enterprise OTLP protocol (`http/json`, `http/protobuf`, or `grpc`). */ +export const COPILOT_OTEL_PROTOCOL_KEY = 'telemetry.protocol'; + +/** Managed-settings key for enterprise OTel content capture. */ +export const COPILOT_OTEL_CAPTURE_CONTENT_KEY = 'telemetry.captureContent'; + +/** Managed-settings key that prevents users from enabling OTel content capture themselves. */ +export const COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY = 'telemetry.lockCaptureContent'; + +/** Managed-settings key for the OTel `service.name` resource attribute. */ +export const COPILOT_OTEL_SERVICE_NAME_KEY = 'telemetry.serviceName'; + +/** Managed-settings key for additional OTel resource attributes (a `{ [k]: string }` map). */ +export const COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY = 'telemetry.resourceAttributes'; + +/** Managed-settings key for extra OTLP exporter headers (a `{ [k]: string }` map). */ +export const COPILOT_OTEL_HEADERS_KEY = 'telemetry.headers'; + const managedSettingValueCallbacks = new Map ManagedSettingValue | undefined>(); /** @@ -55,16 +93,41 @@ export function managedSettingValue(key: string): (policyData: IPolicyData) => M return callback; } -export const ICopilotManagedSettingsService = createDecorator('copilotManagedSettingsService'); +let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined; + +/** + * `value` callback for the default-chat-model managed setting ({@link COPILOT_MODEL_KEY}). Like + * {@link managedSettingValue} it locks the setting to the managed value and otherwise falls through + * to the user's own value, but it additionally trims the string and treats a blank/whitespace-only + * value as "unset" (returns `undefined`) — an admin clearing the field must not lock the setting to + * an empty string. The model-specific normalization lives here, alongside the other managed-settings + * handling, rather than inline at the policy declaration, so every managed-settings control is wired + * the same way. + * + * Memoized (single key) so repeated calls return the SAME function reference, matching the + * reference-identity contract {@link managedSettingValue} relies on for `isSamePolicyDefinition`. + */ +export function managedModelValue(): (policyData: IPolicyData) => ManagedSettingValue | undefined { + if (!managedModelValueCallback) { + managedModelValueCallback = policyData => { + const model = policyData.managedSettings?.[COPILOT_MODEL_KEY]; + const trimmed = typeof model === 'string' ? model.trim() : undefined; + return trimmed ? trimmed : undefined; + }; + } + return managedModelValueCallback; +} + +export const INativeManagedSettingsService = createDecorator('nativeManagedSettingsService'); -export interface ICopilotManagedSettingsService { +export interface INativeManagedSettingsService { readonly _serviceBrand: undefined; readonly managedSettings: ManagedSettingsData; readonly onDidChangeManagedSettings: Event; updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise; } -export class NullCopilotManagedSettingsService implements ICopilotManagedSettingsService { +export class NullNativeManagedSettingsService implements INativeManagedSettingsService { readonly _serviceBrand: undefined; readonly managedSettings: ManagedSettingsData = {}; readonly onDidChangeManagedSettings = Event.None; @@ -72,7 +135,7 @@ export class NullCopilotManagedSettingsService implements ICopilotManagedSetting async updatePolicyDefinitions(): Promise { return this.managedSettings; } } -export function flattenManagedSettings(object: unknown): Record { +function flattenManagedSettings(object: unknown): Record { const result: Record = {}; flattenManagedSettingsValue(object, undefined, result); return result; @@ -159,41 +222,349 @@ export function projectManagedSettings(values: ManagedSettingsData, definitions: } /** - * The delivery channel that provided the active managed-settings bag. Managed settings can be - * delivered by more than one channel, so this names the known sources to give policy evaluation - * and the Policy Diagnostics report one shared vocabulary. Extend this union (and - * {@link selectManagedSettings}) when adding a new channel. + * A delivery channel that can provide managed settings. Managed settings can be delivered by more + * than one channel, so this names the known sources to give policy evaluation and the Policy + * Diagnostics report one shared vocabulary. Extend this union (and {@link MANAGED_SETTINGS_CHANNELS} + * / {@link pickManagedSettings}) when adding a new channel. */ -export type ManagedSettingsSource = - /** No channel currently provides managed settings. */ - | 'none' +export type ManagedSettingsChannel = /** GitHub `/copilot_internal/managed_settings` endpoint (server-delivered). */ | 'server' /** Native MDM: OS registry (Windows) / managed preferences (macOS) via `@vscode/policy-watcher`. */ - | 'nativeMdm'; + | 'nativeMdm' + /** File on a well-known disk path (`managed-settings.json`). */ + | 'file'; + +/** + * The source attributed to an effective managed setting (or to the overall report). A + * {@link ManagedSettingsChannel} once a channel has won, or `'none'` when no channel contributes. + */ +export type ManagedSettingsSource = ManagedSettingsChannel | 'none'; + +/** + * The delivery channels in fixed precedence order (highest first): native MDM → server-delivered → + * file on disk. This single ordered list drives the per-key resolution in {@link pickManagedSettings} + * and is the one place to extend when a new channel is introduced. Rationale for the order: the + * server is harder to bypass than local MDM, and a local file is the most easily tampered with. + */ +export const MANAGED_SETTINGS_CHANNELS: readonly ManagedSettingsChannel[] = ['nativeMdm', 'server', 'file']; + +/** A single channel's contribution to a managed-settings key, for provenance in the resolution. */ +export interface IManagedSettingsContribution { + /** The channel that supplied this value. */ + readonly channel: ManagedSettingsChannel; + /** The value the channel supplied for the key. */ + readonly value: ManagedSettingValue; +} + +/** How a single managed-settings key was resolved across the delivery channels. */ +export interface IManagedSettingResolution { + /** The effective (winning) value applied for the key. */ + readonly value: ManagedSettingValue; + /** The channel whose value won (always the first {@link contributions} entry's channel). */ + readonly source: ManagedSettingsChannel; + /** Every channel that supplied this key, in precedence order (winner first, overridden after). */ + readonly contributions: readonly IManagedSettingsContribution[]; +} + +/** The result of merging managed settings from every delivery channel on a per-key basis. */ +export interface IManagedSettingsPick { + /** The effective merged bag: the winning value for each key contributed by any channel. */ + readonly values: ManagedSettingsData; + /** Per-key provenance: how each key resolved and which channels were overridden. */ + readonly resolutions: ReadonlyMap; + /** The channels that supplied at least one *winning* key, in precedence order. */ + readonly activeSources: readonly ManagedSettingsChannel[]; +} + +/** + * Merge the managed-settings bags from every delivery channel on a **per-key** basis. + * + * Precedence (highest first): native MDM → server-delivered → file on disk. Unlike a single + * authoritative source, the channels *are* merged key-by-key: for each key the highest-precedence + * channel that supplies it wins, but a key that the higher channels never set is still filled in by + * a lower channel. A value an admin locks via native MDM therefore cannot be overwritten by the + * server or a file, while keys those higher channels leave unset remain available to lower ones. + * + * The parameter order matches the precedence so call sites read top-to-bottom. Centralizing the + * resolution here (rather than inlining it at each call site) keeps policy evaluation + * ({@link AccountPolicyService.getPolicyData}) and the Policy Diagnostics report from drifting apart, + * and gives one obvious place to extend when a new channel is introduced. Empty or absent channels + * contribute nothing. + */ +export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsPick { + const bags: Record = { nativeMdm, server, file }; + + // Walk channels highest-precedence first: the first channel to supply a key wins, and later + // channels are appended as overridden contributions for provenance. + const resolutions = new Map(); + for (const channel of MANAGED_SETTINGS_CHANNELS) { + const bag = bags[channel]; + if (!bag) { + continue; + } + // Iterate own keys only (managed-settings bags are untrusted input): avoids enumerating + // inherited enumerable properties the way `for...in` would. + for (const key of Object.keys(bag)) { + const value = bag[key]; + if (value === undefined) { + continue; + } + const existing = resolutions.get(key); + if (existing) { + existing.contributions.push({ channel, value }); + } else { + resolutions.set(key, { value, source: channel, contributions: [{ channel, value }] }); + } + } + } + + const activeSources = new Set(); + const entries: [string, ManagedSettingValue][] = []; + for (const [key, resolution] of resolutions) { + entries.push([key, resolution.value]); + activeSources.add(resolution.source); + } + + return { + // Build via Object.fromEntries (define-property semantics) rather than bracket assignment so + // an untrusted `__proto__` key can't corrupt the merged bag's prototype chain. + values: Object.fromEntries(entries), + resolutions, + // Preserve precedence order for a stable, readable report. + activeSources: MANAGED_SETTINGS_CHANNELS.filter(channel => activeSources.has(channel)), + }; +} + +// --- File-based managed settings --- + +/** macOS well-known path for file-based managed settings. */ +export const MANAGED_SETTINGS_MACOS_FILE_PATH = '/Library/Application Support/GitHubCopilot/managed-settings.json'; + +/** Linux well-known path for file-based managed settings. */ +export const MANAGED_SETTINGS_LINUX_FILE_PATH = '/etc/github-copilot/managed-settings.json'; + +/** Windows directory name under %ProgramFiles% for file-based managed settings. */ +export const MANAGED_SETTINGS_WINDOWS_DIR = 'GitHubCopilot'; + +/** Managed settings file name. */ +export const MANAGED_SETTINGS_FILE_NAME = 'managed-settings.json'; + +/** + * Descriptor for a structured (object/array) managed setting: one carried across every delivery + * channel as a canonical JSON string under a single key. This table is the single place that + * knows how to turn a managed-settings schema field into that canonical value, so adding a + * structured key is one row here (plus the policy declaration that reads the bag key). + * + * `key` is both the source field name read from the parsed input and the canonical bag key the + * JSON string is stored under — for structured settings these are identical by contract (a + * structured key's bag name matches the schema field exactly; only scalar settings flatten to a + * differently-shaped dot-path, and those don't go through this table). + */ +interface IStructuredManagedSetting { + /** Source field name read from the parsed input, and the canonical bag key the JSON string is stored under. */ + readonly key: string; + /** + * Normalize the raw value into the canonical pre-stringify shape an admin authors via native + * MDM. Return `undefined` to omit the key (absent or malformed value). Note an empty array (the + * `strictKnownMarketplaces` lockdown case) is returned as-is, not omitted. + */ + readonly encode: (value: unknown, onWarn?: (msg: string) => void) => unknown; +} + +/** + * Encode a managed-settings value into a canonical `{ [k]: string }` map: keeps string values + * as-is and coerces number/boolean values to strings; drops keys with non-primitive values. + * Returns `undefined` for a non-object input so the structured key is omitted. + */ +function encodeStringMap(value: unknown): Record | undefined { + if (!isObject(value)) { + return undefined; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') { + continue; // defend the shared normalizer against prototype pollution + } + if (isString(v)) { + out[k] = v; + } else if (typeof v === 'number' || typeof v === 'boolean') { + out[k] = String(v); + } + } + return out; +} + +/** Pass an object value through unchanged; omit the key for any non-object value. */ +function encodeObject(value: unknown): object | undefined { + return isObject(value) ? value : undefined; +} + +/** Pass an array value through unchanged (including an empty array); omit the key otherwise. */ +function encodeArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +/** + * Encode the schema's `{ [id]: { source } }` marketplace map into the canonical + * `{ [name]: url-or-shorthand }` dict; drops malformed entries (with an optional warning) and omits + * the key when there are none. + */ +function encodeExtraMarketplaces(value: unknown, onWarn?: (msg: string) => void): Record | undefined { + return extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)); +} + +const STRUCTURED_MANAGED_SETTINGS: readonly IStructuredManagedSetting[] = [ + { + key: COPILOT_ENABLED_PLUGINS_KEY, + encode: encodeObject, + }, + { + key: COPILOT_STRICT_MARKETPLACES_KEY, + encode: encodeArray, + }, + { + key: COPILOT_EXTRA_MARKETPLACES_KEY, + encode: encodeExtraMarketplaces, + }, + { + // Nested under `telemetry`; carried as a JSON-encoded `{ [k]: string }` map. Non-string + // primitive values are coerced to strings; non-primitive values are dropped. + key: COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, + encode: encodeStringMap, + }, + { + // Nested under `telemetry`; carried as a JSON-encoded `{ [k]: string }` map of OTLP headers. + key: COPILOT_OTEL_HEADERS_KEY, + encode: encodeStringMap, + }, +]; + +/** + * Read a (possibly nested) dot-separated key from a parsed managed-settings object, e.g. + * `telemetry.resourceAttributes`. Returns `undefined` if any path segment is missing or not an + * object. Single-segment keys behave like a plain property read. + */ +function readNestedManagedKey(obj: Record, dottedKey: string): unknown { + let current: unknown = obj; + for (const segment of dottedKey.split('.')) { + if (!isObject(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} -export interface IManagedSettingsSelection { - /** Which channel won. */ - readonly source: ManagedSettingsSource; - /** The winning bag, or `undefined` when {@link source} is `'none'`. */ - readonly values: ManagedSettingsData | undefined; +/** + * Return a copy of `obj` with the (possibly nested) dot-separated key removed, cloning only the + * objects along the touched path so the original (and any shared sub-objects) stay untouched. The + * spread-then-`delete` shape matches a destructuring rest: it copies own enumerable keys (including + * an own `__proto__`) without triggering the inherited `__proto__` setter. + */ +function withNestedManagedKeyDeleted(obj: Record, dottedKey: string): Record { + const dot = dottedKey.indexOf('.'); + if (dot === -1) { + const clone = { ...obj }; + delete clone[dottedKey]; + return clone; + } + const head = dottedKey.slice(0, dot); + const child = obj[head]; + if (!isObject(child)) { + return obj; + } + return { ...obj, [head]: withNestedManagedKeyDeleted(child as Record, dottedKey.slice(dot + 1)) }; } /** - * Select the authoritative managed-settings bag from the available delivery channels. + * Normalize a parsed managed-settings object (from the server `managed_settings` API, a file on + * disk, or any other source using the managed-settings schema) into the canonical + * `ManagedSettingsData` bag that the policy framework consumes. This is the **single** + * normalization path for all delivery channels, so downstream projection and policy `value()` + * callbacks behave identically regardless of source. It does not enforce the declared + * `managedSettings` schema — dropping undeclared or type-mismatched keys happens later, at + * {@link projectManagedSettings}. * - * Server-delivered settings win over native MDM and the two are never merged — managed settings - * have a single authoritative source. Centralizing the precedence here (rather than inlining it at - * each call site) keeps policy evaluation ({@link AccountPolicyService.getPolicyData}) and the - * Policy Diagnostics report from drifting apart, and gives one obvious place to extend when a new - * channel is introduced. + * - Scalar leaves (`permissions.*` and any forward-compatible scalar keys) are flattened into + * dot-separated keys. + * - Structured settings (declared in {@link STRUCTURED_MANAGED_SETTINGS}) are carried as canonical + * JSON strings under a single key each — the same shape an admin authors via native MDM. + * `PolicyConfiguration` parses the JSON back into the object-typed setting on read. + * `extraKnownMarketplaces` is normalized from the schema's `{ [id]: { source } }` map to the + * `{ [name]: url-or-shorthand }` dict. + * + * Malformed marketplace entries are dropped (with an optional warning via {@link onWarn}) rather + * than throwing, so a bad enterprise settings file degrades gracefully instead of blocking startup. + */ +export function normalizeManagedSettings(parsed: Record, onWarn?: (msg: string) => void): ManagedSettingsData { + // Spread + delete (not for..in + assignment) so the scalar remainder keeps exact `{ ...rest }` + // semantics: it never triggers the inherited `__proto__` setter for a source-sent own + // `__proto__` key, matching a destructuring rest. Structured keys may be nested (e.g. + // `telemetry.resourceAttributes`), so removal clones only the touched path. + let scalarRest: Record = { ...parsed }; + for (const setting of STRUCTURED_MANAGED_SETTINGS) { + scalarRest = withNestedManagedKeyDeleted(scalarRest, setting.key); + } + + const result: Record = { ...flattenManagedSettings(scalarRest) }; + + for (const setting of STRUCTURED_MANAGED_SETTINGS) { + const encoded = setting.encode(readNestedManagedKey(parsed, setting.key), onWarn); + if (encoded !== undefined) { + result[setting.key] = JSON.stringify(encoded); + } + } + + return result; +} + +/** + * Normalize the schema's `{ [id]: { source } }` marketplace map into an + * {@link IExtraKnownMarketplaceEntry} array, preserving the marketplace `name`, + * source discriminator, and any `ref`. Malformed or off-spec entries are dropped + * (with an optional warning via {@link onWarn}). */ -export function selectManagedSettings(server: ManagedSettingsData | undefined, nativeMdm: ManagedSettingsData | undefined): IManagedSettingsSelection { - if (server && !isEmptyObject(server)) { - return { source: 'server', values: server }; +function normalizeExtraKnownMarketplaces(value: unknown, onWarn?: (msg: string) => void): IExtraKnownMarketplaceEntry[] | undefined { + if (!isObject(value)) { + return undefined; } - if (nativeMdm && !isEmptyObject(nativeMdm)) { - return { source: 'nativeMdm', values: nativeMdm }; + const seen = new Set(); + const entries: IExtraKnownMarketplaceEntry[] = []; + for (const [name, entry] of Object.entries(value)) { + if (!isObject(entry) || !isObject((entry as Record).source)) { + onWarn?.(`Skipping malformed extraKnownMarketplaces entry "${name}": expected { source: { source, repo|url } }`); + continue; + } + const src = (entry as Record).source as { source?: string; repo?: string; url?: string; ref?: string }; + let normalized: IExtraKnownMarketplaceEntry | undefined; + if (src.source === 'github' && isString(src.repo)) { + normalized = { name, source: { source: 'github', repo: src.repo, ...(src.ref ? { ref: src.ref } : {}) } }; + } else if (src.source === 'git' && isString(src.url)) { + normalized = { name, source: { source: 'git', url: src.url, ...(src.ref ? { ref: src.ref } : {}) } }; + } else if (src.source === 'github' || src.source === 'git') { + onWarn?.(`Skipping extraKnownMarketplaces entry "${name}": source "${src.source}" requires ${src.source === 'github' ? '"repo"' : '"url"'}`); + } else { + onWarn?.(`Skipping extraKnownMarketplaces entry "${name}": unknown source type "${src.source}"`); + } + if (normalized && !seen.has(name)) { + seen.add(name); + entries.push(normalized); + } } - return { source: 'none', values: undefined }; + return entries; +} + +export const IFileManagedSettingsService = createDecorator('fileManagedSettingsService'); + +export interface IFileManagedSettingsService { + readonly _serviceBrand: undefined; + readonly managedSettings: ManagedSettingsData; + readonly onDidChangeManagedSettings: Event; +} + +export class NullFileManagedSettingsService implements IFileManagedSettingsService { + readonly _serviceBrand: undefined; + readonly managedSettings: ManagedSettingsData = {}; + readonly onDidChangeManagedSettings = Event.None; } diff --git a/src/vs/platform/policy/common/fileManagedSettingsIpc.ts b/src/vs/platform/policy/common/fileManagedSettingsIpc.ts new file mode 100644 index 0000000000000..d020262209428 --- /dev/null +++ b/src/vs/platform/policy/common/fileManagedSettingsIpc.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { equals } from '../../../base/common/objects.js'; +import { ManagedSettingsData } from '../../../base/common/policy.js'; +import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IFileManagedSettingsService } from './copilotManagedSettings.js'; + +export class FileManagedSettingsChannel implements IServerChannel { + + private readonly disposables = new DisposableStore(); + + constructor(private readonly service: IFileManagedSettingsService) { } + + listen(_: unknown, event: string): Event { + switch (event) { + case 'onDidChangeManagedSettings': return this.service.onDidChangeManagedSettings as Event; + } + + throw new Error(`Event not found: ${event}`); + } + + call(_: unknown, command: string): Promise { + switch (command) { + case 'getManagedSettings': return Promise.resolve(this.service.managedSettings as T); + } + + throw new Error(`Call not found: ${command}`); + } + + dispose(): void { + this.disposables.dispose(); + } +} + +export class FileManagedSettingsChannelClient extends Disposable implements IFileManagedSettingsService { + + readonly _serviceBrand: undefined; + + private _managedSettings: ManagedSettingsData = {}; + get managedSettings(): ManagedSettingsData { return this._managedSettings; } + private hasReceivedManagedSettings = false; + + private readonly _onDidChangeManagedSettings = this._register(new Emitter()); + readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; + + constructor(channel: IChannel) { + super(); + this._register(channel.listen('onDidChangeManagedSettings')(managedSettings => this.updateManagedSettings(managedSettings, true))); + channel.call('getManagedSettings').then(managedSettings => { + if (!this.hasReceivedManagedSettings) { + this.updateManagedSettings(managedSettings, true); + } + }); + } + + private updateManagedSettings(managedSettings: ManagedSettingsData, fireEvent: boolean): void { + this.hasReceivedManagedSettings = true; + if (equals(this._managedSettings, managedSettings)) { + return; + } + + this._managedSettings = managedSettings; + if (fireEvent) { + this._onDidChangeManagedSettings.fire(this._managedSettings); + } + } +} diff --git a/src/vs/platform/policy/common/fileManagedSettingsService.ts b/src/vs/platform/policy/common/fileManagedSettingsService.ts new file mode 100644 index 0000000000000..1dcbd1c83c595 --- /dev/null +++ b/src/vs/platform/policy/common/fileManagedSettingsService.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ThrottledDelayer } from '../../../base/common/async.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { equals } from '../../../base/common/objects.js'; +import { ManagedSettingsData } from '../../../base/common/policy.js'; +import { isObject } from '../../../base/common/types.js'; +import { URI } from '../../../base/common/uri.js'; +import { FileOperationError, FileOperationResult, IFileService } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { IFileManagedSettingsService, normalizeManagedSettings } from './copilotManagedSettings.js'; + +/** + * Upper bound on the size of `managed-settings.json` we will read into the main process. A + * legitimate settings file is a few KB; capping the read keeps a malformed or hostile file on the + * (externally writable, privileged) well-known path from OOM-ing the main process. `readFile` + * throws `FILE_TOO_LARGE` past this, which `refresh()` treats like any other read failure. + */ +const MANAGED_SETTINGS_MAX_FILE_SIZE = 1024 * 1024; // 1 MB + +export class FileManagedSettingsService extends Disposable implements IFileManagedSettingsService { + + readonly _serviceBrand: undefined; + + private _managedSettings: ManagedSettingsData = {}; + get managedSettings(): ManagedSettingsData { return this._managedSettings; } + + private readonly _onDidChangeManagedSettings = this._register(new Emitter()); + readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; + + private readonly throttledDelayer = this._register(new ThrottledDelayer(500)); + + constructor( + private readonly file: URI, + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + const onDidChangeFile = Event.filter(fileService.onDidFilesChange, e => e.affects(file)); + this._register(fileService.watch(file)); + this._register(onDidChangeFile(() => this.throttledDelayer.trigger(() => this.refresh()))); + + // Initial read — routed through the same delayer (with no delay) so it is serialized + // against change-triggered refreshes and can't be clobbered by a racing read. Non-blocking; + // IPC clients handle eventual data arrival. + this.throttledDelayer.trigger(() => this.refresh(), 0); + } + + private async refresh(): Promise { + const previous = this._managedSettings; + + try { + const content = await this.fileService.readFile(this.file, { limits: { size: MANAGED_SETTINGS_MAX_FILE_SIZE } }); + const parsed = JSON.parse(content.value.toString()); + + if (isObject(parsed)) { + this._managedSettings = normalizeManagedSettings(parsed as Record, + msg => this.logService.warn(`[FileManagedSettingsService] ${msg}`)); + } else { + this.logService.warn('[FileManagedSettingsService] managed-settings.json is not a JSON object'); + this._managedSettings = {}; + } + } catch (error) { + if ((error).fileOperationResult !== FileOperationResult.FILE_NOT_FOUND) { + this.logService.error('[FileManagedSettingsService] Failed to read managed-settings.json', error); + } + this._managedSettings = {}; + } + + if (!equals(previous, this._managedSettings)) { + this._onDidChangeManagedSettings.fire(this._managedSettings); + } + } +} diff --git a/src/vs/platform/policy/common/copilotManagedSettingsIpc.ts b/src/vs/platform/policy/common/nativeManagedSettingsIpc.ts similarity index 89% rename from src/vs/platform/policy/common/copilotManagedSettingsIpc.ts rename to src/vs/platform/policy/common/nativeManagedSettingsIpc.ts index d3e4140360f5d..9a3496a94d55d 100644 --- a/src/vs/platform/policy/common/copilotManagedSettingsIpc.ts +++ b/src/vs/platform/policy/common/nativeManagedSettingsIpc.ts @@ -8,14 +8,14 @@ import { IStringDictionary } from '../../../base/common/collections.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { ICopilotManagedSettingsService, ManagedSettingsData } from './copilotManagedSettings.js'; +import { INativeManagedSettingsService, ManagedSettingsData } from './copilotManagedSettings.js'; import { PolicyDefinition } from './policy.js'; -export class CopilotManagedSettingsChannel implements IServerChannel { +export class NativeManagedSettingsChannel implements IServerChannel { private readonly disposables = new DisposableStore(); - constructor(private readonly service: ICopilotManagedSettingsService) { } + constructor(private readonly service: INativeManagedSettingsService) { } listen(_: unknown, event: string): Event { switch (event) { @@ -39,7 +39,7 @@ export class CopilotManagedSettingsChannel implements IServerChannel { } } -export class CopilotManagedSettingsChannelClient extends Disposable implements ICopilotManagedSettingsService { +export class NativeManagedSettingsChannelClient extends Disposable implements INativeManagedSettingsService { readonly _serviceBrand: undefined; diff --git a/src/vs/platform/policy/node/copilotManagedSettingsService.ts b/src/vs/platform/policy/node/nativeManagedSettingsService.ts similarity index 82% rename from src/vs/platform/policy/node/copilotManagedSettingsService.ts rename to src/vs/platform/policy/node/nativeManagedSettingsService.ts index 17306838c46a0..29d621dfd4a66 100644 --- a/src/vs/platform/policy/node/copilotManagedSettingsService.ts +++ b/src/vs/platform/policy/node/nativeManagedSettingsService.ts @@ -10,22 +10,22 @@ import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js import { equals } from '../../../base/common/objects.js'; import { IManagedSettingsPolicyDefinitions, ManagedSettingsData } from '../../../base/common/policy.js'; import { ILogService } from '../../log/common/log.js'; -import { collectManagedSettingsDefinitions, ICopilotManagedSettingsService } from '../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, INativeManagedSettingsService } from '../common/copilotManagedSettings.js'; import { PolicyDefinition, PolicyValue } from '../common/policy.js'; import type { Watcher } from '@vscode/policy-watcher'; -export interface ICopilotPolicyWatcherOptions { +export interface INativePolicyWatcherOptions { readonly registryPath?: string; } -export type CopilotPolicyWatcherFactory = ( +export type NativePolicyWatcherFactory = ( productName: string, policies: Record, onDidChange: (update: Record) => void, - options?: ICopilotPolicyWatcherOptions, + options?: INativePolicyWatcherOptions, ) => Watcher; -export class CopilotManagedSettingsService extends Disposable implements ICopilotManagedSettingsService { +export class NativeManagedSettingsService extends Disposable implements INativeManagedSettingsService { readonly _serviceBrand: undefined; @@ -44,8 +44,8 @@ export class CopilotManagedSettingsService extends Disposable implements ICopilo constructor( @ILogService private readonly logService: ILogService, private readonly productName: string, - private readonly watcherOptions?: ICopilotPolicyWatcherOptions, - private readonly watcherFactory?: CopilotPolicyWatcherFactory, + private readonly watcherOptions?: INativePolicyWatcherOptions, + private readonly watcherFactory?: NativePolicyWatcherFactory, ) { super(); } @@ -79,7 +79,7 @@ export class CopilotManagedSettingsService extends Disposable implements ICopilo private async updateWatcher(): Promise { const managedSettingDefinitions = this.getManagedSettingDefinitions(); - this.logService.trace(`CopilotManagedSettingsService#updateWatcher - Found ${Object.keys(managedSettingDefinitions).length} managed-settings definitions`); + this.logService.trace(`NativeManagedSettingsService#updateWatcher - Found ${Object.keys(managedSettingDefinitions).length} managed-settings definitions`); if (Object.keys(managedSettingDefinitions).length === 0) { this.watcher.clear(); const hadManagedSettings = this.managedSettingsValues.size > 0; @@ -90,16 +90,16 @@ export class CopilotManagedSettingsService extends Disposable implements ICopilo return; } - const { createWatcher } = this.watcherFactory ? { createWatcher: this.watcherFactory } : (await import('@vscode/policy-watcher') as { createWatcher: CopilotPolicyWatcherFactory }); + const { createWatcher } = this.watcherFactory ? { createWatcher: this.watcherFactory } : (await import('@vscode/policy-watcher') as { createWatcher: NativePolicyWatcherFactory }); await this.throttler.queue(() => new Promise((c, e) => { try { - this.logService.trace(`Creating Copilot managed-settings watcher for productName ${this.productName}`); + this.logService.trace(`Creating native managed-settings watcher for productName ${this.productName}`); this.watcher.value = createWatcher(this.productName, managedSettingDefinitions, update => { this._onDidManagedSettingsChange(update as Record); c(); }, this.watcherOptions); } catch (err) { - this.logService.error(`CopilotManagedSettingsService#updateWatcher - Error creating watcher:`, err); + this.logService.error(`NativeManagedSettingsService#updateWatcher - Error creating watcher:`, err); e(err); } })); @@ -121,7 +121,7 @@ export class CopilotManagedSettingsService extends Disposable implements ICopilo } private _onDidManagedSettingsChange(update: Record): void { - this.logService.trace(`CopilotManagedSettingsService#_onDidManagedSettingsChange - Updated managed-settings values: ${JSON.stringify(update)}`); + this.logService.trace(`NativeManagedSettingsService#_onDidManagedSettingsChange - Updated managed-settings values: ${JSON.stringify(update)}`); let changed = false; for (const [key, value] of Object.entries(update)) { diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index 592b2537081cc..b9981ec488109 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, managedSettingValue, projectManagedSettings, selectManagedSettings } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, managedSettingValue, projectManagedSettings, pickManagedSettings } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -107,24 +107,104 @@ suite('Copilot managed settings projection', () => { ); assert.strictEqual(warnings.length, 1); }); +}); + +suite('Copilot managed settings per-key precedence (pickManagedSettings)', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('distinct keys each win from their highest-precedence channel; a lower channel fills a gap the higher ones leave', () => { + // The headline per-key behavior: `shared` is contested by all three (native wins) while + // `nativeOnly`/`serverOnly`/`fileOnly` are each supplied by a single channel and all survive. + const pick = pickManagedSettings( + { 'shared': 'native', 'nativeOnly': 'n' }, + { 'shared': 'server', 'serverOnly': 's' }, + { 'shared': 'file', 'fileOnly': 'f' }, + ); + assert.deepStrictEqual(pick.values, { 'shared': 'native', 'nativeOnly': 'n', 'serverOnly': 's', 'fileOnly': 'f' }); + assert.deepStrictEqual(pick.activeSources, ['nativeMdm', 'server', 'file']); + assert.deepStrictEqual(pick.resolutions.get('shared'), { + value: 'native', + source: 'nativeMdm', + contributions: [ + { channel: 'nativeMdm', value: 'native' }, + { channel: 'server', value: 'server' }, + { channel: 'file', value: 'file' }, + ], + }); + }); + + test('with native absent, the mid-tier server wins a contested key over file', () => { + const pick = pickManagedSettings(undefined, { 'k': 'server' }, { 'k': 'file' }); + assert.deepStrictEqual(pick.resolutions.get('k'), { + value: 'server', + source: 'server', + contributions: [ + { channel: 'server', value: 'server' }, + { channel: 'file', value: 'file' }, + ], + }); + assert.deepStrictEqual(pick.activeSources, ['server']); + }); - test('selectManagedSettings: server wins over native MDM, never merged', () => { - const selection = selectManagedSettings( - { 'permissions.x': 'server' }, - { 'permissions.y': 'native' }, + test('falsy-but-present values are real contributions and win over a lower channel', () => { + // `false`, `0` and `''` must not be mistaken for "unset" — a higher channel that sets them + // still locks the key against a lower channel's value. + const pick = pickManagedSettings( + { 'flag': false, 'count': 0, 'name': '' }, + undefined, + { 'flag': true, 'count': 99, 'name': 'lower' }, ); - assert.deepStrictEqual(selection, { source: 'server', values: { 'permissions.x': 'server' } }); + assert.deepStrictEqual(pick.values, { 'flag': false, 'count': 0, 'name': '' }); + assert.deepStrictEqual(pick.activeSources, ['nativeMdm']); }); - test('selectManagedSettings: falls through to native MDM when server is absent or empty', () => { - const fromUndefined = selectManagedSettings(undefined, { 'permissions.y': 'native' }); - const fromEmptyObject = selectManagedSettings({}, { 'permissions.y': 'native' }); - assert.deepStrictEqual(fromUndefined, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); - assert.deepStrictEqual(fromEmptyObject, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); + test('an explicit `undefined` hole in a higher channel falls through to a lower channel', () => { + // A key present-but-undefined is skipped, so a lower channel can supply it. + const pick = pickManagedSettings( + { 'a': undefined as unknown as string, 'b': 'native' }, + { 'a': 'server' }, + undefined, + ); + assert.deepStrictEqual(pick.values, { 'a': 'server', 'b': 'native' }); + assert.strictEqual(pick.resolutions.get('a')!.source, 'server'); + }); + + test('the merged bag is a fresh object, never an alias of an input channel bag', () => { + // AccountPolicyService projects `pick.values` directly, relying on it not aliasing/mutating a + // channel's bag. + const native = { 'a': 'native' }; + const pick = pickManagedSettings(native, undefined, undefined); + assert.notStrictEqual(pick.values, native); + assert.deepStrictEqual(pick.values, { 'a': 'native' }); + }); + + test('empty/absent channels contribute nothing and activeSources skips a non-contributing middle channel', () => { + assert.deepStrictEqual( + { + partial: pickManagedSettings({}, { 'b': 'server' }, undefined), + // native + file contribute, server does not — activeSources must skip the gap. + gap: pickManagedSettings({ 'x': 'n' }, undefined, { 'y': 'f' }).activeSources, + allUndefined: pickManagedSettings(undefined, undefined, undefined), + allEmpty: pickManagedSettings({}, {}, {}), + }, + { + partial: { values: { 'b': 'server' }, resolutions: new Map([['b', { value: 'server', source: 'server', contributions: [{ channel: 'server', value: 'server' }] }]]), activeSources: ['server'] }, + gap: ['nativeMdm', 'file'], + allUndefined: { values: {}, resolutions: new Map(), activeSources: [] }, + allEmpty: { values: {}, resolutions: new Map(), activeSources: [] }, + }, + ); }); - test('selectManagedSettings: reports `none` with no values when every channel is empty', () => { - assert.deepStrictEqual(selectManagedSettings(undefined, undefined), { source: 'none', values: undefined }); - assert.deepStrictEqual(selectManagedSettings({}, {}), { source: 'none', values: undefined }); + test('a malicious `__proto__` key does not pollute any prototype chain', () => { + // Simulates a JSON-parsed bag carrying an own `__proto__` key with an object value (the + // classic prototype-pollution vector). Merging it must neither pollute Object.prototype nor + // corrupt the returned bag's own prototype. + const malicious = JSON.parse('{ "__proto__": { "polluted": true } }') as Record; + const pick = pickManagedSettings(malicious, undefined, undefined); + assert.strictEqual(({} as Record).polluted, undefined); + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'polluted'), false); + assert.strictEqual(Object.getPrototypeOf(pick.values), Object.prototype); }); }); diff --git a/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts new file mode 100644 index 0000000000000..45a9c0ff3ddab --- /dev/null +++ b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts @@ -0,0 +1,301 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { VSBuffer } from '../../../../base/common/buffer.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ManagedSettingsData } from '../../../../base/common/policy.js'; +import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, normalizeManagedSettings } from '../../common/copilotManagedSettings.js'; +import { FileManagedSettingsService } from '../../common/fileManagedSettingsService.js'; +import { FileManagedSettingsChannelClient } from '../../common/fileManagedSettingsIpc.js'; + +suite('normalizeManagedSettings', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('flattens scalar leaves to dot-paths', () => { + const result = normalizeManagedSettings({ + permissions: { + disableBypassPermissionsMode: 'disable' + } + }); + assert.deepStrictEqual(result, { + 'permissions.disableBypassPermissionsMode': 'disable' + }); + }); + + test('JSON-stringifies structured keys (enabledPlugins)', () => { + const plugins = { 'plugin@marketplace': false }; + const result = normalizeManagedSettings({ + [COPILOT_ENABLED_PLUGINS_KEY]: plugins + }); + assert.deepStrictEqual(result, { + [COPILOT_ENABLED_PLUGINS_KEY]: JSON.stringify(plugins) + }); + }); + + test('normalizes extraKnownMarketplaces from schema format to config dict', () => { + const result = normalizeManagedSettings({ + [COPILOT_EXTRA_MARKETPLACES_KEY]: { + 'a': { source: { source: 'github', repo: 'github/agent-skills' } }, + 'b': { source: { source: 'git', url: 'https://example.com/repo.git', ref: 'v1' } }, + } + }); + assert.deepStrictEqual(result, { + [COPILOT_EXTRA_MARKETPLACES_KEY]: '{"a":"github/agent-skills","b":"https://example.com/repo.git#v1"}', + }); + }); + + test('drops malformed marketplace entries with warning', () => { + const warnings: string[] = []; + const result = normalizeManagedSettings({ + [COPILOT_EXTRA_MARKETPLACES_KEY]: { + 'good': { source: { source: 'github', repo: 'a/b' } }, + 'bad': {} as Record, + } + }, msg => warnings.push(msg)); + assert.deepStrictEqual(result, { + [COPILOT_EXTRA_MARKETPLACES_KEY]: '{"good":"a/b"}', + }); + assert.strictEqual(warnings.length, 1); + }); + + test('handles mixed scalar and structured keys', () => { + const result = normalizeManagedSettings({ + permissions: { disableBypassPermissionsMode: 'disable' }, + strictKnownMarketplaces: ['github/foo'], + [COPILOT_ENABLED_PLUGINS_KEY]: { 'plugin': true }, + }); + assert.deepStrictEqual(result, { + 'permissions.disableBypassPermissionsMode': 'disable', + 'strictKnownMarketplaces': '["github/foo"]', + [COPILOT_ENABLED_PLUGINS_KEY]: '{"plugin":true}', + }); + }); + + test('handles empty object', () => { + assert.deepStrictEqual(normalizeManagedSettings({}), {}); + }); + + test('drops a structured key whose value is not an object', () => { + const result = normalizeManagedSettings({ + [COPILOT_ENABLED_PLUGINS_KEY]: 'already-a-string' + }); + assert.deepStrictEqual(result, {}); + }); +}); + +suite('FileManagedSettingsService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const managedSettingsFile = URI.file('managed-settings.json').with({ scheme: 'vscode-tests' }); + + test('reads managed-settings.json on startup', () => runWithFakedTimers({}, async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const inMemoryProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('vscode-tests', inMemoryProvider)); + + await fileService.writeFile(managedSettingsFile, VSBuffer.fromString(JSON.stringify({ + permissions: { disableBypassPermissionsMode: 'disable' }, + strictKnownMarketplaces: ['github/foo'] + }))); + + const service = disposables.add(new FileManagedSettingsService(managedSettingsFile, fileService, logService)); + + // Wait for the async refresh to complete + await new Promise(resolve => { + if (Object.keys(service.managedSettings).length > 0) { + resolve(); + } else { + const listener = disposables.add(service.onDidChangeManagedSettings(() => { + listener.dispose(); + resolve(); + })); + } + }); + + assert.deepStrictEqual(service.managedSettings, { + 'permissions.disableBypassPermissionsMode': 'disable', + 'strictKnownMarketplaces': '["github/foo"]' + }); + })); + + test('returns empty object when file does not exist', () => runWithFakedTimers({}, async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const inMemoryProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('vscode-tests', inMemoryProvider)); + + const service = disposables.add(new FileManagedSettingsService(managedSettingsFile, fileService, logService)); + + // Give the async refresh a chance to run + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.deepStrictEqual(service.managedSettings, {}); + })); + + test('fires event when file changes', () => runWithFakedTimers({}, async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const inMemoryProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('vscode-tests', inMemoryProvider)); + + await fileService.writeFile(managedSettingsFile, VSBuffer.fromString(JSON.stringify({ + permissions: { disableBypassPermissionsMode: 'disable' } + }))); + + const service = disposables.add(new FileManagedSettingsService(managedSettingsFile, fileService, logService)); + + // Wait for initial read + await new Promise(resolve => { + if (Object.keys(service.managedSettings).length > 0) { + resolve(); + } else { + const listener = disposables.add(service.onDidChangeManagedSettings(() => { + listener.dispose(); + resolve(); + })); + } + }); + + // Update the file + const changePromise = new Promise(resolve => { + const listener = disposables.add(service.onDidChangeManagedSettings(() => { + listener.dispose(); + resolve(); + })); + }); + + await fileService.writeFile(managedSettingsFile, VSBuffer.fromString(JSON.stringify({ + strictKnownMarketplaces: ['github/foo'] + }))); + + await changePromise; + + assert.deepStrictEqual(service.managedSettings, { + 'strictKnownMarketplaces': '["github/foo"]' + }); + })); + + test('returns empty object when the file is malformed JSON', () => runWithFakedTimers({}, async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const inMemoryProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('vscode-tests', inMemoryProvider)); + + await fileService.writeFile(managedSettingsFile, VSBuffer.fromString('{ not: valid json')); + + const service = disposables.add(new FileManagedSettingsService(managedSettingsFile, fileService, logService)); + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.deepStrictEqual(service.managedSettings, {}); + })); + + test('returns empty object when the file is not a JSON object', () => runWithFakedTimers({}, async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const inMemoryProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('vscode-tests', inMemoryProvider)); + + await fileService.writeFile(managedSettingsFile, VSBuffer.fromString(JSON.stringify(['not', 'an', 'object']))); + + const service = disposables.add(new FileManagedSettingsService(managedSettingsFile, fileService, logService)); + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.deepStrictEqual(service.managedSettings, {}); + })); + + test('clears managed settings and fires when the file is deleted', () => runWithFakedTimers({}, async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const inMemoryProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('vscode-tests', inMemoryProvider)); + + await fileService.writeFile(managedSettingsFile, VSBuffer.fromString(JSON.stringify({ + permissions: { disableBypassPermissionsMode: 'disable' } + }))); + + const service = disposables.add(new FileManagedSettingsService(managedSettingsFile, fileService, logService)); + + // Wait for initial read + await new Promise(resolve => { + if (Object.keys(service.managedSettings).length > 0) { + resolve(); + } else { + const listener = disposables.add(service.onDidChangeManagedSettings(() => { + listener.dispose(); + resolve(); + })); + } + }); + + const changePromise = new Promise(resolve => { + const listener = disposables.add(service.onDidChangeManagedSettings(() => { + listener.dispose(); + resolve(); + })); + }); + + await fileService.del(managedSettingsFile); + + await changePromise; + + assert.deepStrictEqual(service.managedSettings, {}); + })); +}); + +suite('FileManagedSettingsChannelClient', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps newer event state when the initial snapshot resolves later', async () => { + const channel = disposables.add(new DeferredManagedSettingsChannel()); + const client = disposables.add(new FileManagedSettingsChannelClient(channel)); + + // A change event arrives before the initial getManagedSettings call resolves; the later, + // stale snapshot must not clobber the newer event-delivered state. + channel.fire({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' }); + channel.resolveInitialSnapshot({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'enable' }); + await channel.initialSnapshot; + + assert.deepStrictEqual(client.managedSettings, { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' }); + }); +}); + +class DeferredManagedSettingsChannel extends Disposable implements IChannel { + private readonly _onDidChangeManagedSettings = this._register(new Emitter()); + private resolveInitialSnapshotPromise!: (managedSettings: ManagedSettingsData) => void; + readonly initialSnapshot = new Promise(resolve => this.resolveInitialSnapshotPromise = resolve); + + call(command: string): Promise { + switch (command) { + case 'getManagedSettings': return this.initialSnapshot as Promise; + } + + throw new Error(`Call not found: ${command}`); + } + + listen(event: string): Event { + assert.strictEqual(event, 'onDidChangeManagedSettings'); + return this._onDidChangeManagedSettings.event as Event; + } + + fire(managedSettings: ManagedSettingsData): void { + this._onDidChangeManagedSettings.fire(managedSettings); + } + + resolveInitialSnapshot(managedSettings: ManagedSettingsData): void { + this.resolveInitialSnapshotPromise(managedSettings); + } +} diff --git a/src/vs/platform/policy/test/node/copilotManagedSettingsService.test.ts b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts similarity index 82% rename from src/vs/platform/policy/test/node/copilotManagedSettingsService.test.ts rename to src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts index 9735a461447fc..fd946c8d8ffbf 100644 --- a/src/vs/platform/policy/test/node/copilotManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts @@ -11,25 +11,25 @@ import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY } from '../../common/copilotManagedSettings.js'; -import { CopilotManagedSettingsChannelClient } from '../../common/copilotManagedSettingsIpc.js'; +import { NativeManagedSettingsChannelClient } from '../../common/nativeManagedSettingsIpc.js'; import { PolicyValue } from '../../common/policy.js'; -import { CopilotManagedSettingsService, CopilotPolicyWatcherFactory } from '../../node/copilotManagedSettingsService.js'; +import { NativeManagedSettingsService, NativePolicyWatcherFactory } from '../../node/nativeManagedSettingsService.js'; -suite('CopilotManagedSettingsService', () => { +suite('NativeManagedSettingsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const policyName = 'ChatToolsAutoApprove'; test('watches managed-settings keys from policy definitions and exposes raw managed settings', async () => { let onDidChange: ((update: Record) => void) | undefined; - const watcherFactory: CopilotPolicyWatcherFactory = (_productName, policies, callback) => { + const watcherFactory: NativePolicyWatcherFactory = (_productName, policies, callback) => { assert.deepStrictEqual(policies, { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: { type: 'string' } }); onDidChange = callback; callback({}); return Disposable.None; }; - const service = disposables.add(new CopilotManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); + const service = disposables.add(new NativeManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); await service.updatePolicyDefinitions({ [policyName]: { type: 'boolean', @@ -48,12 +48,12 @@ suite('CopilotManagedSettingsService', () => { test('does not create the watcher until a managed-settings policy definition is registered', async () => { let watcherCreated = false; - const watcherFactory: CopilotPolicyWatcherFactory = () => { + const watcherFactory: NativePolicyWatcherFactory = () => { watcherCreated = true; return Disposable.None; }; - const service = disposables.add(new CopilotManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); + const service = disposables.add(new NativeManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); await service.updatePolicyDefinitions({ OtherPolicy: { type: 'boolean' } }); assert.strictEqual(watcherCreated, false); @@ -62,13 +62,13 @@ suite('CopilotManagedSettingsService', () => { test('clears stale watcher values when managed-settings definitions are removed', async () => { let onDidChange: ((update: Record) => void) | undefined; let disposeCount = 0; - const watcherFactory: CopilotPolicyWatcherFactory = (_productName, _policies, callback) => { + const watcherFactory: NativePolicyWatcherFactory = (_productName, _policies, callback) => { onDidChange = callback; callback({}); return { dispose: () => disposeCount++ }; }; - const service = disposables.add(new CopilotManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); + const service = disposables.add(new NativeManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); await service.updatePolicyDefinitions({ [policyName]: { type: 'boolean', @@ -87,14 +87,14 @@ suite('CopilotManagedSettingsService', () => { test('keeps raw managed settings while definitions are unchanged', async () => { let onDidChange: ((update: Record) => void) | undefined; let watcherCreateCount = 0; - const watcherFactory: CopilotPolicyWatcherFactory = (_productName, _policies, callback) => { + const watcherFactory: NativePolicyWatcherFactory = (_productName, _policies, callback) => { watcherCreateCount++; onDidChange = callback; callback({}); return Disposable.None; }; - const service = disposables.add(new CopilotManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); + const service = disposables.add(new NativeManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); await service.updatePolicyDefinitions({ [policyName]: { type: 'boolean', @@ -126,13 +126,13 @@ suite('CopilotManagedSettingsService', () => { test('removes raw managed settings whose definitions are no longer watched', async () => { let onDidChange: ((update: Record) => void) | undefined; const otherManagedSettingKey = 'permissions.otherManagedSetting'; - const watcherFactory: CopilotPolicyWatcherFactory = (_productName, _policies, callback) => { + const watcherFactory: NativePolicyWatcherFactory = (_productName, _policies, callback) => { onDidChange = callback; callback({}); return Disposable.None; }; - const service = disposables.add(new CopilotManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); + const service = disposables.add(new NativeManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); await service.updatePolicyDefinitions({ [policyName]: { type: 'boolean', @@ -158,7 +158,7 @@ suite('CopilotManagedSettingsService', () => { test('channel client keeps newer event state when initial snapshot resolves later', async () => { const channel = disposables.add(new DeferredManagedSettingsChannel()); - const client = disposables.add(new CopilotManagedSettingsChannelClient(channel)); + const client = disposables.add(new NativeManagedSettingsChannelClient(channel)); channel.fire({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' }); channel.resolveInitialSnapshot({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'enable' }); @@ -169,7 +169,7 @@ suite('CopilotManagedSettingsService', () => { test('channel client updatePolicyDefinitions updates cache without firing change event', async () => { const channel = disposables.add(new DeferredManagedSettingsChannel()); - const client = disposables.add(new CopilotManagedSettingsChannelClient(channel)); + const client = disposables.add(new NativeManagedSettingsChannelClient(channel)); channel.resolveInitialSnapshot({}); await channel.initialSnapshot; diff --git a/src/vs/platform/request/test/node/requestService.test.ts b/src/vs/platform/request/test/node/requestService.test.ts index 50f7d72068ab9..dfab989d694ab 100644 --- a/src/vs/platform/request/test/node/requestService.test.ts +++ b/src/vs/platform/request/test/node/requestService.test.ts @@ -32,17 +32,42 @@ suite('Request Service', () => { test('Request cancellation during retry backoff', async () => { const cts = store.add(new CancellationTokenSource()); - const startTime = Date.now(); - setTimeout(() => cts.cancel(), 50); + let attemptCount = 0; + const mockRawRequest = (_opts: any, _callback: Function) => { + attemptCount++; + const mockReq: unknown = { + on: (event: string, handler: Function) => { + if (event === 'error') { + const err = new Error('Connection refused') as NodeJS.ErrnoException; + err.code = 'ECONNREFUSED'; + // Fail the first attempt with a transient error, then cancel while the + // retry backoff is pending so cancellation is observed during the backoff. + setTimeout(() => { + handler(err); + cts.cancel(); + }, 0); + } + }, + end: () => { }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; try { - await nodeRequest({ url: 'http://localhost:9999/nonexistent', callSite: 'requestService.test.cancellation' }, cts.token); + await nodeRequest({ + url: 'http://example.com', + type: 'GET', + getRawRequest: () => mockRawRequest as IRawRequestFunction, + callSite: 'requestService.test.cancellation' + }, cts.token); assert.fail('Request should have been cancelled'); } catch (err) { - const elapsed = Date.now() - startTime; - assert.ok(err instanceof CancellationError, 'Error should be CancellationError'); - assert.ok(elapsed < 200, `Request should be cancelled quickly, but took ${elapsed}ms`); + assert.ok(err instanceof CancellationError, 'Error should be a CancellationError'); } + + assert.strictEqual(attemptCount, 1, 'Request should be cancelled during backoff without further retries'); }); test('should retry GET requests on transient errors', async () => { diff --git a/src/vs/platform/sandbox/common/sandboxHelperService.ts b/src/vs/platform/sandbox/common/sandboxHelperService.ts index f00f8238ad92d..d0687b970e10e 100644 --- a/src/vs/platform/sandbox/common/sandboxHelperService.ts +++ b/src/vs/platform/sandbox/common/sandboxHelperService.ts @@ -59,7 +59,6 @@ export interface IWindowsMxcConfig { timeout?: number; }; processContainer?: { - name?: string; leastPrivilege?: boolean; capabilities?: string[]; ui?: { diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts index d48e472cec1fd..8a4f02b5210ce 100644 --- a/src/vs/platform/sandbox/common/settings.ts +++ b/src/vs/platform/sandbox/common/settings.ts @@ -9,6 +9,7 @@ export const enum AgentSandboxSettingId { AgentSandboxEnabled = 'chat.agent.sandbox.enabled', AgentSandboxWindowsEnabled = 'chat.agent.sandbox.enabledWindows', + AgentSandboxAllowNetwork = 'chat.agent.sandbox.allowNetwork', AgentSandboxAllowUnsandboxedCommands = 'chat.agent.sandbox.allowUnsandboxedCommands', AgentSandboxRetryWithAllowNetworkRequests = 'chat.agent.sandbox.retryWithAllowNetworkRequests', AgentSandboxAllowAutoApprove = 'chat.agent.sandbox.allowAutoApprove', @@ -27,3 +28,19 @@ export const enum AgentSandboxEnabledValue { On = 'on', AllowNetwork = 'allowNetwork', } + +export type AgentSandboxEnabledSettingValue = AgentSandboxEnabledValue | boolean; + +export function normalizeAgentSandboxEnabledValue(value: AgentSandboxEnabledSettingValue): AgentSandboxEnabledValue { + if (value === true) { + return AgentSandboxEnabledValue.On; + } + if (value === false) { + return AgentSandboxEnabledValue.Off; + } + return value; +} + +export function isAgentSandboxEnabledValue(value: AgentSandboxEnabledSettingValue | undefined): boolean { + return value !== undefined && normalizeAgentSandboxEnabledValue(value) !== AgentSandboxEnabledValue.Off; +} diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 8cd937622bc87..0356b8644d016 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -18,7 +18,7 @@ import { ILogService } from '../../log/common/log.js'; import { matchesDomainPattern, normalizeDomain } from '../../networkFilter/common/domainMatcher.js'; import { AgentNetworkDomainSettingId } from '../../networkFilter/common/settings.js'; import { ISandboxDependencyStatus, type IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from './sandboxHelperService.js'; -import { AgentSandboxEnabledValue, AgentSandboxSettingId } from './settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId, isAgentSandboxEnabledValue, normalizeAgentSandboxEnabledValue, type AgentSandboxEnabledSettingValue } from './settings.js'; import { IWindowsMxcTerminalSandboxRuntime } from './terminalSandboxMxcRuntime.js'; import { getTerminalSandboxReadAllowListForCommands } from './terminalSandboxReadAllowList.js'; import { getTerminalSandboxRuntimeConfigurationForCommands } from './terminalSandboxRuntimeConfigurationPerOperation.js'; @@ -583,10 +583,11 @@ export class TerminalSandboxEngine extends Disposable { } await this.getOS(); if (this._os === OperatingSystem.Windows) { - return this._getSandboxConfiguredWindowsEnabledValue() === AgentSandboxEnabledValue.AllowNetwork; + const value = this._getSandboxConfiguredWindowsEnabledValue(); + return isAgentSandboxEnabledValue(value); } const value = this._getSandboxConfiguredEnabledValue(); - return value === AgentSandboxEnabledValue.On || value === AgentSandboxEnabledValue.AllowNetwork; + return isAgentSandboxEnabledValue(value); } private async _resolveRuntimeInfo(): Promise { @@ -665,7 +666,6 @@ export class TerminalSandboxEngine extends Disposable { tempDir: this._tempDir, schemaVersion: windowsSchemaVersion, allowNetwork, - networkDomains: this.getResolvedNetworkDomains(), allowReadPaths, allowWritePaths, denyReadPaths, @@ -681,7 +681,7 @@ export class TerminalSandboxEngine extends Disposable { }; if (this._os !== OperatingSystem.Windows) { const sandboxRuntimeSettings = sandboxSettings as Record; - this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, allowNetwork ? this._withoutNetworkRuntimeSetting(runtimeSetting) : runtimeSetting); + this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, runtimeSetting); this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, commandRuntimeSetting); if (this._os === OperatingSystem.Macintosh) { sandboxRuntimeSettings.allowPty ??= true; @@ -843,12 +843,6 @@ export class TerminalSandboxEngine extends Disposable { return paths.filter((path): path is string => typeof path === 'string'); } - private _withoutNetworkRuntimeSetting(runtimeSetting: Record): Record { - const sanitizedRuntimeSetting = { ...runtimeSetting }; - delete sanitizedRuntimeSetting.network; - return sanitizedRuntimeSetting; - } - private _mergeAdditionalSandboxConfigProperties(target: Record, additional: Record): void { for (const [key, value] of Object.entries(additional)) { if (!Object.prototype.hasOwnProperty.call(target, key)) { @@ -928,7 +922,19 @@ export class TerminalSandboxEngine extends Disposable { private async _resolveFileSystemPaths(paths: string[] | undefined): Promise { const resolvedPaths = await Promise.all((paths ?? []).map(path => this._resolveFileSystemPath(path))); - return [...new Set(resolvedPaths.flat())]; + const seenPaths = new Set(); + return resolvedPaths.flat().filter(path => { + const comparisonKey = this._getFileSystemPathComparisonKey(path); + if (seenPaths.has(comparisonKey)) { + return false; + } + seenPaths.add(comparisonKey); + return true; + }); + } + + private _getFileSystemPathComparisonKey(path: string): string { + return this._os === OperatingSystem.Windows ? path.replace(/\//g, '\\').toLowerCase() : path; } private async _resolveFileSystemPath(path: string): Promise { @@ -1030,14 +1036,21 @@ export class TerminalSandboxEngine extends Disposable { } private _getSandboxConfiguredEnabledValue(): AgentSandboxEnabledValue { - return this._getSettingValue(AgentSandboxSettingId.AgentSandboxEnabled) ?? AgentSandboxEnabledValue.Off; + return this._normalizeSandboxEnabledValue(this._getSettingValue(AgentSandboxSettingId.AgentSandboxEnabled)); } private _getSandboxConfiguredWindowsEnabledValue(): AgentSandboxEnabledValue { - return this._getSettingValue(AgentSandboxSettingId.AgentSandboxWindowsEnabled) ?? AgentSandboxEnabledValue.Off; + return this._normalizeSandboxEnabledValue(this._getSettingValue(AgentSandboxSettingId.AgentSandboxWindowsEnabled)); + } + + private _normalizeSandboxEnabledValue(value: AgentSandboxEnabledSettingValue | undefined): AgentSandboxEnabledValue { + return value === undefined ? AgentSandboxEnabledValue.Off : normalizeAgentSandboxEnabledValue(value); } private _isSandboxAllowNetworkConfigured(): boolean { + if (this._getSettingValue(AgentSandboxSettingId.AgentSandboxAllowNetwork) === true) { + return true; + } if (this._os === OperatingSystem.Windows) { return this._getSandboxConfiguredWindowsEnabledValue() === AgentSandboxEnabledValue.AllowNetwork; } diff --git a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts index b0b07fa8ea314..d109c521aa1fc 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts @@ -7,7 +7,6 @@ import { win32 } from '../../../base/common/path.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IWindowsMxcConfig, IWindowsMxcPolicyContainment, IWindowsMxcSandboxPolicy } from './sandboxHelperService.js'; -import type { ITerminalSandboxResolvedNetworkDomains } from './terminalSandboxService.js'; export interface IWindowsMxcConfigOptions { command: string; @@ -16,7 +15,6 @@ export interface IWindowsMxcConfigOptions { tempDir: URI; schemaVersion?: string; allowNetwork: boolean; - networkDomains: ITerminalSandboxResolvedNetworkDomains; allowReadPaths: string[]; allowWritePaths: string[]; denyReadPaths: string[]; @@ -47,8 +45,7 @@ export interface IWindowsMxcTerminalSandboxRuntime { export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSandboxRuntime { declare readonly _serviceBrand: undefined; - private readonly _configVersion = '0.4.0-alpha'; - private readonly _containerName = 'vscode-terminal-sandbox'; + private readonly _configVersion = '0.6.0-alpha'; getExecutablePath(appRoot: string, arch: string | undefined): string { const binArch = arch === 'arm64' ? 'arm64' : 'x64'; @@ -71,17 +68,17 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand const shell = options.shell ? this._quoteWindowsCommandLineArgument(options.shell) : 'pwsh.exe'; - const commandLine = `${shell} -NoProfile -ExecutionPolicy Bypass -Command ${this._quoteWindowsCommandLineArgument(options.command)}`; + const commandLine = `${shell} -NoProfile -Command ${this._quoteWindowsCommandLineArgument(options.command)}`; const cwd = options.cwd ? this.toWindowsPath(options.cwd) : tempDirPath; const policy: IWindowsMxcSandboxPolicy = { version: options.schemaVersion ?? this._configVersion, timeoutMs: 0, filesystem: { - readwritePaths: [...new Set(options.allowWritePaths.map(path => this._normalizeWindowsPath(path)))], - readonlyPaths: [...new Set([tempDirPath, ...(options.shell && win32.isAbsolute(options.shell) ? [win32.dirname(options.shell)] : []), ...options.allowReadPaths].map(path => this._normalizeWindowsPath(path)))], - deniedPaths: [...new Set(options.denyReadPaths.map(path => this._normalizeWindowsPath(path)))], + readwritePaths: options.allowWritePaths.map(path => this._normalizeWindowsPath(path)), + readonlyPaths: [tempDirPath, ...(options.shell && win32.isAbsolute(options.shell) ? [win32.dirname(options.shell)] : []), ...options.allowReadPaths].map(path => this._normalizeWindowsPath(path)), + deniedPaths: options.denyReadPaths.map(path => this._normalizeWindowsPath(path)), }, - network: this._createNetworkPolicy(options.allowNetwork, options.networkDomains), + network: this._createNetworkPolicy(options.allowNetwork), ui: { allowWindows: true, clipboard: 'none', @@ -89,7 +86,7 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand }, }; - const config = await buildSandboxPayload(commandLine, policy, cwd, this._containerName); + const config = await buildSandboxPayload(commandLine, policy, cwd); if (!config?.process) { throw new Error('Unable to build Windows MXC sandbox payload'); } @@ -123,20 +120,10 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand return path.replace(/\//g, '\\'); } - private _createNetworkPolicy(allowNetwork: boolean, networkDomains: ITerminalSandboxResolvedNetworkDomains): NonNullable { - const allowedHosts = networkDomains.allowedDomains.length > 0 ? networkDomains.allowedDomains : undefined; - const blockedHosts = networkDomains.deniedDomains.length > 0 ? networkDomains.deniedDomains : undefined; - const allowOutbound = allowNetwork || !!allowedHosts?.length; - const network: NonNullable = { - allowOutbound, - }; - if (allowOutbound && allowedHosts) { - network.allowedHosts = allowedHosts; - } - if (allowOutbound && blockedHosts) { - network.blockedHosts = blockedHosts; - } - return network; + private _createNetworkPolicy(allowNetwork: boolean): NonNullable { + // MXC does not support per-host network policies on Windows. Rely on the + // overall allow/block policy instead of emitting unsupported host lists. + return { allowOutbound: allowNetwork }; } private _quotePowerShellArgument(value: string): string { diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index db01f21ac685a..3e1600aa9ae6e 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -70,9 +70,7 @@ suite('TerminalSandboxEngine', () => { const network = { defaultPolicy: policy.network?.allowOutbound ? 'allow' : 'block' as 'allow' | 'block', ...(policy.network?.allowLocalNetwork !== undefined ? { allowLocalNetwork: policy.network.allowLocalNetwork } : {}), - ...(policy.network?.allowedHosts ? { allowedHosts: policy.network.allowedHosts } : {}), - ...(policy.network?.blockedHosts ? { blockedHosts: policy.network.blockedHosts } : {}), - ...(policy.network ? { enforcementMode: policy.network.allowedHosts?.length || policy.network.blockedHosts?.length ? 'both' as const : 'capabilities' as const } : {}), + ...(policy.network ? { enforcementMode: 'capabilities' as const } : {}), }; return { version: policy.version, @@ -88,7 +86,6 @@ suite('TerminalSandboxEngine', () => { timeout: policy.timeoutMs ?? 0, }, processContainer: { - name: containerName, leastPrivilege: false, capabilities: policy.network?.allowOutbound ? ['internetClient'] : [], ui: { @@ -167,7 +164,8 @@ suite('TerminalSandboxEngine', () => { } function enableWindowsSandbox(): void { - setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxEnabledValue.AllowNetwork); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxEnabledValue.On); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAllowNetwork, true); } setup(() => { @@ -256,6 +254,28 @@ suite('TerminalSandboxEngine', () => { strictEqual(config.allowPty, false); }); + test('sandbox config preserves advanced runtime network settings when allowNetwork is enabled', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAllowNetwork, true); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAdvancedRuntime, { + network: { + allowAllUnixSockets: true, + enabled: true, + }, + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); + + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + deepStrictEqual(config.network, { + allowedDomains: [], + deniedDomains: [], + enabled: false, + allowAllUnixSockets: true, + }); + }); + test('requestAllowNetwork keeps the command sandboxed and refreshes its network config', async () => { setSandboxSetting(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); @@ -504,7 +524,7 @@ suite('TerminalSandboxEngine', () => { strictEqual(await engine.getSandboxConfigPath(), undefined); }); - test('isEnabled returns true on Windows when Windows sandbox setting allows network even if global sandboxing is off', async () => { + test('isEnabled returns true on Windows when Windows sandbox setting is enabled even if global sandboxing is off', async () => { setSandboxSetting(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); enableWindowsSandbox(); const host = createWindowsHost(); @@ -514,6 +534,16 @@ suite('TerminalSandboxEngine', () => { strictEqual(await engine.isSandboxAllowNetworkEnabled(), true); }); + test('enabledWindows on value does not enable allowNetwork on Windows', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxEnabledValue.On); + const host = createWindowsHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + strictEqual(await engine.isEnabled(), true); + strictEqual(await engine.isSandboxAllowNetworkEnabled(), false); + }); + test('wrapCommand uses MXC executable and writes MXC config on Windows', async () => { enableWindowsSandbox(); const host = createWindowsHost(); @@ -527,10 +557,9 @@ suite('TerminalSandboxEngine', () => { strictEqual(wrapped.isSandboxWrapped, true); ok(wrapped.command.startsWith(`& 'C:\\app\\node_modules\\@microsoft\\mxc-sdk\\bin\\x64\\wxc-exec.exe'`), `Expected MXC executable. Actual: ${wrapped.command}`); ok(wrapped.command.includes(` '${configPath}'`), `Expected wrapped command to pass the MXC config path. Actual: ${wrapped.command}`); - strictEqual(config.version, '0.4.0-alpha'); + strictEqual(config.version, '0.6.0-alpha'); strictEqual(config.containment, 'process'); - strictEqual(config.processContainer.name, 'vscode-terminal-sandbox'); - strictEqual(config.process.commandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo hello"'); + strictEqual(config.process.commandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -Command "echo hello"'); strictEqual(normalizeWindowsPathForAssert(config.process.cwd), 'c:/workspace'); strictEqual(config.ui.disable, false); ok(config.process.env.includes('SystemRoot=C:\\Windows'), 'SystemRoot should be injected into the MXC process env'); @@ -579,6 +608,54 @@ suite('TerminalSandboxEngine', () => { ok(!config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user'), 'User home should not be denied by default on Windows'); }); + test('deduplicates Windows filesystem paths regardless of case or separator', async () => { + enableWindowsSandbox(); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { + allowWrite: ['C:/configured/write'], + allowRead: ['C:\\configured\\read'], + denyRead: ['C:/configured/secret', 'c:\\configured\\secret'], + }); + const host = createWindowsHost({ + getWindowsMxcFilesystemPolicy: () => Promise.resolve({ + readwritePaths: ['c:\\configured\\write'], + readonlyPaths: ['c:/configured/read'], + }), + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + await engine.wrapCommand('echo hello', false, 'pwsh'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + const matchingPaths = (paths: string[], expectedPath: string) => paths.filter(path => normalizeWindowsPathForAssert(path) === expectedPath); + + deepStrictEqual({ + readwrite: matchingPaths(config.filesystem.readwritePaths, 'c:/configured/write'), + readonly: matchingPaths(config.filesystem.readonlyPaths, 'c:/configured/read'), + denied: matchingPaths(config.filesystem.deniedPaths, 'c:/configured/secret'), + }, { + readwrite: ['C:\\configured\\write'], + readonly: ['C:\\configured\\read'], + denied: ['C:\\configured\\secret'], + }); + }); + + test('deduplicates resolved Windows paths regardless of case or separator', async () => { + enableWindowsSandbox(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createWindowsHost())); + await engine.getOS(); + const resolveFileSystemPaths = (engine as unknown as { _resolveFileSystemPaths(paths: string[]): Promise })._resolveFileSystemPaths.bind(engine); + + deepStrictEqual(await resolveFileSystemPaths([ + 'C:/configured/path', + 'c:\\configured\\path', + 'C:\\configured\\other-path', + ]), [ + 'C:/configured/path', + 'C:\\configured\\other-path', + ]); + }); + test('wrapCommand applies configured Windows MXC schema version', async () => { enableWindowsSandbox(); setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsSchemaVersion, '0.5.0-alpha'); @@ -651,18 +728,33 @@ suite('TerminalSandboxEngine', () => { let configPath = await engine.getSandboxConfigPath(); ok(configPath, 'Config path should be defined'); const firstCommandLine = JSON.parse(createdFiles.get(configPath)!).process.commandLine; - strictEqual(firstCommandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo first"'); + strictEqual(firstCommandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -Command "echo first"'); await engine.wrapCommand('echo second', false, 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'); configPath = await engine.getSandboxConfigPath(); ok(configPath, 'Config path should be defined'); const secondCommandLine = JSON.parse(createdFiles.get(configPath)!).process.commandLine; - strictEqual(secondCommandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -Command "echo second"'); + strictEqual(secondCommandLine, '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -Command "echo second"'); }); test('allowNetwork maps to MXC allow network config on Windows', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxEnabledValue.On); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAllowNetwork, true); + const host = createWindowsHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + await engine.wrapCommand('curl https://example.com', false, 'pwsh'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + deepStrictEqual(config.network, { defaultPolicy: 'allow', enforcementMode: 'capabilities' }); + }); + + test('Windows MXC config ignores unsupported network host lists', async () => { enableWindowsSandbox(); - setSandboxSetting(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); + setSandboxSetting(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); + setSandboxSetting(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['blocked.example.com']); const host = createWindowsHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); diff --git a/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts b/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts index 33971f8b07283..fc5cb83fe088d 100644 --- a/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts +++ b/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts @@ -21,12 +21,14 @@ export type LanguageModelToolInvokedEvent = LanguageModelToolTelemetryData & { result: 'success' | 'error' | 'userCancelled'; prepareTimeMs?: number; invocationTimeMs?: number; + provider?: string; }; export type LanguageModelToolInvokedClassification = LanguageModelToolTelemetryClassification & { result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether invoking the LanguageModelTool resulted in an error.' }; prepareTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in prepareToolInvocation method in milliseconds.' }; invocationTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in tool invoke method in milliseconds.' }; + provider?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host provider that invoked the tool (e.g. copilotcli, claude, codex), if applicable.' }; owner: 'roblourens'; comment: 'Provides insight into the usage of language model tools.'; }; diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 2c63e9e679cfb..f00f77df9862c 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -21,7 +21,7 @@ configurationRegistry.registerConfiguration({ enum: ['none', 'manual', 'start', 'default'], default: 'default', scope: ConfigurationScope.APPLICATION, - description: localize('updateMode', "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service."), + description: localize('updateMode', "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service."), tags: ['usesOnlineServices'], enumDescriptions: [ localize('none', "Disable updates."), @@ -34,7 +34,7 @@ configurationRegistry.registerConfiguration({ category: PolicyCategory.Update, minimumVersion: '1.67', localization: { - description: { key: 'updateMode', value: localize('updateMode', "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service."), }, + description: { key: 'updateMode', value: localize('updateMode', "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service."), }, enumDescriptions: [ { key: 'none', @@ -60,7 +60,7 @@ configurationRegistry.registerConfiguration({ type: 'string', default: 'default', scope: ConfigurationScope.APPLICATION, - description: localize('updateMode', "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service."), + description: localize('updateMode', "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service."), deprecationMessage: localize('deprecated', "This setting is deprecated, please use '{0}' instead.", 'update.mode') }, 'update.enableWindowsBackgroundUpdates': { @@ -82,6 +82,7 @@ configurationRegistry.registerConfiguration({ 'update.showPostInstallInfo': { type: 'boolean', default: false, + experiment: { mode: 'auto' }, scope: ConfigurationScope.APPLICATION, description: localize('showPostInstallInfo', "Show a post-install update tooltip in the title bar instead of opening the release notes editor."), tags: ['usesOnlineServices'] diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 92ae7b96f06ea..1afbb0d3985c2 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -34,6 +34,7 @@ export interface IUpdate { * Ready: Code will be updated as soon as it restarts (win32, darwin). * Downloaded: There is an update ready to be installed in the background (win32). * Overwriting: A newer update is being downloaded to replace the pending update (darwin). + * Cancelling: Updates are being disabled at runtime; in-flight/pending work is being torn down before Disabled. */ export const enum StateType { @@ -47,6 +48,7 @@ export const enum StateType { Updating = 'updating', Ready = 'ready', Overwriting = 'overwriting', + Cancelling = 'cancelling', Restarting = 'restarting', } @@ -76,9 +78,10 @@ export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number; explicit: boolean }; export type Ready = { type: StateType.Ready; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Overwriting = { type: StateType.Overwriting; update: IUpdate; explicit: boolean }; +export type Cancelling = { type: StateType.Cancelling }; export type Restarting = { type: StateType.Restarting; update: IUpdate }; -export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting | Restarting; +export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting | Cancelling | Restarting; export const State = { Uninitialized: upcast({ type: StateType.Uninitialized }), @@ -91,6 +94,7 @@ export const State = { Updating: (update: IUpdate, explicit: boolean, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, explicit, currentProgress, maxProgress }), Ready: (update: IUpdate, explicit: boolean, overwrite: boolean): Ready => ({ type: StateType.Ready, update, explicit, overwrite }), Overwriting: (update: IUpdate, explicit: boolean): Overwriting => ({ type: StateType.Overwriting, update, explicit }), + Cancelling: upcast({ type: StateType.Cancelling }), Restarting: (update: IUpdate): Restarting => ({ type: StateType.Restarting, update }), }; diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 8cf5dceb0635d..09971caf6b3e3 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as os from 'os'; -import { IntervalTimer, timeout } from '../../../base/common/async.js'; +import { CancelablePromise, IntervalTimer, Throttler, timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { isMacintosh, isWindows } from '../../../base/common/platform.js'; import { getWindowsReleaseSync } from '../../../base/node/windowsVersion.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; @@ -75,7 +77,26 @@ export type UpdateErrorClassification = { comment: 'This is used to know how often VS Code updates have failed.'; }; -export abstract class AbstractUpdateService implements IUpdateService { +/** + * States representing in-flight or pending update work that takes time to tear down when updates + * are disabled at runtime. Used to decide whether to surface a transient `Cancelling` state. + */ +function isCancellableState(type: StateType): boolean { + switch (type) { + case StateType.CheckingForUpdates: + case StateType.AvailableForDownload: + case StateType.Downloading: + case StateType.Downloaded: + case StateType.Updating: + case StateType.Ready: + case StateType.Overwriting: + return true; + default: + return false; + } +} + +export abstract class AbstractUpdateService extends Disposable implements IUpdateService { declare readonly _serviceBrand: undefined; @@ -84,10 +105,19 @@ export abstract class AbstractUpdateService implements IUpdateService { private _state: State = State.Uninitialized; protected _overwrite: boolean = false; private _hasCheckedForOverwriteOnQuit: boolean = false; - private readonly overwriteUpdatesCheckInterval = new IntervalTimer(); + private readonly overwriteUpdatesCheckInterval = this._register(new IntervalTimer()); private _internalOrg: string | undefined = undefined; - private readonly _onStateChange = new Emitter(); + /** Disabled for a non-reversible reason (e.g. not built, missing config); ignores `update.mode` changes. */ + private _disabledPermanently: boolean = false; + /** Whether one-time platform init (e.g. background update GC, pending update resume) has run. */ + private _postInitialized: boolean = false; + /** Cancels the pending scheduled update check, if any. */ + private readonly scheduler = this._register(new MutableDisposable()); + /** Serializes reconfiguration so overlapping `update.mode` changes settle on the latest value. */ + private readonly reconfigureThrottler = this._register(new Throttler()); + + private readonly _onStateChange = this._register(new Emitter()); readonly onStateChange: Event = this._onStateChange.event; get state(): State { @@ -131,6 +161,8 @@ export abstract class AbstractUpdateService implements IUpdateService { @IMeteredConnectionService protected readonly meteredConnectionService: IMeteredConnectionService, protected readonly supportsUpdateOverwrite: boolean, ) { + super(); + lifecycleMainService.when(LifecycleMainPhase.AfterWindowOpen) .finally(() => this.initialize()); } @@ -142,51 +174,125 @@ export abstract class AbstractUpdateService implements IUpdateService { */ protected async initialize(): Promise { if (!this.environmentMainService.isBuilt) { - this.setState(State.Disabled(DisablementReason.NotBuilt)); + this.setDisabledPermanently(DisablementReason.NotBuilt); return; // updates are never enabled when running out of sources } await this.trackVersionChange(); if (this.environmentMainService.disableUpdates) { - this.setState(State.Disabled(DisablementReason.DisabledByEnvironment)); + this.setDisabledPermanently(DisablementReason.DisabledByEnvironment); this.logService.info('update#ctor - updates are disabled by the environment'); return; } if (!this.productService.updateUrl || !this.productService.commit) { - this.setState(State.Disabled(DisablementReason.MissingConfiguration)); + this.setDisabledPermanently(DisablementReason.MissingConfiguration); this.logService.info('update#ctor - updates are disabled as there is no update URL'); return; } + // React to runtime `update.mode`/policy changes so switching to/from `none` applies without a restart. + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('update.mode')) { + this.reconfigure().catch(err => this.logService.error('update#reconfigure - failed to apply update mode change', err)); + } + })); + + // Apply the currently configured update mode. + await this.reconfigure(); + } + + /** + * Evaluates the current `update.mode` setting (and its policy) and brings the service into the matching state. + * Runs on startup and on every change, enabling or disabling updates without a restart. + */ + private reconfigure(): Promise { + return this.reconfigureThrottler.queue(() => this.doReconfigure()); + } + + private async doReconfigure(): Promise { + if (this._disabledPermanently) { + return; + } + const updateMode = this.configurationService.getValue<'none' | 'manual' | 'start' | 'default'>('update.mode'); const updateModeInspection = this.configurationService.inspect<'none' | 'manual' | 'start' | 'default'>('update.mode'); const policyDisablesUpdates = updateModeInspection.policyValue !== undefined && !this.getProductQuality(updateModeInspection.policyValue); const quality = this.getProductQuality(updateMode); if (!quality) { - if (policyDisablesUpdates) { - this.setState(State.Disabled(DisablementReason.Policy)); - this.logService.info('update#ctor - updates are disabled by policy'); - } else { - this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); - this.logService.info('update#ctor - updates are disabled by user preference'); + const reason = policyDisablesUpdates ? DisablementReason.Policy : DisablementReason.ManuallyDisabled; + + // Skip if already disabled for this reason, so a repeated write or policy refresh is a no-op. + if (this._state.type === StateType.Disabled && this._state.reason === reason) { + return; } + + await this.disable(reason); return; } if (!this.buildUpdateFeedUrl(quality, this.productService.commit!)) { - this.setState(State.Disabled(DisablementReason.InvalidConfiguration)); + this.setDisabledPermanently(DisablementReason.InvalidConfiguration); this.logService.info('update#ctor - updates are disabled as the update URL is badly formed'); return; } this.quality = quality; - this.setState(State.Idle(this.getUpdateType())); + // Move to Idle so one-time platform init (which may resume a pending update) can act; it requires Idle. + if (this._state.type === StateType.Disabled || this._state.type === StateType.Uninitialized) { + this.setState(State.Idle(this.getUpdateType())); + } + + // One-time platform init, gated behind updates being enabled so a pending update is never resumed under `none`. + if (!this._postInitialized) { + this._postInitialized = true; + await this.postInitialize(); + } - await this.postInitialize(); + this.scheduleAccordingToMode(updateMode); + } + + /** + * Disables updates for a reversible reason (user preference or policy), cancelling the scheduled check loop + * and any in-flight or pending update before moving to Disabled. + */ + private async disable(reason: DisablementReason): Promise { + this.scheduler.clear(); + + // Show a transient Cancelling state only when there is in-flight or pending work to tear down. + if (isCancellableState(this._state.type)) { + this.setState(State.Cancelling); + } + + try { + await this.cancelUpdate(); + } catch (err) { + this.logService.warn('update#disable - failed to cancel pending update', err); + } + + this.quality = undefined; + + if (reason === DisablementReason.Policy) { + this.logService.info('update#disable - updates are disabled by policy'); + } else { + this.logService.info('update#disable - updates are disabled by user preference'); + } + + this.setState(State.Disabled(reason)); + } + + /** Disables updates for a non-reversible reason; subsequent `update.mode` changes are ignored. */ + private setDisabledPermanently(reason: DisablementReason): void { + this._disabledPermanently = true; + this.scheduler.clear(); + this.setState(State.Disabled(reason)); + } + + private scheduleAccordingToMode(updateMode: 'none' | 'manual' | 'start' | 'default'): void { + this.scheduler.clear(); if (updateMode === 'manual') { this.logService.info('update#ctor - manual checks only; automatic updates are disabled by user preference'); @@ -197,10 +303,10 @@ export abstract class AbstractUpdateService implements IUpdateService { this.logService.info('update#ctor - startup checks only; automatic updates are disabled by user preference'); // Check for updates only once after 30 seconds - setTimeout(() => this.checkForUpdates(false), 30 * 1000); + this.scheduleCheckForUpdates(30 * 1000, false); } else { // Start checking for updates after 30 seconds - this.scheduleCheckForUpdates(30 * 1000).then(undefined, err => this.logService.error(err)); + this.scheduleCheckForUpdates(30 * 1000, true); } } @@ -276,12 +382,22 @@ export abstract class AbstractUpdateService implements IUpdateService { return updateMode === 'none' ? undefined : this.productService.quality; } - private scheduleCheckForUpdates(delay = 60 * 60 * 1000): Promise { - return timeout(delay) + private scheduleCheckForUpdates(delay = 60 * 60 * 1000, repeat = true): void { + const promise: CancelablePromise = timeout(delay); + this.scheduler.value = toDisposable(() => promise.cancel()); + + promise .then(() => this.checkForUpdates(false)) .then(() => { - // Check again after 1 hour - return this.scheduleCheckForUpdates(60 * 60 * 1000); + if (repeat) { + // Check again after 1 hour + this.scheduleCheckForUpdates(60 * 60 * 1000, true); + } + }) + .catch(err => { + if (!isCancellationError(err)) { + this.logService.error(err); + } }); } @@ -434,8 +550,7 @@ export abstract class AbstractUpdateService implements IUpdateService { const context = await this.requestService.request({ url, headers, callSite: 'updateService.isLatestVersion' }, token); const statusCode = context.res.statusCode; this.logService.trace('update#isLatestVersion() - response', { statusCode }); - // The update server replies with 204 (No Content) when no - // update is available - that's all we want to know. + // The update server replies with 204 (No Content) when no update is available. return statusCode === 204; } catch (error) { @@ -478,6 +593,14 @@ export abstract class AbstractUpdateService implements IUpdateService { // noop } + /** + * Aborts in-flight or pending update work when updates are being disabled at runtime. The default cancels a + * pending update; platform services override this to also abort in-flight checks/downloads. + */ + protected async cancelUpdate(): Promise { + await this.cancelPendingUpdate(); + } + protected abstract buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined; protected abstract doCheckForUpdates(explicit: boolean, pendingCommit?: string): void; } diff --git a/src/vs/platform/update/electron-main/notAvailableUpdateDialog.ts b/src/vs/platform/update/electron-main/notAvailableUpdateDialog.ts index 0d722e3030c08..3ac6c58c1e31e 100644 --- a/src/vs/platform/update/electron-main/notAvailableUpdateDialog.ts +++ b/src/vs/platform/update/electron-main/notAvailableUpdateDialog.ts @@ -3,55 +3,40 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import electron from 'electron'; -import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { isMacintosh } from '../../../base/common/platform.js'; import { localize } from '../../../nls.js'; import { IDialogMainService } from '../../dialogs/electron-main/dialogMainService.js'; +import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IUpdateService, StateType } from '../common/update.js'; /** - * Shows a native "no updates available" dialog when an explicit update check - * finds nothing. Shown by whichever app currently owns focus, so it appears - * once across sibling apps (VS Code / Agents). On macOS the dialog is also - * shown when the app is active but has no focused window (e.g. "Check for - * Updates" invoked from the menu bar with all windows closed/minimized). + * Shows a native "no updates available" dialog after an explicit update check, but only on macOS with no open windows + * (e.g. "Check for Updates" from the menu bar/dock with all windows closed). Otherwise the workbench shows its own + * themed dialog from the last focused window (see `UpdateContribution`). */ export class NotAvailableUpdateDialog extends Disposable { - // macOS: tracks whether this app is currently the active app (NSApp is - // active) regardless of whether any `BrowserWindow` is focused. Apps start - // active when launched. - private macAppActive = isMacintosh; - constructor( updateService: IUpdateService, - private readonly dialogMainService: IDialogMainService, + dialogMainService: IDialogMainService, + windowsMainService: IWindowsMainService, ) { super(); - if (isMacintosh) { - this._register(Event.fromNodeEventEmitter(electron.app, 'did-become-active')(() => this.macAppActive = true)); - this._register(Event.fromNodeEventEmitter(electron.app, 'did-resign-active')(() => this.macAppActive = false)); - } - this._register(updateService.onStateChange(state => { - if (state.type === StateType.Idle && state.notAvailable && !state.error) { - this.show(); + if (state.type !== StateType.Idle || !state.notAvailable || state.error) { + return; } - })); - } - private show(): void { - const focusedWindow = electron.BrowserWindow.getFocusedWindow(); - if (!focusedWindow && !(isMacintosh && this.macAppActive)) { - return; // focus is not in this app — let the focused app show the dialog - } + if (!isMacintosh || windowsMainService.getWindowCount() > 0) { + return; + } - this.dialogMainService.showMessageBox({ - type: 'info', - message: localize('noUpdatesAvailable', "There are currently no updates available."), - }, focusedWindow ?? undefined); + dialogMainService.showMessageBox({ + type: 'info', + message: localize('noUpdatesAvailable', "There are currently no updates available."), + }); + })); } } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index e92c54e2fe8c2..90634472658e3 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -8,7 +8,6 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; import { Event } from '../../../base/common/event.js'; import { hash } from '../../../base/common/hash.js'; -import { DisposableStore } from '../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; @@ -23,8 +22,6 @@ import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdat export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { - private readonly disposables = new DisposableStore(); - @memoize private get onRawError(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'error', (_, message) => message); } @memoize private get onRawCheckingForUpdate(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'checking-for-update'); } @memoize private get onRawUpdateNotAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-not-available'); } @@ -71,11 +68,11 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected override async initialize(): Promise { await super.initialize(); - this.onRawError(this.onError, this, this.disposables); - this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); - this.onRawUpdateAvailable(this.onUpdateAvailable, this, this.disposables); - this.onRawUpdateDownloaded(this.onUpdateDownloaded, this, this.disposables); - this.onRawUpdateNotAvailable(this.onUpdateNotAvailable, this, this.disposables); + this.onRawError(this.onError, this, this._store); + this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this._store); + this.onRawUpdateAvailable(this.onUpdateAvailable, this, this._store); + this.onRawUpdateDownloaded(this.onUpdateDownloaded, this, this._store); + this.onRawUpdateNotAvailable(this.onUpdateNotAvailable, this, this._store); } private onCheckingForUpdate(): void { @@ -86,6 +83,11 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); this.logService.error('UpdateService error:', err); + // Only react while actively checking/downloading; a late error must not clobber Disabled or Ready. + if (this.state.type !== StateType.CheckingForUpdates && this.state.type !== StateType.Downloading && this.state.type !== StateType.Overwriting) { + return; + } + // only show message when explicitly checking for updates const message = (this.state.type === StateType.CheckingForUpdates && this.state.explicit) ? err : undefined; this.setState(State.Idle(UpdateType.Archive, message)); @@ -205,8 +207,4 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.logService.trace('update#quitAndInstall(): running raw#quitAndInstall()'); electron.autoUpdater.quitAndInstall(); } - - dispose(): void { - this.disposables.dispose(); - } } diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts index 2be53f613228d..dbb08664cc5be 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -14,7 +14,7 @@ import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, IUpdate, State, UpdateType } from '../common/update.js'; +import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions } from './abstractUpdateService.js'; export class LinuxUpdateService extends AbstractUpdateService { @@ -51,6 +51,11 @@ export class LinuxUpdateService extends AbstractUpdateService { this.requestService.request({ url, callSite: 'updateService.linux.checkForUpdates' }, CancellationToken.None) .then(asJson) .then(update => { + // If updates were disabled mid-check, ignore the result so we don't leave the Disabled state. + if (this.state.type !== StateType.CheckingForUpdates) { + return; + } + if (!update || !update.url || !update.version || !update.productVersion) { this.setState(State.Idle(UpdateType.Archive, undefined, explicit || undefined)); } else { @@ -58,6 +63,10 @@ export class LinuxUpdateService extends AbstractUpdateService { } }) .then(undefined, err => { + if (this.state.type !== StateType.CheckingForUpdates) { + return; + } + this.logService.error(err); // only show message when explicitly checking for updates const message: string | undefined = explicit ? (err.message || err) : undefined; diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 1911ac0a6bcd1..b8c2c8722e1f4 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -10,8 +10,9 @@ import { mkdir, readFile, unlink } from 'fs/promises'; import { release, tmpdir } from 'os'; import { Delayer, ProcessTimeRunOnceScheduler, timeout } from '../../../base/common/async.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; +import { isCancellationError } from '../../../base/common/errors.js'; import { hash } from '../../../base/common/hash.js'; import * as path from '../../../base/common/path.js'; import { basename } from '../../../base/common/path.js'; @@ -59,6 +60,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun private availableUpdate: IAvailableUpdate | undefined; private updateCancellationTokenSource: CancellationTokenSource | undefined; + /** Cancels an in-flight check/download chain (e.g. when updates are disabled at runtime). */ + private checkCancellationTokenSource: CancellationTokenSource | undefined; + /** Settles when the in-flight check/download chain has fully unwound; used by the cancel path. */ + private checkPromise: Promise | undefined; private readonly readyMutexName: string; private readonly updatingMutexName: string; @@ -168,25 +173,41 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // updatingVersionPath will be deleted by inno setup. } } else { - const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - // GC for background updates in system setup happens via inno_setup since it requires - // elevated permissions. - if (fastUpdatesEnabled && this.productService.target === 'user' && this.productService.commit) { - const versionedResourcesFolder = this.productService.commit.substring(0, 10); - const innoUpdater = path.join(exeDir, versionedResourcesFolder, 'tools', 'inno_updater.exe'); - const exeName = basename(exePath); - await new Promise(resolve => { - const child = spawn(innoUpdater, ['--gc', exePath, versionedResourcesFolder, exeName], { - stdio: ['ignore', 'ignore', 'ignore'], - windowsHide: true, - timeout: 2 * 60 * 1000 - }); - child.once('exit', () => resolve()); - }); - } + await this.collectGarbage(); } } + private async collectGarbage(): Promise { + if (!this.productService.win32VersionedUpdate) { + return; + } + + const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); + // GC for background updates in system setup happens via inno_setup since it requires elevated permissions. + if (!fastUpdatesEnabled || this.productService.target !== 'user' || !this.productService.commit) { + return; + } + + const exePath = app.getPath('exe'); + const exeDir = path.dirname(exePath); + const versionedResourcesFolder = this.productService.commit.substring(0, 10); + const innoUpdater = path.join(exeDir, versionedResourcesFolder, 'tools', 'inno_updater.exe'); + const exeName = basename(exePath); + await new Promise(resolve => { + const child = spawn(innoUpdater, ['--gc', exePath, versionedResourcesFolder, exeName], { + stdio: ['ignore', 'ignore', 'ignore'], + windowsHide: true, + timeout: 2 * 60 * 1000 + }); + // Resolve on 'error' too (missing inno_updater / permission denied) so the awaited promise always settles. + child.once('error', err => { + this.logService.error('update#collectGarbage - failed to spawn inno_updater', err); + resolve(); + }); + child.once('exit', () => resolve()); + }); + } + protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { let platform = `win32-${process.arch}`; @@ -213,12 +234,21 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.setState(State.CheckingForUpdates(explicit)); } + // Track this check/download chain so it can be cancelled if updates are disabled at runtime. + this.checkCancellationTokenSource?.dispose(true); + const cts = this.checkCancellationTokenSource = new CancellationTokenSource(); + const token = cts.token; + const headers = getUpdateRequestHeaders(this.productService.version); - this.requestService.request({ url, headers, callSite: 'updateService.win32.checkForUpdates' }, CancellationToken.None) + const promise = this.requestService.request({ url, headers, callSite: 'updateService.win32.checkForUpdates' }, token) .then(asJson) .then(update => { const updateType = getUpdateType(); + if (token.isCancellationRequested) { + return Promise.resolve(null); + } + if (!update || !update.url || !update.version || !update.productVersion) { // If we were checking for an overwrite update and found nothing newer, // restore the Ready state with the pending update @@ -256,7 +286,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const downloadPath = `${updatePackagePath}.tmp`; - return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, CancellationToken.None) + return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, token) .then(context => { // Get total size from Content-Length header const contentLengthHeader = context.res.headers['content-length']; @@ -288,6 +318,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => updatePackagePath); }); }).then(packagePath => { + if (token.isCancellationRequested) { + return; + } + this.availableUpdate = { packagePath }; this.saveUpdateMetadata(update); this.setState(State.Downloaded(update, explicit, this._overwrite)); @@ -302,6 +336,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); }) .then(undefined, err => { + // The chain was cancelled because updates are being disabled; leave state to the disable flow. + if (token.isCancellationRequested || isCancellationError(err)) { + return; + } + this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); this.logService.error(err); @@ -317,6 +356,18 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.setState(State.Idle(getUpdateType(), message)); } }); + + this.checkPromise = promise; + + promise.finally(() => { + if (this.checkCancellationTokenSource === cts) { + this.checkCancellationTokenSource = undefined; + } + if (this.checkPromise === promise) { + this.checkPromise = undefined; + } + cts.dispose(); + }); } protected override async doDownloadUpdate(state: AvailableForDownload): Promise { @@ -462,6 +513,42 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); } + protected override async cancelUpdate(): Promise { + // Abort an in-flight check/download so it never reaches the background installer. + const hadInFlightCheck = !!this.checkCancellationTokenSource; + const hadPendingUpdate = !!this.availableUpdate; + this.checkCancellationTokenSource?.dispose(true); + this.checkCancellationTokenSource = undefined; + + // Only clean up if a check/download was in flight; avoids creating the cache dir when just disabled. + if (hadInFlightCheck) { + try { + await this.checkPromise; + } catch { + // the chain swallows its own errors; ignore + } + await this.cleanupTempFiles(); + } + + // Tear down any pending (downloaded/applying) update. + await this.cancelPendingUpdate(); + + // Reclaim a partial versioned-resource folder a cancelled update may leave; only after real teardown. + if (hadInFlightCheck || hadPendingUpdate) { + this.collectGarbage().catch(err => this.logService.error('update#collectGarbage - failed to collect garbage', err)); + } + } + + private async cleanupTempFiles(): Promise { + try { + const cachePath = await this.cachePath; + const files = await pfs.Promises.readdir(cachePath); + await Promise.all(files.filter(file => file.endsWith('.tmp')).map(file => this.unlink(path.join(cachePath, file)))); + } catch (err) { + this.logService.warn('update#cleanupTempFiles: failed to remove temporary download files', err); + } + } + protected override async cancelPendingUpdate(): Promise { if (!this.availableUpdate) { return; diff --git a/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts b/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts new file mode 100644 index 0000000000000..2766d433ed537 --- /dev/null +++ b/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * 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 sinon from 'sinon'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationValue } from '../../../configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IEnvironmentMainService } from '../../../environment/electron-main/environmentMainService.js'; +import { ILifecycleMainService } from '../../../lifecycle/electron-main/lifecycleMainService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IMeteredConnectionService } from '../../../meteredConnection/common/meteredConnection.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { IRequestService } from '../../../request/common/request.js'; +import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { DisablementReason, State, StateType } from '../../common/update.js'; +import { AbstractUpdateService, IUpdateURLOptions } from '../../electron-main/abstractUpdateService.js'; + +class TestUpdateService extends AbstractUpdateService { + + private readonly _initialized = new DeferredPromise(); + get whenInitialized(): Promise { return this._initialized.p; } + + private _checkCount = 0; + get checkCount(): number { return this._checkCount; } + + private _cancelCount = 0; + get cancelCount(): number { return this._cancelCount; } + + /** When set, `cancelUpdate` blocks on this promise so tests can observe the transient Cancelling state. */ + private _cancelGate: Promise | undefined; + blockCancelUpdate(gate: Promise): void { this._cancelGate = gate; } + + /** Forces the service into a given state so tests can exercise cancellation from a cancellable state. */ + forceState(state: State): void { this.setState(state); } + + feedUrl: string | undefined = 'https://update.example/feed'; + + protected override async initialize(): Promise { + try { + await super.initialize(); + } finally { + this._initialized.complete(); + } + } + + protected buildUpdateFeedUrl(_quality: string, _commit: string, _options?: IUpdateURLOptions): string | undefined { + return this.feedUrl; + } + + protected doCheckForUpdates(): void { + this._checkCount++; + } + + protected override async cancelUpdate(): Promise { + this._cancelCount++; + if (this._cancelGate) { + await this._cancelGate; + } + await super.cancelUpdate(); + } +} + +suite('AbstractUpdateService', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + class PolicyTestConfigurationService extends TestConfigurationService { + policyValue: string | undefined; + + override getValue(arg1?: string | IConfigurationOverrides, arg2?: IConfigurationOverrides): T | undefined { + // Mirror the real configuration service: a policy value overrides the user setting. + if (arg1 === 'update.mode' && this.policyValue !== undefined) { + return this.policyValue as T; + } + return super.getValue(arg1, arg2); + } + + override inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + const result = super.inspect(key, overrides); + if (key === 'update.mode') { + return { ...result, policyValue: this.policyValue as T }; + } + return result; + } + } + + let configurationService: PolicyTestConfigurationService; + + function createService(mode: string, options?: { isBuilt?: boolean; disableUpdates?: boolean; updateUrl?: string }): TestUpdateService { + configurationService = new PolicyTestConfigurationService(); + configurationService.setUserConfiguration('update.mode', mode); + + const lifecycleMainService = { + when: () => Promise.resolve(), + setRelaunchHandler: () => { }, + quit: () => Promise.resolve(false), + onWillShutdown: Event.None + } as unknown as ILifecycleMainService; + + const environmentMainService = { + isBuilt: options?.isBuilt ?? true, + disableUpdates: options?.disableUpdates ?? false + } as unknown as IEnvironmentMainService; + + const requestService = { + request: () => Promise.reject(new Error('not expected')) + } as unknown as IRequestService; + + const productService = { + updateUrl: options?.updateUrl ?? 'https://update.example', + commit: 'abc123', + quality: 'stable', + version: '1.0.0', + target: 'user' + } as unknown as IProductService; + + const applicationStorageMainService = { + whenReady: Promise.resolve(), + get: () => undefined, + store: () => { } + } as unknown as IApplicationStorageMainService; + + const meteredConnectionService = { isConnectionMetered: false } as unknown as IMeteredConnectionService; + + const service = new TestUpdateService( + lifecycleMainService, + configurationService, + environmentMainService, + requestService, + store.add(new NullLogService()), + productService, + NullTelemetryService, + applicationStorageMainService, + meteredConnectionService, + false + ); + + return store.add(service); + } + + function changeMode(service: TestUpdateService, mode: string): Promise { + configurationService.setUserConfiguration('update.mode', mode); + const next = Event.toPromise(service.onStateChange); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + return next; + } + + function setPolicy(service: TestUpdateService, policyValue: string | undefined): Promise { + configurationService.policyValue = policyValue; + const next = Event.toPromise(service.onStateChange); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + return next; + } + + teardown(() => { + sinon.restore(); + }); + + test('mode none disables updates at startup', async () => { + const service = createService('none'); + await service.whenInitialized; + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + }); + + test('mode default enables updates at startup', async () => { + const service = createService('default'); + await service.whenInitialized; + + assert.strictEqual(service.state.type, StateType.Idle); + }); + + test('policy forces updates off even when the user setting keeps them enabled', async () => { + const service = createService('default'); + await service.whenInitialized; + assert.strictEqual(service.state.type, StateType.Idle); + + // User setting stays 'default' (enabled); policy alone forces 'none'. + await setPolicy(service, 'none'); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.Policy }); + }); + + test('switching to none at runtime cancels and disables', async () => { + const service = createService('default'); + await service.whenInitialized; + + await changeMode(service, 'none'); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + assert.strictEqual(service.cancelCount, 1); + }); + + test('switching from none to default at runtime re-enables', async () => { + const service = createService('none'); + await service.whenInitialized; + assert.strictEqual(service.state.type, StateType.Disabled); + + await changeMode(service, 'default'); + + assert.strictEqual(service.state.type, StateType.Idle); + }); + + test('default schedules a background check, none does not', async () => { + const clock = sinon.useFakeTimers(); + try { + const service = createService('default'); + await service.whenInitialized; + await clock.tickAsync(30 * 1000); + assert.strictEqual(service.checkCount, 1, 'default should schedule a check'); + + await changeMode(service, 'none'); + await clock.tickAsync(60 * 60 * 1000); + assert.strictEqual(service.checkCount, 1, 'none should not schedule further checks'); + } finally { + clock.restore(); + } + }); + + test('permanent disablement ignores runtime mode changes', async () => { + const service = createService('default', { isBuilt: false }); + await service.whenInitialized; + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.NotBuilt }); + + configurationService.setUserConfiguration('update.mode', 'none'); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.NotBuilt }); + }); + + test('redundant update.mode write does not re-disable', async () => { + const service = createService('none'); + await service.whenInitialized; + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + + const cancelsAfterInit = service.cancelCount; + let stateChanges = 0; + store.add(service.onStateChange(() => stateChanges++)); + + // Re-write the same 'none' value: this affects `update.mode` but does not change the outcome. + configurationService.setUserConfiguration('update.mode', 'none'); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + await timeout(0); + + assert.strictEqual(service.cancelCount, cancelsAfterInit, 'should not cancel again while already disabled'); + assert.strictEqual(stateChanges, 0, 'should not re-fire the Disabled state'); + }); + + test('surfaces Cancelling while tearing down in-flight work, then Disabled', async () => { + const service = createService('default'); + await service.whenInitialized; + + // Put the service into a cancellable state and make cancellation block until we release it. + service.forceState(State.CheckingForUpdates(false)); + const gate = new DeferredPromise(); + service.blockCancelUpdate(gate.p); + + const states: StateType[] = []; + store.add(service.onStateChange(s => states.push(s.type))); + + configurationService.setUserConfiguration('update.mode', 'none'); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + await timeout(0); + + assert.strictEqual(service.state.type, StateType.Cancelling, 'should show Cancelling while cancellation is in progress'); + + gate.complete(); + await timeout(0); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + assert.deepStrictEqual(states, [StateType.Cancelling, StateType.Disabled]); + }); + + test('does not enter Cancelling when nothing is in flight', async () => { + const service = createService('default'); + await service.whenInitialized; + assert.strictEqual(service.state.type, StateType.Idle); + + const states: StateType[] = []; + store.add(service.onStateChange(s => states.push(s.type))); + + await changeMode(service, 'none'); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + assert.deepStrictEqual(states, [StateType.Disabled], 'should go straight to Disabled without a Cancelling flash'); + }); +}); diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index 291648bca96e3..2187c2fb6c903 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -69,6 +69,13 @@ export interface IOpenWindowOptions extends IBaseOpenWindowsOptions { readonly gotoLineMode?: boolean; readonly waitMarkerFileURI?: URI; + + /** + * When set, the opened window is asked to open the chat session identified + * by this resource once it is ready. Used to hand off a session (e.g. from + * the Agents window) so the new window restores both the folder and session. + */ + readonly chatSessionToOpen?: URI; } export interface IAddRemoveFoldersRequest { @@ -394,7 +401,7 @@ export interface INativeOpenFileRequest extends IOpenFileRequest { export interface INativeRunActionInWindowRequest { readonly id: string; - readonly from: 'menu' | 'touchbar' | 'mouse'; + readonly from: 'menu' | 'touchbar' | 'mouse' | 'systemWideKeybinding'; readonly args?: unknown[]; } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index ba9b61786c88a..50c2a45a8a297 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -14,7 +14,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagement.ts # IDs + context keys ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input -├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle +├── aiCustomizationListWidget.ts # Search + grouped list ├── aiCustomizationItemsModel.ts # IAICustomizationItemsModel: aggregated item model + section counts ├── aiCustomizationItemSource.ts # Item pipeline: ICustomizationItem → IAICustomizationListItem view model ├── aiCustomizationWelcomePage.ts # Welcome page host (AICustomizationWelcomePage + implementation interface) @@ -59,7 +59,7 @@ src/vs/sessions/contrib/chat/browser/ ├── customizationHarnessService.ts # Sessions harness service (accepts any content-provider-backed session type) └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ -├── aiCustomizationShortcutsWidget.ts # Sidebar shortcuts widget with header overview action +├── aiCustomizationShortcutsWidget.ts # Resizable sidebar shortcuts widget with overview + section links └── customizationsToolbar.contribution.ts # Sidebar customization links ``` @@ -76,7 +76,6 @@ The `IAICustomizationWorkspaceService` interface controls per-window behavior: | Property / Method | Core VS Code | Agent Sessions Window | |----------|-------------|----------| | `managementSections` | All sections except Models | All sections except Models | -| `getStorageSourceFilter(type)` | Delegates to `ICustomizationHarnessService` | Delegates to `ICustomizationHarnessService` | | `isSessionsWindow` | `false` | `true` | | `activeProjectRoot` | First workspace folder | Active session worktree | | `welcomePageFeatures` | Shows getting-started banner + per-card AI actions | Shows getting-started banner, hides per-card AI actions | @@ -87,7 +86,7 @@ A harness represents the AI execution environment that consumes customizations. Storage answers "where did this come from?"; harness answers "who consumes it?". The service is defined in `common/customizationHarnessService.ts` which also provides: -- **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. +- **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list - **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). - **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[AICustomizationSources.extension, AICustomizationSources.builtin]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[AICustomizationSources.builtin]`. - **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. @@ -130,13 +129,11 @@ Key properties on the harness descriptor: ### IStorageSourceFilter -A unified per-type filter controlling which storage sources and user file roots are visible. -Replaces the old `visibleStorageSources`, `getVisibleStorageSources(type)`, and `excludedUserFileRoots`. +A per-type filter controlling which storage sources are visible. ```typescript interface IStorageSourceFilter { - sources: readonly PromptsStorage[]; // Which storage groups to display - includedUserFileRoots?: readonly URI[]; // Allowlist for user roots (undefined = all) + sources: readonly PromptsStorage[]; // Which storage groups to display } ``` @@ -144,31 +141,31 @@ The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, **Sessions filter behavior (CLI harness):** -| Type | sources | includedUserFileRoots | -|------|---------|----------------------| -| Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.copilot, ~/.claude, ~/.agents]` | +| Type | sources | +|------|---------| +| Hooks | `[local, plugin]` | +| Prompts | `[local, user, plugin, builtin]` | +| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | **Core VS Code filter behavior:** -Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. +Local harness: all types use `[local, user, extension, plugin, builtin]`. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. CLI harness (core): -| Type | sources | includedUserFileRoots | -|------|---------|----------------------| -| Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.copilot, ~/.claude, ~/.agents]` | +| Type | sources | +|------|---------| +| Hooks | `[local, plugin]` | +| Prompts | `[local, user, plugin]` | +| Agents, Skills, Instructions | `[local, user, plugin]` | Claude harness (core): -| Type | sources | includedUserFileRoots | -|------|---------|----------------------| -| Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.claude]` | +| Type | sources | +|------|---------| +| Hooks | `[local, plugin]` | +| Prompts | `[local, user, plugin]` | +| Agents, Skills, Instructions | `[local, user, plugin]` | Claude additionally applies: - `hiddenSections: [Prompts, Plugins]` @@ -186,7 +183,7 @@ In core VS Code, customization items contributed by the default chat extension ( ### Management Editor Item Pipeline -All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, and status fields. +All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, plugin provenance (`pluginUri`/`pluginLabel`), and status fields. ``` promptsService ──→ PromptsServiceCustomizationItemProvider ──→ ICustomizationItem[] @@ -255,11 +252,15 @@ Counts shown in the sidebar (per-link badges and the header total in `AICustomiz Provider-supplied customization rows that include an explicit storage origin are treated as authoritative even when no local URI inference is available. In particular, `storage: PromptsStorage.plugin` keeps AHP remote host plugin customizations out of the User group when no local `pluginUri` exists, and `storage: BUILTIN_STORAGE` keeps provider-supplied built-ins in the Built-in group. -### Sidebar Entrypoint Mode +### MCP Active Session Status -The Agents sidebar `AICustomizationShortcutsWidget` supports three entrypoint modes via `sessions.customizations.sidebarMode`: `welcome` (default) keeps the per-category sidebar rows but opens the AI Customization management editor welcome page, `section` restores per-category deep linking, and `single` replaces the per-category rows with one Customizations entry that opens the welcome page. All modes keep the active customization harness in sync with the active session before opening the editor. +The MCP Servers section combines locally known MCP servers with MCP servers reported by the active agent-host session (`IAgentHostCustomizationService.getMcpServers(activeSessionResource)`). Active-session servers are matched to known workspace, user, extension, plugin, or built-in rows by stable identifiers and display names so the row can show the active session's status, matching `MCP: List Servers`. Active-session servers that do not match any known local/runtime server are appended under an **Active Session** group and counted with the rest of the section. -When the harness selector dropdown is disabled in the management editor, the sidebar overview button displays the active harness name and harness icon (matching the picker icon) with a distinct background treatment. +### Sidebar Customizations Section + +The Agents sidebar `AICustomizationShortcutsWidget` appears as a collapsible, vertically resizable section below the sessions list. Its resize sash is the horizontal separator above the section and uses the same `SplitView` styling as the Checks section in the changes view, with a 4px separator and sash inset on each side. The section's expanded minimum height is 129px, while its initial and maximum height are capped to the rendered content height so the pane does not open with empty space. When collapsed, the section shrinks to its header height and shows the total customization count to the left of the hover-revealed chevron. The collapsed/expanded state is persisted per profile (`StorageScope.PROFILE`) and restored on reload. + +The first sidebar entry is `Overview`, which opens the AI Customization management editor welcome page. The remaining per-category rows deep-link directly to their corresponding management editor section. All entries keep the active customization harness in sync with the active session before opening the editor. ### Item Badges @@ -302,8 +303,4 @@ All commands and UI respect `ChatContextKeys.enabled`. ## Settings -User-facing settings use the `chat.customizations.` namespace: - -| Setting | Default | Description | -|---------|---------|-------------| -| `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar | +User-facing settings use the `chat.customizations.` namespace. Currently, no settings are exposed for the management editor. diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 30c0519a126a6..96fe5371f8c63 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -60,6 +60,21 @@ The titlebar spans the full window width at the root level. Below it, a content The **Sessions Part is the flexible ("remaining width") view** in the top-right row: it has `LayoutPriority.High` so it absorbs auxiliary bar / editor visibility changes and window resizes. The editor and auxiliary bar keep their user-set widths (`LayoutPriority.Normal` / `Low`). Making the editor the high-priority view caused its width to drift to its 300px minimum when the auxiliary bar was toggled across session switches. +### 2.3 Layout Priority Model + +The workbench grid is built with `proportionalLayout: false` (see `createWorkbenchLayout()` in [browser/workbench.ts](src/vs/sessions/browser/workbench.ts)). In this mode the split views do **not** distribute resize deltas proportionally — instead each delta (window resize, or a part being shown/hidden) is absorbed by the highest-`LayoutPriority` view, while the others keep their established sizes. Each part therefore declares an explicit `priority`: + +| Part | `LayoutPriority` | Width behaviour | +|------|------------------|-----------------| +| Sidebar | `Low` | Fixed user-set width; never absorbs deltas. `minimumWidth` 170 (270 web), `maximumWidth` ∞, snaps closed below the minimum. | +| Sessions Part | **`High`** | The single flexible view — grows/shrinks to absorb every horizontal delta. `minimumWidth` 300, `maximumWidth` ∞. | +| Editor | `Normal` | Keeps its user-set width (`600` default); only resized via its own sash. | +| Auxiliary Bar | `Low` | Keeps its user-set width (`340` default); only resized via its own sash. | + +**Invariant — exactly one `High` view in the horizontal chain.** A grid branch derives its priority from its children (`BranchNode.priority` in [base/browser/ui/grid/gridview.ts](src/vs/base/browser/ui/grid/gridview.ts)): `High` if any child is `High`, else `Low` if any child is `Low`, else `Normal`. The Top Right row contains a `Low` auxiliary bar, so unless the Sessions Part is `High` the whole Right Section derives to `Low`. The Content Section would then be `Sidebar (Low) | Right Section (Low)` — two equal-priority views — and with no high-priority absorber the resize delta spreads across **both**, growing the sidebar toward half the window. The Sessions Part being `High` is what lifts the Right Section to `High` so it (not the sidebar) absorbs the delta. + +> **Pitfall:** the `High` role must live on the Sessions Part, not the editor. It was previously on the editor, but that made the editor drift to its 300px minimum when the auxiliary bar was toggled across session switches. When moving the role, set the Sessions Part to `High` **and** the editor to `Normal` together — removing `High` from the editor without adding it to the Sessions Part leaves the chain with no `High` view and reintroduces the growing-sidebar bug. + --- ## 3. Titlebar @@ -68,9 +83,9 @@ The titlebar is a standalone implementation (`TitlebarPart`) — not extending ` | Section | Menu ID | Content | |---------|---------|---------| -| Left | `Menus.TitleBarLeftLayout` | Toggle sidebar, agent host filter | +| Left | `Menus.TitleBarLeftLayout` | Toggle sidebar, new session (when sidebar hidden, A/B experiment), agent host filter | | Center | `Menus.CommandCenter` | Session picker widget (plus `Menus.TitleBarSessionMenu` for active-session actions) | -| Right | `Menus.TitleBarRightLayout` | Run script (split button), Open Terminal/VS Code, toggle auxiliary bar, account widget | +| Right | `Menus.TitleBarRightLayout` | Remote connections, run script (split button), Open Terminal/VS Code, toggle auxiliary bar, account widget | No menubar, no editor actions, no `WindowTitle` dependency. @@ -93,6 +108,12 @@ When multiple remote agent hosts are known, a dropdown pill in the left toolbar Shows the signed-in GitHub profile image (falls back to the account codicon). Clicking opens a combined account and Copilot status panel with sign-in/sign-out and settings actions. +### Remote Connections (Right) + +The remote connections toggle is a global titlebar action (`Menus.TitleBarRightLayout`) rather than a per-chat input action. This keeps tunnel hosting state visually scoped to the Agents window as a whole, so users do not interpret it as a setting that must be enabled separately for each chat session. + +This Agents-window placement is intentionally different from the main editor window: outside the Agents window the same toggle remains in `MenuId.ChatInputSecondary` for agent-host chat inputs. Keep both menu items mutually exclusive with `IsSessionsWindowContext` so the editor window keeps its chat-input affordance while the Agents window shows only the titlebar affordance. + --- ## 4. Sessions Part @@ -103,8 +124,8 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (workspace · diff stats), and the session toolbars (Run, Open in VS Code, New Chat). The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; the changes view contributes the diff stats as a clickable menu item (gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's changes, with its custom action view item registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. -- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility is **sticky per opened session**: it appears once the session has more than one chat, or its single (default) chat carries a title that differs from the session title (so both independent titles stay visible), and from then on it stays shown for that session's lifetime — it is never hidden again when chats are later removed or renamed, keeping the experience consistent. A single chat that still inherits the session title (and never diverged) keeps the strip hidden. +- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request / terminals buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind — plus the workspace label, and a hover showing the working-directory path and git branch, registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. The sessions terminal contribution similarly contributes a terminals button (order 2, so it follows the pull request button) showing the terminal icon + `{n} terminals` (gated by the per-view `SessionHasTerminalsContext` key, which `SessionView` sets from the terminal counts exposed by `ISessionTerminalsService` — backed by `SessionsTerminalContribution`). The label counts the session's terminals that have had at least one command sent in them (empty terminals that never ran a command are excluded), while the hover reports how many of those are currently running something (active) via `ITerminalInstance.hasChildProcesses` — e.g. a watch task or an in-progress `npm install`. The contribution records "has had a command" stickily per terminal (from executed text, command detection or a started child process) and recomputes the active subset live; the custom action view item extends `SessionHeaderMetaActionViewItem` to render the live count and the `{n} active terminals` hover, registered from `contrib/terminal/browser/terminalMetaActions.ts`) that, when activated, reveals the terminal view for the session. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. +- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **chats** in the session (**including in-composer draft chats**, and **counting closed chats**): it is shown as soon as the session has more than one chat and stays shown when chats are **closed** back down to a single open chat, hiding only when there is exactly one chat overall whose title matches the session title. It is **also** shown for a single remaining chat whose **title diverged** from the session title (so that chat's title stays visible somewhere). This rule is a single shared observable `IActiveSession.shouldShowChatTabs` ([services/sessions/browser/visibleSessions.ts](src/vs/sessions/services/sessions/browser/visibleSessions.ts)), read by both the composite bar and the `SessionShouldShowChatTabsContext` context key. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single chat with no diverged title); once the strip is shown the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionShouldShowChatTabsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. While the tab strip is shown the chat tabs are keyboard-navigable from the active session: `Ctrl/Cmd+Shift+]` / `Ctrl/Cmd+Shift+[` go to the next / previous chat (wrapping), `Ctrl/Cmd+W` closes the active chat tab (deleting an in-composer draft, hiding a committed chat) instead of the session — the same command (`sessions.chatCompositeBar.closeChat`) is contributed to the per-tab `Menus.SessionChatTab`, which the chat tab strip renders as each non-main tab's close button (forwarding the tab's chat as the action argument), and `Ctrl+Tab` / `Ctrl+Shift+Tab` open a **chat switcher** — a no-input, editor-switcher (MRU) quick pick over the session's **open** chats (skipping in-composer drafts), each shown with a chat icon (hold the modifier, press `Tab` to cycle, release to select), winning over the session-history secondary on that chord while the session has multiple open chats and falling back to session navigation otherwise (and to the editor's own `Ctrl+Tab` switcher while a quick pick is already open, since the open chords are gated on `inQuickOpen` negated); the **Go to Chat in Session** palette command (`sessions.showChatsPicker`, `Ctrl/Cmd+Shift+O`, gated on more than one committed chat) opens a **searchable** variant that additionally lists **Closed** chats in a separate group (selecting one reopens it) — these commands (`sessions.chatCompositeBar.navigateNextChat` / `navigatePreviousChat` / `closeChat` and `sessions.showChatsPicker` in `contrib/sessions/browser/sessionsActions.ts`) outrank the session-level navigation/close chords via a higher keybinding weight. Chat-to-chat navigation (next/previous chat and the `Ctrl+Tab` switcher) is gated on `SessionHasMultipleOpenChatsContext` (more than one **open** tab) — distinct from the broader `SessionShouldShowChatTabsContext` that drives strip visibility — so it stays a no-op when only a single open chat remains (e.g. a diverged-title single chat, or one open + one closed chat); `closeChat` is gated on `SessionActiveChatIsClosableContext`, and the searchable palette command on `SessionHasMultipleCommittedChatsContext`. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. @@ -112,6 +133,12 @@ The header and the composite bar are deliberately separate widgets: the header r **Pitfall:** don't cap the chat viewport width in `SessionView` layout when you need edge-aligned scrollbars. Keep the viewport full-width and center only the inner chat content so alignment and scroll ergonomics both hold. +**Pitfall:** a meta-row action view item that renders a `Button` (`.monaco-text-button`) cannot color a codicon glyph via a normal inline `style.color`, because `button.css` forces `.monaco-text-button .codicon { color: inherit !important }`. To give a meta icon its own theme color (e.g. the PR state color), set the color inline **with `!important` priority** (`el.style.setProperty('color', value, 'important')`) — an inline `!important` declaration wins over an external author `!important` rule in the cascade. + +**Pitfall:** combined codicon glyphs (e.g. `git-pull-request-done`) have a wider horizontal advance (~16px) than `*-compact` glyphs (e.g. `worktree-compact`, 12px), so even at `font-size: 12px` their layout box stays wide and pushes the following label away. Setting `font-size` alone does not fix it — clamp the icon box with explicit `width`/`height` set to `--vscode-codiconFontSize-compact` plus `justify-content: center` so the extra advance overflows harmlessly and the label sits tight against the glyph. + +**Pitfall:** don't put `overflow: hidden` on the meta row. The meta buttons are secondary `Button`s whose focus ring is drawn with `outline-offset: 2px`, so it extends a few pixels outside the button. When the meta row's height equals the button height (22px) and the row clips its overflow, the ring is sheared flat at the top and bottom. Leave the row `overflow: visible` and rely on the header's `padding-bottom` and the title-row gap above to give the ring room. + The chat view inside a session view is one of three kinds (`ChatViewKind` in [browser/parts/chatView.ts](src/vs/sessions/browser/parts/chatView.ts)), selected per autorun based on the bound session: | Kind | Used when | Concrete view | @@ -122,6 +149,8 @@ The chat view inside a session view is one of three kinds (`ChatViewKind` in [br Concrete implementations live under `contrib/chat/` and are obtained via `IChatViewFactory` so the `browser/` layer doesn't have to import contrib code. +`ChatView` mounts session input banners directly above the chat input. The CI failures banner uses the orange accent for the card border/icon and for the primary Fix Checks button background/border. + When a `ChatView` loads its chat model (`acquireOrLoadSession`), it surfaces progress on **its own** progress bar, pinned to the top of that grid leaf. This mirrors how each editor group owns its `ProgressBar` (see `EditorGroupView`): the bar is created by the leaf host `AbstractChatView`, wrapped in a `ScopedProgressIndicator` (reused from `vs/workbench`) with an always-active scope, and driven via `AbstractChatView.showProgressWhile(promise, delay)`. Concurrent loads in other visible sessions each show their own progress instead of competing for a single part-wide bar, and overlapping loads on the same leaf are joined by the indicator so the bar only hides once all have settled. A short delay avoids flashing the bar for fast (cached) loads. ### 4.2 Visibility Model @@ -217,15 +246,25 @@ All session-window contributions use `WindowVisibility.Sessions` to only appear ## 10. Per-Session Layout State -`LayoutController` (`contrib/layout/browser/sessionLayoutController.ts`) manages layout state as the user switches between sessions. All state is persisted to workspace storage so it survives restarts. This section is a summary — see **[LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md)** for the full specification (switch trigger, multi-session handling, auto-reveal, persistence, and invariants). +The session layout controllers manage layout state as the user switches between sessions. All state is persisted to workspace storage so it survives restarts. This section is a summary — see **[LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md)** for the full specification (switch trigger, multi-session handling, persistence, and invariants). + +The implementation is split across three files in `contrib/layout/browser/`, each with a file-level spec of numbered rules (`B*`/`D*`/`M*`) that the code and tests reference by tag. Each concrete controller self-registers behind a platform guard: + +- **`BaseLayoutController`** ([baseSessionLayoutController.ts](contrib/layout/browser/baseSessionLayoutController.ts), [spec](contrib/layout/browser/baseSessionLayoutController.md)) — abstract; shared panel / working-set / persistence / multi-session logic. +- **`LayoutController`** ([desktopSessionLayoutController.ts](contrib/layout/browser/desktopSessionLayoutController.ts), [spec](contrib/layout/browser/desktopSessionLayoutController.md)) — desktop and web desktop layout. Adds the auxiliary bar / view-state management described below (via the `_registerViewStateManagement()` hook). Imported from `sessions.desktop.main.ts` and `sessions.web.main.ts`. +- **`MobileLayoutController`** ([mobileSessionLayoutController.ts](contrib/layout/browser/mobileSessionLayoutController.ts), [spec](contrib/layout/browser/mobileSessionLayoutController.md)) — web phone layout (`isWeb && isMobile`). Keeps the shared logic but omits auxiliary bar management, which would cause disruptive auto-expand on narrow viewports. Imported from `sessions.web.main.ts`. ### Auxiliary Bar Each session independently remembers whether the auxiliary bar is visible and which view container is active. When switching to a session, the saved state is restored. When switching away, the current state is captured. -**Auto-reveal on changes:** When a chat turn completes and new file changes appeared (changes count was zero when the turn was submitted, non-zero when it ends), the auxiliary bar is automatically revealed to show the Changes view. This lets the user see what the agent modified without manual intervention. On mobile the auto-reveal is suppressed to avoid disruptive layout shifts. +**The side pane never opens automatically for existing sessions.** It is only shown when the user opens it; the controller never auto-reveals it on session switch or when a chat turn produces new file changes. A session with no explicit "visible" choice (including one that just converted from the new-session view to an existing session) keeps the side pane hidden until the user opens it. + +**Default view on new sessions:** An untitled (new-session) session opens the side pane by default — the Files view, or the Changes view once it has changes — and that choice sticks until the user changes it. When a new session is submitted (it converts to a real session while staying active) the side pane is kept as the user left it: if it was open it stays open and switches to the Changes view so changes are visible as soon as they land; if it was closed it stays closed. -**Default view on new sessions:** An untitled session always opens the Files view. A session with a workspace but no changes defaults to the Files view; once changes exist it defaults to the Changes view. +**Editor maximized:** While the editor area is maximized (`IAgentWorkbenchLayoutService.isEditorMaximized()`), the Changes view is always shown in the auxiliary bar, **irrespective of the session's previous or saved state**. This is driven directly from the auxiliary-bar sync autorun, so it holds across session changes and changes-state updates while maximized. The forced visibility is never captured as the session's per-session preference, so when the editor is un-maximized the autorun re-runs and restores the session's real auxiliary bar state. + +`setEditorMaximized` (in `browser/workbench.ts`) treats maximize as a fully reversible state: on entering it snapshots the editor part's size and the surrounding parts' visibility, and on exiting it restores the auxiliary bar to its pre-maximize visibility and resizes the editor part back to its captured width. Without this, the auxiliary bar that the controller forces visible while maximized would otherwise remain (and shrink the editor) after un-maximizing, so the editor would not return to its previous size. ### Panel @@ -233,7 +272,9 @@ The panel (terminal / debug output) is hidden by default for all sessions. Each ### Editor Working Sets -When `workbench.editor.useModal` is not `'all'`, each session remembers which editors were open. On session switch the previous session's open editors are saved as a named working set and the incoming session's working set is restored. Archived or deleted sessions have their working sets removed. +Each session remembers which editors were open, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid editor part even when other editors are forced modal (`useModal: 'all'`), so their tabs still need per-session tracking. On session switch the previous session's open editors are saved as a named working set and the incoming session's working set is restored. Archived or deleted sessions have their working sets removed. + +A session also remembers whether its editor part was hidden (e.g. the user closed the Side Panel while keeping editors open). Restoring such a session keeps the editor part hidden rather than forcing it back open with the working set. This is coordinated carefully: the active session observable is updated before the workspace folders update, so `LayoutController` waits until the workspace folders reflect the new session before applying the working set (to avoid restoring editors into the wrong workspace). diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index 78ed6891628c4..b846eb8a2a968 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -1,8 +1,21 @@ # Layout Controller — Per-Session Layout State -This document specifies the behaviour of `LayoutController` -([contrib/layout/browser/sessionLayoutController.ts](contrib/layout/browser/sessionLayoutController.ts)), -the contribution that manages workbench layout as the user switches between sessions. +This document specifies how the session layout controllers manage workbench layout as the user +switches between sessions. The implementation is split across three files, each with its own +file-level spec. Each spec states the behaviour as numbered **scenario rules** (and keeps the *how* in +a separate "Implementation notes" section); the code and tests reference these rules by tag: + +| File | Spec | Rules | +|------|------|-------| +| `contrib/layout/browser/baseSessionLayoutController.ts` (`BaseLayoutController`) | [baseSessionLayoutController.md](contrib/layout/browser/baseSessionLayoutController.md) | `B1`–`B5` | +| `contrib/layout/browser/desktopSessionLayoutController.ts` (`LayoutController`) | [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) | `D1`–`D10` | +| `contrib/layout/browser/mobileSessionLayoutController.ts` (`MobileLayoutController`) | [mobileSessionLayoutController.md](contrib/layout/browser/mobileSessionLayoutController.md) | `M1`–`M2` | + +The abstract `BaseLayoutController` owns the platform-agnostic mechanics (panel, editor working sets, +persistence, multi-session suppression). `LayoutController` (desktop / web desktop) adds auxiliary bar +management; `MobileLayoutController` (web phone) omits it. `contrib/layout/browser/sessions.layout.contribution.ts` +contributes the correct controller per platform (and registers the experimental responsive-sidebar +setting); it is imported from `sessions.desktop.main.ts` (desktop) and `sessions.web.main.ts` (web). It is the detailed companion to [LAYOUT.md §10 Per-Session Layout State](LAYOUT.md#10-per-session-layout-state). @@ -22,10 +35,11 @@ resource (`URI`) and persisted to workspace storage: | Auxiliary bar (secondary side bar) | `_viewStateBySession` | visibility + active view container | | Panel (terminal / debug output) | `_panelVisibilityBySession` | visibility only | | Editor working set | `_workingSets` | open editors in the grid editor part | +| Editor part visibility | `_editorPartHiddenBySession` | whether the editor part was left hidden | All state flows from the `activeSession` **observable** (never events). The controller derives -`activeSessionResourceObs`, `activeSessionHasChangesObs`, `activeSessionIsUntitledObs`, -`activeSessionHasWorkspaceObs`, and `multipleSessionsVisibleObs`, then reacts with `autorun`. +`activeSessionResourceObs`, `activeSessionIsCreatedObs`, `activeSessionHasWorkspaceObs`, and +`multipleSessionsVisibleObs`, then reacts with `autorun`. --- @@ -41,8 +55,8 @@ When more than one session is visible at once (the Sessions Part grid shows seve **all per-session sync is suppressed**: - The aux-bar / panel sync autoruns bail out early (`multipleSessionsVisibleObs`). -- A dedicated autorun **clears** `_viewStateBySession`, `_panelVisibilityBySession`, and - `_pendingTurnStateByResource` for every visible session. +- A dedicated autorun **clears** `_viewStateBySession` and `_panelVisibilityBySession` for every + visible session. This guarantees that after collapsing back to a single session the **default visibility logic** (§3.2) runs again instead of restoring stale single-session state. Editor working sets are *not* @@ -63,51 +77,80 @@ Skipped entirely on mobile web (`isWeb && isMobile`) to avoid disruptive auto-ex ### 3.2 Switching to — restore -`_syncAuxiliaryBarVisibility(resource, hasWorkspace, isUntitled, hasChanges)` applies state in +`_syncAuxiliaryBarVisibility(resource, hasWorkspace, isCreated)` applies state in strict priority order: 1. **No resource / no workspace** → do nothing. -2. **Untitled session** → open the Files container (`SESSIONS_FILES_CONTAINER_ID`), leave visibility as is. -3. **Saved state exists**: - - was **hidden** → hide the aux bar and stop. - - was visible with an active container → reopen that container and stop. -4. **No saved state (first visit) — defaults**: - - session **has changes** → open the Changes view (`CHANGES_VIEW_ID`). - - otherwise → open the Files container. - -### 3.3 Auto-reveal on new changes - -A separate autorun watches for turn completion (also skipped on mobile web). When a chat request -is submitted (`onDidSubmitRequest`), the controller records `IPendingTurnState` -(`hadChangesBeforeSend`, `submittedAt`) for the session. When that session's `lastTurnEnd` -advances past `submittedAt`: - -- if there were **no** changes before the turn but there **are** changes now, the aux bar is - revealed (`setPartHidden(false, AUXILIARYBAR_PART)`) and the session's saved view state is - cleared so it stays visible on the next switch. - -This only applies to the single-visible-session case; the pending state is dropped when multiple -sessions are visible. - -### 3.4 Live visibility tracking +2. **Uncreated session (new-session view)** → all uncreated sessions share a single state object + (`_newSessionViewState`, persisted to workspace storage under `sessions.newSessionViewState`): if + the user explicitly hid the aux bar on a new session it stays hidden (across switches *and* + reloads); otherwise the default container (§3.2 step 4) is shown. This is the main place the + side pane is opened automatically — a new session opens it by default so the user starts with + Files visible. +3. **Created session** (existing session): the side pane is **never auto-opened** except for the + same-session submit transition (§3.3). + - saved state is **hidden** *or* there is **no saved state** → hide the aux bar and stop. A + session with no explicit "visible" choice — including one that just converted from the + new-session view to an existing session — stays closed until the user opens it. + - saved state is **visible** with a still-pinned active container → reopen that container. + - saved state is **visible** but its container is gone → fall back to the default container + (§3.2 step 4). +4. **Default container** (`_openDefaultAuxiliaryBarContainer`), used only when the side pane is being + shown (new-session default, or restoring a session the user explicitly left visible): + - session **is created** → open the Changes view (`CHANGES_VIEW_ID`). + - otherwise → open the Files container (falling back to Changes if Files is hidden). + +### 3.3 New-session submit + +When the active new session becomes created (`isCreated` changes from false to true for the same +session), the side pane stays in whatever visibility state the user left it. If it is visible, the +controller switches it to Changes immediately. If it is hidden, the controller records Changes as that +session's default active container so opening the side pane later shows Changes. + +### 3.4 No auto-reveal on changes + +The side pane is **not** revealed, and the active container is not changed, when a chat turn produces +new file changes. The controller does not track pending turns or file-change counts for default +selection; the automatic switch to Changes is driven by the created transition (§3.3). Once a session +is created the side pane stays in whatever state the user left it. + +### 3.5 Live visibility tracking Aux-bar visibility is also tracked **live** (not only on session switch) via an -`onDidChangePartVisibility` listener for `AUXILIARYBAR_PART` that re-runs `_captureViewState` for -the active session (skipped on mobile web and while multiple sessions are visible). Without this, -the sync autorun — which re-evaluates whenever the session's changes/workspace state updates, not -just on switch — would find no saved state and re-run the default visibility logic (§3.2), -re-revealing a side bar the user had just hidden. +`onDidChangePartVisibility` listener for `AUXILIARYBAR_PART` (skipped on mobile web and while +multiple sessions are visible). For a titled active session it re-runs `_captureViewState`; for an +uncreated active session it updates the shared `_newSessionViewState` (§3.2 step 2). When a created +session with hidden saved state is opened, the saved/default active container is restored before the +visible state is captured, so a hidden-on-submit session opens to Changes. -### 3.5 Editor reveal on session switch +### 3.6 Editor reveal on session switch The editor part is revealed programmatically when a session's editor working set is restored on a -session **switch** (`_revealEditorPartForWorkingSet`, §5). It is **not** revealed on the initial -restore after a reload (§5.2) — the editor part visibility the workbench restored is preserved, so a -session whose editor part was hidden (e.g. by closing the Side Panel, which hides both the auxiliary -bar and the editor part while keeping the editors open) stays hidden. The editor part visibility -otherwise follows direct editor open/close events and the user's chevron toggle. Each session's -saved aux-bar visibility wins on switch — a side bar the user hid for a session stays hidden when -they return to it. +session **switch** (`_revealEditorPartForWorkingSet`, §5) — **unless** that session left the editor part +hidden. Each session's editor part hidden state is captured on switch-away (`_saveWorkingSet` records +`_editorPartHiddenBySession`); a session whose editor part was hidden (e.g. by closing the Side Panel, +which hides both the auxiliary bar and the editor part while keeping the editors open) keeps the editor +part hidden when restored. It is also **not** revealed on the initial restore after a reload (§5.2) — +the editor part visibility the workbench restored is preserved. The editor part visibility otherwise +follows direct editor open/close events and the user's chevron toggle. Each session's saved aux-bar +visibility wins on switch — a side bar the user hid for a session stays hidden when they return to it. + +### 3.7 Empty auxiliary bar (D10) + +The auxiliary-bar **part** is kept hidden whenever it has **no active view container** — for example a +workspace-less quick chat, where the Changes and Files containers are gated off by their `when` clauses. +`_hasActiveAuxViewContainers()` (base) counts active aux-bar containers via +`IViewDescriptorService.getViewContainersByLocation(AuxiliaryBar)` + `IViewsService.isViewContainerActive` +(the same rule the workbench uses: `!hideIfEmpty || activeViewDescriptors.length > 0`). +`_registerAuxiliaryBarPartVisibility` (desktop) re-checks it reactively — on container add/remove, location +moves, each container model's `onDidChangeActiveViewDescriptors` (the gating signal), and aux-bar +`onDidChangeViewContainerVisibility` — and `_syncAuxiliaryBarPartVisibility` hides the part (routing +through `_hideAuxiliaryBarForRestore` so §3.5 does not record it as a choice). It **only hides**; a +container becoming active again lets the normal restore rules (§3.2 / D8) reveal the part. The +`toggleSidePane` re-open path (§ base) guards the aux-bar un-hide with `_hasActiveAuxViewContainers()` +symmetric to `hasEditors`, and its "ensure a visible effect" fallback prefers the editor and never reveals +an empty aux bar. The `Toggle Side Panel` command is additionally **disabled** for quick chats +(`precondition: IsQuickChatSessionContext.negate()`), since a quick chat has no side pane to toggle. --- @@ -127,8 +170,11 @@ session (suppressed while multiple sessions are visible). ## 5. Editor Working Sets -Active only when `workbench.editor.useModal` is **not** `'all'` (editors live in the grid editor -part rather than as modal overlays). Driven by `_useModalConfigObs`. +Always active, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid +editor part even when editors are otherwise forced modal (`useModal: 'all'`) — they except themselves +from the modal part — so their tabs still need per-session capture/restore. `_useModalConfigObs` is +consulted only inside `_applyWorkingSet`, to decide whether to auto-reveal the editor part on switch +(skipped in modal mode, since modal editors manage their own visibility). ### 5.1 Workspace-folder ordering @@ -143,11 +189,15 @@ Using `runOnChange(activeSessionForWorkingSet, ...)`: - **Outgoing session** (skip untitled): `_saveWorkingSet` snapshots the currently open editors as a named working set (`session-working-set:`); sessions with no visible editors store nothing. + It also records whether the editor part is currently hidden in `_editorPartHiddenBySession`, but only + while a single session is visible — in multi-session mode the editor area is shared, so its visibility + is not captured as a per-session choice. - **Incoming session**: `_applyWorkingSet` restores its saved working set (or `'empty'`). All - applies are serialized through a `Sequencer`. When not in modal mode and the working set is - non-empty, the editor part is revealed before/after applying via `_revealEditorPartForWorkingSet`, - which suppresses the editor→aux-bar invariant (§3.4) so the session's saved aux-bar visibility is - honored. + applies are serialized through a `Sequencer`. When not in modal mode, the working set is + non-empty, **and the session did not leave the editor part hidden**, the editor part is revealed + before/after applying via `_revealEditorPartForWorkingSet`, which suppresses the editor→aux-bar + invariant (§3.4) so the session's saved aux-bar visibility is honored. A session whose + `_editorPartHiddenBySession` entry is `true` keeps the editor part hidden on switch. On initial load (no previous session) the controller only applies a working set if one is already saved for the incoming session — it never applies `'empty'`, to avoid closing editors being restored. @@ -157,23 +207,28 @@ because the user closed the Side Panel) is preserved across reloads. ### 5.3 Cleanup -`onDidChangeSessions` removes working sets **and** per-session view state for **archived** or -**deleted** sessions. View-state removal is done explicitly in that handler — `_deleteWorkingSet` -only drops the editor working set. (It must **not** drop the view state, because it is also called -from `_saveWorkingSet` on every switch-away / shutdown; coupling the two would wipe a session's -saved aux-bar visibility whenever it had editors but no longer does, causing the aux bar to fall -back to the default-visible logic (§3.2) on the next reload.) +`onDidChangeSessions` removes working sets, per-session view state, **and** the editor part hidden +state for **archived** or **deleted** sessions. View-state and editor-part-visibility removal is done +explicitly in that handler — `_deleteWorkingSet` only drops the editor working set. (It must **not** +drop the view state, because it is also called from `_saveWorkingSet` on every switch-away / shutdown; +coupling the two would wipe a session's saved aux-bar visibility whenever it had editors but no longer +does, causing the aux bar to fall back to the default-visible logic (§3.2) on the next reload.) --- ## 6. Persistence -- All state serializes to the workspace-scoped key `sessions.layoutState` on +- All per-session state serializes to the workspace-scoped key `sessions.layoutState` on `IStorageService.onWillSaveState` (`_saveState`), with a `StorageTarget.MACHINE` target. -- `_saveState` captures the active session's current view state and working set (skipping untitled / - multi-session cases) and writes one `ISessionLayoutEntry` per known session resource. -- `_loadState` reads `sessions.layoutState`; if absent it performs a one-time migration from the - legacy `sessions.workingSets` key and then removes it. Corrupted data is dropped defensively. +- `_saveState` captures the active session's current view state, working set, and editor part hidden + state (skipping untitled / multi-session cases) and writes one `ISessionLayoutEntry` per known + session resource. +- The shared new-session view state (§3.2 step 2) is persisted separately under the workspace-scoped + key `sessions.newSessionViewState` as an `INewSessionViewState` object, written immediately whenever + the user toggles the aux bar on the new-session view (not on shutdown). +- `_loadState` reads `sessions.newSessionViewState` and `sessions.layoutState`; if the latter is + absent it performs a one-time migration from the legacy `sessions.workingSets` key and then removes + it. Corrupted data is dropped defensively. --- @@ -182,5 +237,23 @@ back to the default-visible logic (§3.2) on the next reload.) - **Observables, not events**, drive all session-switch logic. - **Multiple visible sessions** disable per-session view/panel sync and clear that state (working sets preserved). -- **Default visibility** (§3.2 step 4) only applies when a session has no saved aux-bar state. +- **The side pane is never auto-opened for existing sessions on restore** — it opens automatically as + the new-session default (§3.2 step 2) and stays visible when an already-visible new session is + submitted (§3.3). A created session with no explicit "visible" choice stays closed until the user + opens it. +- **The sessions sidebar is auto-managed on a small window (desktop, [D7])** — when the main container is + 1800px wide or narrower and both the editor and auxiliary bar are open, the sidebar is hidden; it is shown + again once either closes or the window widens, unless the user closed it themselves. Suspended while + multiple sessions are visible, and switching sessions never auto-hides the sidebar: the base-controller + restore epoch (`_withSessionLayoutRestore` / `_isRestoringSessionLayout`) wraps both the aux-bar restore + and the editor working-set apply (`_applyWorkingSet`), so the side-pane / editor reveals a switch causes + re-baseline the state instead of triggering an auto-hide. Gated by the + experimental setting `sessions.layout.autoCollapseSessionsSidebar` (default on in non-stable builds). See + [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) D7. - Working-set save/apply waits for **workspace folders** to catch up with the active session. +- **An empty auxiliary bar is hidden (desktop, [D10])** — when the aux bar has no active view container + (e.g. a workspace-less quick chat where Changes/Files are gated off), the `AUXILIARYBAR_PART` is kept + hidden instead of showing an empty column, updating reactively as the active session flips. The + controller only hides an empty aux bar (reveals stay with D3/D8), and **Toggle Side Panel** only + reveals the part that has content — never an empty aux bar, and is **disabled entirely for quick chats** + (`IsQuickChatSessionContext.negate()`). diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index c1fb75eab6317..05362ac50d170 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -135,6 +135,33 @@ Session-level properties are derived from chats: - `status` is aggregated (`NeedsInput` > `InProgress` > other) - `isRead` is `true` only when all chats are read +#### Read-only and hidden chats + +Each `IChat` exposes `interactivity: IObservable` — a provider-agnostic tri-state (`Full` / `ReadOnly` / `Hidden`) that mirrors the agent host protocol's `ChatInteractivity` but is decoupled from it so any provider can report it. Providers that don't distinguish interactivity report `Full`. + +- **`Full`** — the user can send messages (default). Composer shown. +- **`ReadOnly`** — the chat is shown but the composer is hidden: the agents-window chat view (`ChatView`) calls `ChatWidget.setReadOnly(true)`, which removes the composer (via an inline `display` style so it wins over the stylesheet without a specificity battle), focuses the message list, and sets the widget-scoped `chatIsReadonly` context key. That context key gates mutating per-request actions so read-only chats do not offer **Start Over**, **Restore Checkpoint**, **Restore to Last Checkpoint**, or **Undo Requests** (their menus and keybindings negate `ChatContextKeys.readOnly`). The tab shows a lock icon (`chatCompositeBar`). This supports the agent-team pattern where worker chats are observable but not directly steerable. +- **`Hidden`** — an internal worker chat that must not be surfaced in the UI at all. The visible session model (`VisibleSession`) filters `Hidden` chats out of `openChats` (the tab strip) and never selects one as the active chat (the close-chat and active-chat fallbacks skip them). `Hidden` is a *visibility* concern handled by the UI layer; providers still report it faithfully on `IChat`. + +`ChatView` treats any non-`Full` interactivity as read-only (`setReadOnly(interactivity !== Full)`); `Hidden` chats are filtered before they reach a `ChatView`. + +In the agent host, the real producer of read-only chats is **subagent (worker) chats**: when an agent's tool spawns a subagent, `AgentSideEffects._handleSubagentStarted` (`src/vs/platform/agentHost/node/agentSideEffects.ts`) calls `stateManager.addChat(...)` with `interactivity: ChatInteractivity.ReadOnly` and an `origin` of `{ kind: Tool, ... }`. The lead chat stays `Full` (the user steers the agent there) while the subagent chat is observable but read-only. The interactivity flows on the protocol `ChatSummary` into `applyChatCatalog` and through the provider-agnostic `IChat.interactivity` mapping above. + +**Surfacing subagent chats as tabs.** Subagent chats are surfaced as read-only peer tabs (in addition to the inline `ChatSubagentContentPart` rendering in the parent chat). Two pieces make this work: + +- `applyChatCatalog` (`baseAgentHostSessionsProvider.ts`) surfaces a non-default chat as a peer when the session supports multiple chats (`copilotcli`) **or** the chat is a subagent (`origin.kind === Tool`). So subagent chats appear as peers even in single-chat session types (e.g. `claude`), while ordinary user/fork peers still require multi-chat support. +- `chatCompositeBar` renders those tabs (it no longer filters out `origin.kind === Tool` chats) but keeps the trailing **New Chat** action gated to `capabilities.supportsMultipleChats`, so single-chat sessions that merely host a subagent don't expose chat creation. + +Subagent chats **persist** in the session catalog after the subagent completes (completion only marks the chat's turn complete; the chat is removed only when the whole session is disposed), so the read-only tab stays reviewable for the lifetime of the session. + +**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The pill is inserted at the **start** of the subagent header row (before the streaming title) so it keeps a fixed position instead of shifting as the title grows; its label reactively shows the **subagent chat's own title** (resolved from the forwarded chat resource via `findSubagentChat`), so in the Agents window the duplicate inline header title is hidden (single chip — the pill is only contributed there, so the CSS is scoped to `.agent-sessions-workbench`), with the subagent's **agent name** (e.g. "General-purpose", "Task"; forwarded on the toolbar context, falling back to "Subagent") rendered as a prefix before the pill. The pill itself is a standalone chip styled like the chat file/diff pill (`chat-codeblock-pill-widget`) — a colorless, bordered chip rather than a filled button (`OpenSubagentChatActionViewItem` extends `BaseActionViewItem` and renders its own DOM, avoiding the meta-button's inline-style foreground that CSS can't override) — with a leading conversation icon by default, swapped for a **spinner** while the subagent is still running, driven purely by CSS from the `chat-thinking-active` class the chat widget toggles on the enclosing `.chat-subagent-part` (see `media/openSubagentChat.css`). The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes the plain-string command `CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID` (`workbench.action.chat.openAgentHostChat`) with the subagent chat URI; the sessions layer registers the handler (`openSubagentChat.ts`), which derives the chatId from the URI (handling the AHP, synthetic-fragment, and backend-path forms), finds the matching surfaced peer across the visible sessions, and calls `sessionsService.openChat` to activate the tab. The command no-ops when no handler is registered (e.g. the widget hosted outside the Agents window). + +**Restoring subagent chats.** Subagent chats are in-memory only; on restart the agent host restores them as separate sessions but no longer re-adds them to the parent catalog. `AgentService._registerRestoredSubagent` mirrors the live `_handleSubagentStarted` flow on restore — it re-adds the subagent to the parent session's catalog (same `ahp-chat://subagent/...` chat URI, `origin: Tool`, `interactivity: ReadOnly`, restored turns) so it reappears as a read-only tab. + +**Subagents dropdown.** `SessionAgentsControl` (`contrib/chat/browser/sessionAgentsControl.ts`), mounted by `ChatView` directly **above the chat input** (as the first child of the input part, above the session banners), is a **"Subagents"** dropdown (comment-discussion icon) that lists the subagents spawned by the **currently-viewed** chat. Activating it opens a context menu of those subagents; selecting one opens that read-only subagent chat via `sessionsService.openChat`. Per-chat association uses `IChatOrigin.parentChat` — the sessions-layer origin now carries the spawning chat's resource (mapped from the protocol `ChatOrigin.chat` by the agent host provider's `_resolveParentChatResource`). The control hides when the active chat has no subagents. + +Read-only is honored on both rendering paths: `SessionView` only routes an `Untitled` chat to the editable new-chat composer (`NewChatView`) when the chat is also `Full` — a non-interactive chat always uses the standard `ChatView` (whose `setReadOnly(true)` hides the input). Without this guard a freshly-added read-only peer chat (which is briefly `Untitled`) would surface the new-chat composer and remain editable. + The active session (`IActiveSession`) extends `ISession` with an `activeChat` observable that tracks which chat the user is viewing. Chat input history in the Agents Window is scoped by `ISession.sessionId`. Pressing Up/Down in a chat input only navigates prompts previously submitted in the same session, including across multiple chats in that session. Users can disable `chat.agentSessions.scopedInputHistory` to restore shared input history across sessions. When a provider replaces a temporary untitled session with a committed session after the first send, history is moved from the temporary session id to the committed session id. @@ -157,6 +184,23 @@ The session type picker persists the last selection as `{ providerId, sessionTyp On reload, providers register asynchronously and agent hosts connect lazily, so the preferred provider may not have surfaced its session types when the restored draft is created. Rather than blocking on a "ready" gate, `NewChatWidget` creates the draft immediately with the best available provider, then upgrades it in place once the preferred `(providerId, sessionTypeId)` pair becomes servable (driven by `onDidChangeSessionTypes`). The upgrade listener lives for the widget's lifetime — there is **no** timeout or `LifecyclePhase` give-up, since an agent host can connect arbitrarily late — and is cancelled if the user picks a different type or the draft is sent. +### Quick Chats + +A **quick chat** is a workspace-less session — one that is not scoped to any folder, so `ISession.workspace` resolves to `undefined`. Quick chats let the user start a conversation immediately, without first picking a repository or worktree. + +The contract is small and provider-agnostic: + +- **`ISessionsProvider.supportsQuickChats`** (optional `boolean`) — whether the provider can mint quick chats. Providers that truly change capabilities at runtime can signal that via the optional **`onDidChangeCapabilities`** event. The local agent-host provider snapshots `chat.agentHost.enabled` at startup, so its value is stable for the life of the provider instance. +- **`ISessionsProvider.createQuickChat(sessionTypeId)`** — required when `supportsQuickChats` is `true`. Returns an untitled draft (like `createNewSession`) that is not added to the session list until the first request is sent. +- **`ISessionsManagementService.createQuickChat(options?)`** — selects the first quick-chat-capable provider (honouring `order` and `options.providerId`), resolves the session type from `options.sessionTypeId` or the last-used / first advertised type, persists the resolved type as last-used, and mints a new quick-chat session **per call** (New Quick Chat = new session). +- **`ISessionsManagementService.getQuickChatSessionTypes()`** — every session type advertised by quick-chat-capable providers, for the inline composer type picker. +- **`ISessionsService.openQuickChat(options?)`** — view-layer entry point; opens the quick chat as a normal session. +- **`ISession.isQuickChat`** (optional `IObservable`) — set only by quick-chat-capable providers (absent ⇒ `false`). Consumers read it via the `isQuickChatSession(session)` helper. The agent-host adapter derives it from the host's `workspaceless` tag, **not** from `workspace === undefined`, which can be transiently undefined for workspace-bound sessions too. + +Presentation: a quick chat is a **single-chat** session that uses the normal session header (no peer-chat tab strip); only the Done/archive affordance is hidden. Its untitled-title fallback is **"New Chat"** (not "New Session") — every fallback site (titlebar, session header, list hover, sessions picker) routes through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`). **Cmd+N always creates a new session** (`NewChatInSessionsWindowAction` → `openNewSession`); a quick chat is created **only** via the "Chats"-section **"+"** (`NewQuickChatAction`, also bound to **Cmd+K Cmd+N**), which opens the composer with the inline session-type picker feeding `openQuickChat({ sessionTypeId })` on send. Peer chats within a session are a third gesture (chat **"+"** / Cmd+T). Keep these three creation actions distinct. + +On the agent host, workspace-less is **inferred from an absent `workingDirectory`** at session start (forks are excluded — they inherit the source context), not from any wire flag. The host tags such sessions with `workspaceless` in the session `_meta` bag, gives each a stable per-session scratch directory, and uses a repo-less system prompt. See [`AGENT_HOST_SESSIONS_PROVIDER.md`](contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md) for the host-side details and [`SESSIONS_LIST.md`](SESSIONS_LIST.md) for the in-list "Chats" section. + ### Changesets Sessions produce file changes organized into **`ISessionChangeset`** groups — named, togglable collections of file modifications that let users review and selectively apply changes. @@ -203,7 +247,11 @@ Sessions produce file changes organized into **`ISessionChangeset`** groups — ``` Follow-up messages to an existing chat go through `SessionsManagementService.sendRequest(session, chat, options)`. The view makes -the sent chat the active chat by reacting to the send events. +the sent chat the active chat by reacting to the send events. When +`options.background` is set, the send is **fire-and-forget** and skips the +`onWillSendRequest` notification, so the view's send-follow never navigates the +visible slot into the sent chat — see *Adding a Chat to an Existing Session* +below. Explicit user-initiated "new session" gestures (Ctrl/Cmd+N, the **New** button, the mobile titlebar "+" button, and the sessions quick picker's "New Session" @@ -213,6 +261,31 @@ when one exists or showing the empty placeholder otherwise. Internal callers (restore fallback, archive, background reseed, and the close-session fallback) invoke `openNewSession()` the same way. +The new-session input separately persists its text and attachments in +workspace-scoped machine storage. `NewChatWidget` saves that draft when it is +disposed (for example, when navigating to an existing session), and the +replacement widget restores it when the user returns to the new-session view. +Starting a send clears the stored draft before request dispatch and any view +replacement. + +Per-session view state (the last active chat, the set of closed chats, grid +order, stickiness, and which slot was active) is held in `SessionsService`'s +`_sessionStates` map and serialized to workspace-scoped machine storage. The +grid order / stickiness / active-slot flags are snapshotted from the live grid +at save time (`onWillSaveState`), the last active chat is tracked reactively, +and the closed-chat set is maintained **deterministically** in +`closeChat`/`openChat` (`_setChatClosedState`) — adding the chat's resource when +it is closed and removing it when reopened. This matters because switching to +another session disposes the previous session's `VisibleSession` wrapper (and +its in-memory closed set) before the next storage flush; keeping +`_sessionStates` current means switching back re-seeds the wrapper +(`_restoreClosedChats`) with the right closed chats, so closed tabs stay hidden +across both reloads and session switches. The set is updated on the close/open +action itself rather than derived from the `closedChats` observable (which +intersects with the session's *loaded* chats), so it never depends on chats +having loaded or on autorun timing. Stale URIs for chats that were later deleted +are harmless: restore intersects the persisted set with the live chat list. + `sendNewChatRequest(session, options)` accepts a `background` flag: a background new-session send returns the agents window to a fresh new-session view (via `openNewSession`) **before** creating and sending the session, and skips the @@ -230,10 +303,10 @@ longer referenced by `_pendingNewSession`. `background` lives on the management-layer `ISendRequestOptions` (which extends the provider's send-request options). Providers do not interpret the flag; it is -purely a management/UI concern. In the new-session composer the gesture is -**Alt+Enter** (or **Alt-click** the Send button); plain Enter / click sends in -the foreground. The background gesture is only offered for the new-session -composer, not when sending a new chat within an existing session. +purely a management/UI concern. The gesture is **Alt+Enter** (or **Alt-click** +the Send button); plain Enter / click sends in the foreground. It is offered both +by the new-session composer and by the new-chat-in-session composer (see *Adding +a Chat to an Existing Session* below). For callers outside the new-session composer, `createAndSendNewChatRequest(folderUri, options, createOptions?)` creates a fresh @@ -248,8 +321,9 @@ can react. Providers that set `capabilities.supportsMultipleChats` can host several peer chats inside one session that share a single backend scope (workspace, model, -config). For the local agent host provider this is enabled for the -`copilotcli` session type only. +config). For the agent host providers this is enabled for the `copilotcli` and +`claude` session types, whose backends (`CopilotAgent` / `ClaudeAgent`) +implement the peer-chat lifecycle (`createChat` / `disposeChat` / `getChats`). ``` 1. User adds a chat to a running session @@ -268,6 +342,36 @@ config). For the local agent host provider this is enabled for the → Returns the new IChat ``` +The **new-chat-in-session composer** (`NewChatInSessionWidget`) is shown when the +active chat is `Untitled` (`openNewChatInSession` creates/reuses an untitled chat +and makes it active). Sending from it calls +`sendRequest(session, untitledChat, options)`. Plain Enter / click sends in the +**foreground** (the view follows the send and navigates into the now-running +chat). **Alt+Enter** / **Alt-click** sends in the **background**: the widget first +resets the composer to a fresh untitled chat via +`openNewChatInSession(session, { forceNew: true })`, then the management service +runs the send fire-and-forget without firing `onWillSendRequest` (so the view's +send-follow never navigates into it). `forceNew` skips the reuse-untitled lookup +so a genuinely new chat is created rather than re-binding the composer to the +chat being sent. The user stays in the composer to start another parallel +conversation while the sent chat appears in the session's chat list once it +commits. + +The reset is sequenced **before** the send on purpose. Creating the replacement +chat (`provider.createNewChat`) and dispatching the send both reach into shared +chat-session state (`acquireOrLoadSession` / `getOrCreateChatSession`) for chats +in the **same group**. Running them concurrently raced and left the sent chat +stuck spinning with its message never dispatched. Fully awaiting the composer +reset before firing the background send keeps the send running on its own. + +Tab order in the chat composite bar is **stabilised by the renderer**, not by +the providers. The rebuild autorun (in `browser/parts/chatCompositeBar.ts`) +keeps each provider's reported chat order but moves any in-composer `Untitled` +chat to the end. This is provider-agnostic on purpose: the agent host re-sorts +its `state.chats` catalog when a chat finishes a turn (moving the just-completed +chat to the end) — pinning the untitled composer chat last keeps a +just-completed background chat from visibly jumping past it in the tab strip. + On the host, `AgentHostStateManager` keeps an authoritative multi-chat catalog per session: `addChat`/`removeChat` create/delete a per-chat `ChatState` and dispatch `SessionChatAdded`/`SessionChatRemoved`; the default chat (whose @@ -297,6 +401,58 @@ when reconciliation drops them and when the adapter itself is evicted from a peer with `map.clear()`/`map.delete()` — use `clearAndDisposeAll()`/ `deleteAndDispose()` so the `AdditionalChat` is actually torn down. +#### Forking into a new chat (multi-chat sessions) + +For sessions that support multiple chats, the **Fork Conversation** gesture +creates a new **peer chat** in the *same* session — seeded with the source +chat's history up to the fork point — instead of a brand-new session. The +single-chat fork (which mints a new session via `createSession({ fork })`) is +kept as the fallback for non-multi-chat sessions. + +Routing: `ForkConversationAction` exposes a `_tryForkAsChat` hook (default +no-op). The Agents window override (in `localChatSessions.contribution.ts`) +resolves the owning `ISession`, and only for agent-host sessions that +`supportsMultipleChats`, calls +`ISessionsManagementService.forkChatInSession(session, sourceChat, turnId)` → +`ISessionsProvider.forkChat` and then `openChat`s the new chat. The service +returns the new chat or throws (for example when the session does not support +multi-chat forking); it never returns `undefined`. Non-agent-host sessions keep +the new-session fork path. The `turnId` is the **last turn to keep**: forking +from a selected request forks *before* it (so `turnId` is the previous request's +id), matching the new-session fork path (`AgentHostSessionHandler._forkSession`); +forking the whole conversation keeps everything up to the source chat's last +request. + +On the agent host, `forkChat` mints a client-chosen chat URI and calls +`connection.createChat(sessionUri, chatUri, { fork: { source, turnId } })`. The +`source` is the backend chat URI (a `chatId` fragment addresses a peer chat, +otherwise the session's default chat). `AgentService.createChat` resolves the +source chat's turns up to the fork point, mints fresh turn IDs +(`fork.turnIdMapping`), forwards the fork to the agent, and seeds the new chat's +`ChatState` with the remapped turns (`addChat({ turns })`) plus a `Forked:` +title. If the requested `turnId` is not present in the source state, the fork is +dropped (mirroring the no-turn `createSession` fallback) so the agent does not +inherit the whole backend conversation while the new chat is seeded with zero +turns. `CopilotAgent.createChat` forks the source chat's SDK conversation +(`sessions.fork` at the turn's event id), copies its database into the new +chat's data dir, resumes it, and `remapTurnIds`. The forked chat is committed +(not `Untitled`) and surfaces through the normal `SessionChatAdded` catalog +flow. + +The `Forked: ` title is only a placeholder: because a fork seeds +pre-existing turns, the usual first-message/first-turn title generation never +fires for it. Instead `AgentService` calls +`AgentHostSessionTitleController.generateForkedTitle` once at fork time (for both +forked chats and forked sessions), which summarizes the inherited conversation +via the Copilot utility model and replaces the placeholder with a +content-derived title. The context lists the kept turns oldest-first and, when +the source title is known, prepends a short framing note that the conversation +was branched from that earlier chat so the model titles the ongoing topic (the +prompt forbids labelling the result as forked/branched). The conversation +context is bounded to the same character budget (middle-truncated) as first-turn +refinement, so it costs at most one small-model call, and a concurrent manual +`/rename` suppresses it. + The session handler (`agentHostSessionHandler.ts`) routes each chat widget to its own AHP chat channel. Session-scoped reads (`summary`/`config`/`activeClient`) stay on the session URI, while conversation reads/dispatches @@ -305,6 +461,33 @@ tool-call confirmations, input requests) are threaded through the resolved chat URI so peer chats run concurrently without cross-talk. `_resolveSessionUri` ignores the fragment to find the parent session; `_resolveChatUri` returns the fragment's chat URI (or the default chat URI when there is no fragment). +Agent backends must emit chat progress signals against the chat channel that owns +the turn/tool call. `AgentSideEffects` treats that channel as authoritative; if a +permission request from an additional chat arrives on the parent session URI, that +is a producer bug because the peer-chat UI will not receive the AHP update. When +an `ahp-chat` channel is malformed, handlers throw instead of falling back to the +parent session URI so routing bugs are not hidden. +Tool-call confirmation bookkeeping (`_toolCallAgents`) is keyed by the same chat +channel that received `ChatToolCallStart`/`ChatToolCallReady`; confirmations sent +to the parent session URI are invalid and will not resolve the SDK permission +request. + +Subagents are modelled as additional chats on the parent session, not as separate +sessions. When a `subagent_started` signal arrives, the host adds a subagent chat +to the parent session and dispatches the subagent turn on that chat URI; restoring +a standalone subagent session would create only session state and leave chat +actions with no `_chatStates` entry. Subagent chat URIs use the stable +`ahp-chat://subagent/...` authority and store the case-sensitive tool call id in +the path (`buildSubagentChatUri`), because URI authorities are case-insensitive. +Subagent chats are created with `origin.kind === "tool"` and are hidden from the +chat tab strip; the parent tool invocation is their visible UI entry point. + +On the workbench side, `AgentHostSessionHandler` stores the upstream chat channel +in `_chatURIsBySessionResource` after hydrating the session state. For default +chats this URI comes from `SessionState.defaultChat`; for peer chats it is matched +from `SessionState.chats` by the resource fragment. The handler must not +reconstruct the default URI with `buildDefaultChatUri` before dispatching turns, +because providers are free to choose a different default-chat URI shape. #### Renaming: session vs chat are independent @@ -340,10 +523,13 @@ Single-chat providers (`copilotChatSessions`, `localChatSessions`) implement `ISessionsProvider` method (no optional methods — see the interface guideline). Whether the rename UI is *offered* is gated on `capabilities.supportsRename`, not -on the provider id. The session header inline-rename (`SessionHeader._isTitleEditable`) -and the sessions-list "Rename..." action (gated on the -`chatSessionSupportsRename` context-menu-overlay key, set from -`element.capabilities.supportsRename` in `sessionsList`) both read this flag. +on the provider id. `ISession.capabilities` is an `IObservable` +so consumers react when a provider's advertised capabilities hydrate or change after +the session is first surfaced (e.g. an agent host whose root state arrives after the +session's first state update). The session header inline-rename +(`SessionHeader._isTitleEditable`) and the sessions-list "Rename..." action (gated on +the `sessionSupportsRename` context-menu-overlay key, set from +`element.capabilities.get().supportsRename` in `sessionsList`) both read this flag. Providers declare it truthfully: agent-host and `localChatSessions` sessions are always renameable; `copilotChatSessions` sets it only for the CopilotCLI and Claude session types, since `renameChat` throws for other backends. Omitting the flag means diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index da8eae0726310..1ccd533d6b813 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -15,6 +15,8 @@ The sessions list (`SessionsView` + `SessionsList`) displays every session known | `contrib/sessions/browser/views/sessionsView.ts` | `SessionsView` — ViewPane with header, new-session button, sort/group/filter persistence | | `contrib/sessions/browser/views/sessionsList.ts` | `SessionsList` — tree control, grouping/filtering logic, menu IDs, context keys | | `services/sessions/browser/sessionsListModelService.ts` | `ISessionsListModelService` — pin/read state + shared status icon (UI-only, not synced to providers) | +| `services/sessions/browser/sessionGroupsService.ts` | `ISessionGroupsService` — user-created groups and session→group membership (UI-only) | +| `services/sessions/browser/sessionSectionOrderService.ts` | `ISessionSectionOrderService` — manual top-level order of groups + workspace sections and workspace promotion (UI-only) | | `contrib/sessions/browser/views/sessionsViewActions.ts` | All registered actions (sort, group, filter, pin, archive, rename, navigate) | --- @@ -37,15 +39,23 @@ Each session row displays: Sessions are organized into sections with fixed priority: ``` -1. Pinned ← always first -2. Regular ← grouped by workspace or date -3. Done/Archived ← always last +1. Pinned ← always first, not reorderable +2. Chats ← workspace-less "quick chat" sessions; always-visible, directly below Pinned +3. Groups ← user-created groups, user-ordered (see below) +4. Regular ← grouped by workspace or date +5. Done/Archived ← always last, not reorderable ``` +The **Chats** section holds workspace-less quick-chat sessions, detected via the `isQuickChatSession(session)` helper (which reads the session's own `ISession.isQuickChat` observable — **not** `workspace === undefined`, which can be transiently undefined for workspace-bound sessions too). It renders **inside the Sessions list directly below the Pinned section** (above the workspace/date groups) in **both** grouping modes — quick chats are neither a workspace nor a date bucket, so they are partitioned out of workspace/date grouping and rendered as their own entry right after Pinned. The section is **always visible** (even with no quick chats) whenever a provider advertises `supportsQuickChats`, so its create affordance is always reachable. Both the Pinned and Chats section headers carry a **leading icon** (`Codicon.pinned` for Pinned, `Codicon.commentDiscussion` for Chats) and share the standard section-header font/styling (the two headers look consistent — no prominent top-title variant). The Chats header shows the chat icon, the label "Chats", and a **"+" New Quick Chat** action in its section toolbar (also bound to **Cmd+K Cmd+N**) — the *only* create affordance for quick chats (Cmd+N always creates a new **session**, not a quick chat; there is no quick-chat action in the top Sessions header). "Mark All as Done" is not offered on the Chats section. When the section has **no quick chats**, it shows a muted, centered **"No chats" placeholder row** (a synthetic non-session list item, like the "show more" rows) instead of an empty section. A pinned quick chat still appears in Pinned (pin wins), and an archived one still goes to Done (archive wins). Quick-chat rows use a comment/chat icon instead of the folder/worktree/cloud workspace icon and carry no workspace badge. That per-row chat icon is **suppressed when the row renders under the Chats section** (whose header already carries a chat icon) and shown only where the chat identity is useful — a quick chat pinned to Pinned or moved into a custom group. + +Each quick chat is its **own single-chat session** (New Quick Chat = a new session per create), so it occupies one list row like any other session — there are no chat-level (`IChat`) rows. A quick chat is pinned/grouped/archived as a whole session: a pinned quick chat appears in Pinned (pin wins), an archived one goes to Done (archive wins). The earlier "single quick-chat container session whose peer `IChat`s become their own rows" model was descoped. + Two grouping modes (user-switchable): -- **By Workspace** (default) — one section per workspace label, sorted alphabetically. "Unknown" workspace sorts last. -- **By Date** — sections: Today, Yesterday, Last 7 Days, Older. +- **By Workspace** (default) — user groups and one section per workspace label share a single, freely-reorderable user-managed order below Pinned. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them. +- **By Date** — user groups form a contiguous, user-ordered block directly below Pinned; the non-grouped sessions follow in the fixed date sections (Recent, Older), where Recent holds up to 10 sessions from the last 7 days and Older holds the rest. Groups never mix into the date sections. + +User groups are **fully user-managed**: their order is owned by `ISessionSectionOrderService`, defaults to newest-first, and is shared across both grouping modes (it no longer derives from the recency of a group's member sessions). Archived sessions always go to the "Done" section regardless of grouping mode. Archive wins over pin — an archived session is never shown in Pinned. @@ -54,13 +64,13 @@ Archived sessions always go to the "Done" section regardless of grouping mode. A - **By Created** (default) — `createdAt` descending - **By Updated** — `updatedAt` descending -### Workspace Group Capping +### Workspace and User Group Capping When grouping by workspace, the list shows only **primary** workspace sections by default: - A workspace qualifies as primary if it has recent activity (last 4 days), matches the open window's folder, or contains the most recently updated session - Remaining workspaces collapse behind a "+N more workspaces" toggle -- Within each workspace, sessions beyond 5 also show a "Show more" toggle +- Within each workspace or user-created group, sessions beyond 5 (configurable by the same assignment treatment) also show a "Show more" toggle and then a "Show less" toggle once expanded - The find widget bypasses all capping ### Filtering @@ -83,22 +93,42 @@ A built-in find widget filters the list by session title and section label. When ### Pinning -Pinned sessions appear in a dedicated "Pinned" section at the top. Pin state is managed by `ISessionsListModelService` and persisted locally (not synced to providers). +Pinned sessions appear in a dedicated "Pinned" section at the top. The section is **always visible** (even with no pinned sessions), mirroring the "Chats" section; when empty it shows a muted, centered **"No pinned sessions" placeholder row**. Pin state is managed by `ISessionsListModelService` and persisted locally (not synced to providers). + +The **Pinned** and **Chats** sections start **collapsed on first open** (their default collapse state is `PreserveOrCollapsed` when no saved state exists). Once the user expands or collapses either section, that choice is persisted per-section under `sessionsListControl.sectionCollapseState` and honored on subsequent loads. ### Manual Reordering (Drag & Drop) -Regular sessions can be reordered by dragging them up or down within the list. An insertion line is shown between rows while dragging. +Two things can be reordered by dragging: **sessions** (within a section/group) and **top-level headers** (user groups and workspace sections). An insertion line is shown between rows/headers while dragging. + +#### Reordering sessions + +Regular sessions can be reordered by dragging them up or down within the list. Pinned sessions can be reordered within the Pinned section. - **Storage** — reordering stores a synthetic numeric *sort key* per session in `ISessionsListModelService` (persisted locally, not synced). It is used **only** for sorting; the provider's real `createdAt`/`updatedAt` are never modified. A separate override map is kept for each sort mode (Created vs Updated). - **Sort key** — on drop, the new key is the midpoint between the effective keys of the sessions immediately above and below the drop point. Dropping above the first session uses the current time (so it sorts to the top). Dropping below the last session steps below the last key. - **Dropping the fake value** — if a session's natural timestamp already sorts it into the dropped slot (e.g. after dragging it down and back), the stored override is removed so the list falls back to natural ordering. -- **Grouping by Date** — the regular list is one continuous sequence, so dragging can move a session across date buckets (e.g. to the top makes it "Today"). +- **Grouping by Date** — the regular list is one continuous sequence, so dragging can move a session across date buckets (e.g. to the top makes it "Recent"). - **Grouping by Workspace** — reordering is restricted to within the same workspace group; drops onto another workspace are rejected. -- **Scope** — only regular sessions reorder. Drops onto the Pinned and Done sections, section headers, and "show more" rows are rejected. +- **Pinned** — dropping a non-archived session on the Pinned header pins it and lets it sort naturally. Dropping it on a pinned session shows an insertion line, pins it, and stores the sort key needed to place it at that location. +- **User groups** — dropping a non-archived session on a group header adds or moves it into the group and lets it sort naturally. Dropping it on a session inside the group shows an insertion line for the exact slot and highlights only the group header to indicate the receiving group. +- **Scope** — archived (Done) sessions do not reorder or move into groups. Drops onto the Done section, unsupported section headers, and "show more" rows are rejected. - **Multi-selection** — dragging multiple selected sessions moves them as a contiguous block, preserving their relative order. The drag label reads `"N sessions"`. Dragging sessions into the sessions grid opens all of them. +#### Reordering groups and workspaces + +Dragging a **group header** or a **workspace section header** over another reorderable header shows an insertion line above/below it and moves the dragged header to that slot on drop. + +- **Storage** — the top-level order is owned by `ISessionSectionOrderService` (persisted locally, not synced) as a flat list of identities (`group:` and `workspace: