asJEI/vscode
GitHub用于在VS Code源码开发中启动隔离的Code OSS实例。支持复用已登录配置以端到端测试Chat/Agent,结合Playwright进行UI自动化,并可通过dap-cli附加调试器,同时自动分配端口避免冲突。
安装全部 Skills
npx skills add asJEI/vscode --all -g -y
更多选项
预览集合内 Skills
npx skills add asJEI/vscode --list
集合内 Skills (47)
.agents/skills/launch/SKILL.md
npx skills add asJEI/vscode --skill launch -g -y
SKILL.md
Frontmatter
{
"name": "launch",
"description": "Launch Code OSS (VS Code from sources) into an isolated throwaway profile with unique debug ports so you can drive it with @playwright\/cli AND attach a Node debugger via dap-cli in the same session. Use when working on VS Code itself and you want to interact with the running workbench, automate chat or UI flows, test UI features, take screenshots, set breakpoints in the renderer \/ extension host \/ main process, or combine UI driving with debugging."
}
Code OSS Dev - Launch + Debug
You're working on VS Code itself and you want to:
- Launch a Code OSS build from sources that is already signed in (Copilot, GitHub, etc.) so chat / agent flows work end-to-end.
- Drive it with
@playwright/cliover CDP (UI automation). - Optionally attach a debugger via dap-cli to set breakpoints in the renderer, extension host, or main process.
- Run multiple instances at once without port conflicts.
This skill provides a launcher that clones an authenticated user-data-dir to a throwaway temp folder, picks free ports for every debug surface, and prints them as JSON so you can pick them up programmatically.
The clone is slim: workspace storage, browser caches, file history, cached VSIX backups, and old logs are excluded by default. Auth tokens themselves live in the OS keychain (shared automatically) plus small files inside User/globalStorage - both of which are preserved.
Prerequisites
- macOS or Linux. The launcher is a bash script and depends on
rsync,curl,nohup, and Node onPATH. The example caller snippets below also usejq(parse the JSON output) andlsof(kill-by-port fallback) — install those if you plan to use them, but the launcher itself does not require them. - A VS Code checkout with
node_modules/installed (npm installif missing — do not symlink from a sibling worktree; that breaks builds in subtle ways). - A VS Code checkout with sources built. Run
npm run compileonce (one-shot) ornpm run watchfor incremental rebuilds. Both build the full client and all built-in extensions underextensions/. You must build the full product to run successfully, building just the client is not enough. - An authenticated Code OSS profile to seed from. By default the launcher uses
~/.vscode-oss-dev, which is the user-data-dir the repo'slaunch.jsonconfigs use - if the user has ever signed in to Copilot in a dev build, this should work. Only pass--source-user-data-dir <path>(or set$CODE_OSS_DEV_AUTHED_USER_DATA_DIR) when you specifically want to seed from a different profile (e.g. your regular~/Library/Application Support/Codeinstall).- If Code OSS launches and needs a sign-in, don't give up! Use the questions tool to ask the user to sign in.
@playwright/cliavailable (it's a devDependency in the vscode repo -npm installthen usenpx @playwright/cli).- For debugger work:
dap-clionPATH. If debugger support would be useful but thedap-cliskill is not present, prompt the user to install it from https://github.com/roblourens/dap-cli. - CSS selectors are internal implementation details. If a selector-based
evalstops working, take a freshsnapshot, inspect the current DOM, and update the selector rather than assuming an old one still applies.
The launcher copies the source profile to a temp dir and never mutates the original. Each launch gets its own isolated
--user-data-dirand--extensions-dir.
The launcher always sets
files.simpleDialog.enable: truein the launched profile'sUser/settings.json. This is required for automation: VS Code's native OS file dialogs cannot be driven via@playwright/cliover CDP and are completely unreachable over SSH on headless macOS. The simple (quick-input) dialog can be navigated withpressand clipboard paste. The override is per-launch and only affects throwaway profiles.
Launch
The launcher script lives next to this SKILL.md at scripts/launch.sh. Resolve it relative to wherever this skill file is installed - do not hardcode an absolute path.
# LAUNCH=<dir-of-this-SKILL.md>/scripts/launch.sh
"$LAUNCH" # default: workbench
"$LAUNCH" --agents # Agents window
"$LAUNCH" -- <workspace-path> # forward extra args to code.sh
"$LAUNCH" --source-user-data-dir <path> # pick a specific authed profile
"$LAUNCH" --repo <vscode-repo-root> # if not run from the repo
"$LAUNCH" --clone-extensions # start with a copy of the source extensions/ (~few seconds)
"$LAUNCH" --full # skip slim excludes; copy everything
What gets copied (slim mode, the default)
The exclude list mirrors the one used by VS Code's own perf-test skill (.github/skills/auto-perf-optimize), which is known to keep Copilot auth and language-model availability working. Specifically WebStorage/, Service Worker/, Local Storage/, Cookies, Network Persistent State, TransportSecurity, Trust Tokens, Preferences, machineid, and the entire User/globalStorage/ (which holds state.vscdb - where extension SecretStorage blobs live, encrypted with the OS keychain key) are all preserved. Auth tokens themselves stay in the OS keychain, which is per-user, so they follow automatically.
Excluded (transient, regenerable, or known-not-needed):
User/workspaceStorage/- per-workspace state, including stored chat sessions (often multi-GB)User/History/- local file edit historyCachedExtensionVSIXs- backup VSIXs (hundreds of MB)logs- Chromium caches:
Cache,Code Cache,CachedData,GPUCache,ShaderCache,Dawn*Cache,component_crx_cache Backups,blob_storage,BrowserMetrics,Crashpad,Session StorageSingleton*,*.lock,*.sock(would conflict with the source instance)
extensions/ defaults to a fresh empty directory - fastest and conflict-free, but the launched instance starts with no third-party extensions installed. Pass --clone-extensions to copy the source extensions dir into the temp profile so the new instance is independent of the source. Pass --full to skip all excludes if you suspect the slim copy is missing something you need.
Why never share the source
extensions/dir directly? The extension management service writes a shared.obsoletefile; two concurrent writers crash each other's shared background process. The launcher always uses an isolated extensions dir for the same reason it uses--shared-data-dir(see below).
If the launched window says "language model unavailable" or otherwise looks unauthed, ask the user to sign in.
The script runs pre-launch (electron download, compile-if-missing, built-in extensions) in the foreground, then starts Code OSS detached and blocks until the renderer's CDP endpoint is responding (up to ~90s) before printing the JSON line on stdout. If anything fails — preLaunch errors, code.sh exits early, CDP never opens — the script exits non-zero and dumps the relevant log tail to stderr.
{"pid":12345,"cdpPort":53111,"extHostPort":53112,"mainPort":53113,"agentHostPort":53114,"userDataDir":".../user-data","extensionsDir":".../extensions","sharedDataDir":".../shared-data","runDir":"...","logFile":".../code.log","repo":"...","agents":false}
Capture it with jq — no retry loop needed, CDP is already up when the JSON is printed:
INFO=$("$LAUNCH" | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
EXT=$(jq -r .extHostPort <<<"$INFO")
MAIN=$(jq -r .mainPort <<<"$INFO")
AGENT=$(jq -r .agentHostPort <<<"$INFO")
LOG=$(jq -r .logFile <<<"$INFO")
PID=$(jq -r .pid <<<"$INFO")
What each port is for
| Port | Process | Use with |
|---|---|---|
cdpPort (--remote-debugging-port) |
Renderer (the workbench window) | @playwright/cli over CDP, also Chrome DevTools |
extHostPort (--inspect-extensions) |
Extension host (Node) | dap-cli (Node inspector protocol) |
mainPort (--inspect) |
Electron main process (Node) | dap-cli (Node inspector protocol) |
agentHostPort (--inspect-agenthost) |
Agent host process (Node) | dap-cli (Node inspector protocol) |
Drive the UI with @playwright/cli
Use the dynamic cdpPort from the launch JSON. The normal loop is: attach, confirm the target, snapshot, interact, then re-snapshot after meaningful UI changes.
Always pick a unique
PW_SESSIONname and pass it as-s=$PW_SESSIONon everynpx @playwright/cli ...call. The CLI is backed by a persistent daemon (cliDaemon.js) keyed by session name; if two shells both omit-s=, they share the implicit"default"session and the most-recently-attached CDP "wins" for every subsequent command from either shell. The launch skill is built around isolation (per-instance UDD, ports, shared-data-dir), and this pattern keeps that isolation intact at the Playwright-driving layer too. A note on the alternativePLAYWRIGHT_CLI_SESSIONenv var: it's documented in the package README and works correctly foropen-style workflows, but it interacts poorly withattach --cdp=...(the daemon ends up with both--cdp=...and--endpoint=<env-value>, and the latter wins, causing aconnect ENOENTfailure). Confirmed against@playwright/cli@0.1.13. Explicit-s=NAMEworks in all modes.
# At the top of your script / subagent prompt:
PW_SESSION="my-uniq-$$" # any unique string; $$ is fine for one shell per agent
# launch.sh blocks until CDP is ready, so a single attach is enough.
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
npx @playwright/cli -s=$PW_SESSION tab-list
npx @playwright/cli -s=$PW_SESSION snapshot
After attach, later @playwright/cli commands keep using the connected app until you close or reattach — as long as you keep passing the same -s=$PW_SESSION.
Selecting the right Electron target
Electron apps can expose multiple windows or webviews. If tab-list shows about:blank, a webview, or otherwise the wrong target, switch targets before interacting:
npx @playwright/cli -s=$PW_SESSION tab-list
npx @playwright/cli -s=$PW_SESSION tab-select 2
npx @playwright/cli -s=$PW_SESSION snapshot
If a target looks stale after relaunching, run npx @playwright/cli -s=$PW_SESSION close, attach again with $CDP, and re-check tab-list.
Focusing the chat input (works on Code OSS, including the Agents window)
# macOS
npx @playwright/cli -s=$PW_SESSION press Control+Meta+i
# Linux / Windows
npx @playwright/cli -s=$PW_SESSION press Control+Alt+i
Typing into Monaco (chat input, editors)
fill and type silently fail on Code OSS — Monaco's native-edit-context element doesn't react to Playwright's default input pipeline. Use one of these alternatives:
-
scripts/monaco-paste.shhelper (recommended — fast, no system clipboard, parallel-safe). Reads text from a positional arg or stdin and dispatches aClipboardEvent('paste')with aDataTransferpayload into the focused chat-input Monaco editor. Honors--session NAMEor$PW_SESSIONenv so it stays inside the same-s=session as everything else.LAUNCH_DIR=<dir-of-this-SKILL.md> # the same dir that holds scripts/launch.sh PASTE="$LAUNCH_DIR/scripts/monaco-paste.sh" export PW_SESSION # helper reads this env var # Send a prompt: npx @playwright/cli -s=$PW_SESSION press Control+Meta+i # focus chat input "$PASTE" 'Please run `pwd && ls` using your terminal tool.' npx @playwright/cli -s=$PW_SESSION press Enter # Long / arbitrary text via stdin (avoids any shell-quoting headaches): printf 'multi-line prompt\nwith backticks `x`\nand emoji 🎉' | "$PASTE" # Append without clearing: "$PASTE" --append " continued text" # Skip the read-back check (useful when intentionally pasting more than the # chat input's ~600-character soft cap): "$PASTE" --no-verify "...long text..." # Or pass the session explicitly per call (if you don't want to export PW_SESSION): "$PASTE" --session "$PW_SESSION" "..."The helper prints a single JSON line on stdout:
{ok, actualLength, expectedLength, viewLineCount, firstViewLine, error?}. Exit 0 on success, 1 on verify failure, 2 on argument errors. Tested reliable across 20+ sequential pastes including unicode (中文), emoji (🎉), backticks, ampersands, embedded quotes, and newlines.Why a helper script and not just docs: the inline recipe involves a multi-line
node -eheredoc with embedded JS template literals, which is exactly the kind of code that gets miscopied. There are also three non-obvious correctness traps the helper handles internally:- Monaco's
native-edit-contextdoesn't react tofillortype, only to actual paste events (or per-keypress). - Monaco renders ASCII spaces as U+00A0 (NBSP) in the view-line DOM, so verification has to normalize before comparing.
- Monaco updates its DOM asynchronously after a paste event — a synchronous read-back inside the same
evalreturns stale state. The helper waits tworequestAnimationFrameticks before reading.
- Monaco's
-
Per-key
press(universal but slow — each press is a separate CLI invocation with Node startup cost):npx @playwright/cli -s=$PW_SESSION press H npx @playwright/cli -s=$PW_SESSION press i npx @playwright/cli -s=$PW_SESSION press Enter -
Clipboard paste via
pbcopy(fast on macOS, butNSPasteboardis system-wide so any concurrent shell that touches the pasteboard will collide). Only use when nothing else on the machine is using the clipboard for the duration of the paste.printf '%s' "Your prompt here" | pbcopy npx @playwright/cli -s=$PW_SESSION press Control+Meta+i npx @playwright/cli -s=$PW_SESSION press Meta+v npx @playwright/cli -s=$PW_SESSION press Enter
The focus shortcut should leave document.activeElement on VS Code's native-edit-context editing surface. That is a useful sanity check when key presses appear to do nothing.
Parallel multi-instance pattern
Because the launch skill is built around isolation, the natural workload is many agents on one machine, each driving their own Code OSS. The pattern boils down to giving each agent a unique PW_SESSION and passing it everywhere:
# In agent A's shell:
PW_SESSION="agent-A-$$"
INFO=$("$LAUNCH" --agents -- --use-mock-keychain | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
"$PASTE" "prompt for A" # helper picks up $PW_SESSION
# In agent B's shell (running concurrently):
PW_SESSION="agent-B-$$"
INFO=$("$LAUNCH" --agents -- --use-mock-keychain | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
"$PASTE" "prompt for B"
Each agent gets its own cliDaemon bound to its own CDP, so the pastes / clicks / snapshots don't cross-contaminate. Verified live with two concurrent instances. macOS Mach-ports caveat: on macOS, beyond ~2–3 concurrent Code OSS instances Crashpad's exception handler tends to die with mach_port_request_notification: invalid capability. That's a separate, OS-level limit; it's not affected by the session name.
Cleanup for
cliDaemonprocesses: stop your session's daemon withnpx @playwright/cli -s=$PW_SESSION close, or nuke all stale daemons (after killing all the Code OSS windows) withnpx @playwright/cli kill-all. Session daemons live under~/Library/Caches/ms-playwright/daemon/<hash>/.
Agents window selector differences
The Agents window does not use the regular workbench .interactive-input-editor wrapper. Selector checks that are scoped to that wrapper may return nothing even when the Agents chat input is focused.
// Regular-workbench-specific selector; do not assume this exists in Agents.
document.querySelectorAll('.interactive-input-editor .view-line')
// More useful checks in Agents.
document.querySelectorAll('.view-line')
document.activeElement?.className === 'native-edit-context'
The Control+Meta+i / Control+Alt+i focus shortcut still works; only the DOM shape after focus differs.
Verifying and clearing chat text
For the regular workbench sidebar, this confirms that text landed in the Monaco input:
npx @playwright/cli -s=$PW_SESSION eval '
(() => {
const sidebar = document.querySelector(".part.auxiliarybar");
const viewLines = sidebar?.querySelectorAll(".interactive-input-editor .view-line") ?? [];
return Array.from(viewLines).map(viewLine => viewLine.textContent).join("|");
})()'
For the Agents window, use a fresh snapshot plus the broader selector/focus checks above instead of assuming the regular sidebar wrapper is present.
To clear the focused Monaco input:
# macOS
npx @playwright/cli -s=$PW_SESSION press Meta+a
# Linux / Windows
npx @playwright/cli -s=$PW_SESSION press Control+a
npx @playwright/cli -s=$PW_SESSION press Backspace
If the keyboard shortcut cannot focus chat because the surface is not available yet, take a snapshot and navigate the UI into a state where chat exists before retrying. Avoid treating completed CLI commands as proof that text was entered.
Screenshots (paper trail)
SHOTS="$PWD/screenshots/$(date +%Y-%m-%dT%H-%M-%S)"
mkdir -p "$SHOTS"
npx @playwright/cli -s=$PW_SESSION screenshot --filename="$SHOTS/after-launch.png"
Keep screenshots inside the workspace, not
/tmp, so they survive for review.
For wide windows, --full-page can make layout easier to inspect, and element screenshots are useful when a snapshot gives a stable ref for the panel you care about:
npx @playwright/cli -s=$PW_SESSION screenshot --full-page --filename="$SHOTS/full-window.png"
npx @playwright/cli -s=$PW_SESSION screenshot e42 --filename="$SHOTS/panel.png"
On macOS, a screenshot "Permission denied" failure usually means the terminal lacks Screen Recording permission. Use text/state verification while resolving that permission issue.
Debug with dap-cli
To set breakpoints in VS Code source while the window is running, attach dap-cli to one of the ports. If dap-cli would help but the corresponding skill is unavailable, prompt the user to install it from https://github.com/roblourens/dap-cli before continuing with debugger-specific steps.
Read the dap-cli skill for the full attach/breakpoint/inspect workflow when it is available - this skill only tells you which port to point it at:
- Extension host (most common - Copilot Chat extension, built-in extensions, your own extension under development) ->
extHostPort - Main process (Electron lifecycle, window/menu wiring, IPC) ->
mainPort - Local agent host (
src/vs/platform/agentHost/node/..., agent session lifecycle, AHP wiring, Claude/Copilot agent providers) ->agentHostPort - Renderer (the workbench itself,
src/vs/workbench/...) ->cdpPort
You can run @playwright/cli and dap-cli against the same window simultaneously - drive the UI with one terminal, hit a breakpoint and inspect state in another.
Multiple instances
Every launch picks fresh ports and a fresh temp runDir, so you can run as many concurrent Code OSS windows as your machine can handle. Each one's ports come back in its own JSON blob - keep them separate.
The launcher also passes --shared-data-dir=<runDir>/shared-data. This is required for multi-instance isolation: Code OSS keeps a fixed-path SQLite DB at ~/.<dataFolderName>-shared/sharedStorage/state.vscdb that is not covered by --user-data-dir. Without overriding it, two concurrent instances would fight over the same file and one would die with "shared background process terminated unexpectedly". Each launch gets its own shared-data dir.
Restart after source changes
Workbench code is loaded when the Code OSS window starts; source changes are not hot-reloaded into an already-running instance. After the build output is current, kill the launched process, launch again, and reattach to the new cdpPort from the new JSON blob.
kill "$PID" 2>/dev/null || true
INFO=$("$LAUNCH" | tail -n1)
CDP=$(jq -r .cdpPort <<<"$INFO")
PID=$(jq -r .pid <<<"$INFO")
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
npx @playwright/cli -s=$PW_SESSION tab-list
npx @playwright/cli -s=$PW_SESSION snapshot
If you are iterating frequently, keep the repo build/watch task running separately so relaunches pick up already-generated output.
Cleanup
The launcher writes everything under a temp runDir (printed in the JSON). When you're done:
# Disconnect this session's playwright daemon (leaves other sessions' daemons alone)
npx @playwright/cli -s=$PW_SESSION close
# Or nuke any stale daemons left behind by crashed callers across all sessions:
# npx @playwright/cli kill-all
# Kill the Code OSS instance
kill "$PID" 2>/dev/null || true
# Or by port if you've lost the pid:
pids=$(lsof -t -i :$CDP); [ -n "$pids" ] && kill $pids
# Remove the throwaway profile
rm -rf "$(dirname "$LOG")"
Code OSS is a full Electron app and easily eats 1-4 GB. Always clean up.
Troubleshooting
- "Sent env to running instance. Terminating..." - The dynamic
--user-data-dirshould prevent this. If you see it, another Code OSS is using the same profile path; pass--source-user-data-dirto a different source or check that the temp copy actually happened (ls "$(jq -r .userDataDir <<<"$INFO")"). - Renderer ESM errors /
import { Menu } from 'electron'-ELECTRON_RUN_AS_NODEis set in your env. The launcher unsets it for the child, but if you spawncode.shyourself, do the same. - Built-in extension fails to load (
Cannot find module .../extensions/.../out/extension.js) - extensions weren't compiled. Runnpm run compile(one-shot, also rebuilds all built-in extensions) ornpm run watch(incremental). A common cause: you rannpm run transpile-clientto satisfy unit tests, which populatedout/but notextensions/*/out/, so preLaunch's "isout/missing?" check skipped the compile. launch.shexits non-zero with a log tail - either pre-launch failed,code.shdied before CDP came up, or CDP never opened within 90s. The tail printed to stderr is fromrunDir/code.log- read it to diagnose.- Snapshot shows the wrong page or no expected controls - use
tab-list, switch withtab-select <index>if needed, then re-snapshot before interacting. - CLI typing commands complete but the input stays empty - focus chat with the platform shortcut, use
pressor clipboard paste rather thanfill/type, then verify the input state before sending. - Auth missing in the launched window - confirm the source profile is actually authed (
ls "$SOURCE_UDD"should containUser/, andls "$SOURCE_UDD/User/globalStorage"should show persisted extension state). Some auth lives in the OS keychain - that's per-user, so it follows automatically as long as you're running as the same user.
.github/skills/accessibility/SKILL.md
npx skills add asJEI/vscode --skill accessibility -g -y
SKILL.md
Frontmatter
{
"name": "accessibility",
"description": "Primary accessibility skill for VS Code. REQUIRED for new feature and contribution work, and also applies to updates of existing UI. Covers accessibility help dialogs, accessible views, verbosity settings, signals, ARIA announcements, keyboard navigation, and ARIA labels\/roles."
}
When to Use This Skill
Use this skill for any VS Code feature work that introduces or changes interactive UI. Use this skill by default for new features and contributions, including when the request does not explicitly mention accessibility.
Trigger examples:
- "add a new feature"
- "implement a new panel/view/widget"
- "add a new command or workflow"
- "new contribution in workbench/editor/extensions"
- "update existing UI interactions"
Do not skip this skill just because accessibility is not named in the prompt.
When adding a new interactive UI surface to VS Code — a panel, view, widget, editor overlay, dialog, or any rich focusable component the user interacts with — you must provide three accessibility components (if they do not already exist for the feature):
- An Accessibility Help Dialog — opened via the accessibility help keybinding when the feature has focus.
- An Accessible View — a plain-text read-only editor that presents the feature's content to screen reader users (when the feature displays non-trivial visual content).
- An Accessibility Verbosity Setting — a boolean setting that controls whether the "open accessibility help" hint is announced.
Examples of existing features that have all three: the terminal, chat panel, notebook, diff editor, inline completions, comments, debug REPL, hover, and notifications. Features with only a help dialog (no accessible view) include find widgets, source control input, keybindings editor, problems panel, and walkthroughs.
Sections 4–7 below (signals, ARIA announcements, keyboard navigation, ARIA labels) apply more broadly to any UI change, including modifications to existing features.
When updating an existing feature — for example, adding new commands, keyboard shortcuts, or interactive capabilities — you must also update the feature's existing accessibility help dialog (provideContent()) to document the new functionality. Screen reader users rely on the help dialog as the primary way to discover available actions.
1. Accessibility Help Dialog
An accessibility help dialog tells the user what the feature does, which keyboard shortcuts are available, and how to interact with it via a screen reader.
Steps
-
Create a class implementing
IAccessibleViewImplementationwithtype = AccessibleViewType.Help.- Set a
priority(higher = shown first when multiple providers match). - Set
whento aContextKeyExpressionthat matches when the feature is focused. getProvider(accessor)returns anAccessibleContentProvider.
- Set a
-
Create a content-provider class implementing
IAccessibleViewContentProvider.id— add a new entry in theAccessibleViewProviderIdenum insrc/vs/platform/accessibility/browser/accessibleView.ts.verbositySettingKey— reference the newAccessibilityVerbositySettingIdentry (see §3).options—{ type: AccessibleViewType.Help }.provideContent()— return localized, multi-line help text.
-
Implement
onClose()to restore focus to whatever element was focused before the help dialog opened. This ensures keyboard users and screen reader users return to their previous context. -
Register the implementation:
AccessibleViewRegistry.register(new MyFeatureAccessibilityHelp());in the feature's
*.contribution.tsfile.
Example skeleton
The simplest approach is to return an AccessibleContentProvider directly from getProvider(). This is the most common pattern in the codebase (used by chat, inline chat, quick chat, etc.):
import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId } from '../../../../platform/accessibility/browser/accessibleView.js';
import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js';
import { AccessibilityVerbositySettingId } from '../../../../platform/accessibility/common/accessibilityConfiguration.js';
export class MyFeatureAccessibilityHelp implements IAccessibleViewImplementation {
readonly priority = 100;
readonly name = 'my-feature';
readonly type = AccessibleViewType.Help;
readonly when = MyFeatureContextKeys.isFocused;
getProvider(accessor: ServicesAccessor) {
const helpText = [
localize('myFeature.help.overview', "You are in My Feature. …"),
localize('myFeature.help.key1', "- {0}: Do something", '<keybinding:myFeature.doSomething>'),
].join('\n');
return new AccessibleContentProvider(
AccessibleViewProviderId.MyFeature,
{ type: AccessibleViewType.Help },
() => helpText,
() => { /* onClose — refocus whatever was focused before */ },
AccessibilityVerbositySettingId.MyFeature,
);
}
}
Alternatively, if the provider needs injected services or must track state (e.g., storing a reference to the previously focused element), create a custom class that extends Disposable and implements IAccessibleViewContentProvider, then instantiate it via IInstantiationService (see CommentsAccessibilityHelpProvider for an example):
class MyFeatureAccessibilityHelpProvider extends Disposable implements IAccessibleViewContentProvider {
readonly id = AccessibleViewProviderId.MyFeature;
readonly verbositySettingKey = AccessibilityVerbositySettingId.MyFeature;
readonly options: IAccessibleViewOptions = { type: AccessibleViewType.Help };
provideContent(): string { /* … */ }
onClose(): void { /* … */ }
}
// In getProvider():
getProvider(accessor: ServicesAccessor) {
return accessor.get(IInstantiationService).createInstance(MyFeatureAccessibilityHelpProvider);
}
2. Accessible View
An accessible view presents the feature's visual content as plain text in a read-only editor. It is required when the feature renders rich or visual content that a screen reader cannot directly read (for example: chat responses, hover tooltips, notifications, terminal output, inline completions).
If the feature is purely keyboard-driven with native text input/output (e.g., a simple input field), an accessible view is not needed — only an accessibility help dialog is required.
Steps
- Create a class implementing
IAccessibleViewImplementationwithtype = AccessibleViewType.View. - Create a content-provider similar to the help dialog, but:
options—{ type: AccessibleViewType.View }, optionally with alanguagefor syntax highlighting.provideContent()— return the feature's current content as plain text.- Optionally implement
provideNextContent()/providePreviousContent()for item-by-item navigation. - Implement
onClose()to restore focus to whatever was focused before the accessible view was opened. - Optionally provide
actionsfor actions the user can take from the accessible view.
- Register alongside the help dialog:
AccessibleViewRegistry.register(new MyFeatureAccessibleView());
Example skeleton
export class MyFeatureAccessibleView implements IAccessibleViewImplementation {
readonly priority = 100;
readonly name = 'my-feature';
readonly type = AccessibleViewType.View;
readonly when = MyFeatureContextKeys.isFocused;
getProvider(accessor: ServicesAccessor) {
// Retrieve services, build content from the feature's current state
const content = getMyFeatureContent();
if (!content) {
return undefined;
}
return new AccessibleContentProvider(
AccessibleViewProviderId.MyFeature,
{ type: AccessibleViewType.View },
() => content,
() => { /* onClose — refocus whatever was focused before the accessible view opened */ },
AccessibilityVerbositySettingId.MyFeature,
);
}
}
3. Accessibility Verbosity Setting
A verbosity setting controls whether a hint such as "press Alt+F1 for accessibility help" is announced when the feature gains focus. Users who already know the shortcut can disable it.
Steps
-
Add an entry to
AccessibilityVerbositySettingIdinsrc/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts:export const enum AccessibilityVerbositySettingId { // … existing entries … MyFeature = 'accessibility.verbosity.myFeature' } -
Register the configuration property in the same file's
configuration.propertiesobject:[AccessibilityVerbositySettingId.MyFeature]: { description: localize('verbosity.myFeature.description', 'Provide information about how to access the My Feature accessibility help menu when My Feature is focused.'), ...baseVerbosityProperty },The
baseVerbosityPropertygives ittype: 'boolean',default: true, andtags: ['accessibility']. -
Reference the setting key in both the help-dialog provider (
verbositySettingKey) and the accessible-view provider so the runtime can check whether to show the hint.
4. Accessibility Signals (Sounds & Announcements)
Accessibility signals provide audible and spoken feedback for events that happen visually. Use IAccessibilitySignalService to play signals when something important occurs (e.g., an error appears, a task completes, content changes).
When to use
- Use an existing signal when the event already has one defined (see
AccessibilitySignal.*static members — e.g.,AccessibilitySignal.error,AccessibilitySignal.terminalQuickFix,AccessibilitySignal.clear). - If no existing signal fits, reach out to @meganrogge to discuss adding a new one. Do not register new signals without coordinating first.
How signals work
Each signal has two modalities controlled by user settings:
- Sound — a short audio cue, configurable to
auto(on when screen reader attached),on, oroff. - Announcement — a spoken message via
aria-live, configurable toautooroff.
Usage
// Inject the service via constructor parameter
constructor(
@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService
) { }
// Play a signal
this._accessibilitySignalService.playSignal(AccessibilitySignal.terminalQuickFix);
// Play with options
this._accessibilitySignalService.playSignal(AccessibilitySignal.error, { userGesture: true });
5. ARIA Alerts vs. Status Messages
Use the alert() and status() functions from src/vs/base/browser/ui/aria/aria.ts to announce dynamic changes to screen readers.
alert(msg) — Assertive live region (role="alert")
- Use for: Urgent, important information that the user must know immediately.
- Examples: Errors, warnings, critical state changes, results of a user-initiated action.
- Behavior: Interrupts the screen reader's current speech.
status(msg) — Polite live region (aria-live="polite")
- Use for: Non-urgent, informational updates that should be spoken when the screen reader is idle.
- Examples: Progress updates, search result counts, background state changes.
- Behavior: Queued and spoken after the screen reader finishes its current output.
Guidelines
- Prefer
status()overalert()unless the information is time-sensitive or the result of a direct user action. Overusingalert()creates a noisy, disruptive experience. - Keep messages concise. Screen readers read the entire message; long messages delay the user.
- Do not duplicate — if an accessibility signal already announces the event, do not also call
alert()/status()for the same information. - Localize all messages with
nls.localize().
6. Keyboard Navigation
Every interactive UI element must be fully operable via the keyboard.
Requirements
- Tab order: All interactive elements must be reachable via
Tab/Shift+Tabin a logical order. - Arrow key navigation: Lists, trees, grids, and toolbars must support arrow key navigation following WAI-ARIA patterns.
- Focus visibility: Focused elements must have a visible focus indicator (VS Code's theme system provides this via
focusBorder). - No mouse-only interactions: Every action reachable by click or hover must also be reachable via keyboard (context menus, buttons, toggles, etc.).
- Escape to dismiss: Overlays, dialogs, and popups must be dismissable with
Escape, returning focus to the previous element. - Focus trapping: Modal dialogs must trap focus within the dialog until dismissed.
7. ARIA Labels and Roles
All interactive UI elements must have appropriate ARIA attributes so screen readers can identify and describe them.
Requirements
aria-label: Every interactive element without visible text (icon buttons, icon-only actions, custom widgets) must have a descriptivearia-label. Labels should be localized.aria-labelledby/aria-describedby: Use these to associate elements with existing visible text rather than duplicating strings.role: Custom widgets that do not use native HTML elements must declare the correct ARIA role (e.g.,role="button",role="tree",role="tablist").aria-expanded,aria-selected,aria-checked: Toggle and selection states must be communicated via the appropriate ARIA state attributes.aria-hidden="true": Decorative or redundant elements (icons next to text labels, decorative separators) must be hidden from the accessibility tree.
Guidelines
- Avoid generic labels like "button" or "icon" — describe the action: "Close panel", "Toggle sidebar", "Run task".
- Test with a screen reader (VoiceOver on macOS, NVDA on Windows) to verify labels are spoken correctly in context.
- Lists and trees should use
aria-setsizeandaria-posinsetwhen virtualized so screen readers report the correct count.
Checklist for Every New Feature
- New
AccessibleViewProviderIdentry added inaccessibleView.ts - New
AccessibilityVerbositySettingIdentry added inaccessibilityConfiguration.ts - Verbosity setting registered in the configuration properties with
...baseVerbosityProperty -
IAccessibleViewImplementationwithtype = Helpcreated and registered - Content provider references the correct
verbositySettingKey - Help text is fully localized using
nls.localize() - Keybindings in help text use
<keybinding:commandId>syntax for dynamic resolution -
whencontext key is set so the dialog only appears when the feature is focused - If the feature has rich/visual content:
IAccessibleViewImplementationwithtype = Viewcreated and registered - Registration calls in the feature's
*.contribution.tsfile - Accessibility signal played for important events (use existing
AccessibilitySignal.*or register a new one) -
aria.alert()oraria.status()used appropriately for dynamic changes (preferstatus()unless urgent) - All interactive elements reachable and operable via keyboard
- All interactive elements without visible text have a localized
aria-label - Custom widgets declare the correct ARIA
roleand state attributes - Decorative elements are hidden with
aria-hidden="true"
Key Files
src/vs/platform/accessibility/browser/accessibleView.ts—AccessibleViewProviderId,AccessibleContentProvider,IAccessibleViewContentProvidersrc/vs/platform/accessibility/browser/accessibleViewRegistry.ts—AccessibleViewRegistry,IAccessibleViewImplementationsrc/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts—AccessibilityVerbositySettingId, verbosity setting registrationsrc/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts—IAccessibilitySignalService,AccessibilitySignalsrc/vs/base/browser/ui/aria/aria.ts—alert(),status()for ARIA live region announcements
.github/skills/add-policy/SKILL.md
npx skills add asJEI/vscode --skill add-policy -g -y
SKILL.md
Frontmatter
{
"name": "add-policy",
"description": "Use when adding, modifying, or reviewing VS Code configuration policies. Covers the full policy lifecycle from registration to export to platform-specific artifacts. Run on ANY change that adds a `policy:` field to a configuration property."
}
Adding a Configuration Policy
Policies allow enterprise administrators to lock configuration settings via OS-level mechanisms (Windows Group Policy, macOS managed preferences, Linux config files) or via Copilot account-level policy data. This skill covers the complete procedure.
When to Use
- Adding a new
policy:field to any configuration property - Modifying an existing policy (rename, category change, etc.)
- Reviewing a PR that touches policy registration
- Adding account-based policy support via
IPolicyData - Wiring an enterprise managed setting (native MDM / GitHub server) — see github-managed-settings.md
- Having one policy govern multiple settings via
policyReference - Testing account/managed-settings policies locally without the real backend — see local-testing.md
Architecture Overview
Policy Sources (layered, last writer wins)
| Source | Implementation | How it reads policies |
|---|---|---|
| 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 (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 |
| 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 |
| 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
| File | Purpose |
|---|---|
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 + 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 (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 |
src/vs/base/common/defaultAccount.ts |
IPolicyData interface (incl. managedSettings) for account-level policy fields |
build/lib/policies/policyData.jsonc |
Auto-generated policy catalog incl. referencedSettings (DO NOT edit manually) |
build/lib/policies/policyGenerator.ts |
Generates ADMX/ADML (Windows), plist (macOS), JSON (Linux) |
build/lib/test/policyConversion.test.ts |
Tests for policy artifact generation |
Procedure
Step 1 — Add the policy field to the configuration property
Find the configuration registration (typically in a *.contribution.ts file) and add a policy object to the property schema.
Required fields:
Determining minimumVersion: Always read version from the root package.json and use the major.minor portion. For example, if package.json has "version": "1.112.0", use minimumVersion: '1.112'. Never hardcode an old version like '1.99'.
policy: {
name: 'MyPolicyName', // PascalCase, unique across all policies
category: PolicyCategory.InteractiveSession, // From PolicyCategory enum
minimumVersion: '1.112', // Use major.minor from package.json version
localization: {
description: {
key: 'my.config.key', // NLS key for the description
value: nls.localize('my.config.key', "Human-readable description."),
}
}
}
Optional: value function for account-based policy:
If this policy should also be controllable via Copilot account policy data (from IPolicyData), add a value function:
policy: {
name: 'MyPolicyName',
category: PolicyCategory.InteractiveSession,
minimumVersion: '1.112', // Use major.minor from package.json version
value: (policyData) => policyData.my_field === false ? false : undefined,
localization: { /* ... */ }
}
The value function receives IPolicyData (from src/vs/base/common/defaultAccount.ts) and should:
- Return a concrete value to override the user's setting
- Return
undefinedto not apply any account-level override (falls through to OS policy or user setting)
If you need a new field on IPolicyData, add it to the interface in src/vs/base/common/defaultAccount.ts.
Optional: enumDescriptions for enum/string policies:
IMPORTANT: If the configuration property has type: 'string' and an enum array, you must include enumDescriptions in the localization block with the same number of entries as the enum array. Without this, npm run export-policy-data will fail with: enumDescriptions must exist and have the same length as enum for policy "...".
localization: {
description: { key: '...', value: nls.localize('...', "...") },
enumDescriptions: [
{ key: 'opt.none', value: nls.localize('opt.none', "No access.") },
{ key: 'opt.all', value: nls.localize('opt.all', "Full access.") },
]
}
Step 2 — Ensure PolicyCategory is imported
import { PolicyCategory } from '../../../../base/common/policy.js';
Existing categories in the PolicyCategory enum:
ExtensionsIntegratedTerminalInteractiveSession(used for all chat/Copilot policies)TelemetryUpdate
If you need a new category, add it to PolicyCategory in src/vs/base/common/policy.ts and add corresponding PolicyCategoryData localization.
Step 3 — Validate TypeScript compilation
Check the VS Code - Build watch task output, or run:
npm run typecheck-client
Step 4 — Export the policy data
Regenerate the auto-generated policy catalog:
npm run export-policy-data
This script handles transpilation, sets up GITHUB_TOKEN (via gh CLI or GitHub OAuth device flow), and runs --export-policy-data. The export command reads extension configuration policies from the distro's product.json via the GitHub API and merges them into the output.
This updates build/lib/policies/policyData.jsonc. Never edit this file manually. Verify your new policy appears in the output. You will need code review from a codeowner to merge the change to main.
Policy for extension-provided settings
Extension authors cannot add policy: fields directly—their settings are defined in the extension's package.json, not in VS Code core. Instead, policies for extension settings are defined in vscode-distro's product.json under the extensionConfigurationPolicy key.
How it works
- Source of truth: The
extensionConfigurationPolicymap lives invscode-distroundermixin/{quality}/product.json(stable, insider, exploration). - Runtime: When VS Code starts with a distro-mixed
product.json,configurationExtensionPoint.tsreadsextensionConfigurationPolicyand attaches matchingpolicyobjects to extension-contributed configuration properties. - Export/build: The
--export-policy-datacommand fetches the distro'sproduct.jsonat the commit pinned inpackage.jsonand merges extension policies into the output. Usenpm run export-policy-datawhich sets up authentication automatically.
Distro format
Each entry in extensionConfigurationPolicy must include:
"extensionConfigurationPolicy": {
"publisher.extension.settingName": {
"name": "PolicyName",
"category": "InteractiveSession",
"minimumVersion": "1.99",
"description": "Human-readable description."
}
}
name: PascalCase policy name, unique across all policiescategory: Must be a validPolicyCategoryenum value (e.g.,InteractiveSession,Extensions)minimumVersion: The VS Code version that first shipped this policydescription: Human-readable description string used to generate localization key/value pairs for ADMX/ADML/macOS/Linux policy artifacts
Adding a new extension policy
- Add the entry to
extensionConfigurationPolicyin all three qualityproduct.jsonfiles invscode-distro(mixin/stable/,mixin/insider/,mixin/exploration/) - Update the
distrocommit hash inpackage.jsonto point to the distro commit that includes your new entry — the export command fetches extension policies from the pinned distro commit - Regenerate
policyData.jsoncby runningnpm run export-policy-data(see Step 4 above) - Update the test fixture at
src/vs/workbench/contrib/policyExport/test/node/extensionPolicyFixture.jsonwith the new entry
Test fixtures
The file src/vs/workbench/contrib/policyExport/test/node/extensionPolicyFixture.json is a test fixture that must stay in sync with the extension policies in the checked-in policyData.jsonc. When extension policies are added or changed in the distro, this fixture must be updated to match — otherwise the integration test will fail because the test output (generated from the fixture) won't match the checked-in file (generated from the real distro).
Downstream consumers
| Consumer | What it reads | Output |
|---|---|---|
policyGenerator.ts |
policyData.jsonc |
ADMX/ADML (Windows GP), .mobileconfig (macOS), policy.json (Linux) |
vscode-website (gulpfile.policies.js) |
policyData.jsonc |
Enterprise policy reference table at code.visualstudio.com/docs/enterprise/policies |
vscode-docs |
Generated from website build | docs/enterprise/policies.md |
GitHub Preview Features
If your setting is a GitHub Preview Feature — meaning it's a Copilot/chat feature that organizations can disable via their GitHub account-level policy — you must add a value function that checks policyData.chat_preview_features_enabled.
When to add this flag
Add the chat_preview_features_enabled check when all of these apply:
- The setting controls a Copilot or chat feature (e.g., agent tools, hooks, MCP, auto-approve)
- The feature is in preview or experimental status (typically tagged
'preview'or'experimental') - An organization admin should be able to disable it for all users in their org via GitHub account policy
How it works
The chat_preview_features_enabled field on IPolicyData (defined in src/vs/base/common/defaultAccount.ts) is populated from the user's GitHub Copilot token entitlements. When an organization admin disables preview features, chat_preview_features_enabled is set to false.
Pattern
Add a value function to the policy that returns a disabling value when chat_preview_features_enabled === false, and undefined otherwise (to fall through to the user's own setting):
policy: {
name: 'MyPreviewFeaturePolicy',
category: PolicyCategory.InteractiveSession,
minimumVersion: '1.xx', // Must match the first VS Code release that ships this policy.
value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined,
localization: {
description: {
key: 'my.setting.description',
value: nls.localize('my.setting.description', "Description of the setting."),
}
}
}
Key details:
- Always compare with
=== false, not!policyData.chat_preview_features_enabled— the field is optional andundefinedmeans "no policy data available", which should not disable the feature. - Return
undefinedwhen the flag is notfalseso the account-level policy does not override the user's setting. - Return the disabling value for the setting's type:
falsefor booleans, a restrictive string/enum value for other types.
Real-world examples
See chat.tools.global.autoApprove and chat.useHooks in src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts for existing settings that use this pattern.
Enterprise Managed Settings (native MDM / GitHub server)
GitHub Copilot enterprise admins can lock settings through a managed-settings bag.
VS Code feeds the bag from two channels: native MDM (Windows registry / macOS plist)
and the GitHub /copilot_internal/managed_settings endpoint. (The external
managed-settings-schema.json also describes a managed-settings.json file channel, but
VS Code does not read such a file.) Both VS Code channels converge on
IPolicyData.managedSettings (a flat dot-path bag) and are consumed by the existing
policy.value(policyData) callback — there is no new IPolicyService.
To drive a policy from a managed setting, declare managedSettings on the policy and
read policyData.managedSettings?.[KEY] in value (the real ChatToolsAutoApprove also
ORs in chat_preview_features_enabled === false):
// Existing policy shown verbatim; `minimumVersion: '1.99'` is its historical value —
// a NEW policy derives minimumVersion from package.json major.minor (see Step 1).
policy: {
name: 'ChatToolsAutoApprove',
category: PolicyCategory.InteractiveSession,
minimumVersion: '1.99',
value: (policyData) =>
policyData.managedSettings?.[COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY] === 'disable'
|| policyData.chat_preview_features_enabled === false ? false : undefined,
managedSettings: {
[COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: { type: 'string' },
},
localization: { /* ... */ }
}
This is its own modality — full details, schema source of truth, helpers, wiring, and the new-key checklist are in github-managed-settings.md. Read it before adding or reviewing any managed-settings key.
Testing locally: to exercise the account/managed-settings flow without the real GitHub backend, use the mock policy server — see local-testing.md.
One Policy for Many Settings (policyReference)
A single policy can govern multiple settings (e.g. gate an agent in both the editor
window and the Agents window). The owner declares the full policy: { name, … };
other settings declare policyReference: { name } pointing at the owner's policy name.
// Owner setting (existing policy; `minimumVersion: '1.126'` is its historical value —
// a NEW policy uses package.json major.minor, see Step 1)
policy: { name: 'Codex3PIntegration', category: PolicyCategory.InteractiveSession, minimumVersion: '1.126', /* ... */ }
// Subordinate setting (no type/value/localization of its own)
policyReference: { name: 'Codex3PIntegration' }
policyReference is not managed-settings-specific: use it whenever one enterprise
policy should lock multiple settings to the same value. The reference is a pure
pointer. It contributes no type, value, managedSettings, restrictedValue, or
localization of its own; the owner remains the single source of truth for policy
metadata and runtime behavior.
Key rules and internals:
- A setting must not declare both
policyandpolicyReference(rejected during configuration registration). - Exactly one setting may own a policy name with
policy; additional settings attach withpolicyReference. - The reference setting's type must match the owner's type;
npm run export-policy-dataenforces this because the same resolved policy value is applied verbatim to owner and references. ConfigurationRegistry.getPolicyReferenceConfigurations()trackspolicyName → Set<settingKey>, andPolicyConfigurationupdates both the owner setting and all registered references when the policy value changes.AbstractPolicyService.serialize()usestoSerializablePolicyDefinition()to strip the non-cloneablevalue()callback before sending policy definitions over IPC.AbstractPolicyService.updatePolicyDefinitions()replaces definitions per policy name, so a late-registering owner supersedes an earlier reference fallback; if the owner is removed, a reference can still provide a bare type fallback.- Exported policy data includes
referencedSettingsfor references that are registered during export, and Developer: Policy Diagnostics lists registered owner/reference settings under the same policy name.
For managed-settings-specific examples that combine policyReference with Copilot
managed settings, see github-managed-settings.md.
Examples
Search the codebase for policy: to find all the examples of different policy configurations.
Learnings
- Never hand-edit
build/lib/policies/policyData.jsonc(its header explicitly forbids it). Ifnpm run export-policy-datais 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. - Regenerate
policyData.jsoncin a clean environment, or thePolicyExportintegration test will fail in CI.referencedSettingsis only captured for references loaded at export time. A plainnpm run export-policy-dataloads your dev-profile extensions (e.g. the Copilot extension), which injectsreferencedSettingsonto 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 extrareferencedSettingsand CI fails. This is not reproducible locally because the test reuses your default extensions dir. Regenerate the way the test exports:DISTRO_PRODUCT_JSON=<extensionPolicyFixture.json> ./scripts/code.sh --export-policy-data="$PWD/build/lib/policies/policyData.jsonc" --user-data-dir="$(mktemp -d)" --extensions-dir="$(mktemp -d)"(withVSCODE_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 apolicy/managedSettings/valuecallback — not for restating runtime plumbing.
.github/skills/agent-host-e2e-tests/SKILL.md
npx skills add asJEI/vscode --skill agent-host-e2e-tests -g -y
SKILL.md
Frontmatter
{
"name": "agent-host-e2e-tests",
"description": "Use when writing, recording, updating, or troubleshooting the agent host end-to-end tests under src\/vs\/platform\/agentHost\/test\/node\/protocol (black-box tests that drive the whole agent host over the AHP protocol, using a CapiReplayProxy record\/replay system for Claude\/Copilot\/Codex). Covers adding a cross-provider test, re-recording fixtures after an SDK bump, gating non-deterministic or platform-specific tests, and diagnosing replay cache misses."
}
Agent host end-to-end tests
These tests run the whole agent host end-to-end (real server, real bundled provider SDK/CLI, real AHP protocol) while replaying recorded model traffic from committed YAML fixtures — deterministic and tokenless.
Before doing anything, read the architecture + troubleshooting reference:
src/vs/platform/agentHost/test/node/protocol/README.md
It documents the mental model, the fixture format, every config flag, and a symptom→cause→fix troubleshooting table. This skill is only the workflows; the README is the source of truth for how it works.
Non-negotiable rules
- Replay is default and strict. No env var → serves committed fixtures, no token, no network. An unrecorded request is a hard cache miss that fails the run.
- A fixture's filename is derived from the test title (
${provider}-${slug}.yaml). Renaming a test orphans its fixture — re-record after any rename. - Recording needs a real token (
GITHUB_TOKENorgh auth token) and talks to real CAPI. Only run it intentionally, with trivial/read-only prompts in temp dirs. - Never hand-write or hand-edit fixture contents (especially not secrets/paths). Fixtures are always produced by recording; normalization/redaction is the proxy's job.
- Gate, don't fight. If a behavior can't replay deterministically, gate the test (see Workflow C) instead of loosening timeouts or the strict check.
Workflow A — Add a cross-provider test
- Add a
test(...)insidedefineAgentHostE2ETestsinagentHostE2ETestHelpers.ts. Drive it withdispatchTurn(...)+client.waitForNotification(...); assert on AHP notifications, never on wall-clock timing. - Keep the prompt minimal and deterministic (fewer model turns → smaller, more robust fixtures).
- Record fixtures for every enabled provider (Workflow B).
- Review the diff (Workflow B step 3), then run the test in plain replay mode to confirm it's green, then commit the test + fixtures together.
Provider-specific assertions go in that provider's *.integrationTest.ts after the defineAgentHostE2ETests(config) call.
Workflow B — Record / re-record fixtures
Re-record when you add a test, or when a bundled SDK/CLI bump changes its wire behavior (new endpoint, different turn count, changed tool schema).
- Ensure a token is available:
gh auth token(or exportGITHUB_TOKEN). - Record per provider:
Repeat forAGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ src/vs/platform/agentHost/test/node/protocol/claudeAgentHostE2E.integrationTest.tscopilotAgentHostE2E/codexAgentHostE2Eas needed. - Review
git diffon the fixtures: no local usernames/absolute paths, no tokens, no unreleased model ids. If something leaked, the fix is to extend normalization/redaction incapiReplayProxy.ts(_normalize+ the*_REredactors) and re-record — not to edit the fixture. - Run plain replay (no env var) to confirm green, then commit.
If an SDK now hits a new ancillary/bootstrap endpoint (a probe, not a real model turn), add it to capiStubs.ts (served, not recorded) instead of recording it — see how /models/session is handled.
Workflow C — When a test can't replay deterministically
Real-time streaming, mid-turn aborts, and POSIX-specific local execution (shell tools, pwd, git worktrees) don't replay reliably. Gate them precisely so you keep coverage where it works:
- Record-only (no deterministic replay at all):
(RECORD ? test : test.skip)('…')— seecan abort a running turn. - Subagent fixtures stale after an SDK bump: re-record them (
AGENT_HOST_REPLAY_RECORD=1 …). Subagent flows are the most SDK-version-sensitive (parent + child share one/v1/messagessequence), but replay reliably once re-recorded, so no gating is needed. - POSIX-only (fails on Windows): gate with
!isWindows, or a per-provider flag likeshellPermissionReplayUnstableOnWindowswhen only one provider diverges. See the worktree and shell-permission tests.
Always add a comment explaining why the gate exists.
Verifying & troubleshooting
- Run a single provider in replay:
./scripts/test-integration.sh --run <path>(no env var). - Filter to one test: add
--grep "<test title fragment>". - For any failure (
cache miss, missing fixture, per-OS timeout, leaked PII, subagent staleness, accidental real-CAPI contact), go to the Troubleshooting section of the README — it maps each symptom to its cause and fix.
.github/skills/agent-host-logs/SKILL.md
npx skills add asJEI/vscode --skill agent-host-logs -g -y
SKILL.md
Frontmatter
{
"name": "agent-host-logs",
"description": "Analyze Agent Host debug log exports. Use when given an ah-logs or ahp-logs zip\/folder, an Export Agent Host Debug Logs bundle, events.jsonl, AHP JSONL transport logs, Agent Host.log, remote-agenthost.log, or copilot-logs."
}
Agent Host Debug Logs
Use this skill to orient to bundles produced by Developer: Export Agent Host Debug Logs.... These are different from the normal timestamped Code OSS log directory.
Treat the bundle as sensitive: it can contain tokens, prompts, file contents, terminal output, paths, and settings. Keep analysis local and avoid quoting secrets or unrelated user content. Timestamps, event names, IDs, status values, and general property values are fine.
Open the Bundle
The export name usually starts with ah-logs and may be a zip or an already-unpacked folder. For a zip, use the bundled extractor:
python3 .github/skills/agent-host-logs/scripts/extract.py "<archive>.zip"
The final line gives the temporary extraction path. Work from that folder and delete only that exact folder when finished.
Files are collected best-effort, so a valid bundle may contain only some of these:
events.jsonl
Agent Host.log
Window.log
Shared.log
ahp/*.jsonl
copilot-logs/*.log
remote-agenthost.log
What the Files Mean
The basic flow is:
Window/client <-> AHP <-> Agent Host process <-> Copilot SDK
| Path | What it shows |
|---|---|
events.jsonl |
Persisted Copilot SDK events for the selected session: turns, messages, tools, permissions, hooks, skills, and subagents. It can cover a much longer period than the other logs. |
ahp/*.jsonl |
AHP traffic for a client connection. _ahpLog.dir is c2s or s2c; _ahpLog.ts is the wire timestamp. Use this to see requests, responses, subscriptions, actions, notifications, and client-visible ordering. |
Agent Host.log |
Local Agent Host process behavior: startup, auth, sessions, provider events, tools, Git/worktrees, and host-side errors. |
copilot-logs/*.log |
Copilot SDK process logs that mention the selected session ID. A process log may contain other sessions too. |
Window.log |
Renderer/client behavior: connections, session adapters, UI state, permissions, rendering, and client-side errors. |
Shared.log |
Shared-process activity. Usually secondary evidence and often noisy. |
Agent Host (<name>).log |
Forwarded logs from a named remote Agent Host. |
remote-agenthost.log |
A directly downloaded remote agenthost.log, when available. |
How to Start
- List the files and note their sizes.
- Identify the reported symptom, approximate time, local or remote host, and any known session, chat, turn, request, or tool ID.
- Start with the file closest to the symptom:
- Turn or provider behavior:
events.jsonl - Client/server state or ordering:
ahp/*.jsonl - Host implementation failure:
Agent Host.log - SDK behavior:
copilot-logs/*.log - UI behavior:
Window.log
- Turn or provider behavior:
- Search by the known time or ID, then follow the same operation into the adjacent layer.
Useful correlation fields include the raw session ID, session/chat URI, turnId, interactionId, tool/request IDs, JSON-RPC request id, AHP serverSeq, and event id/parentId.
Important Tips
events.jsonl, Copilot SDK logs, and AHP timestamps are normally UTC. The plain.logfiles may use local machine time; remote logs may use another timezone.- An AHP log is connection-scoped and can contain multiple sessions. A Copilot SDK process log can also contain multiple sessions.
- AHP files rotate as
.jsonl,.1.jsonl,.2.jsonl, and so on. Use_ahpLog.tsto reconstruct order. - A
subscriberesult can contain a full snapshot; its contents did not necessarily change at subscription time. _ahpLog.truncated: truemeans large values were omitted from that log record.- Warning or error severity alone does not prove causality. Look for the matching failed response, missing completion, or user-visible consequence.
- Missing files are normal because export collection is best-effort.
.github/skills/author-contributions/SKILL.md
npx skills add asJEI/vscode --skill author-contributions -g -y
SKILL.md
Frontmatter
{
"name": "author-contributions",
"description": "Identify all files a specific author contributed to on a branch vs its upstream, tracing code through renames. Use when asked who edited what, what code an author contributed, or to audit authorship before a merge. This skill should be run as a subagent — it performs many git operations and returns a concise table."
}
When asked to find all files a specific author contributed to on a branch (compared to main or another upstream), follow this procedure. The goal is to produce a simple table that both humans and LLMs can consume.
Run as a Subagent
This skill involves many sequential git commands. Delegate it to a subagent with a prompt like:
Find every file that author "Full Name" contributed to on branch
<branch>compared to<upstream>. Trace contributions through file renames. Return a markdown table with columns: Status (DIRECT or VIA_RENAME), File Path, and Lines (+/-). Include a summary line at the end.
Procedure
1. Identify the author's exact git identity
git log --format="%an <%ae>" <upstream>..<branch> | sort -u
Match the requested person to their exact --author= string. Do not guess — short usernames won't match full display names (resolve via git log or the GitHub MCP get_me tool).
2. Collect all files the author directly committed to
git log --author="<Exact Name>" --format="%H" <upstream>..<branch>
For each commit hash, extract touched files:
git diff-tree --no-commit-id --name-only -r <hash>
Union all results into a set (author_files).
3. Build rename map across the entire branch
For every commit on the branch (not just the author's), extract renames:
git diff-tree --no-commit-id -r -M <hash>
Parse lines with R status to build a map: new_path → {old_paths}.
4. Get the merge diff file list
git diff --name-only <upstream>..<branch>
These are the files that will actually land when the branch merges.
5. Classify each file in the merge diff
For each file in step 4:
- If it's in
author_files→ DIRECT - Else, walk the rename map transitively (follow chains: current → old → older) and check if any ancestor is in
author_files→ VIA_RENAME - Otherwise → not this author's contribution
6. Get diff stats
git diff --stat <upstream>..<branch> -- <file1> <file2> ...
7. Return the table
Format the result as a markdown table:
| Status | File | +/- |
|--------|------|-----|
| DIRECT | src/vs/foo/bar.ts | +120/-5 |
| VIA_RENAME | src/vs/baz/qux.ts | +300 |
| ... | ... | ... |
**Total: N files, +X/-Y lines**
Important Notes
- Use Python for the heavy lifting. Shell loops with inline comments break in zsh. Write a temp
.pyscript, run it, then delete it. - Author matching is exact. Always run step 1 first.
--authordoes substring matching but you must verify the right person is matched (e.g., don't match "Joshua Smith" when looking for "Josh S."). Use the GitHub MCPget_metool orgit logoutput to resolve the correct full name. - Renames can be multi-hop. A file may have moved
contrib/chat/→agentSessions/→sessions/. The rename map must be walked transitively. - Only report files in the merge diff (step 4). Files the author touched that were later deleted entirely should not appear — they won't land in the upstream.
- The rename map must include all authors' commits, not just the target author's. Other people often do the rename commits (e.g., bulk refactors/moves).
Example Python Script
import subprocess, os
os.chdir('<repo_root>')
UPSTREAM = 'main'
AUTHOR = '<Author Name>' # Resolve via `git log` or GitHub MCP `get_me`
# Step 2: author's files
commits = subprocess.check_output(
['git', 'log', f'--author={AUTHOR}', '--format=%H', f'{UPSTREAM}..HEAD'],
text=True).strip().split('\n')
author_files = set()
for h in (c for c in commits if c):
files = subprocess.check_output(
['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', h],
text=True).strip().split('\n')
author_files.update(f for f in files if f)
# Step 3: rename map from ALL commits
all_commits = subprocess.check_output(
['git', 'log', '--format=%H', f'{UPSTREAM}..HEAD'],
text=True).strip().split('\n')
rename_map = {} # new_name -> set(old_names)
for h in (c for c in all_commits if c):
out = subprocess.check_output(
['git', 'diff-tree', '--no-commit-id', '-r', '-M', h],
text=True, timeout=5).strip()
for line in out.split('\n'):
if not line:
continue
parts = line.split('\t')
if len(parts) >= 3 and 'R' in parts[0]:
rename_map.setdefault(parts[2], set()).add(parts[1])
# Step 4: merge diff
diff_files = subprocess.check_output(
['git', 'diff', '--name-only', f'{UPSTREAM}..HEAD'],
text=True).strip().split('\n')
# Step 5: classify
results = []
for f in (x for x in diff_files if x):
if f in author_files:
results.append(('DIRECT', f))
else:
# walk rename chain
chain, to_check = set(), [f]
while to_check:
cur = to_check.pop()
if cur in chain:
continue
chain.add(cur)
to_check.extend(rename_map.get(cur, []))
chain.discard(f)
if chain & author_files:
results.append(('VIA_RENAME', f))
# Step 6: stats
if results:
stat = subprocess.check_output(
['git', 'diff', '--stat', f'{UPSTREAM}..HEAD', '--'] +
[f for _, f in results], text=True)
print(stat)
# Step 7: table
for kind, f in sorted(results, key=lambda x: x[1]):
print(f'| {kind:12s} | {f} |')
print(f'\nTotal: {len(results)} files')
Alternative Script
After following the process above, run this script to cross-check files touched by an author against the branch diff. You can do this both with an without src/vs/sessions.
AUTHOR=""
# 1. Find commits by author on this branch (not on main)
git log main...HEAD --author="$AUTHOR" --format="%H"
# 2. Get unique files touched across all those commits, excluding src/vs/sessions/
git log main...HEAD --author="$AUTHOR" --format="%H" \
| xargs -I{} git diff-tree --no-commit-id -r --name-only {} \
| sort -u \
| grep -v '^src/vs/sessions/'
# 3. Cross-reference with branch diff to keep only files still changed vs main
git log main...HEAD --author="$AUTHOR" --format="%H" \
| xargs -I{} git diff-tree --no-commit-id -r --name-only {} \
| sort -u \
| grep -v '^src/vs/sessions/' \
| while read f; do git diff main...HEAD --name-only -- "$f" 2>/dev/null; done \
| sort -u
.github/skills/auto-perf-optimize/SKILL.md
npx skills add asJEI/vscode --skill auto-perf-optimize -g -y
SKILL.md
Frontmatter
{
"name": "auto-perf-optimize",
"metadata": {
"allowed-tools": "Bash(npx @playwright\/cli:*)"
},
"description": "Run agent-driven VS Code performance or memory investigations. Use when asked to launch Code OSS, automate a VS Code scenario, run the Chat memory smoke runner, capture renderer heap snapshots, take workflow screenshots, compare run summaries, or drive a repeatable scenario before heap-snapshot analysis."
}
VS Code Performance Workflow
Drive a repeatable VS Code scenario, collect memory/performance artifacts, verify that the scenario actually happened, then hand the resulting heap snapshots to the generic heap-snapshot-analysis skill when object-level investigation is needed.
When to Use
- User describes a VS Code workflow and asks whether it leaks or grows memory
- User asks the agent to launch VS Code, drive a scenario, and capture heap snapshots
- User asks to run the Chat memory smoke runner bundled with this skill
- User wants screenshots,
summary.json, renderer heap samples, and targeted.heapsnapshotfiles for one scenario - User wants a new automation runner for a non-Chat VS Code scenario
Do not use this skill when snapshots already exist and the user only wants heap object/retainer analysis. Use heap-snapshot-analysis directly.
The Story
- Define the scenario. Write down one warmup action, one repeatable iteration, and one quiescent point where it is fair to force GC and sample memory.
- Develop the automation. Start with a tiny no-snapshot run. If it fails or the UI state is uncertain, keep the Code window open, connect
@playwright/clito the same CDP port, take workspace-local screenshots, inspect snapshots, and update the runner's selectors/waits. - Run a fast smoke. Disable heap snapshots first. Prove the scenario completes and the artifact summary says what you think it says.
- Capture targeted snapshots. Snapshot a warmed-up baseline and a later iteration. Do not snapshot every sample unless necessary; snapshots are huge and slow.
- Verify the run. Inspect
summary.jsonand screenshots. Do not analyze a failed login, trust prompt, stuck progress row, or wrong UI state. - Analyze snapshots. Switch to heap-snapshot-analysis for compare scripts, object grouping, and retainer paths.
- Fix and verify. After identifying leaks, make product-code fixes. Then rerun the same scenario with the same snapshot labels and compare like-for-like. Do not stop at analysis — the goal is to ship a fix, not just a report.
- Document. Save a summary of findings, fixes, and before/after measurements to session memory so the work is preserved.
Checked-in Runners
The scripts/ folder contains stable, generic runners. Use them directly or as templates for scratchpad scripts:
- chat-memory-smoke.mts — Multi-turn chat smoke runner. Sends prompts, waits for responses, samples heap, takes optional snapshots. The most versatile runner.
- chat-session-switch-smoke.mts — Creates multiple chat sessions with different content types, then repeatedly switches between them via the sessions sidebar.
- userDataProfile.mts — Utility for managing user-data profiles in smoke test runs.
Chat Workflow: Chat Memory Smoke Runner
Use the bundled Chat memory smoke runner when the scenario is Chat-specific or can be expressed as repeated Chat prompts. It launches Code OSS, opens Chat, sends prompts, waits for responses, writes screenshots and summary.json, samples renderer heap, and can take selected heap snapshots.
Fast health check:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 3 --no-heap-snapshots
Targeted post-warmup snapshots:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 8 --heap-snapshot-label 03-iteration-01 --heap-snapshot-label 03-iteration-08
User-described Chat scenario:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 8 --message 'For memory investigation iteration {iteration}, summarize the active workspace in one paragraph.' --heap-snapshot-label 03-iteration-01 --heap-snapshot-label 03-iteration-08
Important runner behavior:
- The default profile is persistent at
.build/auto-perf-optimize/user-dataso auth can be reused by all runners in this skill. - Pass
--temporary-user-dataonly if a clean profile is part of the scenario. - Pass
--seed-user-data-dir <path>to copy a logged-in profile into a fresh target profile before launch. The target profile may contain auth secrets; keep it inside ignored local.build/...folders and never attach it to issues or PRs.
Safety: chat runs execute on the real machine. The Code OSS instance launched by these runners is a full VS Code with Copilot auth on the user's actual computer — not a sandbox. Chat prompts you craft will be sent to a real LLM, and any tool calls the agent makes (terminal commands, file edits, etc.) will execute for real. Be responsible:
- Use a throwaway workspace, not the real repo. Pass
--workspace <scratch-folder>pointing to a temporary or gitignored directory (e.g., the runner's scratchpad subfolder, or a folder under.build/). The default workspace in checked-in runners is the repo root for convenience, but scratchpad runners for Chat scenarios should always override it to avoid accidental file modifications in the source tree. - Use safe, read-only commands for prompts that trigger terminal tools (e.g.,
touch /tmp/foo,git log --oneline,ls). Never instruct the agent to delete files, run destructive commands, or modify the user's workspace. - If you need tool calls for testing, use harmless operations and clean up any temp files afterward.
- Don't be afraid to run terminal commands — just be thoughtful about what you ask.
- Pass
--keep-openwhen the user needs to log in or watch the window, then close the window before the next automated run unless intentionally reusing it. - Pass
--reuseonly when attaching to a Code window that was launched with--enable-smoke-test-driverand the chosen remote-debugging port.
Profiles and Auth
Prefer the shared persistent performance profile for routine runs:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --keep-open --iterations 1 --no-heap-snapshots
If Chat asks for auth, let the user sign in once, close the Code window, then rerun the fast smoke without --keep-open. The same profile is reused by the bundled Chat runner and by other runners that follow this skill's profile convention.
To bootstrap the shared performance profile from an older logged-in automation profile, copy it once into the default target:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --seed-user-data-dir .build/chat-memory-smoke/user-data --keep-open --iterations 1 --no-heap-snapshots
To run a fresh disposable copy of a logged-in seed:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --temporary-user-data --seed-user-data-dir .build/auto-perf-optimize/user-data --iterations 3 --no-heap-snapshots
Seed-copy rules:
- The seed Code window must be closed. Never copy a profile while a Code process is using it.
- The target user-data-dir must be absent or empty. If the script refuses to copy, pick a fresh
--user-data-dir, use--temporary-user-data, or delete the local target deliberately. - The copy skips root-level caches, logs, crash dumps, singleton lock/socket files, and session storage. It intentionally keeps user/global storage that may contain auth or extension state.
- Use explicit
--user-data-dir <fresh-path> --seed-user-data-dir <seed-path>when you want to keep the copied profile after the run. User-provided--user-data-diris never deleted by the runner.
Develop and Watch a Runner
The first version of an automation runner is rarely correct. Treat the runner as a test you are developing: run a cheap scenario, observe the live workbench, adjust one selector or wait condition, and repeat. Do not collect heap snapshots until the runner is boringly reliable.
New runners go in the scratchpad folder (gitignored). Checked-in scripts in scripts/ are stable, generic runners — don't modify them for a one-off investigation. Instead, copy patterns from them into a scratchpad script.
Organize scratchpad work into dated subfolders named YYYY-MM-DD-short-description/ (e.g., 2026-04-09-chat-scroll-leak/). Each subfolder should contain:
- The investigation scripts (
.mts,.mjs, etc.) - A
findings.mdfile documenting the full investigation: all ideas considered, which ones led to changes and which were rejected (and why), before/after measurements, and a summary of the outcome. This lets the user review the agent's reasoning, decide which changes to keep, and follow up on deferred ideas.
Start fresh. Ignore any existing scratchpad subfolders from previous investigations. They belong to earlier sessions and their context, scripts, and findings are not relevant to your current task. Always create a new dated subfolder for your investigation.
Import path depth: Scripts in dated subfolders are 6 levels below the repo root (.github/skills/auto-perf-optimize/scratchpad/YYYY-MM-DD-name/script.mts), not 4 like the checked-in scripts/*.mts runners. Adjust relative imports accordingly — use 5 .. segments to reach the repo root from a dated subfolder (e.g., '../../../../../src/vs/base/common/stopwatch.ts'), and '../../scripts/userDataProfile.mts' to reach sibling checked-in scripts.
Suggested watch loop for the bundled Chat runner:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --keep-open --iterations 1 --no-heap-snapshots --port 9224 --output .build/chat-memory-smoke/watch-chat
While that Code window is open, inspect it with @playwright/cli from the repo root:
npx @playwright/cli attach --cdp=http://127.0.0.1:9224
npx @playwright/cli tab-list
npx @playwright/cli snapshot
npx @playwright/cli screenshot --filename=.build/chat-memory-smoke/watch-chat/observation.png
@playwright/cli checkpoints:
- Run
tab-listfirst. If the selected target isabout:blankor a webview instead of the workbench, switch targets before trusting snapshots. - Use
snapshotto rediscover buttons, textboxes, list rows, webviews, and current accessible names. Prefer discovered state over stale selectors. - Save screenshots inside the runner output folder or another workspace-local
.build/...folder. Do not use/tmpfor screenshots you expect the user to review. The output directory must already exist —screenshot --filenamewill fail withENOENTif it does not. Create it withmkdir -p <dir>first. - If the script is stuck, capture a screenshot and read the incremental
summary.jsonbefore killing the window. The last submitted turn and last screenshot usually identify the missing wait condition. - If auth is required, use
--keep-open, let the user sign in once in the persistent default profile, close the window, then rerun the fast smoke.
When editing a scenario runner:
- Keep a stable output contract:
summary.json, checkpoint screenshots, heap samples, optionalheap/*.heapsnapshotfiles, and anerrorfield on failure. - Write summary/screenshot artifacts before long waits so failed runs are diagnosable.
- Wait for user-visible scenario completion, not arbitrary time. Prefer an observed response, progress disappearance, row-count change, editor content change, or command result.
- Validate with
--no-heap-snapshotsfirst. A broken runner plus a 2GB heap snapshot wastes time and hides the real failure. - Close owned Code windows between runs unless the command intentionally uses
--keep-openor--reuse.
Verify Before Analyzing
Read the run's summary.json before opening heap snapshots. Check:
erroris absentchatTurnshas the expected count- each turn has a response-start reason and final response text, unless the run intentionally used
--skip-send analysis.postFirstTurnUsedBytesandanalysis.postFirstTurnUsedBytesPerTurnare present for multi-turn memory probes- requested snapshot labels exist under
heap/ - screenshots show the requested workflow and settled UI
Prefer a warmed-up baseline such as 03-iteration-01.heapsnapshot over startup snapshots. Startup, Chat opening, login, extension activation, and first-use model loads are expected allocations.
Compare a Chat Runner Result
After capture, use heap-snapshot-analysis. A minimal scratchpad comparison script looks like this:
import path from 'node:path';
import { compareSnapshots, printComparison } from '../helpers/compareSnapshots.ts';
const runDir = process.env.RUN;
if (!runDir) {
throw new Error('Set RUN to a chat-memory-smoke output directory');
}
const before = path.join(runDir, 'heap', '03-iteration-01.heapsnapshot');
const after = path.join(runDir, 'heap', '03-iteration-08.heapsnapshot');
printComparison(compareSnapshots(before, after));
Run it from the heap-snapshot-analysis skill folder:
cd .github/skills/heap-snapshot-analysis
RUN=../../../.build/chat-memory-smoke/<run-folder> node --max-old-space-size=16384 scratchpad/compare-chat-run.mjs
Non-Chat VS Code Scenarios
When the user describes a non-Chat scenario, ask only for the missing essentials: what action starts the scenario, what counts as one repeatable iteration, what indicates the UI is settled, and whether the profile should be persistent or temporary.
Write new scenario runners in the scratchpad folder. This folder is gitignored — use it freely for one-off investigation scripts. If a runner proves generally useful, promote it to scripts/ with documentation and validation.
Put each investigation in a dated subfolder (see "Develop and Watch a Runner" for the naming convention).
Example scratchpad workflow:
# Create a dated investigation folder
mkdir -p .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak
# Write a runner inside it
cat > .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak/scenario.mts << 'EOF'
// ... your scenario using patterns from the checked-in scripts
EOF
# Validate without snapshots first
node .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak/scenario.mts \
--iterations 3 --no-heap-snapshots --skip-prelaunch \
--user-data-dir .build/chat-memory-smoke/user-data
# Then capture targeted snapshots
node .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak/scenario.mts \
--iterations 10 --heap-snapshot-label baseline --heap-snapshot-label final \
--skip-prelaunch --user-data-dir .build/chat-memory-smoke/user-data
# Write findings.md when the investigation concludes
Reuse these patterns from the checked-in scripts (chat-memory-smoke.mts, chat-session-switch-smoke.mts):
- launch
scripts/code.shorscripts/code.bat - pass
--enable-smoke-test-driver,--disable-workspace-trust, a known--remote-debugging-port, explicit--user-data-dir, explicit--extensions-dir,--skip-welcome, and--skip-release-notes - use a throwaway workspace (
--workspace <scratch-folder>) instead of the repo root to prevent Chat tool calls from modifying real source files - connect Playwright with
chromium.connectOverCDP - wait for
globalThis.driver?.whenWorkbenchRestored?.() - enable CDP
PerformanceandHeapProfiler - collect garbage before memory samples
- write screenshots at important checkpoints
- write a machine-readable
summary.jsonincrementally, especially before long waits - support
--no-heap-snapshotsand targeted snapshot labels so validation stays fast - make cleanup explicit: close the CDP browser, terminate owned Code processes, and preserve user-provided profiles
Keep scenario-specific UI selectors and wait logic in the scenario runner. Avoid making the Chat runner a generic abstraction unless multiple proven scenarios share the exact same lifecycle.
Handoff to Heap Snapshot Analysis
Use heap-snapshot-analysis when you need to:
- compare two
.heapsnapshotfiles by constructor/object group - find direct retainers or paths to GC roots
- inspect why a particular class, model, widget, editor input, or DOM tree survived
- write investigation-specific scratchpad analysis against parsed snapshots
The output of this workflow is evidence: run summaries, screenshots, heap samples, targeted snapshots, comparison output, and retainer paths. Use that evidence to form a concrete leak hypothesis, then fix the product code and verify the fix with another run.
Root-Cause, Don't Treat Symptoms
A surface-level observation ("this Map is growing") is not a diagnosis. Before writing a fix, understand why the code is structured the way it is:
- Use
git blameandgit logon the leaking code. Read the commit message, the PR description, and any linked issues. A guard likeif (this._isDisposed) returnmay exist because removing it once caused crashes — understand the original intent before changing it. - Trace the full lifecycle, not just the leak site. If
disposeContext()is silently dropped, ask: why is the parent disposed before the child? Is the disposal order wrong, or is the guard wrong? The answer determines whether you fix the guard, fix the disposal order, or add a different cleanup path. - Distinguish caches from leaks. A Map that grows but has a trim/eviction mechanism (like
UriIdentityService._canonicalUriswith its 2^16 limit) is a cache, not a leak. Don't "fix" caches unless they lack any eviction policy. - Look for the design-level problem. If transient objects register in a global singleton and the singleton never unregisters them, the fix isn't just adding a
deletecall — ask whether the registration should happen at all for transient objects, or whether an intermediate scoped registry should exist. - Check for prior art. Search the codebase for similar patterns that handle the same lifecycle correctly. If a sibling pool/service already disposes in-use items on clear, follow that pattern. If nothing else does, understand why before introducing a new contract.
The goal is to fix the cause, not paper over the effect. A fix that adds cleanup code without understanding why cleanup was missing will often introduce new bugs or re-break a previous fix.
Fix, Don't Just Report
The goal of this workflow is to ship fixes, not produce reports. After identifying leaks:
-
Make product-code changes that address the root cause. Common patterns:
- Pools that
clear()idle items but leave_inUseorphaned — also dispose_inUseon clear - Global service maps (
ContextKeyService._contexts,HoverService._managedHovers,UriIdentityService._canonicalUris) that grow because transient objects register but never unregister - Disposable chains where
_register(service.createScoped(...))is correct but the parentdispose()is never called - Observable subscriptions (
autorunIterableDeltalastValues) that retain stale model references
- Pools that
-
Verify the fix by rerunning the same scenario with the same snapshot labels. Compare the
postFirst*UsedBytestrend and the snapshot diff. A successful fix should show flat or decreasing memory in the iteration phase. -
Run all tests. Before finishing, run all unit tests and integration tests for any files you changed. Unit tests in this repo are expected to be stable — any unit test failure is very likely caused by your changes and must be fixed. Integration tests are slightly more prone to flakiness, but failures should still be investigated.
-
Document results in the scratchpad
findings.mdand session memory before declaring done: what leaked, what was fixed, before/after measurements.
Do not stop at analysis. If you have evidence of a leak, attempt a fix. If the fix is unclear or risky, explain why and propose alternatives.
.github/skills/azure-pipelines/SKILL.md
npx skills add asJEI/vscode --skill azure-pipelines -g -y
SKILL.md
Frontmatter
{
"name": "azure-pipelines",
"description": "Use when validating Azure DevOps pipeline changes for the VS Code build. Covers queueing builds, checking build status, viewing logs, and iterating on pipeline YAML changes without waiting for full CI runs."
}
Validating Azure Pipeline Changes
When modifying Azure DevOps pipeline files (YAML files in build/azure-pipelines/), you can validate changes locally using the Azure CLI before committing. This avoids the slow feedback loop of pushing changes, waiting for CI, and checking results.
Prerequisites
-
Check if Azure CLI is installed:
az --versionIf not installed, install it:
# macOS brew install azure-cli # Windows (PowerShell as Administrator) winget install Microsoft.AzureCLI # Linux (Debian/Ubuntu) curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash -
Check if the DevOps extension is installed:
az extension show --name azure-devopsIf not installed, add it:
az extension add --name azure-devops -
Authenticate:
az login az devops configure --defaults organization=https://dev.azure.com/monacotools project=Monaco
VS Code Main Build
The main VS Code build pipeline:
- Organization:
monacotools - Project:
Monaco - Definition ID:
111 - URL: https://dev.azure.com/monacotools/Monaco/_build?definitionId=111
VS Code Insider Scheduled Builds
Two Insider builds run automatically on a scheduled basis:
- Morning build: ~7:00 AM CET
- Evening build: ~7:00 PM CET
These scheduled builds use the same pipeline definition (111) but run on the main branch to produce Insider releases.
Queueing a Build
Use the queue command to queue a validation build:
# Queue a build on the current branch
node .github/skills/azure-pipelines/azure-pipeline.ts queue
# Queue with a specific source branch
node .github/skills/azure-pipelines/azure-pipeline.ts queue --branch my-feature-branch
# Queue with custom parameters
node .github/skills/azure-pipelines/azure-pipeline.ts queue --parameter "VSCODE_BUILD_WEB=false" --parameter "VSCODE_PUBLISH=false"
# Parameter value with spaces
node .github/skills/azure-pipelines/azure-pipeline.ts queue --parameter "VSCODE_BUILD_TYPE=Product Build"
Important: Before queueing a new build, cancel any previous builds on the same branch that you no longer need. This frees up build agents and reduces resource waste:
# Find the build ID from status, then cancel it node .github/skills/azure-pipelines/azure-pipeline.ts status node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id <id> node .github/skills/azure-pipelines/azure-pipeline.ts queue
Script Options
| Option | Description |
|---|---|
--branch <name> |
Source branch to build (default: current git branch) |
--definition <id> |
Pipeline definition ID (default: 111) |
--parameter <entry> |
Pipeline parameter in KEY=value format (repeatable); use this when the value contains spaces |
--parameters <list> |
Space-separated parameters in KEY=value KEY2=value2 format; values must not contain spaces |
--dry-run |
Print the command without executing |
Product Build Queue Parameters (build/azure-pipelines/product-build.yml)
| Name | Type | Default | Allowed Values | Description |
|---|---|---|---|---|
VSCODE_QUALITY |
string | insider |
exploration, insider, stable |
Build quality channel |
VSCODE_BUILD_TYPE |
string | Product Build |
Product, CI |
Build mode for Product vs CI |
NPM_REGISTRY |
string | https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/ |
any URL | Custom npm registry |
CARGO_REGISTRY |
string | sparse+https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/Cargo/index/ |
any URL | Custom Cargo registry |
VSCODE_BUILD_WIN32 |
boolean | true |
true, false |
Build Windows x64 |
VSCODE_BUILD_WIN32_ARM64 |
boolean | true |
true, false |
Build Windows arm64 |
VSCODE_BUILD_LINUX |
boolean | true |
true, false |
Build Linux x64 |
VSCODE_BUILD_LINUX_SNAP |
boolean | true |
true, false |
Build Linux x64 Snap |
VSCODE_BUILD_LINUX_ARM64 |
boolean | true |
true, false |
Build Linux arm64 |
VSCODE_BUILD_LINUX_ARMHF |
boolean | true |
true, false |
Build Linux armhf |
VSCODE_BUILD_ALPINE |
boolean | true |
true, false |
Build Alpine x64 |
VSCODE_BUILD_ALPINE_ARM64 |
boolean | true |
true, false |
Build Alpine arm64 |
VSCODE_BUILD_MACOS |
boolean | true |
true, false |
Build macOS x64 |
VSCODE_BUILD_MACOS_ARM64 |
boolean | true |
true, false |
Build macOS arm64 |
VSCODE_BUILD_MACOS_UNIVERSAL |
boolean | true |
true, false |
Build macOS universal (requires both macOS arches) |
VSCODE_BUILD_WEB |
boolean | true |
true, false |
Build Web artifacts |
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:
node .github/skills/azure-pipelines/azure-pipeline.ts queue \
--parameter "VSCODE_BUILD_TYPE=CI Build" \
--parameter "VSCODE_PUBLISH=false" \
--parameter "VSCODE_RELEASE=false"
Checking Build Status
Use the status command to monitor a running build:
# Get status of the most recent builds
node .github/skills/azure-pipelines/azure-pipeline.ts status
# Get overview of a specific build by ID
node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456
# Watch build status (refreshes every 30 seconds)
node .github/skills/azure-pipelines/azure-pipeline.ts status --watch
# Watch with custom interval (60 seconds)
node .github/skills/azure-pipelines/azure-pipeline.ts status --watch 60
Script Options
| Option | Description |
|---|---|
--build-id <id> |
Specific build ID (default: most recent on current branch) |
--branch <name> |
Filter builds by branch name (shows last 20 builds for branch) |
--reason <reason> |
Filter builds by reason: manual, individualCI, batchedCI, schedule, pullRequest |
--definition <id> |
Pipeline definition ID (default: 111) |
--watch [seconds] |
Continuously poll status until build completes (default: 30s) |
--download-log <id> |
Download a specific log to /tmp |
--download-artifact <name> |
Download artifact to /tmp |
--json |
Output raw JSON for programmatic consumption |
Cancelling a Build
Use the cancel command to stop a running build:
# Cancel a build by ID (use status command to find IDs)
node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456
# Dry run (show what would be cancelled)
node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run
Script Options
| Option | Description |
|---|---|
--build-id <id> |
Build ID to cancel (required) |
--definition <id> |
Pipeline definition ID (default: 111) |
--dry-run |
Print what would be cancelled without executing |
Testing Pipeline Changes
When the user asks to test changes in an Azure Pipelines build, follow this workflow:
- Queue a new build on the current branch
- Poll for completion by periodically checking the build status until it finishes
Polling for Build Completion
Use a shell loop with sleep to poll the build status. The sleep command works on all major operating systems:
# Queue the build and note the build ID from output (e.g., 123456)
node .github/skills/azure-pipelines/azure-pipeline.ts queue
# Poll every 60 seconds until complete (works on macOS, Linux, and Windows with Git Bash/WSL)
# Replace <BUILD_ID> with the actual build ID from the queue command
while true; do
node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id <BUILD_ID> --json 2>/dev/null | grep -q '"status": "completed"' && break
sleep 60
done
# Check final result
node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id <BUILD_ID>
Alternatively, use the built-in --watch flag which handles polling automatically:
node .github/skills/azure-pipelines/azure-pipeline.ts queue
# Use the build ID returned by the queue command
node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id <BUILD_ID> --watch
Note: The
--watchflag polls every 30 seconds by default. Use--watch 60for a 60-second interval to reduce API calls.
Common Workflows
1. Quick Pipeline Validation
# Make your YAML changes, then:
git add -A && git commit -m "test: pipeline changes"
git push origin HEAD
# Check for any previous builds on this branch and cancel if needed
node .github/skills/azure-pipelines/azure-pipeline.ts status
node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id <id> # if there's an active build
# Queue and watch the new build
node .github/skills/azure-pipelines/azure-pipeline.ts queue
node .github/skills/azure-pipelines/azure-pipeline.ts status --watch
2. Investigate a Build
# Get overview of a build (shows stages, artifacts, and log IDs)
node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456
# Download a specific log for deeper inspection
node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-log 5
# Download an artifact
node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-artifact unsigned_vscode_cli_win32_x64_cli
3. Test with Modified Parameters
# Customize build matrix for quicker validation
node .github/skills/azure-pipelines/azure-pipeline.ts queue \
--parameter "VSCODE_BUILD_TYPE=CI Build" \
--parameter "VSCODE_BUILD_WEB=false" \
--parameter "VSCODE_BUILD_ALPINE=false" \
--parameter "VSCODE_BUILD_ALPINE_ARM64=false" \
--parameter "VSCODE_PUBLISH=false"
4. Cancel a Running Build
# First, find the build ID
node .github/skills/azure-pipelines/azure-pipeline.ts status
# Cancel a specific build by ID
node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456
# Dry run to see what would be cancelled
node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run
5. Iterate on Pipeline Changes
When iterating on pipeline YAML changes, always cancel obsolete builds before queueing new ones:
# Push new changes
git add -A && git commit --amend --no-edit
git push --force-with-lease origin HEAD
# Find the outdated build ID and cancel it
node .github/skills/azure-pipelines/azure-pipeline.ts status
node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id <id>
# Queue a fresh build and monitor
node .github/skills/azure-pipelines/azure-pipeline.ts queue
node .github/skills/azure-pipelines/azure-pipeline.ts status --watch
Troubleshooting
Authentication Issues
# Re-authenticate
az logout
az login
# Check current account
az account show
Extension Not Found
az extension add --name azure-devops --upgrade
Rate Limiting
If you hit rate limits, add delays between API calls or use --watch with a longer interval.
.github/skills/chat-customizations-editor/SKILL.md
npx skills add asJEI/vscode --skill chat-customizations-editor -g -y
SKILL.md
Frontmatter
{
"name": "chat-customizations-editor",
"metadata": {
"allowed-tools": "Bash(npx @playwright\/cli:*)"
},
"description": "Use when working on the Chat Customizations editor — the management UI for agents, skills, instructions, hooks, prompts, MCP servers, and plugins."
}
Chat Customizations Editor
Split-view management pane for AI customization items across workspace, user, extension, and plugin storage. Supports harness-based filtering (Local, Copilot CLI, Claude).
Spec
src/vs/sessions/AI_CUSTOMIZATIONS.md — always read before making changes, always update after.
Key Folders
| Folder | What |
|---|---|
src/vs/workbench/contrib/chat/common/ |
ICustomizationHarnessService, ISectionOverride, IStorageSourceFilter — shared interfaces and filter helpers |
src/vs/workbench/contrib/chat/browser/aiCustomization/ |
Management editor, list widgets (prompts, MCP, plugins), harness service registration |
src/vs/sessions/contrib/chat/browser/ |
Sessions-window overrides (harness service, workspace service) |
src/vs/sessions/contrib/sessions/browser/ |
Sessions tree view counts and toolbar |
When changing harness descriptor interfaces or factory functions, verify both core and sessions registrations compile.
Key Interfaces
IHarnessDescriptor— drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference.ISectionOverride— per-section button customization (command invocation, root file creation, type labels, file extensions).IStorageSourceFilter— controls which storage sources and user roots are visible per harness/type.IExternalCustomizationItemProvider/IExternalCustomizationItem— internal interfaces (incustomizationHarnessService.ts) for extension-contributed providers that supply items directly. These mirror the proposed extension API types.
Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code.
Extension API (chatSessionCustomizationProvider)
The proposed API in src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts lets extensions register customization providers. Changes to IExternalCustomizationItem or IExternalCustomizationItemProvider must be kept in sync across the full chain:
| Layer | File | Type |
|---|---|---|
| Extension API | vscode.proposed.chatSessionCustomizationProvider.d.ts |
ChatSessionCustomizationItem |
| IPC DTO | extHost.protocol.ts |
IChatSessionCustomizationItemDto |
| ExtHost mapping | extHostChatAgents2.ts |
$provideChatSessionCustomizations() |
| MainThread mapping | mainThreadChatAgents2.ts |
provideChatSessionCustomizations callback |
| Internal interface | customizationHarnessService.ts |
IExternalCustomizationItem |
When adding fields to IExternalCustomizationItem, update all five layers. The proposed API .d.ts is additive-only (new optional fields are backward-compatible and do not require a version bump).
Testing
Component explorer fixtures (see component-fixtures skill): aiCustomizationListWidget.fixture.ts, aiCustomizationManagementEditor.fixture.ts under src/vs/workbench/test/browser/componentFixtures/.
Screenshotting specific tabs
The management editor fixture supports a selectedSection option to render any tab. Each tab has Dark/Light variants auto-generated by defineThemedFixtureGroup.
Available fixture IDs (use with mcp_component-exp_screenshot):
| Fixture ID pattern | Tab shown |
|---|---|
chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/{Dark,Light} |
Agents |
chat/aiCustomizations/aiCustomizationManagementEditor/SkillsTab/{Dark,Light} |
Skills |
chat/aiCustomizations/aiCustomizationManagementEditor/InstructionsTab/{Dark,Light} |
Instructions |
chat/aiCustomizations/aiCustomizationManagementEditor/HooksTab/{Dark,Light} |
Hooks |
chat/aiCustomizations/aiCustomizationManagementEditor/PromptsTab/{Dark,Light} |
Prompts |
chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/{Dark,Light} |
MCP Servers |
chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/{Dark,Light} |
Plugins |
chat/aiCustomizations/aiCustomizationManagementEditor/LocalHarness/{Dark,Light} |
Default (Agents, Local harness) |
chat/aiCustomizations/aiCustomizationManagementEditor/CliHarness/{Dark,Light} |
Default (Agents, CLI harness) |
chat/aiCustomizations/aiCustomizationManagementEditor/ClaudeHarness/{Dark,Light} |
Default (Agents, Claude harness) |
chat/aiCustomizations/aiCustomizationManagementEditor/Sessions/{Dark,Light} |
Sessions window variant |
Adding a new tab fixture: Add a variant to the defineThemedFixtureGroup in aiCustomizationManagementEditor.fixture.ts:
MyNewTab: defineComponentFixture({
labels: { kind: 'screenshot' },
render: ctx => renderEditor(ctx, {
harness: CustomizationHarness.VSCode,
selectedSection: AICustomizationManagementSection.MySection,
}),
}),
The selectedSection calls editor.selectSectionById() after setInput, which navigates to the specified tab and re-layouts.
Populating test data
Each customization type requires its own mock path in createMockPromptsService:
- Agents —
getCustomAgents()returns agent objects - Skills —
findAgentSkills()returnsIAgentSkill[] - Prompts —
getPromptSlashCommands()returnsIChatPromptSlashCommand[] - Instructions/Hooks —
listPromptFiles()filtered byPromptsType - MCP Servers —
mcpWorkspaceServers/mcpUserServersarrays passed toIMcpWorkbenchServicemock - Plugins —
IPluginMarketplaceService.installedPluginsandIAgentPluginService.pluginsobservables
All test data lives in allFiles (prompt-based items) and the mcpWorkspace/UserServers arrays. Add enough items per category (8+) to invoke scrolling.
Exercising built-in grouping
The list widget regroups items from the default chat extension under a "Built-in" header. Three things must be in place for fixtures to exercise this:
- Include
BUILTIN_STORAGEin the harness descriptor's visible sources - Mock
IProductService.defaultChatAgent.chatExtensionId(e.g.,'GitHub.copilot-chat') - Give mock items extension provenance via
extensionId/extensionDisplayNamematching that ID
Without all three, built-in regrouping silently doesn't run and the fixture only shows flat lists.
Editor contribution service mocks
The management editor embeds a CodeEditorWidget. Electron-side editor contributions (e.g., AgentFeedbackEditorWidgetContribution) are instantiated automatically and crash if their injected services aren't registered. The fixture must mock at minimum:
IAgentFeedbackService— needsonDidChangeFeedback,onDidChangeNavigation,onDidAddFeedback,onDidConvertFeedback,onDidAddReply,onDidSubmitFeedbackasEvent.NoneICodeReviewService— needsgetReviewState()/getPRReviewState()returning idle observablesIChatEditingService— needseditingSessionsObsas empty observableIAgentSessionsService— needsmodel.sessionsas empty array
These are cross-layer imports from vs/sessions/ — use // eslint-disable-next-line local/code-import-patterns on the import lines.
CI regression gates
Key fixtures have blocksCi: true in their labels. The component-fixtures.yml GitHub Action captures screenshots on every PR to main and fails the CI status check if any blocks-ci-labeled fixture's screenshot changes. This catches layout regressions automatically.
Currently gated fixtures: LocalHarness, McpServersTab, McpServersTabNarrow, AgentsTabNarrow. When adding a new section or layout-critical fixture, add blocksCi: true:
MyFixture: defineComponentFixture({
labels: { kind: 'screenshot', blocksCi: true },
render: ctx => renderEditor(ctx, { ... }),
}),
Don't add blocksCi to every fixture — only ones that cover critical layout paths (default view, section with list + footer, narrow viewport). Too many gated fixtures creates noisy CI.
Screenshot stability
Scrollbar fade transitions cause screenshot instability — the scrollbar shifts from visible to invisible fade class ~2 seconds after a programmatic scroll. After calling revealLastItem() or any scroll action, wait for the transition to complete before the fixture's render promise resolves:
await new Promise(resolve => setTimeout(resolve, 2400));
// Then optionally poll until .scrollbar.vertical loses the 'visible' class
Running unit tests
./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts"
npm run typecheck-client && npm run valid-layers-check
See the sessions skill for sessions-window specific guidance.
Debugging Layout in the Real Product
Component fixtures use mock data and a fixed container size. Layout bugs caused by reflow timing, real data shapes, or narrow window sizes often don't reproduce in fixtures. When a user reports a broken layout, debug in the live Code OSS product.
For launching Code OSS with CDP and connecting @playwright/cli, see the launch skill. Use --user-data-dir /tmp/code-oss-debug to avoid colliding with an already-running instance from another worktree.
Navigating to the customizations editor
After connecting, use snapshot to find the "Open Customizations" button (in the Chat panel header), then click it. To switch sections, use eval with a DOM click since sidebar items aren't interactive refs:
npx @playwright/cli eval "(() => { const items = [...document.querySelectorAll('.section-list-item')]; items.find(el => el.textContent?.includes('MCP'))?.click(); })()"
Inspecting widget layout
@playwright/cli eval wraps the expression in () => (...) — use an IIFE for multi-statement code. Use document.title as a return channel:
npx @playwright/cli eval "(() => { const w = document.querySelector('.mcp-list-widget'); \
const lc = w?.querySelector('.mcp-list-container'); \
const rows = lc?.querySelectorAll('.monaco-list-row'); \
document.title = 'DBG:rows=' + (rows?.length ?? -1) \
+ ',listH=' + (lc?.offsetHeight ?? -1) \
+ ',seStH=' + (lc?.querySelector('.monaco-scrollable-element')?.style?.height ?? '') \
+ ',wH=' + (w?.offsetHeight ?? -1); })()"
npx @playwright/cli eval "document.title" 2>&1
Key diagnostics:
rows— fewer than expected meanslist.layout()never received the correct viewport height.seStH— empty means the list was never properly laid out.listHvswH— list container height should be widget height minus search bar minus footer.
Common layout issues
| Symptom | Root cause | Fix pattern |
|---|---|---|
| List shows 0-1 rows in a tall container | layout() bailed out because offsetHeight returned 0 during display:none → visible transition |
Defer layout via DOM.getWindow(this.element).requestAnimationFrame(...) |
| Badge or row content clips at right edge | Widget container missing overflow: hidden |
Add overflow: hidden to the widget's CSS class |
| Items visible in fixture but not in product | Fixture uses many mock items; real product has few | Add fixture variants with fewer items or narrower dimensions (width/height options) |
Fixture vs real product gaps
Fixtures render at a fixed size (default 900×600) with many mock items. They won't catch:
- Reflow timing — the real product's
display:none → visibletransition may not have reflowed beforelayout()fires - Narrow windows — add narrow fixture variants (e.g.,
width: 550, height: 400) - Real data counts — a user with 1 MCP server sees very different layout than a fixture with 12
.github/skills/chat-perf/SKILL.md
npx skills add asJEI/vscode --skill chat-perf -g -y
SKILL.md
Frontmatter
{
"name": "chat-perf",
"description": "Run chat perf benchmarks and memory leak checks against the local dev build or any published VS Code version. Use when investigating chat rendering regressions, validating perf-sensitive changes to chat UI, or checking for memory leaks in the chat response pipeline."
}
Chat Performance Testing
When to use
- Before/after modifying chat rendering code (
chatListRenderer.ts,chatInputPart.ts, markdown rendering) - When changing the streaming response pipeline or SSE processing
- When modifying disposable/lifecycle patterns in chat components
- To compare performance between two VS Code releases
- In CI to gate PRs that touch chat UI code
Quick start
# Run perf regression test (compares local dev build vs VS Code 1.115.0):
npm run perf:chat -- --scenario text-only --runs 3
# Run all scenarios with no baseline (just measure):
npm run perf:chat -- --no-baseline --runs 3
# Compare two local builds (apples-to-apples):
npm run perf:chat -- --build /path/to/build-A --baseline-build /path/to/build-B --runs 5
# Build a local production package and compare against a release:
npm run perf:chat -- --production-build --baseline-build 1.115.0 --runs 5
# Run memory leak check (10 messages in one session):
npm run perf:chat-leak
# Run leak check with more messages for accuracy:
npm run perf:chat-leak -- --messages 20 --verbose
Perf regression test
Script: scripts/chat-simulation/test-chat-perf-regression.js
npm: npm run perf:chat
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-baselineto 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 |
|---|---|---|
--runs <n> |
5 |
Runs per scenario. More = more stable. Use 5+ for CI. |
--scenario <id> / -s |
all | Scenario to test (repeatable). See common/perf-scenarios.js. |
--build <path|ver> / -b |
local dev | Build to test. Accepts path or version (1.110.0, insiders, commit hash). |
--baseline <path> |
— | Compare against a previously saved baseline JSON file. |
--baseline-build <path|ver> |
1.115.0 |
Version or local path to benchmark as baseline. |
--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 <path>). |
--resume <path> |
— | Resume a previous run, adding more iterations to increase confidence. |
--threshold <frac> |
0.2 |
Regression threshold (0.2 = flag if 20% slower). |
--production-build |
— | Build a local bundled package via gulp vscode for comparison against a release baseline. |
--no-cache |
— | Ignore cached baseline data, always run fresh. |
--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 <k=v> |
— | Set a VS Code setting override for all builds (repeatable). |
--test-setting <k=v> |
— | Set a VS Code setting override for the test build only. |
--baseline-setting <k=v> |
— | Set a VS Code setting override for the baseline build only. |
--verbose |
— | Print per-run details including response content. |
Comparing two remote builds
# Compare 1.110.0 against 1.115.0 (no local build needed):
npm run perf:chat -- --build 1.110.0 --baseline-build 1.115.0 --runs 5
Comparing two local builds
Both --build and --baseline-build accept local paths to VS Code executables. This enables apples-to-apples comparisons between any two builds:
# Compare two dev builds (e.g. feature branch vs main):
npm run perf:chat -- \
--build .build/electron/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--baseline-build /path/to/other/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--runs 5
# Compare two production builds:
npm run perf:chat -- \
--build ../VSCode-darwin-arm64-feature/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--baseline-build ../VSCode-darwin-arm64-main/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--runs 5
Local path baselines are never cached (the build may change between runs). Version string baselines are cached for reuse.
Build modes and mismatch detection
The tool classifies builds into three modes based on the executable path:
| Mode | Source | Characteristics |
|---|---|---|
dev |
.build/electron/ (local dev) |
Unbundled sources, VSCODE_DEV=1, NODE_ENV=development. Higher memory and startup overhead. |
production |
../VSCode-<platform>-<arch>/ (from gulp vscode) |
Bundled JS, no dev flags. Matches release characteristics but uses local source. |
release |
.vscode-test/ (downloaded via @vscode/test-electron) |
Official published build. |
When test and baseline builds have different modes (e.g. dev vs release), the tool shows a warning and prompts for confirmation. Use --force or --ci to skip the prompt.
Using --production-build builds a local bundled package via gulp vscode for fair comparison against a release baseline. This eliminates dev-mode overhead while still testing your local changes.
# Production build vs release baseline (fair comparison):
npm run perf:chat -- --production-build --baseline-build 1.115.0 --runs 5
Settings overrides
Use --setting, --test-setting, and --baseline-setting to inject VS Code settings into the launched instance. This is useful for A/B testing experimental features:
# Enable a feature for the test build only:
npm run perf:chat -- --test-setting chat.experimental.incrementalRendering.enabled=true --runs 3
# Compare two builds with different settings:
npm run perf:chat -- \
--baseline-build "../vscode2/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS" \
--baseline-setting chat.experimental.incrementalRendering.enabled=true \
--test-setting chat.experimental.incrementalRendering.enabled=false \
--runs 3
# Set a value for both builds:
npm run perf:chat -- --setting chat.mcp.enabled=false --runs 3
Precedence: --test-setting / --baseline-setting override --setting for the same key. Values are auto-parsed: true/false become booleans, numbers become numbers, everything else stays a string.
Resuming a run for more confidence
When results exceed the threshold but aren't statistically significant, the tool prints a --resume hint. Use it to add more iterations to an existing run:
# Initial run with 3 iterations — may be inconclusive:
npm run perf:chat -- --scenario text-only --runs 3
# Add 3 more runs to the same results file (both test + baseline):
npm run perf:chat -- --resume .chat-simulation-data/2026-04-14T02-15-14/results.json --runs 3
# Keep adding until confidence is reached:
npm run perf:chat -- --resume .chat-simulation-data/2026-04-14T02-15-14/results.json --runs 5
--resume loads the previous results.json and its associated baseline-*.json, runs N more iterations for both builds, merges rawRuns, recomputes stats, and re-runs the comparison. The updated files are written back in-place. You can resume multiple times — samples accumulate.
Statistical significance
Regression detection uses Welch's t-test to avoid false positives from noisy measurements. A metric is only flagged as REGRESSION when it both exceeds the threshold AND is statistically significant (p < 0.05). Otherwise it's reported as (likely noise — p=X, not significant).
With typical variance (cv ≈ 20%), you need:
- n ≥ 5 per build to detect a 35% regression at 95% confidence
- n ≥ 10 per build to detect a 20% regression reliably
Confidence levels reported: high (p < 0.01), medium (p < 0.05), low (p < 0.1), none.
Exit codes
0— all metrics within threshold, or exceeding threshold but not statistically significant1— statistically significant regression detected, or all runs failed
Scenarios
Scenarios are defined in scripts/chat-simulation/common/perf-scenarios.js and registered via registerPerfScenarios(). There are three categories:
- Content-only — plain streaming responses (e.g.
text-only,large-codeblock,rapid-stream) - Tool-call — multi-turn scenarios with tool invocations (e.g.
tool-read-file,tool-edit-file) - Multi-turn user — multi-turn conversations with user follow-ups, thinking blocks (e.g.
thinking-response,multi-turn-user,long-conversation)
Run npm run perf:chat -- --help to see the full list of registered scenario IDs.
Metrics collected
- Timing: time to first token, time to complete, time to render complete (includes typewriter animation)
- Rendering: layout count, layout duration (ms), style recalculation count, forced reflows, long tasks (>50ms), long animation frame count and duration
- Memory: heap before/after, heap delta post-GC (informational, noisy for single requests)
- Extension host: heap before/after/delta via CDP inspector
Regression triggers vs informational metrics
Only these metrics trigger a regression failure (when they exceed the threshold with statistical significance):
timeToFirstToken,timeToComplete— user-perceived latencylayoutDurationMs— total layout time from the trace (the real layout cost)forcedReflowCount— forced synchronous layouts are always badlongTaskCount,longAnimationFrameCount— main thread jank
These are reported but informational only (won't fail CI):
layoutCount— number of layout ops; inflated by CSS animations (compositor-driven, cheap). A build can do more but cheaper layouts, so gate onlayoutDurationMs, 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
Statistics
Results use IQR-based outlier removal and median (not mean) to handle startup jitter. The coefficient of variation (cv) is reported — under 15% is stable, over 15% gets a ⚠ warning. Baseline comparison uses Welch's t-test on raw run values to determine statistical significance before flagging regressions. Use 5+ runs to get stable results.
Memory leak check
Script: scripts/chat-simulation/test-chat-mem-leaks.js
npm: npm run perf:chat-leak
Launches one VS Code session, sends N messages sequentially, forces GC between each, and measures renderer heap and DOM node count. Uses linear regression on the samples to compute per-message growth rate, which is compared against a threshold.
Key flags
| Flag | Default | Description |
|---|---|---|
--messages <n> / -n |
10 |
Number of messages to send. More = more accurate slope. |
--build <path|ver> / -b |
local dev | Build to test. |
--threshold <MB> |
2 |
Max per-message heap growth in MB. |
--setting <k=v> |
— | Set a VS Code setting override (repeatable). |
--verbose |
— | Print per-message heap/DOM counts. |
What it measures
- Heap growth slope (MB/message) — linear regression over forced-GC heap samples. A leak shows as sustained positive slope.
- DOM node growth (nodes/message) — catches rendering leaks where elements aren't cleaned up. Healthy chat virtualizes old messages so node count plateaus.
Interpreting results
0.3–1.0 MB/msg— normal (V8 internal overhead, string interning)>2.0 MB/msg— likely leak, investigate retained objects- 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
# 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 <run-id> -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=<branch|sha|version> -f baseline_build=<version>
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-<group> |
Everything below plus traces, CPU profiles, heap snapshots (large) | 30 days |
leak-results |
Leak log + chat-simulation-leak-results.json |
30 days |
perf-summary-<group> |
results.json (full per-run metrics incl. rawRuns) + baseline-*.json |
1 day |
# List a run's artifacts + whether they've expired
gh api repos/microsoft/vscode/actions/runs/<run-id>/artifacts \
--jq '.artifacts[] | [.name, .expired] | @tsv'
# Download the human-readable summary (best first stop; survives 30 days)
gh run download <run-id> -R microsoft/vscode -n chat-perf-summary
Pinpointing where a metric regressed / went flaky
- Bisect across dates. Pull
chat-perf-summary/ci-summary.mdfrom several runs spanning the window. Compare the test median (and ±stddev) for the suspect metric+scenario run-to-run — the day it jumps is whenmainchanged. 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. - 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. - Deep-dive the cause. Download
perf-results-<group>(has thetrace.jsonper run) for a slow run and inspect what dominates the slow window. Trick that found thegc_statsartifact: sum main-threadRunTaskdurations between twocode/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 largestX-phase events in that window (MajorGC,Layout, etc.). - Reproduce a suspect commit locally to bisect precisely:
npm run perf:chat -- --build <sha> --baseline-build <ver> --runs 7(or two commits directly via--build <shaA> --baseline-build <shaB>).
Tip:
perf-summary-*(the machine-readableresults.jsonwithrawRuns) is deleted after 1 day, so for older runs rely onchat-perf-summary(raw tables, 30 days) or extractresults.jsonfromperf-results-*(also 30 days).
Architecture
scripts/chat-simulation/
├── common/
│ ├── mock-llm-server.js # Mock CAPI server matching @vscode/copilot-api URL structure
│ ├── perf-scenarios.js # Built-in scenario definitions (content, tool-call, multi-turn)
│ └── utils.js # Shared: paths, env setup, stats, launch helpers
├── config.jsonc # Default config (baseline version, runs, thresholds)
├── fixtures/ # TypeScript fixture files used by tool-call scenarios
├── test-chat-perf-regression.js
└── test-chat-mem-leaks.js
Mock server
The mock LLM server (common/mock-llm-server.js) implements the full CAPI URL structure from @vscode/copilot-api's DomainService:
GET /models— returns model metadataPOST /models/session— returnsAutoModeAPIResponsewithavailable_modelsandsession_tokenPOST /models/session/intent— model routerPOST /chat/completions— SSE streaming response matching the scenario- Agent, session, telemetry, and token endpoints
The copilot extension connects to this server via IS_SCENARIO_AUTOMATION=1 mode with overrideCapiUrl and overrideProxyUrl settings. The vscode-api-tests extension must be disabled (--disable-extension=vscode.vscode-api-tests) because it contributes a duplicate copilot vendor that blocks the real extension's language model provider registration.
Adding a scenario
- Add a new entry to the appropriate object (
CONTENT_SCENARIOS,TOOL_CALL_SCENARIOS, orMULTI_TURN_SCENARIOS) incommon/perf-scenarios.jsusing theScenarioBuilderAPI fromcommon/mock-llm-server.js - The scenario is auto-registered by
registerPerfScenarios()— no manual ID list to update - Run:
npm run perf:chat -- --scenario your-new-scenario --runs 1 --no-baseline --verbose
Related skills
- heap-snapshot-analysis — When a perf regression or leak check identifies high memory growth, use the heap-snapshot-analysis skill to dig deeper. It can parse
.heapsnapshotfiles, compare before/after snapshots, group object deltas, and trace retainer paths to find what keeps disposed objects alive. The chat-perf leak check measures overall heap slope; heap-snapshot-analysis finds the specific objects responsible. - auto-perf-optimize — For launching VS Code, driving a scenario, and capturing heap snapshots or CPU profiles automatically before doing low-level analysis.
.github/skills/code-oss-logs/SKILL.md
npx skills add asJEI/vscode --skill code-oss-logs -g -y
SKILL.md
Frontmatter
{
"name": "code-oss-logs",
"description": "Find and read timestamped process logs from Code OSS dev builds, including main.log, renderer.log, extension host logs, and agenthost.log. For bundles produced by Export Agent Host Debug Logs, use agent-host-logs."
}
Code OSS Logs
Find and display logs from the most recent Code OSS or Agents app dev run.
Log Root Directories
| App | Default User Data Dir | Logs Path |
|---|---|---|
| Code OSS | $HOME/.vscode-oss-dev |
$HOME/.vscode-oss-dev/logs/ |
| Agents app | $HOME/.vscode-oss-dev |
$HOME/.vscode-oss-dev/logs/ |
If Code OSS was launched with --user-data-dir=<dir>, use <dir>/logs/ instead of the defaults above. Launch and debugging helpers often create temporary user data dirs under .build/; always prefer the exact user data dir from the launch command when it is known.
Each run creates a timestamped folder like 20260330T163430. The most recent folder sorted by modification time is usually the one the user cares about.
Procedure
- Identify which app the user is asking about: Code OSS or Agents app. If unclear, check both.
- Find the most recent log folder:
ls -lt "$HOME/.vscode-oss-dev/logs" | head -5 # or for a custom user data dir: ls -lt "<user-data-dir>/logs" | head -5 - Navigate into the most recent folder and list contents.
- Read the relevant log file(s) based on what the user is investigating. Use
tailfor recent entries orrgto filter.
Directory Layout
Each timestamped log folder has this structure:
<timestamp>/
├── main.log # Electron main process (app lifecycle, window management)
├── agenthost.log # Agent host process (Copilot agent, model listing, agent sessions)
├── mcpGateway.log # MCP gateway/server coordination
├── sharedprocess.log # Shared process (extensions gallery, global services)
├── telemetry.log # Telemetry events
├── terminal.log # Terminal/pty activity
├── ptyhost.log # Pty host process
├── network-shared.log # Shared network activity
├── editSessions.log # Edit sessions / cloud changes
├── userDataSync.log # Settings sync
├── remoteTunnelService.log # Remote tunnel service
│
└── window1/ # Per-window logs (window1, window2, etc.)
├── renderer.log # Renderer process (workbench UI, services, startup)
├── network.log # Per-window network activity
├── views.log # View/panel activity
├── notebook.rendering.log # Notebook rendering
├── customizationsDebug.log # Agent customizations debug info (Agents app)
├── mcpServer.*.log # Per-MCP-server logs (one file per configured server)
│
├── exthost/ # Extension host logs
│ ├── exthost.log # Extension host main log (activation, errors)
│ ├── extHostTelemetry.log
│ ├── <publisher.extension>/ # Per-extension log folders
│ │ └── <extension>.log
│ └── output_logging_<timestamp>/ # Extension output channels
│
└── output_<timestamp>/ # Output channel logs (workbench side)
├── tasks.log # Tasks output
├── agentSessionsOutput.log # Agent sessions output (Agents app)
└── agenthost.<clientId>.log # Agent host IPC traffic when tracing is enabled
Multiple output_ Folders
A new output_<timestamp>/ folder and a corresponding output_logging_<timestamp>/ inside exthost/ is created each time the window reloads within the same session. The session-level timestamped folder, such as 20260330T163430/, stays the same, but each reload gets fresh output channel directories. The most recent output_* folder by timestamp has the logs for the current or latest reload. Earlier folders contain logs from prior reloads in that session.
Key Files by Use Case
| Investigating... | Check these files |
|---|---|
| App startup / crashes | main.log, window1/renderer.log |
| Extension issues | window1/exthost/exthost.log, window1/exthost/<publisher.ext>/ |
| Copilot / agent issues | agenthost.log, window1/exthost/GitHub.copilot-chat/ |
| Agent host IPC (Agents app) | window1/output_<timestamp>/agenthost.*.log |
| MCP server problems | mcpGateway.log, window1/mcpServer.*.log |
| Terminal problems | terminal.log, ptyhost.log |
| Network / auth issues | network-shared.log, window1/network.log |
| Settings sync | userDataSync.log |
| Agent customizations | window1/customizationsDebug.log (Agents app) |
Useful Commands
# Recent entries from a log file
tail -50 "<timestamp>/window1/renderer.log"
# Search all logs in a run for a probe marker or error
rg -n "MY_PROBE|error" "<timestamp>"
# Show non-empty logs in a run
find "<timestamp>" -type f -size +0 -print
Temporary Console Forwarding Workflow
When using temporary console.log probes and you need those probes to persist in the normal log files, enable dev console forwarding locally before launching Code OSS.
- In
src/vs/platform/log/common/log.ts, findisDevConsoleLogForwardingEnabled. - Temporarily enable the commented
Boolean("true")line:export const isDevConsoleLogForwardingEnabled = false || Boolean("true") // done "weirdly" so that a lint warning prevents you from pushing this ; - Build or let the watch task pick up the change.
- Launch Code OSS or the Agents app and reproduce the issue.
- Read the relevant logs.
- Before finishing, restore the flag to its default-off state and remove every temporary
console.logprobe.
The Boolean("true") form is intentionally lint-hostile so an accidentally enabled flag should be caught before check-in. Do not check in this flag enabled.
Tips
- For temporary dev probes in source builds, either
console.logorILogServiceis fine. Use whichever is easiest in the code you are touching. console.logprobes must never be checked in. If logging code is intended to stay in the product, useILogServiceinstead.- If dev console forwarding is enabled in the source build,
console.debug,console.error,console.info,console.log, andconsole.warnare written through the process log service into the normal log files. - Console probes land in the log for the process that emitted them: main process in
main.log, renderer/workbench inwindow1/renderer.log, shared process insharedprocess.log, pty host inptyhost.log, and agent host inagenthost.log. Extension host console output is observed from the renderer side and appears inwindow1/renderer.logwhen forwarding all extension-host console output is enabled. - If console forwarding is not enabled, use
ILogServicefor probes that must persist in the log files; nativeconsole.logmay only appear in DevTools or stdout. - Not all log files have content. Many are created empty and only populated if that subsystem produces output.
window1/is the first window; multi-window sessions will havewindow2/, etc.- Log lines follow the format:
YYYY-MM-DD HH:MM:SS.mmm [level] message.
.github/skills/component-fixtures/SKILL.md
npx skills add asJEI/vscode --skill component-fixtures -g -y
SKILL.md
Frontmatter
{
"name": "component-fixtures",
"description": "Use when creating or updating component fixtures for screenshot testing, or when designing UI components to be fixture-friendly. Covers fixture file structure, theming, service setup, CSS scoping, async rendering, and common pitfalls."
}
Component Fixtures
Component fixtures render isolated UI components for visual screenshot testing via the component explorer. Fixtures live in src/vs/workbench/test/browser/componentFixtures/ and are auto-discovered by the Vite dev server using the glob src/**/*.fixture.ts.
Use tools mcp_component-exp_* to list and screenshot fixtures. If you cannot see these tools, inform the user to them on.
Running Fixtures Locally
- Start the component explorer server: run the Component Explorer Server task
- Use the
mcp_component-exp_list_fixturestool to see all available fixtures and their URLs - Use the
mcp_component-exp_screenshottool to capture screenshots programmatically
File Structure
Each fixture file exports a default defineThemedFixtureGroup(...). The file must end with .fixture.ts.
src/vs/workbench/test/browser/componentFixtures/
fixtureUtils.ts # Shared helpers (DO NOT import @vscode/component-explorer elsewhere)
myComponent.fixture.ts # Your fixture file
Basic Pattern
import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js';
export default defineThemedFixtureGroup({ path: 'myFeature/' }, {
Default: defineComponentFixture({ render: renderMyComponent }),
AnotherVariant: defineComponentFixture({ render: renderMyComponent }),
});
function renderMyComponent({ container, disposableStore, theme }: ComponentFixtureContext): void {
container.style.width = '400px';
const instantiationService = createEditorServices(disposableStore, {
colorTheme: theme,
additionalServices: (reg) => {
// Register additional services the component needs
reg.define(IMyService, MyServiceImpl);
reg.defineInstance(IMockService, mockInstance);
},
});
const widget = disposableStore.add(
instantiationService.createInstance(MyWidget, /* constructor args */)
);
container.appendChild(widget.domNode);
}
Key points:
defineThemedFixtureGroupautomatically creates Dark and Light variants for each fixturedefineComponentFixturewraps your render function with theme setup and shadow DOM isolationcreateEditorServicesprovides aTestInstantiationServicewith base editor services pre-registered- Always register created widgets with
disposableStore.add(...)to prevent leaks - Pass
colorTheme: themetocreateEditorServicesso theme colors render correctly
Utilities from fixtureUtils.ts
| Export | Purpose |
|---|---|
defineComponentFixture |
Creates Dark/Light themed fixture variants from a render function |
defineThemedFixtureGroup |
Groups multiple themed fixtures into a named fixture group |
createEditorServices |
Creates TestInstantiationService with all base editor services |
registerWorkbenchServices |
Registers additional workbench services (context menu, label, etc.) |
createTextModel |
Creates a text model via ModelService for editor fixtures |
setupTheme |
Applies theme CSS to a container (called automatically by defineComponentFixture) |
darkTheme / lightTheme |
Pre-loaded ColorThemeData instances |
Important: Only fixtureUtils.ts may import from @vscode/component-explorer. All fixture files must go through the helpers in fixtureUtils.ts.
CSS Scoping
Fixtures render inside shadow DOM. The component-explorer automatically adopts the global VS Code stylesheets and theme CSS.
Matching production CSS selectors
Many VS Code components have CSS rules scoped to deep ancestor selectors (e.g., .interactive-session .interactive-input-part > .widget-container .my-element). In fixtures, you must recreate the required ancestor DOM structure for these selectors to match:
function render({ container }: ComponentFixtureContext): void {
container.classList.add('interactive-session');
// Recreate ancestor structure that CSS selectors expect
const inputPart = dom.$('.interactive-input-part');
const widgetContainer = dom.$('.widget-container');
inputPart.appendChild(widgetContainer);
container.appendChild(inputPart);
widgetContainer.appendChild(myWidget.domNode);
}
Design recommendation for new components: Avoid deeply nested CSS selectors that require specific ancestor elements. Use self-contained class names (e.g., .my-widget .my-element rather than .parent-view .parent-part > .wrapper .my-element). This makes components easier to fixture and reuse.
Services
Using createEditorServices
createEditorServices pre-registers these services: IAccessibilityService, IKeybindingService, IClipboardService, IOpenerService, INotificationService, IDialogService, IUndoRedoService, ILanguageService, IConfigurationService, IStorageService, IThemeService, IModelService, ICodeEditorService, IContextKeyService, ICommandService, ITelemetryService, IHoverService, IUserInteractionService, and more.
Additional services
Register extra services via additionalServices:
createEditorServices(disposableStore, {
additionalServices: (reg) => {
// Class-based (instantiated by DI):
reg.define(IMyService, MyServiceImpl);
// Instance-based (pre-constructed):
reg.defineInstance(IMyService, myMockInstance);
},
});
Mocking services
Use the mock<T>() helper from base/test/common/mock.js to create mock service instances:
import { mock } from '../../../../base/test/common/mock.js';
const myService = new class extends mock<IMyService>() {
override someMethod(): string { return 'test'; }
override onSomeEvent = Event.None;
};
reg.defineInstance(IMyService, myService);
For mock view models or data objects:
const element = new class extends mock<IChatRequestViewModel>() { }();
Async Rendering
The component explorer waits 2 animation frames after the synchronous render function returns. For most components, this is sufficient.
If your render function returns a Promise, the component explorer waits for the promise to resolve.
Pitfall: DOM reparenting causes flickering
Avoid moving rendered widgets between DOM parents after initial render. This causes:
- Layout recalculation (the widget jumps as
position: absolutecoordinates become invalid) - Focus loss (blur events can trigger hide logic in widgets like QuickInput)
- Screenshot instability (the component explorer may capture an intermediate layout state)
Bad pattern — reparenting a widget after async wait:
async function render({ container }: ComponentFixtureContext): Promise<void> {
const host = document.createElement('div');
container.appendChild(host);
// ... create widget inside host ...
await waitForWidget();
container.appendChild(widget); // BAD: reparenting causes flicker
host.remove();
}
Better pattern — render in-place with the correct DOM structure from the start:
function render({ container }: ComponentFixtureContext): void {
// Set up the correct DOM structure first, then create the widget inside it
const widget = createWidget(container);
container.appendChild(widget.domNode);
}
If the component absolutely requires async setup (e.g., QuickInput which renders internally), minimize DOM manipulation after the widget appears by structuring the host container to match the final layout from the beginning.
Adapting Existing Components for Fixtures
Existing components often need small changes to become fixturable. When writing a fixture reveals friction, fix the component — don't work around it in the fixture. Common adaptations:
Decouple CSS from ancestor context
If a component's CSS only works inside a deeply nested selector like .workbench .sidebar .my-view .my-widget, refactor the CSS to be self-contained. Move the styles so they're scoped to the component's own root class:
/* Before: requires specific ancestors */
.workbench .sidebar .my-view .my-widget .header { font-weight: bold; }
/* After: self-contained */
.my-widget .header { font-weight: bold; }
If the component shares styles with its parent (e.g., inheriting background color), use CSS custom properties rather than relying on ancestor selectors.
Extract hard-coded service dependencies
If a component reaches into singletons or global state instead of using DI, refactor it to accept services through the constructor:
// Before: hard to mock in fixtures
class MyWidget {
private readonly config = getSomeGlobalConfig();
}
// After: injectable and testable
class MyWidget {
constructor(@IConfigurationService private readonly configService: IConfigurationService) { }
}
Add options to control auto-focus and animation
Components that auto-focus on creation or run animations cause flaky screenshots. Add an options parameter:
interface IMyWidgetOptions {
shouldAutoFocus?: boolean;
}
The fixture passes shouldAutoFocus: false. The production call site keeps the default behavior.
Expose internal state for "already completed" rendering
Many components have lifecycle states (loading → active → completed). If the component can only reach the "completed" state through user interaction, add support for initializing directly into that state via constructor data:
// The fixture can pass pre-filled data to render the summary/completed state
// without simulating the full user interaction flow.
const carousel: IChatQuestionCarousel = {
questions,
allowSkip: true,
kind: 'questionCarousel',
isUsed: true, // Already completed
data: { 'q1': 'answer' }, // Pre-filled answers
};
Make DOM node accessible
If a component builds its DOM internally and doesn't expose the root element, add a public readonly domNode: HTMLElement property so fixtures can append it to the container.
Writing Fixture-Friendly Components
When designing new UI components, follow these practices to make them easy to fixture:
1. Accept a container element in the constructor
// Good: container is passed in
class MyWidget {
constructor(container: HTMLElement, @IFoo foo: IFoo) {
this.domNode = dom.append(container, dom.$('.my-widget'));
}
}
// Also good: widget creates its own domNode for the caller to place
class MyWidget {
readonly domNode: HTMLElement;
constructor(@IFoo foo: IFoo) {
this.domNode = dom.$('.my-widget');
}
}
2. Use dependency injection for all services
All external dependencies should come through DI so fixtures can provide test implementations:
// Good: services injected
constructor(@IThemeService private readonly themeService: IThemeService) { }
// Bad: reaching into globals
constructor() { this.theme = getGlobalTheme(); }
3. Keep CSS selectors shallow
/* Good: self-contained, easy to fixture */
.my-widget .my-header { ... }
.my-widget .my-list-item { ... }
/* Bad: requires deep ancestor chain */
.workbench .sidebar .my-view .my-widget .my-header { ... }
4. Avoid reading from layout/window services during construction
Components that measure the window or read layout dimensions during construction are hard to fixture because the shadow DOM container has different dimensions than the workbench:
// Prefer: use CSS for sizing, or accept dimensions as parameters
container.style.width = '400px';
container.style.height = '300px';
// Avoid: reading from layoutService during construction
const width = this.layoutService.mainContainerDimension.width;
5. Support disabling auto-focus in fixtures
Auto-focus can interfere with screenshot stability. Provide options to disable it:
interface IMyWidgetOptions {
shouldAutoFocus?: boolean; // Fixtures pass false
}
6. Expose the DOM node
The fixture needs to append the widget's DOM to the container. Expose it as a public readonly domNode: HTMLElement.
Multiple Fixture Variants
Create variants to show different states of the same component:
export default defineThemedFixtureGroup({
// Different data states
Empty: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: [] }) }),
WithItems: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: sampleItems }) }),
// Different configurations
ReadOnly: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: true }) }),
Editable: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: false }) }),
// Lifecycle states
Loading: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'loading' }) }),
Completed: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'done' }) }),
});
Learnings
Update this section with insights from your fixture development experience!
-
Do not copy the component to the fixture and modify it there. Always adapt the original component to be fixture-friendly, then render it in the fixture. This ensures the fixture tests the real component code and lifecycle, rather than a modified version that may hide bugs.
-
Don't recompose child widgets in fixtures. Never manually instantiate and add a sub-widget (e.g., a toolbar content widget) that the parent component is supposed to create. Instead, configure the parent correctly (e.g., set the right editor option, register the right provider) so the child appears through the normal code path. Manually recomposing hides integration bugs and doesn't test the real widget lifecycle.
.github/skills/cpu-profile-analysis/SKILL.md
npx skills add asJEI/vscode --skill cpu-profile-analysis -g -y
SKILL.md
Frontmatter
{
"name": "cpu-profile-analysis",
"description": "Analyze V8\/Chrome CPU profiles (.cpuprofile) and DevTools trace files (Trace-*.json). Use when: profiling performance, investigating slow functions, comparing code paths, finding bottlenecks, analyzing timeToRequest, understanding call trees from sampling profiler data, analyzing layout\/paint\/rendering, investigating user timing marks."
}
Analyze Performance Profiles
Analyze .cpuprofile files (V8 sampling profiler) and DevTools trace files (Trace-*.json, Chrome Trace Event Format) to find performance bottlenecks, compare code paths, and understand timing.
When to Use
- User provides a
.cpuprofileorTrace-*.jsonfile and wants to understand performance - Investigating why one code path is slower than another
- Finding what functions consume the most time
- Comparing "before/after" or "old/new" implementations in a single profile
- Investigating layout thrashing, long tasks, or rendering bottlenecks (trace files)
- Analyzing VS Code user timing marks like
code/didResolveTextFileEditorModel(trace files) - Understanding multi-process behavior (Browser, Renderer, GPU processes in trace files)
Detecting File Type
.cpuprofile: Top-level JSON withnodes,samples,timeDeltaskeys. Created by the VS Code profiler.Trace-*.json: Top-level JSON withtraceEventsarray (and optionalmetadata). Created by Chrome/Electron DevTools (Performance tab). These are richer than.cpuprofile-- they contain CPU samples, layout/paint events, user timing marks, GC events, input events, and multi-process data.
Key Concepts
- Sampling profiler: The profiler periodically snapshots the call stack. Not every function appears -- only those on the stack when the profiler sampled. Don't expect exact function names; look for patterns and nearby activity.
- Self time: Time spent in the function itself (the leaf/innermost frame).
- Total time: Time the function was anywhere on the stack (includes callees).
- Idle samples: Frames labeled
(idle),(program), or(garbage collector)represent no user code running.
Part 1: .cpuprofile Files
Profile Format
A .cpuprofile is JSON with these top-level keys:
nodes: Array of call frame nodes forming a tree (each hasid,callFrame,children)samples: Array of node IDs -- one per profiler tick, referencing the leaf (innermost) frametimeDeltas: Array of microsecond deltas between consecutive samplesstartTime/endTime: Absolute timestamps in microseconds$vscode: Optional VS Code metadata
Procedure
1. Check File Size and Parse
Profile and trace files can exceed V8's string limit (~512MB). Always check the file size first and choose the right parsing strategy:
import { readFileSync, statSync } from 'fs';
const stat = statSync(profilePath);
const sizeMB = stat.size / (1024 * 1024);
console.log(`File size: ${sizeMB.toFixed(0)}MB`);
let data;
if (sizeMB < 400) {
// Small enough for JSON.parse
data = JSON.parse(readFileSync(profilePath, 'utf8'));
} else {
// Too large -- use Buffer-based extraction (see "Handling Huge Files" section)
data = parseProfileFromBuffer(readFileSync(profilePath));
}
For files under ~400MB, JSON.parse(readFileSync(..., 'utf8')) works fine. For larger files, see the Handling Huge Files section below.
2. Reformat the File (small files only)
Profiles are often single-line JSON. Reformat for inspection (only if small enough):
if (sizeMB < 400) {
const data = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
fs.writeFileSync(profilePath, JSON.stringify(data, null, 2));
}
3. Build Data Structures
Write a Node.js analysis script. Build these structures:
// Node lookup
const nodeMap = new Map(); // id -> node
const parentMap = new Map(); // id -> parent id
// Absolute timestamps from deltas
const timestamps = [data.startTime];
for (let i = 0; i < data.timeDeltas.length; i++) {
timestamps.push(timestamps[i] + data.timeDeltas[i]);
}
// Stack walker (leaf to root)
function getStack(sampleNodeId) {
const stack = [];
let id = sampleNodeId;
while (id !== undefined) {
const node = nodeMap.get(id);
if (node) stack.push(node.callFrame.functionName);
id = parentMap.get(id);
}
return stack; // [leaf, ..., root]
}
4. Identify Activity Regions
Split the timeline into buckets (e.g. 500ms) and find which contain relevant function names. Use marker functions related to the user's question to detect activity windows. Allow small gaps (1-2 empty buckets) when merging regions.
Important: Because this is a sampling profiler, don't require exact function names. Use sets of related marker functions and look for the broader flow.
5. Measure Timing Between Milestones
For questions like "time from X to Y":
- Find the first non-idle sample containing a marker for X on the stack
- Find the first sample containing a marker for Y on the stack
- The gap in absolute timestamps is the approximate duration
- List all non-idle samples between these points to see what work happens in the gap
6. Compare Code Paths
When comparing two implementations:
- Identify the activity region for each
- For each region, compute self-time per function (time attributed to the leaf frame)
- Sort by self-time descending to find the top cost centers
- Show the first N non-idle stacks in each region to visualize the startup sequence
7. Report Findings
Present results as:
- Timeline: When each activity region occurred relative to profile start
- Duration: How long each region lasted
- Top functions by self-time: Where CPU time was actually spent
- Comparison table: Side-by-side metrics when comparing paths
- Stack traces: Key sample stacks showing the critical path
Part 2: DevTools Trace Files (Trace-*.json)
DevTools traces are the future of perf tracing for VS Code. They are created from the built-in Electron/Chrome DevTools Performance tab and contain far more information than .cpuprofile files.
Trace Format
A Trace-*.json file has these top-level keys:
traceEvents: Array of trace event objects (hundreds of thousands of entries)metadata: Object withsource,startTime,dataOrigin, and optional DevTools state (breadcrumbs, annotations)
Trace Event Structure
Each event in traceEvents follows the Chrome Trace Event Format:
{
"pid": 3406, // Process ID
"tid": 7534980, // Thread ID
"ts": 200420830729, // Timestamp in microseconds
"ph": "X", // Phase (event type)
"cat": "devtools.timeline", // Category
"name": "EventDispatch", // Event name
"dur": 9, // Duration in microseconds (for complete events)
"tdur": 8, // Thread duration (excludes time thread was suspended)
"args": { ... }, // Event-specific arguments
"tts": 7078808 // Thread timestamp
}
Phase Types (ph)
| Phase | Name | Meaning |
|---|---|---|
X |
Complete | Event with duration (dur field). Most common. |
B |
Begin | Start of a duration event (paired with E). |
E |
End | End of a duration event (paired with B). |
I |
Instant | Point-in-time event (no duration). |
P |
Sample | CPU profiler sample. |
R |
Mark | Navigation timing mark. |
M |
Metadata | Process/thread name metadata. |
N |
Object Created | Object lifecycle tracking. |
D |
Object Destroyed | Object lifecycle tracking. |
s |
Flow Start | Async flow connection start. |
f |
Flow End | Async flow connection end. |
b |
Async Begin | Async event begin. |
e |
Async End | Async event end. |
n |
Async Instant | Async event instant. |
Key Categories and What They Contain
| Category | What it captures |
|---|---|
disabled-by-default-devtools.timeline |
RunTask, EvaluateScript, TracingStartedInBrowser -- core task scheduling |
devtools.timeline |
FunctionCall, EventDispatch, TimerInstall/Fire, PrePaint, Paint -- main thread activity |
blink.user_timing |
VS Code performance marks (e.g. code/willResolveTextFileEditorModel, code/didResolveTextFileEditorModel) |
blink,devtools.timeline |
UpdateLayoutTree, HitTest, IntersectionObserver, ParseAuthorStyleSheet -- layout/rendering |
disabled-by-default-v8.cpu_profiler |
Profile, ProfileChunk -- embedded CPU profile data (same as .cpuprofile but chunked) |
v8 |
v8.callFunction, v8.newInstance, V8.DeoptimizeCode -- V8 engine events |
v8,devtools.timeline |
v8.compile -- script compilation |
devtools.timeline,v8 |
MinorGC, MajorGC -- garbage collection |
cppgc |
C++ GC events (Blink garbage collection) |
loading |
LayoutShift, URLLoader -- resource loading and layout shifts |
cc,benchmark,disabled-by-default-devtools.timeline.frame |
Frame pipeline events (PipelineReporter, Commit, etc.) |
__metadata |
process_name, thread_name -- process/thread identification |
Processes and Threads
Trace files contain events from multiple processes:
| Process | Role | Key Thread |
|---|---|---|
| Renderer (pid varies) | VS Code's renderer process -- where JS runs | CrRendererMain (main thread) |
| Browser (pid varies) | Electron's main/browser process | CrBrowserMain |
| GPU Process (pid varies) | GPU compositing and rendering | CrGpuMain, VizCompositorThread |
Identify processes/threads via metadata events:
const procNames = events.filter(e => e.name === 'process_name');
// => [{args: {name: 'Renderer'}, pid: 3406}, {args: {name: 'Browser'}, pid: 3348}, ...]
const threadNames = events.filter(e => e.name === 'thread_name');
// => [{args: {name: 'CrRendererMain'}, pid: 3406, tid: 7534980}, ...]
For VS Code perf analysis, focus on the Renderer process, CrRendererMain thread -- this is where JavaScript execution, layout, and painting happen.
Procedure
1. Check File Size and Parse
Trace files are typically 50-200MB but can exceed V8's string limit (~512MB). Always check first:
import { readFileSync, statSync } from 'fs';
const stat = statSync(tracePath);
const sizeMB = stat.size / (1024 * 1024);
console.log(`File size: ${sizeMB.toFixed(0)}MB`);
let data;
if (sizeMB < 400) {
data = JSON.parse(readFileSync(tracePath, 'utf8'));
} else {
// Too large -- use Buffer-based extraction (see "Handling Huge Files" section)
data = parseTraceFromBuffer(readFileSync(tracePath));
}
const events = data.traceEvents;
2. Reformat the File (small files only)
For small trace files, reformat for inspection:
if (sizeMB < 400) {
fs.writeFileSync(tracePath, JSON.stringify(data, null, 2));
}
3. Build Data Structures
const data = JSON.parse(fs.readFileSync(tracePath, 'utf8'));
const events = data.traceEvents;
// Identify Renderer main thread
const rendererPid = events.find(e => e.name === 'process_name' && e.args?.name === 'Renderer')?.pid;
const mainTid = events.find(e => e.name === 'thread_name' && e.pid === rendererPid && e.args?.name === 'CrRendererMain')?.tid;
// Filter to main thread events for most analysis
const mainEvents = events.filter(e => e.pid === rendererPid && e.tid === mainTid);
4. Analyze User Timing Marks
VS Code emits performance.mark() calls that appear as blink.user_timing events. These are the most direct way to measure VS Code-specific milestones:
const userTimings = events.filter(e => e.cat?.includes('blink.user_timing') && !e.cat.includes('rail'));
// Each has: name (e.g. 'code/didResolveTextFileEditorModel'), ts (microseconds), args.data.startTime (ms from navigation)
5. Analyze Long Tasks
Find expensive tasks on the main thread:
const longTasks = mainEvents
.filter(e => e.name === 'RunTask' && e.ph === 'X' && e.dur > 50000) // > 50ms
.sort((a, b) => b.dur - a.dur);
6. Analyze Function Calls
FunctionCall events include source location info:
const funcCalls = mainEvents
.filter(e => e.name === 'FunctionCall' && e.dur > 10000) // > 10ms
.sort((a, b) => b.dur - a.dur);
// args.data contains: functionName, url, lineNumber, columnNumber, scriptId
7. Analyze Layout and Rendering
Find layout thrashing and expensive paints:
const layoutEvents = mainEvents.filter(e =>
e.name === 'UpdateLayoutTree' || e.name === 'Layout' ||
e.name === 'PrePaint' || e.name === 'Paint'
);
// UpdateLayoutTree.args.elementCount tells you how many elements were restyled
8. Extract Embedded CPU Profile
Trace files contain the full CPU profile as ProfileChunk events. Reconstruct it:
const profileEvent = events.find(e => e.name === 'Profile' && e.pid === rendererPid);
const chunks = events.filter(e => e.name === 'ProfileChunk' && e.pid === rendererPid && e.id === profileEvent.id);
// Each chunk's args.data.cpuProfile contains: {nodes: [...], samples: [...]}
// Each chunk's args.data.timeDeltas contains sample timing
// Merge all chunks to reconstruct a full cpuprofile-like structure
const allNodes = [];
const allSamples = [];
const allDeltas = [];
for (const chunk of chunks) {
const cp = chunk.args.data.cpuProfile;
if (cp.nodes) allNodes.push(...cp.nodes);
if (cp.samples) allSamples.push(...cp.samples);
if (chunk.args.data.timeDeltas) allDeltas.push(...chunk.args.data.timeDeltas);
}
// Now analyze allNodes/allSamples/allDeltas using the same approach as .cpuprofile
9. Analyze GC Pressure
const gcEvents = mainEvents.filter(e => e.name === 'MinorGC' || e.name === 'MajorGC');
const totalGcTime = gcEvents.reduce((sum, e) => sum + (e.dur || 0), 0);
// Also check cppgc events for Blink GC
const cppgcEvents = events.filter(e => e.cat?.includes('cppgc'));
10. Analyze Input Latency
const dispatches = mainEvents.filter(e => e.name === 'EventDispatch');
// args.data.type tells you the event type: 'click', 'keydown', 'mousedown', etc.
// dur tells you how long the handler took
const longHandlers = dispatches.filter(e => e.dur > 50000).sort((a, b) => b.dur - a.dur);
11. Report Findings
Present results as:
- Timeline: When each activity region occurred relative to trace start
- User timing marks: VS Code milestone events and their timestamps
- Long tasks: Tasks > 50ms that block the main thread
- Top functions by duration: Where CPU time was spent, with source locations
- Layout/rendering: Expensive style recalculations and paints
- GC pressure: Total GC time and frequency
- Input latency: Slow event handlers that degrade responsiveness
- Process breakdown: What work happened in Browser vs Renderer vs GPU
Handling Huge Files
When a .cpuprofile or Trace-*.json file exceeds ~400MB, readFileSync(..., 'utf8') may fail because V8 cannot create a string that large. Use Buffer-based extraction instead: read the file as a raw Buffer and extract sections by scanning for known JSON keys. This is the same technique used for heap snapshots (see parseSnapshot.ts).
Key principle: Read the file as a Buffer, locate JSON array/object boundaries by scanning bytes, extract individual sections as sub-buffers that are small enough for JSON.parse, then assemble the result.
Always run analysis scripts with extra memory: node --max-old-space-size=16384 script.mjs
Buffer-based Parsing for .cpuprofile
A .cpuprofile has top-level keys nodes, samples, timeDeltas, startTime, endTime. Extract each section from the buffer:
import { readFileSync, statSync } from 'fs';
function parseProfileFromBuffer(buf) {
// Helper: find the array value for a given key, return parsed array
function extractArray(key) {
const keyBuf = Buffer.from(`"${key}":[`);
const pos = buf.indexOf(keyBuf);
if (pos === -1) throw new Error(`${key} not found`);
const arrayStart = pos + keyBuf.length;
// Find matching ']' -- arrays of numbers have no nested brackets
const arrayEnd = buf.indexOf(0x5D, arrayStart); // 0x5D = ']'
return JSON.parse('[' + buf.subarray(arrayStart, arrayEnd).toString('utf8') + ']');
}
// Helper: find a scalar value for a given key
function extractScalar(key) {
const keyBuf = Buffer.from(`"${key}":`);
const pos = buf.indexOf(keyBuf);
if (pos === -1) throw new Error(`${key} not found`);
const valueStart = pos + keyBuf.length;
// Scan to next comma or closing brace
let end = valueStart;
while (end < buf.length && buf[end] !== 0x2C && buf[end] !== 0x7D) end++;
return JSON.parse(buf.subarray(valueStart, end).toString('utf8'));
}
// Extract the nodes array -- contains objects, so we need bracket matching
function extractNodesArray() {
const keyBuf = Buffer.from('"nodes":[');
const pos = buf.indexOf(keyBuf);
if (pos === -1) throw new Error('nodes not found');
const start = pos + keyBuf.length - 1; // include '['
let depth = 0, end = -1;
for (let i = start; i < buf.length; i++) {
if (buf[i] === 0x5B) depth++;
else if (buf[i] === 0x5D) { depth--; if (depth === 0) { end = i + 1; break; } }
// Skip strings to avoid counting brackets inside them
if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) i++; else if (buf[i] === 0x22) break; i++; } }
}
if (end === -1) throw new Error('nodes array end not found');
return JSON.parse(buf.subarray(start, end).toString('utf8'));
}
const nodes = extractNodesArray();
const samples = extractArray('samples');
const timeDeltas = extractArray('timeDeltas');
const startTime = extractScalar('startTime');
const endTime = extractScalar('endTime');
return { nodes, samples, timeDeltas, startTime, endTime };
}
Buffer-based Parsing for Trace-*.json
Trace files have two top-level keys: metadata (small object) and traceEvents (huge array of objects). The strategy is to extract metadata normally and stream-parse traceEvents by scanning for individual event objects:
import { readFileSync } from 'fs';
function parseTraceFromBuffer(buf) {
// 1. Extract metadata (small, near the top of the file)
let metadata = {};
const metaKeyBuf = Buffer.from('"metadata":{');
const metaPos = buf.indexOf(metaKeyBuf);
if (metaPos !== -1) {
const metaStart = metaPos + metaKeyBuf.length - 1; // include '{'
let depth = 0, metaEnd = -1;
for (let i = metaStart; i < buf.length; i++) {
if (buf[i] === 0x7B) depth++;
else if (buf[i] === 0x7D) { depth--; if (depth === 0) { metaEnd = i + 1; break; } }
if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) i++; else if (buf[i] === 0x22) break; i++; } }
}
if (metaEnd !== -1) {
metadata = JSON.parse(buf.subarray(metaStart, metaEnd).toString('utf8'));
}
}
// 2. Extract traceEvents by parsing in chunks
// Each event is a JSON object {...}. Scan for object boundaries.
const eventsKeyBuf = Buffer.from('"traceEvents":[');
const eventsPos = buf.indexOf(eventsKeyBuf);
if (eventsPos === -1) throw new Error('traceEvents not found');
const arrayStart = eventsPos + eventsKeyBuf.length;
const traceEvents = [];
let i = arrayStart;
while (i < buf.length) {
// Skip whitespace and commas
while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x0A || buf[i] === 0x0D || buf[i] === 0x09 || buf[i] === 0x2C)) i++;
if (i >= buf.length || buf[i] === 0x5D) break; // end of array
if (buf[i] !== 0x7B) { i++; continue; } // expect '{'
// Find matching '}'
let depth = 0, objEnd = -1;
for (let j = i; j < buf.length; j++) {
if (buf[j] === 0x7B) depth++;
else if (buf[j] === 0x7D) { depth--; if (depth === 0) { objEnd = j + 1; break; } }
if (buf[j] === 0x22) { j++; while (j < buf.length) { if (buf[j] === 0x5C) j++; else if (buf[j] === 0x22) break; j++; } }
}
if (objEnd === -1) break;
// Parse this individual event object -- each is small enough for JSON.parse
traceEvents.push(JSON.parse(buf.subarray(i, objEnd).toString('utf8')));
i = objEnd;
}
return { metadata, traceEvents };
}
When to Use Buffer Parsing
| File size | Approach |
|---|---|
| < 400MB | JSON.parse(readFileSync(path, 'utf8')) is fine |
| 400MB - 1GB | Use Buffer-based extraction functions above |
| > 1GB | Use Buffer-based extraction + --max-old-space-size=16384 |
Tips for Large File Analysis
- Always run with
node --max-old-space-size=16384to give Node.js enough heap space. - For trace files, consider filtering events during parsing (e.g. skip categories you don't need) to reduce memory.
- The Buffer approach reads the entire file into memory as bytes (which is fine -- Buffer doesn't have the ~512MB string limit). Individual
JSON.parsecalls operate on small sub-buffers. - When reformatting would create a file too large to write back, skip reformatting and work directly with the parsed data structures.
- If you only need specific event types from a huge trace, add a filter callback to the parsing loop to avoid allocating objects you'll discard.
Tips
- Timestamps in both formats are microseconds. Divide by 1000 for milliseconds.
- For bundled/minified code (e.g.
extension.js), function names may be mangled. Use line numbers fromcallFrame.lineNumberto cross-reference with source maps. - Filter out idle/program/GC samples when measuring active CPU work.
- When the user asks about a gap, check if it's truly idle (event loop waiting) vs. active work in unrelated code.
- Clean up any analysis scripts you create when done.
- Trace files are large (50-200MB). Always filter to the relevant process/thread before analysis to reduce memory and noise.
- When a trace file contains embedded
ProfileChunkevents, prefer analyzing those over asking for a separate.cpuprofile-- the data is equivalent but already correlated with other trace events. - Use
args.data.urlinFunctionCallandEvaluateScriptevents to map back to VS Code source files (paths likevscode-file://vscode-app/Users/.../out/vs/...). - The
durfield is wall-clock duration;tduris thread-time duration. The difference reveals time the thread was suspended (e.g. waiting for I/O or preempted).
.github/skills/design-philosophy/SKILL.md
npx skills add asJEI/vscode --skill design-philosophy -g -y
SKILL.md
Frontmatter
{
"name": "design-philosophy",
"description": "The VS Code design philosophy — a shared Values→Principles→Moves vocabulary for reasoning about UI in design terms instead of raw pixels. Use when designing, building, reviewing, or giving feedback on any visual surface; when deciding a radius, spacing, type role, icon size, border, color, or motion; or when translating a \"this feels off\" observation into a concrete, principled fix."
}
VS Code Design Philosophy
This skill is the canonical VS Code design philosophy — the single source of truth for how we reason about UI, for both developers and agents.
As more and more of the UI is implemented via agents and tooling, the pixels increasingly take care of themselves, and the scarce, human part becomes the design judgment behind them.
This is about shifting left - talking about UI in design terms, not pixels, and having that conversation early. When something looks off, "this is 8px, should be 6px" fixes one number; "this surface is rounded at the wrong elevation tier" names the rule, so the fix lands everywhere and we catch it on a mockup or screenshot rather than in code review. The aim is to agree on what the UI should be and why, in language a designer and an engineer can share - the implementation (which token, which file) tends to follow once the design is settled.
How to read this: three layers
The hardest part of any design language is getting from "this feels off" to a clear, shared fix everyone agrees on. This bridges that in three steps, each explaining the one below it:
| Layer | What it is | Example |
|---|---|---|
| Values | what we want people to feel, and why it's at stake | Focused |
| Principles | evaluative rules you can hold a design up against, phrased as a question | "One thing leads; the rest supports" - where does the eye go first? |
| Moves | the concrete mechanics, each with a rule for which one and why | demote the secondary panel to a recessed background token / a quieter type role |
Read top-down when you're learning the system, and bottom-up when you're fixing a bug: name the feeling, find the principle it breaks, then reach for the move that restores it.
Worked example - a busy panel
- See: a panel feels busy and keeps pulling your eye off the editor.
- Feeling: not Calm, not Focused.
- Principle: breaks Quiet at rest (it carries a permanent fill it shouldn't) and One thing leads (secondary chrome competing with the editor).
- Move: drop the panel to a recessed/transparent background token, reveal its border only on hover, and demote its labels to a quieter type role.
The values: what we design for
These four values name what we want people to feel using VS Code - they're the top layer, the thing every principle and move below is ultimately in service of. Learn them and you can describe almost any bug. Each is a feeling the UI should give; each is upheld by one or more principles below. Reach for the value first, the principle second, the move last.
| Value | Means | When it's broken it feels… | Principles |
|---|---|---|---|
| Calm | quiet at rest, uncluttered; room to breathe; soft, not sharp; plain-spoken and human | busy, noisy, cramped, jagged, cryptic, shouty | 1, 2, 3 |
| Focused | clear hierarchy; one thing leads, the rest supports | flat, samey, the eye has nowhere to go | 4, 5 |
| Consistent | everything sits on the same ramps and tiers | random, off-grid, drifting | 5, 6 |
| Delightful | small moments that do a job - guide, confirm, orient | lifeless, abrupt, or gimmicky | 7 |
Say "this feels noisy, it's not Calm" before "the background should be transparent." The value points everyone at the same principle.
Why these four?
It's fair to ask: who would ever want a UI that isn't calm, focused, consistent, and delightful? That's exactly the point. These aren't differentiators we're claiming over other products - they're the qualities most under threat in a tool like ours, and naming them is how we defend them. We chose them because they're the values VS Code is structurally inclined to lose:
- Calm is the first casualty of an IDE. VS Code is information-dense and lived in for hours; every feature team has a good reason to add one more affordance, and the sum is noise. Calm is the value that pushes back on our own gravity.
- Focused matters because our surfaces are deep. A screen full of equally-weighted, equally-valid controls is the default failure mode of a power tool. Naming focus forces us to decide what leads.
- Consistent has the largest surface area to defend. VS Code is built by many hands over many years; drift is the natural state. Consistency is less an aspiration than a maintenance discipline.
- Delightful is the one we deliberately ration. It earns its place only by doing a job (Principle 7). We name it not to add more polish but to keep it honest, so delight guides and confirms rather than decorates.
And each value names a real tension, not a free win - which is what makes it a choice worth stating rather than a platitude:
- Calm trades against density - how much do we surface at rest?
- Focused trades against equal access - what do we demote so one thing can lead?
- Consistent trades against the genuinely special case - when is a difference earned rather than drift?
- Delightful trades against restraint - when does a flourish cross into noise?
So the useful question isn't "would we ever not want these?" but "which one is this surface trading away, and did we mean to?" When a value really must yield - say, a critical warning that should break calm to seize attention - that's a deliberate, named exception, not a reason to drop the value.
Principles
Principles are evaluative: each one is a question you can ask of any surface, and the answer tells you whether the design is right - independent of any pixel value. They're grouped by the value they uphold.
Calm
1. Quiet at rest, present on intent
Ask: is anything shouting when nothing is happening?
Chrome should recede until you engage it. A surface carrying a permanent fill, border, or focus ring it hasn't earned is competing with the user's content for attention it doesn't need. The modern look is restrained: things reveal themselves on intent - hover, focus, interaction - and sit quietly otherwise.
When this is wrong it sounds like "this is loud at rest / should be quiet until hover" or "the focus ring is firing on click" - a behavioral description, not a color value.
Carried out by: reveal-on-intent behaviors, one stroke, theme color.
2. Room to breathe
Ask: does this feel cramped, tight, or jagged?
Calm UI is generous and soft. Space isn't wasted; it's what lets the eye rest and groups things without drawing a single line. Edges are rounded rather than sharp. When a surface feels boxed-in or busy, the fix is usually more breathing room on the spacing ramp, not smaller content.
Carried out by: the spacing ramp, elevation tiers (rounded, not sharp).
3. The interface explains itself, plainly and kindly
Ask: could someone understand this at a glance, without guessing?
The UI should read like a person talking. Prefer a word to a mystery glyph; write in sentence case; keep the tone human. A lone unlabeled icon, or a Title-Cased Marketing Headline, makes the user work harder than they should - never cryptic, never shouting.
Carried out by: words & casing.
Worked example - a boxed-in toolbar
- See: a toolbar sits on a solid filled bar, separator lines between every button, the buttons pushed up against each other.
- Feeling: not Calm - busy and boxed-in.
- Principle: breaks Quiet at rest (the fill and separators are permanent chrome that hasn't earned its place) and Room to breathe (buttons jammed together).
- Move: let the bar read transparent at rest, drop the separators (one stroke - and here the answer is no), and space the buttons out on the ramp.
Focused
4. One thing leads; the rest supports
Ask: where does the eye go first - and is anything secondary competing with it?
Every view should have a clear center of attention - the one thing the eye should land on first. Keep secondary information out of that spot: demote it with a quieter type role, a recessed surface, or a smaller icon, so the thing that matters leads and everything else supports it. A view where everything has equal weight is flat - the eye has nowhere to go.
Carried out by: type roles, elevation tiers, icon size by context.
5. Elevation is encoded, not eyeballed
Ask: does this surface's roundness match how far it floats above what's beneath it?
How rounded - and how raised - a surface is means something: it tells you where the surface sits in the stack. A floating overlay reads as more rounded than a control sitting flat inside a pane. Radius is chosen by the surface's role in the hierarchy, never by taste. (This serves both Focused and Consistent.)
Carried out by: elevation tiers, reveal-on-intent (scroll shadows).
Worked example - a flat settings row
- See: a settings row shows its label, its help text, and a "Reset" action all at the same size and weight.
- Feeling: not Focused - flat; the eye doesn't know where to land.
- Principle: breaks One thing leads - nothing is the center of attention.
- Move: lift the label to the leading type role, demote the help text to
body2, and make "Reset" a quiet secondary action so the row reads top-down.
Consistent
6. Sameness signals sameness
Ask: do two like things look identical - and is any difference here intentional?
Equivalent elements must look equivalent, because they pull from the same named scale. When two similar things differ, that difference should mean something; accidental drift is the enemy. This is why every size, radius, weight, and color is a named token with a role, not a literal - the literal is only ever a stand-in for the token it lands on.
Carried out by: every move below - they are the shared scales. See especially design tokens, the spacing ramp, the type ramp, icon sizes, one stroke.
Worked example - two mismatched dialogs
- See: two dialogs open side by side; one has 8px corners, the other 6px - both are floating overlays.
- Feeling: not Consistent - the UI feels like it's drifting.
- Principle: breaks Sameness signals sameness (two equivalent surfaces look different for no reason) and Elevation is encoded (an overlay belongs on the Outer tier).
- Move: point both at the Outer radius token; the 6px was a literal someone typed instead of naming the tier.
Delightful
7. Delight earns its keep
Ask: what does this help the user do?
Motion, reveals, and finishing touches are welcome - but each must do a job:
guide the eye, confirm an action, orient the user, or smooth a jump. If there's no
answer to the question, it's decoration, and decoration reads as noise (back to
Calm). Keep it subtle, short, and interruptible; honor
prefers-reduced-motion; never animate for its own sake.
A delight bug sounds like "this transition is doing nothing for me" (cut it) or "this jump is disorienting - it needs a functional transition" (add one) - not a duration or easing value to fiddle with.
Carried out by: reveal-on-intent behaviors.
Worked example - a bouncing panel
- See: a side panel slides in with a 600ms bounce every time you toggle it.
- Feeling: not Delightful (gimmicky) and not Calm (slow and shouty).
- Principle: breaks Delight earns its keep - the bounce isn't doing a job, and the toggle is already obvious.
- Move: if nothing needs orienting, cut the animation; if the panel's arrival genuinely needs explaining, replace it with a short, subtle, interruptible fade that honors
prefers-reduced-motion.
The moves: the mechanical toolbox
Moves are the concrete mechanics - the tokens, ramps, and tiers. On their own they're just lists of allowed values; what makes each one usable is the decision rule that says which value, and why, plus the principle it serves. When you reach for a move, lead with the rule, not the literal.
This is the one section that touches implementation - and even here, the goal is to keep the conversation about design. Treat the Moves as the shared vocabulary that lets an agreed design be built consistently, not as the opening move in a review. Reach for them after you've named the feeling and the principle, never instead of it.
The size tokens live in
baseSizes.ts and the
font ramp in sizes.ts; the full
reference is in
design-tokens.instructions.md.
Tokens are the source of truth, not the pixel
Design-system spacing, radii, type roles, and colors should use a named token or ramp where one exists. Raw literals remain valid for structural measurements.
- Decision rule: when something looks wrong, find the token it should be expressing and reason about that - not the literal.
- Serves: Sameness signals sameness (6).
An
8pxcorner isn't "8px." It's an Outer surface radius that someone typed as a literal. Name the role and the fix follows.
Elevation tiers - round by role, not by literal
Corner radius encodes how far a surface floats above the one beneath it. Every rounded thing belongs to exactly one tier:
| Tier | Radius | Token | What it is |
|---|---|---|---|
| Control | 4px | --vscode-cornerRadius-small |
interactible elements - buttons, inputs, list rows, tabs |
| Inner | 6px | --vscode-cornerRadius-medium |
non-control containers sitting inside a surface |
| Outer | 8px | --vscode-cornerRadius-large |
floating / overlay surfaces - menus, hovers, dialogs, toasts |
Pills (radius ≈ half the height) are fully round
(--vscode-cornerRadius-circle), not "a big radius."
- Decision rule: pick the tier by the surface's role in the stack, not by how the corner looks. A bug here sounds like "this overlay is rounded at the control tier," never "this needs more border-radius."
- Serves: Elevation is encoded (5), Room to breathe (2).
The spacing ramp - on-scale or off-scale
Padding, margin, and gap come from the spacing ramp (0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 28, 32, 36, 40).
- Decision rule: a value either lands on the ramp or it doesn't. Off-scale values (3, 5, 7, 14, 26…) break the rhythm; snap to the nearest step, ties rounding up. Report rhythm bugs as "this is off the spacing ramp," not "add a couple of pixels."
- Serves: Room to breathe (2), Sameness signals sameness (6).
The type ramp - roles and two weights, not free sizes
Text styles are roles, not arbitrary sizes: heading1–3, body1–2,
label1–3. There are exactly two weights - regular (400) and semiBold
(600). There is no medium (500). "Strong" is not a bigger size; it is the same
role token at semiBold.
- Decision rule: choose the role by the text's rank in the hierarchy, not
by eyeballing a size. A heading that looks weak is "the wrong type role" or
"missing the semiBold weight," not "bump the font to 14." A
500anywhere is off the ramp. - Serves: One thing leads (4), Sameness signals sameness (6).
Icon sizes - two sizes, chosen by context
Codicons are 16px (base) or 12px (compact) - nothing in between; 14px is
always a bug. At the compact size, also swap to the *Compact glyph
(Codicon.close → Codicon.closeCompact) so the icon is optically tuned, not
just scaled.
- Decision rule: size tracks the density and rank of the context. Use base (16px) for standalone or primary actions and comfortable click targets; use compact (12px) for dense rows, inline glyphs, and secondary chrome where the icon rides alongside text. So the answer to "16 or 12?" is "what is this icon's role here?" - not a taste call. Say "this icon should be compact," not "shrink it a bit."
- Serves: One thing leads (4), Sameness signals sameness (6).
One stroke
The standard border/separator stroke is 1px (--vscode-strokeThickness).
- Decision rule: whether an ordinary border is present is a yes/no decision. Preserve thicker semantic or accessibility strokes, such as focus indicators.
- Serves: Quiet at rest (1), Sameness signals sameness (6).
Color comes from the theme
Every color is a --vscode-* theme token, so the UI tracks the active theme and
high-contrast modes.
- Decision rule: a hardcoded hex is a bug by construction. Color bugs are "wrong theme token" (or "a token that disappears in light/HC"), never a hex value to nudge.
- Serves: Quiet at rest (1), Sameness signals sameness (6).
Reveal-on-intent behaviors
Chrome that used to carry a permanent fill now reads transparent at rest and only reveals on interaction. Each behavior also does a job, so they pull double duty:
-
commandCenter- transparent at rest; shows its background/border on hover, so a pointer confirms the thing it's over. -
statusBar- neutralized to the window; only the background changes on hover, the foreground stays put. -
keyboardFocusOnly- the focus ring appears for keyboard focus (:focus-visible), not on mouse click, so the affordance shows exactly when it's useful. -
scrollShadows- content fades under a surface edge instead of being cut at a hard line, so you feel there's more beyond the fold. -
Serves: Quiet at rest (1), Delight earns its keep (7), and (scroll shadows) Elevation is encoded (5).
Words & casing
Prefer a text label to an icon. A word is read; an icon is guessed. Reach for a glyph only when its meaning is near-universal (close, search) or space is genuinely scarce - otherwise label the thing, or pair icon + label when you need both recognition and recall. A lone mystery glyph is the bug, not a badly-chosen one: "this action is unlabeled - give it a word," not "find a clearer icon."
Write in sentence case. Labels, buttons, menus, and messages capitalize the first word and proper nouns only - "Start new session," not "Start New Session." It's calmer, friendlier, and quicker to scan. Report it as "this is Title Case - make it sentence case," a tone fix, not a reword.
⚠️ Known conflict - reconcile later. This sentence-case guidance is the intended modern-UI direction, but it contradicts the repo-wide coding guideline that mandates title-style capitalization for command labels, buttons, and menu items (see coding-guidelines.instructions.md, "UI labels"). The two conventions have not yet been reconciled. Until they are, follow the existing title-case rule for shipped, non-experimental UI, and apply sentence case only within surfaces that have explicitly adopted the modern UI direction. TODO: agree a single casing convention and update whichever doc loses.
- Serves: The interface explains itself (3).
The phrasebook: describe the bug, name the principle
Lead with the role / tier / ramp, not the number - then name the principle so the fix lands everywhere, not just here. The number is a symptom.
| ❌ Don't say | ✅ Do say | Principle |
|---|---|---|
| "border-radius should be 6, not 8" | "this is an Inner surface rounded at the Outer tier" | 5 · Elevation |
| "this menu corner is too sharp" | "this overlay should be on the Outer radius tier" | 5 · Elevation |
| "add 2px of padding" | "this content is off the spacing ramp / needs ramp breathing room" | 2 · Room to breathe |
| "make this 14px" | "this should use the label1 / body1 type role" |
4 · One thing leads |
| "the title looks thin" | "the heading is missing the semiBold weight" |
4 · One thing leads |
| "font-weight 500 here" | "500 is off the ramp - snap to semiBold (600)" |
6 · Sameness |
| "shrink this icon a touch" | "this icon should be the compact (12px) size + *Compact glyph" |
4 · One thing leads |
| "this icon is 14px" | "codicons are 16 or 12 only - pick base or compact" | 6 · Sameness |
| "this ordinary border is too thick" | "standard borders are one stroke (1px) - this should/shouldn't have one; preserve thicker focus/semantic strokes" | 1 · Quiet at rest |
| "change this grey hex" | "this is the wrong theme token / it vanishes in light/HC" | 6 · Sameness |
| "the command center has a box around it" | "chrome should be quiet at rest, reveal on hover" | 1 · Quiet at rest |
| "the focus outline shows when I click" | "the ring is keyboard-focus only (:focus-visible)" |
1 · Quiet at rest |
| "the list is cut off at the bottom" | "the surface is missing its scroll-shadow fade" | 7 · Delight / 5 · Elevation |
| "the pane header has a line / hard edge" | "pane headers are rounded, separator-less section titles" | 1 · Quiet at rest |
Giving and receiving feedback
Describe the design, not the pixel. When you name the rule that's broken, the fix lands everywhere it's broken - not just in the one spot you happened to notice.
When something looks wrong, say it in this order:
- The feeling - which of the four values is it missing? Calm, Focused, Consistent, Delightful.
- The surface - what and where? "the sidebar pane header," "the Save dialog."
- The principle it breaks - the rule behind the feeling.
- The move that would fix it - the mechanic that restores it.
- Only then, if useful, the number - "shows 4px, looks like it wants 6px."
Example "The activity bar doesn't feel Calm - it's loud at rest. The icons carry a permanent fill that's competing with the editor (Quiet at rest, present on intent). Could the fill reveal on hover instead? It reads about 2px too tight as well, but the fill is the real thing."
Lead with the feeling, not the fix
The word points everyone at the same principle, even before anyone agrees on a pixel:
| Say this first | Not this |
|---|---|
| "this doesn't feel Calm - it's noisy at rest" | "make the background transparent" |
| "this isn't Focused - the eye has nowhere to land" | "make the title bigger" |
| "this feels inconsistent - these two dialogs don't match" | "change the radius to 6" |
| "this transition isn't Delightful, it's just decoration" | "make the animation faster" |
What makes feedback hard to act on
These aren't wrong, they're just incomplete - they fix one spot and drift back out of sync:
- A bare number. "This should be 6px." Which token? Why? Name the role and the fix lands everywhere.
- A taste claim with no rule. "I don't like this corner." Tie it to a value or principle so it's a shared standard, not a preference.
- A fix with no feeling. "Make it transparent." Maybe - but say what's wrong first ("it's loud at rest"), so we fix the cause, not just this instance.
- Several things at once. Split "this panel is busy, off-grid, and the icon's wrong" into three, each with its own value.
Practical notes
- Attach the surface. A screenshot or a short clip (for motion) plus the name of the view beats a paragraph of description. Circle the spot.
- One observation per thread. Keep each piece of feedback to a single surface + value, so it can be discussed and resolved on its own.
- Light vs. dark vs. high-contrast. If something only breaks in one theme, say so - that usually points at a theme token problem, not a color choice.
- Reduced motion. For animation feedback, note whether you have
prefers-reduced-motionon; a transition should still make sense without it.
.github/skills/fix-ci-failures/SKILL.md
npx skills add asJEI/vscode --skill fix-ci-failures -g -y
SKILL.md
Frontmatter
{
"name": "fix-ci-failures",
"description": "Investigate and fix CI failures on a pull request. Use when CI checks fail on a PR branch — covers finding the PR, identifying failed checks, downloading logs and artifacts, extracting the failure cause, and iterating on a fix. Requires the `gh` CLI."
}
Investigating and Fixing CI Failures
This skill guides you through diagnosing and fixing CI failures on a PR using the gh CLI. The user has the PR branch checked out locally.
Workflow Overview
- Identify the current branch and its PR
- Check CI status and find failed checks
- Download logs for failed jobs
- Extract and understand the failure
- Fix the issue and push
Step 1: Identify the Branch and PR
# Get the current branch name
git branch --show-current
# Find the PR for this branch
gh pr view --json number,title,url,statusCheckRollup
If no PR is found, the user may need to specify the PR number.
Step 2: Check CI Status
# List all checks and their status (pass/fail/pending)
gh pr checks --json name,state,link,bucket
# Filter to only failed checks
gh pr checks --json name,state,link,bucket --jq '.[] | select(.bucket == "fail")'
The link field contains the URL to the GitHub Actions job. Extract the run ID from the URL — it's the number after /runs/:
https://github.com/microsoft/vscode/actions/runs/<RUN_ID>/job/<JOB_ID>
If checks are still IN_PROGRESS, wait for them to complete before downloading logs:
gh pr checks --watch --fail-fast
Step 3: Get Failed Job Details
# List failed jobs in a run (use the run ID from the check link)
gh run view <RUN_ID> --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name: .name, id: .databaseId}'
Step 4: Download Failure Logs
There are two approaches depending on the type of failure.
Option A: View Failed Step Logs Directly
Best for build/compile/lint failures where the error is in the step output:
# View only the failed step logs (most useful — shows just the errors)
gh run view <RUN_ID> --job <JOB_ID> --log-failed
Important:
--log-failedrequires the entire run to complete, not just the failed job. If other jobs are still running, this command will block or error. Use Option C below to get logs for a completed job while the run is still in progress.
The output can be large. Pipe through tail or grep to focus:
# Last 100 lines of failed output
gh run view <RUN_ID> --job <JOB_ID> --log-failed | tail -100
# Search for common error patterns
gh run view <RUN_ID> --job <JOB_ID> --log-failed | grep -E "Error|FAIL|error TS|AssertionError|failing"
Option B: Download Artifacts
Best for integration test failures where detailed logs (terminal logs, ext host logs, crash dumps) are uploaded as artifacts:
# List available artifacts for a run
gh run download <RUN_ID> --pattern '*' --dir /dev/null 2>&1 || gh run view <RUN_ID> --json jobs --jq '.jobs[].name'
# Download log artifacts for a specific failed job
# Artifact naming convention: logs-<platform>-<arch>-<test-type>-<attempt>
# Examples: logs-linux-x64-electron-1, logs-linux-x64-remote-1
gh run download <RUN_ID> -n "logs-linux-x64-electron-1" -D /tmp/ci-logs
# Download crash dumps if available
gh run download <RUN_ID> -n "crash-dump-linux-x64-electron-1" -D /tmp/ci-crashes
Tip: Use the test runner name from the failed check (e.g., "Linux / Electron" →
electron, "Linux / Remote" →remote) and platform map ("Windows" →windows-x64, "Linux" →linux-x64, "macOS" →macos-arm64) to construct the artifact name.
Warning: Log artifacts may be empty if the test runner crashed before producing output (e.g., Electron download failure). In that case, fall back to Option C.
Option C: Download Per-Job Logs via API (works while run is in progress)
When the run is still in progress but the failed job has completed, use the GitHub API to download that job's step logs directly:
# Save the full job log to a temp file (can be very large — 30k+ lines)
gh api repos/microsoft/vscode/actions/jobs/<JOB_ID>/logs > "$TMPDIR/ci-job-log.txt"
Then search the saved file. Start with ##[error] — this is the GitHub Actions error annotation that marks the exact line where the step failed:
# Step 1: Find the error annotation (fastest path to the failure)
grep -n '##\[error\]' "$TMPDIR/ci-job-log.txt"
# Step 2: Read context around the error (e.g., if error is on line 34371, read 200 lines before it)
sed -n '34171,34371p' "$TMPDIR/ci-job-log.txt"
If ##[error] doesn't reveal enough, use broader patterns:
# Find test failures, exceptions, and crash indicators
grep -n -E 'HTTPError|ECONNRESET|ETIMEDOUT|502|exit code|Process completed|node:internal|triggerUncaughtException' "$TMPDIR/ci-job-log.txt" | head -20
Why save to a file? The API response for a full job log can be 30k+ lines. Tool output gets truncated, so always redirect to a file first, then search.
VS Code Log Artifacts Structure
Downloaded log artifacts typically contain:
logs-linux-x64-electron-1/
main.log # Main process log
terminal.log # Terminal/pty host log (key for run_in_terminal issues)
window1/
renderer.log # Renderer process log
exthost/
exthost.log # Extension host log (key for extension test failures)
Key files to examine first:
- Test assertion failures: Check
exthost.logfor the extension host output and stack traces - Terminal/sandbox issues: Check
terminal.logfor rewriter pipeline, shell integration, and strategy logs - Crash/hang: Check
main.logand look for crash dumps artifacts
Step 5: Extract the Failure
For Test Failures
Look for the test runner output in the failed step log:
# Find failing test names and assertion messages
gh run view <RUN_ID> --job <JOB_ID> --log-failed | grep -A 5 "failing\|AssertionError\|Expected\|Unexpected"
Common patterns in VS Code CI:
AssertionError [ERR_ASSERTION]: Test assertion failed — check expected vs actual valuesExtension host test runner exit code: 1: Integration test suite had failuresCommand produced no output: Shell integration may not have captured command output (see terminal.log)Error: Timeout: Test timed out — could be a hang or slow CI machine
For Build Failures
# Find TypeScript compilation errors
gh run view <RUN_ID> --job <JOB_ID> --log-failed | grep "error TS"
# Find hygiene/lint errors
gh run view <RUN_ID> --job <JOB_ID> --log-failed | grep -E "eslint|stylelint|hygiene"
Step 6: Determine if Failures are Related to the PR
Before fixing, determine if the failure is caused by the PR changes or is a pre-existing/infrastructure issue:
- Check if the failing test is in code you changed — if the test is in a completely unrelated area, it may be a flake
- Check the test name — does it relate to the feature area you modified?
- Look at the failure output — does it reference code paths your PR touches?
- Check if the same tests fail on main — if identical failures exist on recent main commits, it's a pre-existing issue
- Look for infrastructure failures — network timeouts, npm registry errors, and machine-level issues are not caused by code changes
# Check recent runs on main for the same workflow
gh run list --branch main --workflow pr-linux-test.yml --limit 5 --json databaseId,conclusion,displayTitle
Recognizing Infrastructure / Flaky Failures
Not all CI failures are caused by code changes. Common infrastructure failures:
Network / Registry issues:
npm ERR! network,ETIMEDOUT,ECONNRESET,EAI_AGAIN— npm registry unreachableerror: RPC failed; curl 56,fetch-pack: unexpected disconnect— git network failureError: unable to get local issuer certificate— TLS/certificate issuesrate limit exceeded— GitHub API rate limitingHTTPError: Request failed with status code 502onelectron/electron/releases— Electron CDN download failure (common in thenode.js integration testsstep, which downloads Electron at runtime)
Machine / Environment issues:
No space left on device— CI disk fullENOMEM,JavaScript heap out of memory— CI machine ran out of memoryThe runner has received a shutdown signal— CI preemption / timeoutError: The operation was canceled— GitHub Actions cancelled the jobXvfb failed to start— display server for headless Linux tests failed
Test flakes (not infrastructure, but not your fault either):
- Timeouts on tests that normally pass — slow CI machine
- Race conditions in async tests
- Shell integration not reporting exit codes (see terminal.log for
exitCode: undefined)
What to do with infrastructure failures:
- Don't change code — the failure isn't caused by your PR
- Re-run the failed jobs via the GitHub UI or:
gh run rerun <RUN_ID> --failed - If failures persist across re-runs, check if main is also broken:
gh run list --branch main --limit 10 --json databaseId,conclusion,displayTitle - If main is broken too, wait for it to be fixed — your PR is not the cause
Step 7: Fix and Iterate
- Make the fix locally
- Verify compilation: check the
VS Code - Buildtask or runnpm run typecheck-client - Run relevant unit tests locally:
./scripts/test.sh --grep "<pattern>" - Commit and push:
git add -A git commit -m "fix: <description>" git push - Watch CI again:
gh pr checks --watch --fail-fast
Quick Reference
| Task | Command |
|---|---|
| Find PR for branch | gh pr view --json number,url |
| List all checks | gh pr checks --json name,state,bucket |
| List failed checks only | gh pr checks --json name,state,link,bucket --jq '.[] | select(.bucket == "fail")' |
| Watch checks until done | gh pr checks --watch --fail-fast |
| Failed jobs in a run | gh run view <RUN_ID> --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name, id: .databaseId}' |
| View failed step logs | gh run view <RUN_ID> --job <JOB_ID> --log-failed (requires full run to complete) |
| Download job log via API | gh api repos/microsoft/vscode/actions/jobs/<JOB_ID>/logs > "$TMPDIR/ci-job-log.txt" (works while run is in progress) |
| Find error line in log | grep -n '##\[error\]' "$TMPDIR/ci-job-log.txt" |
| Download log artifacts | gh run download <RUN_ID> -n "<artifact-name>" -D /tmp/ci-logs |
| Re-run failed jobs | gh run rerun <RUN_ID> --failed |
| Recent main runs | gh run list --branch main --workflow <workflow>.yml --limit 5 |
.github/skills/fix-errors/SKILL.md
npx skills add asJEI/vscode --skill fix-errors -g -y
SKILL.md
Frontmatter
{
"name": "fix-errors",
"description": "Guidelines for fixing unhandled errors from the VS Code error telemetry dashboard. Use when investigating error-telemetry issues with stack traces, error messages, and hit\/user counts. Covers tracing data flow through call stacks, identifying producers of invalid data vs. consumers that crash, enriching error messages for telemetry diagnosis, and avoiding common anti-patterns like silently swallowing errors."
}
When fixing an unhandled error from the telemetry dashboard, the issue typically contains an error message, a stack trace, hit count, and affected user count.
Approach
1. Do NOT fix at the crash site
The error manifests at a specific line in the stack trace, but the fix almost never belongs there. Fixing at the crash site (e.g., adding a typeof guard in a revive() function, swallowing the error with a try/catch, or returning a fallback value) only masks the real problem. The invalid data still flows through the system and will cause failures elsewhere.
2. Trace the data flow upward through the call stack
Read each frame in the stack trace from bottom to top. For each frame, understand:
- What data is being passed and what is expected
- Where that data originated (IPC message, extension API call, storage, user input, etc.)
- Whether the data could have been corrupted or malformed at that point
The goal is to find the producer of invalid data, not the consumer that crashes on it.
3. When the producer cannot be identified from the stack alone
Sometimes the stack trace only shows the receiving/consuming side (e.g., an IPC server handler). The sending side is in a different process and not in the stack. In this case:
- Enrich the error message at the consuming site with diagnostic context: the type of the invalid data, a truncated representation of its value, and which operation/command received it. This information flows into the error telemetry dashboard automatically via the unhandled error pipeline.
- Do NOT silently swallow the error — let it still throw so it remains visible in telemetry, but with enough context to identify the sender in the next telemetry cycle.
- Consider adding the same enrichment to the low-level validation function that throws (e.g., include the invalid value in the error message) so the telemetry captures it regardless of call site.
4. When the producer IS identifiable
Fix the producer directly:
- Validate or sanitize data before sending it over IPC / storing it / passing it to APIs
- Ensure serialization/deserialization preserves types correctly (e.g., URI objects should serialize as
UriComponentsobjects, not as strings)
Example
Given a stack trace like:
at _validateUri (uri.ts) ← validation throws
at new Uri (uri.ts) ← constructor
at URI.revive (uri.ts) ← revive assumes valid UriComponents
at SomeChannel.call (ipc.ts) ← IPC handler receives arg from another process
Wrong fix: Add a typeof guard in URI.revive to return undefined for non-object input. This silences the error but the caller still expects a valid URI and will fail later.
Right fix (when producer is unknown): Enrich the error at the IPC handler level and in _validateUri itself to include the actual invalid value, so telemetry reveals what data is being sent and from where. Example:
// In the IPC handler — validate before revive
function reviveUri(data: UriComponents | URI | undefined | null, context: string): URI {
if (data && typeof data !== 'object') {
throw new Error(`[Channel] Invalid URI data for '${context}': type=${typeof data}, value=${String(data).substring(0, 100)}`);
}
// ...
}
// In _validateUri — include the scheme value
throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`);
Right fix (when producer is known): Fix the code that sends malformed data. For example, if an authentication provider passes a stringified URI instead of a UriComponents object to a logger creation call, fix that call site to pass the proper object.
Understanding error construction before fixing
Before proposing any fix, always find and read the code that constructs the error. Search the codebase for the error class name or a unique substring of the error message. The construction code reveals:
- What conditions trigger the error — thresholds, validation checks, state assertions
- What classifications or categories the error encodes — the error may have subtypes that require different fix strategies
- What the error's parameters mean — numeric values, ratios, or flags embedded in the message often encode diagnostic context
- Whether the error is actionable — some errors are threshold-based warnings where the threshold may be legitimately exceeded by design
Use this understanding to determine the correct fix strategy. The construction code is the source of truth — do NOT assume what the error means from its message alone.
Example: Listener leak errors
Searching for ListenerLeakError leads to src/vs/base/common/event.ts, where the construction code reveals:
const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular';
const error = new ListenerLeakError(kind, message, topStack);
Reading this code tells you:
- The error has two categories based on a ratio
- Dominated (ratio > 30%): one code path accounts for most listeners → that code path is the problem, fix its disposal
- Popular (ratio ≤ 30%): many diverse code paths each contribute a few listeners → the identified stack trace is NOT the root cause; it's just the most identical stack among many. Investigate the emitter and its aggregate subscribers instead
- For popular leaks: do NOT remove caching/pooling/reuse patterns that appear in the top stack — they exist to solve other problems. If the aggregate count is by design (e.g., many menus subscribing to a shared context key service), close the issue as "not planned"
This analysis came from reading the construction code, not from memorized rules about listener leaks.
Guidelines
- Prefer enriching error messages over adding try/catch guards
- Truncate any user-controlled values included in error messages (to avoid PII and keep messages bounded)
- Do not change the behavior of shared utility functions (like
URI.revive) in ways that affect all callers — fix at the specific call site or producer - Run the relevant unit tests after making changes
- Check for compilation errors via the build task before declaring work complete
.github/skills/heap-snapshot-analysis/SKILL.md
npx skills add asJEI/vscode --skill heap-snapshot-analysis -g -y
SKILL.md
Frontmatter
{
"name": "heap-snapshot-analysis",
"description": "Analyze V8 heap snapshots to investigate memory leaks and retention issues. Use when given .heapsnapshot files, asked to compare before\/after snapshots, asked to find what retains objects, or investigating why objects survive GC. Provides snapshot parsing, comparison, retainer-path helpers, and scratchpad scripts."
}
Heap Snapshot Analysis
Investigate memory leaks from V8 heap snapshots (.heapsnapshot files). This skill starts when snapshots already exist: either the user provided them, DevTools exported them, or another workflow produced them. Use the helpers here to compare snapshots, group object deltas, and trace retainer paths.
IGNORE Prior Investigations
Start every investigation fresh. Do NOT read, consult, or be influenced by prior investigations found in:
/memories/(user, session, or repo memory).github/skills/heap-snapshot-analysis/scratchpad/(previous dated subfolders and theirfindings.mdfiles)- Any other notes from earlier sessions
Previous findings can bias the analysis toward suspects that are no longer relevant, or cause the agent to skip steps and jump to conclusions. Let the current snapshots speak for themselves. Only reference prior work if the user explicitly asks you to.
When to Use
- User provides
.heapsnapshotfiles (before/after a workflow) - User has heap snapshots captured by another skill or script
- Need to find what retains disposed objects (retainer path analysis)
- Comparing object counts/sizes between two snapshots
- Investigating why particular objects survive GC
Workflow
If the user needs the agent to launch VS Code, drive a scenario, and capture snapshots first, use the VS Code performance workflow skill before returning here for low-level snapshot analysis.
1. Parse Snapshots
Use the helpers in parseSnapshot.ts to load snapshots. The files are often >500MB and too large for JSON.parse as a string — the helpers use Buffer-based extraction. In scratchpad scripts, import helpers from ../helpers/*.ts.
For very large snapshots, the helper may still be too eager. Node cannot create a Buffer larger than roughly 2 GiB, so snapshots above that size can fail with ERR_FS_FILE_TOO_LARGE even before parsing. In that case, do not try to raise --max-old-space-size and retry the same full-file read. Switch to a streaming script.
import { parseSnapshot, buildGraph } from '../helpers/parseSnapshot.ts';
const data = parseSnapshot('/path/to/snapshot.heapsnapshot');
const graph = buildGraph(data);
Snapshots Larger Than 2 GiB
When a snapshot is too large to load into a single Buffer, write scratchpad scripts that scan and parse only the sections needed for the question. Use streamSnapshot.mjs for the common streaming primitives instead of copying them between scratch scripts.
Useful tricks:
- Find top-level section offsets first. Scan the file as bytes for markers like
"nodes":,"edges":,"strings":, and"trace_function_infos":. This lets follow-up scripts jump directly to the large arrays instead of searching the whole file repeatedly. - Parse
snapshot.metaseparately from the small header at the start of the file. Usemeta.node_fields,meta.node_types,meta.edge_fields, andmeta.edge_typesto avoid hard-coding tuple widths. - Stream numeric arrays in chunks. For
nodesandedges, keep a small carryover string between chunks, split on commas, and process complete numeric tokens as they arrive. - Avoid materializing the full
stringstable unless the investigation truly needs it. If you only need suspicious names, collect string indexes from matching nodes/edges first, then resolve only those indexes in a second streaming pass. - If you do need many strings, store only short previews and category counters. Full source strings, ref-listing strings, and prompt payloads can dominate memory and make the analyzer become the leak.
- Write intermediate outputs to files in the scratchpad. Large heap analysis is iterative and slow; cached node ids, offsets, and retainer traces save repeated multi-minute passes.
- Prefer self-size attribution and field-level ownership for huge graphs. Full retained-size walks can wildly overcount shared services, roots, maps, and singleton caches.
- When quantifying a suspected owner, count obvious owned fields separately: wrapper object, key arrays, array elements, direct strings, and parent strings of sliced/concatenated strings. This often gives a better lower-bound than a single direct string bucket.
- Be explicit about approximation boundaries. A field-level subtotal usually undercounts listeners/watchers/back-references but avoids the much worse problem of attributing the whole runtime to one object.
Example large-snapshot workflow:
import { findArrayStart, findTokenOffsets, parseMeta, streamNumberTuples } from '../../helpers/streamSnapshot.mjs';
const { size, offsets } = findTokenOffsets(snapshotPath);
const meta = parseMeta(snapshotPath);
const nodeFieldCount = meta.node_fields.length;
const nodesStart = findArrayStart(snapshotPath, offsets.get('"nodes"'));
streamNumberTuples(snapshotPath, nodesStart, offsets.get('"edges"'), nodeFieldCount, (node, nodeIndex) => {
// node is reused for speed; copy it before storing.
});
cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/findOffsets.mjs /path/to/Heap.heapsnapshot
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/streamAnalyze.mjs /path/to/Heap.heapsnapshot > scratchpad/YYYY-MM-DD-topic/streamAnalyze.out
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/traceNodes.mjs /path/to/Heap.heapsnapshot 12345 67890 > scratchpad/YYYY-MM-DD-topic/traceNodes.out
2. Compare Before/After
Use compareSnapshots.ts to diff two snapshots:
import { compareSnapshots } from '../helpers/compareSnapshots.ts';
const result = compareSnapshots('/path/to/before.heapsnapshot', '/path/to/after.heapsnapshot');
// result.topBySize, result.topByCount, result.newObjectGroups, result.summary
3. Find Retainer Paths
Use findRetainers.ts to trace why an object is alive:
import { findRetainerPaths } from '../helpers/findRetainers.ts';
// Find what keeps ChatModel instances alive (skipping weak edges)
findRetainerPaths(graph, 'ChatModel', { maxPaths: 5, maxDepth: 25, maxAttempts: 200 });
4. Write Investigation Scripts
Write investigation-specific scripts in the scratchpad directory. This folder is gitignored — use it freely for one-off analysis.
Organize scratchpad work into dated subfolders named YYYY-MM-DD-short-description/ (e.g., 2026-04-09-chat-model-retainers/). Each subfolder should contain:
- The analysis scripts (
.mjs,.mts, etc.) - A
findings.mdfile documenting the full investigation: all ideas considered, which ones led to changes and which were rejected (and why), before/after measurements, and a summary of the outcome. This lets the user review the agent's reasoning, decide which changes to keep, and follow up on deferred ideas.
Scripts can import the helpers:
cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=16384 scratchpad/2026-04-09-chat-model-retainers/analyze.mjs
Key Concepts
V8 Heap Snapshot Format
The .heapsnapshot file is JSON with these key sections:
snapshot.meta: Field definitions for nodes and edgesnodes: Flat array, every N values = one node (N =meta.node_fields.length, typically 6:type, name, id, self_size, edge_count, detachedness)edges: Flat array, every M values = one edge (M =meta.edge_fields.length, typically 3:type, name_or_index, to_node)strings: String table indexed bynamefields in nodes/edges
Edge Types That Matter
| Type | Meaning | Prevents GC? |
|---|---|---|
property |
Named JS property | Yes |
element |
Array index | Yes |
context |
Closure variable | Yes |
internal |
V8 internal reference | Yes |
hidden |
V8 hidden reference | Yes |
weak |
WeakRef/WeakMap key | No |
shortcut |
Convenience link | Depends |
Always skip weak edges when tracing retainer paths. WeakMap entries show up as edges from key → backing array, but they don't prevent collection — they're red herrings.
Common VS Code Retention Patterns
-
RowCache templates: ListView's
RowCachestores template rows. Templates havecurrentElementpointing to old viewmodel items. If not cleared on session switch, retains entire model chains. -
Resource pools:
pool.clear()only disposes idle items. If_onDidUpdateViewModel.fire()runs AFTERpool.clear(), released items re-enter the empty pool and are never disposed. Fire event first, then clear. -
autorunIterableDeltalastValues: The closure captures aMapof previous iteration values. Values stay until the autorun re-runs. Async disposal delays keep models in observable stores longer than expected. -
HoverService._delayedHovers: Global singleton Map retaining disposed objects viashowclosure →resolveHoverOptionsclosure →this. If hover cleanup disposable doesn't fire, the entire object tree is retained. -
ObjectMutationLog._previous: The incremental serializer keeps a full snapshot of the last-serialized state. Every loaded ChatModel holds 2x its data: live +_previous. -
_previousModelRefpattern:MutableDisposablesetter disposes the old value. Reading.valueand storing it elsewhere, then setting.value = undefined, disposes the stored reference. UseclearAndLeak()to extract without disposing.
Defensive Nulling
Null heavy fields in dispose() to break retention chains even when something retains the disposed object:
override dispose() {
super.dispose();
this._requests.length = 0; // conversation data
this.dataSerializer = undefined; // serialization snapshot
this._editingSession = undefined; // editing session + TextModels
this._session = undefined!; // back-reference cycles
}
Caveat: Don't null fields on viewmodel items (ChatResponseViewModel._model). The tree's diffIdentityProvider accesses them after the parent viewmodel is disposed but before setChildren replaces them.
False Retainers to Watch For
- DevTools debugger global handles: If the snapshot was captured after opening DevTools, large source strings, compiled scripts, preview data, inspected objects, or debugger bookkeeping can be retained by paths like
DevTools debugger(internal)→synthetic::(Global handles)→ GC roots. Treat these as debugger-induced until proven otherwise. They may not exist in the app before DevTools opens, and they should not be confused with application-owned leaks. DevToolsLogger._aliveInstances(Map): Enabled byVSCODE_DEV_DEBUG_OBSERVABLESenv var. Retains ALL observed observables. Check if this is active before investigating observable-rooted paths.GCBasedDisposableTracker(FinalizationRegistry): Ifregister(target, held, target)is used (target === unregister token), creates a strong self-reference preventing GC. Currently commented out in production.- WeakMap backing arrays: Show up in retainer paths but don't prevent collection.
Running Analysis
All helper scripts use ESM and need Node with extra memory:
node --max-old-space-size=16384 scratchpad/analyze.mjs
Typical analysis takes 30-120 seconds per snapshot depending on size.
.github/skills/hygiene/SKILL.md
npx skills add asJEI/vscode --skill hygiene -g -y
SKILL.md
Frontmatter
{
"name": "hygiene",
"description": "Use when making code changes to ensure they pass VS Code's hygiene checks. Covers the pre-commit hook, unicode restrictions, string quoting rules, copyright headers, indentation, formatting, ESLint, and stylelint. Run the hygiene check before declaring work complete."
}
Hygiene Checks
VS Code runs a hygiene check as a git pre-commit hook. Commits will be rejected if hygiene fails.
Running the hygiene check
Always run the pre-commit hygiene check before declaring work complete. This catches issues that would block a commit.
To run the hygiene check on your staged files:
npm run precommit
This executes node --experimental-strip-types build/hygiene.ts, which scans only staged files (from git diff --cached).
To check specific files directly (without staging them first):
node --experimental-strip-types build/hygiene.ts path/to/file.ts
What it checks
The hygiene linter scans staged files for issues including (but not limited to):
- Unicode characters: Non-ASCII characters (em-dashes, curly quotes, emoji, etc.) are rejected. Use ASCII equivalents in comments and code. Suppress with
// allow-any-unicode-next-lineor// allow-any-unicode-comment-file. - Double-quoted strings: Only use
"double quotes"for externalized (localized) strings. Use'single quotes'everywhere else. - Copyright headers: All files must include the Microsoft copyright header.
- Indentation: Tabs only, no spaces for indentation.
- Formatting: TypeScript files must match the formatter output (run
Format Documentto fix). - ESLint: TypeScript files are linted with ESLint.
- Stylelint: CSS files are linted with stylelint.
.github/skills/integrated-browser/SKILL.md
npx skills add asJEI/vscode --skill integrated-browser -g -y
SKILL.md
Frontmatter
{
"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
WebContentsViewthe 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
platformso the agent host, which can't depend onworkbench, 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-*/nodeand let thebrowser/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).
.github/skills/integration-tests/SKILL.md
npx skills add asJEI/vscode --skill integration-tests -g -y
SKILL.md
Frontmatter
{
"name": "integration-tests",
"description": "Use when running integration tests in the VS Code repo. Covers scripts\/test-integration.sh (macOS\/Linux) and scripts\/test-integration.bat (Windows), their supported arguments for filtering, and the difference between node.js integration tests and extension host tests."
}
Running Integration Tests
Integration tests in VS Code are split into two categories:
- Node.js integration tests - files ending in
.integrationTest.tsundersrc/. These run in Electron via the same Mocha runner as unit tests. - Extension host tests - tests embedded in built-in extensions under
extensions/(API tests, Git tests, TypeScript tests, etc.). These launch a full VS Code instance with--extensionDevelopmentPath.
Scripts
- macOS / Linux:
./scripts/test-integration.sh [options] - Windows:
.\scripts\test-integration.bat [options]
When run without filters, both scripts execute all node.js integration tests followed by all extension host tests.
When run with --run or --runGlob (without --suite), only the node.js integration tests are run and the filter is applied. Extension host tests are skipped since these filters are node.js-specific.
When run with --grep alone (no --run, --runGlob, or --suite), all tests are run -- both node.js integration tests and all extension host suites -- with the grep pattern forwarded to every test runner.
When run with --suite, only the matching extension host test suites are run. Node.js integration tests are skipped. Combine --suite with --grep to filter individual tests within the selected suites.
Options
--run <file> - Run tests from a specific file
Accepts a source file path (starting with src/). Works identically to scripts/test.sh --run.
./scripts/test-integration.sh --run src/vs/workbench/services/search/test/browser/search.integrationTest.ts
--runGlob <pattern> (aliases: --glob, --runGrep) - Select test files by path
Selects which test files to load by matching compiled .js file paths against a glob pattern. Overrides the default **/*.integrationTest.js glob. Only applies to node.js integration tests (extension host tests are skipped).
./scripts/test-integration.sh --runGlob "**/search/**/*.integrationTest.js"
--grep <pattern> (aliases: -g, -f) - Filter test cases by name
Filters which test cases run by matching against their test titles (e.g. describe/test names). When used alone, the grep is applied to both node.js integration tests and all extension host suites. When combined with --suite, only the matched suites run with the grep.
./scripts/test-integration.sh --grep "TextSearchProvider"
--suite <pattern> - Run specific extension host test suites
Runs only the extension host test suites whose name matches the pattern. Supports comma-separated values and shell glob patterns (on macOS/Linux). Node.js integration tests are skipped.
Available suite names: api-folder, api-workspace, colorize, terminal-suggest, typescript, markdown, emmet, git, git-base, ipynb, notebook-renderers, configuration-editing, github-authentication, css, html.
# Run only Git extension tests
./scripts/test-integration.sh --suite git
# Run API folder and workspace tests (glob, macOS/Linux only)
./scripts/test-integration.sh --suite 'api*'
# Run multiple specific suites
./scripts/test-integration.sh --suite 'git,emmet,typescript'
# Filter tests within a suite by name
./scripts/test-integration.sh --suite api-folder --grep 'should open'
--help, -h - Show help
./scripts/test-integration.sh --help
Other options
All other options (e.g. --timeout, --coverage, --reporter) are forwarded to the underlying scripts/test.sh runner for node.js integration tests. These extra options are not forwarded to extension host suites when using --suite.
Examples
# Run all integration tests (node.js + extension host)
./scripts/test-integration.sh
# Run a single integration test file
./scripts/test-integration.sh --run src/vs/workbench/services/search/test/browser/search.integrationTest.ts
# Run integration tests matching a grep pattern
./scripts/test-integration.sh --grep "TextSearchProvider"
# Run integration tests under a specific area
./scripts/test-integration.sh --runGlob "**/workbench/**/*.integrationTest.js"
# Run only Git extension host tests
./scripts/test-integration.sh --suite git
# Run API folder + workspace extension tests (glob)
./scripts/test-integration.sh --suite 'api*'
# Run multiple extension test suites
./scripts/test-integration.sh --suite 'git,typescript,emmet'
# Grep for specific tests in the API folder suite
./scripts/test-integration.sh --suite api-folder --grep 'should open'
# Combine file and grep
./scripts/test-integration.sh --run src/vs/workbench/services/search/test/browser/search.integrationTest.ts --grep "should search"
Compilation requirement
Tests run against compiled JavaScript output. Ensure the VS Code - Build watch task is running or that compilation has completed before running tests.
Distinction from unit tests
- Unit tests (
.test.ts) → usescripts/test.shor therunTeststool - Integration tests (
.integrationTest.tsand extension tests) → usescripts/test-integration.sh
Do not mix these up: scripts/test.sh will not find integration test files unless you explicitly pass --runGlob **/*.integrationTest.js, and scripts/test-integration.sh is not intended for .test.ts files.
.github/skills/memory-leak-audit/SKILL.md
npx skills add asJEI/vscode --skill memory-leak-audit -g -y
SKILL.md
Frontmatter
{
"name": "memory-leak-audit",
"description": "Audit code for memory leaks and disposable issues. Use when reviewing event listeners, DOM handlers, lifecycle callbacks, or fixing leak reports. Covers addDisposableListener, Event.once, MutableDisposable, DisposableStore, and onWillDispose patterns."
}
Memory Leak Audit
The #1 bug category in VS Code. This skill encodes the patterns that prevent and fix leaks.
When to Use
- Reviewing code that registers event listeners or DOM handlers
- Fixing reported memory leaks (listener counts growing over time)
- Creating objects in methods that are called repeatedly
- Working with model lifecycle events (onWillDispose, onDidClose)
- Adding event subscriptions in constructors or setup methods
Audit Checklist
Work through each check in order. A single missed pattern can cause thousands of leaked objects.
Step 1: DOM Event Listeners
Rule: Never use raw .onload, .onclick, or addEventListener() directly. Always use addDisposableListener().
// BAD — leaks a listener every call
this.iconElement.onload = () => { ... };
// GOOD — tracked and disposable
this._register(addDisposableListener(this.iconElement, 'load', () => { ... }));
Validated by: PR #280566 — Extension icon widget leaked 185 listeners after 37 toggles.
Step 2: One-Time Events
Rule: Use Event.once() for events that should only fire once (lifecycle events, close events, first-change events).
// BAD — listener stays registered forever after first fire
model.onDidDispose(() => store.dispose());
// GOOD — auto-removes after first invocation
Event.once(model.onDidDispose)(() => store.dispose());
Validated by: PRs #285657, #285661 — Terminal lifecycle hacks replaced with Event.once().
Step 3: Repeated Method Calls
Rule: Objects created in methods called multiple times must NOT be registered to the class this._register(). Use MutableDisposable or return IDisposable to the caller.
// BAD — every call adds another listener to the class store
startSearch() {
this._register(this.model.onResults(() => { ... }));
}
// GOOD — MutableDisposable ensures max 1 listener
private readonly _searchListener = this._register(new MutableDisposable());
startSearch() {
this._searchListener.value = this.model.onResults(() => { ... });
}
When the event should only fire once per method call, combine Event.once() with MutableDisposable — this auto-removes the listener after the first invocation while still guarding against repeated calls:
private readonly _searchListener = this._register(new MutableDisposable());
startSearch() {
this._searchListener.value = Event.once(this.model.onResults)(() => { ... });
}
Validated by: PR #283466 — Terminal find widget leaked 1 listener per search.
Step 4: Model-Tied DisposableStores
Rule: When creating a DisposableStore tied to a model's lifetime, register model.onWillDispose(() => store.dispose()) to the store itself.
const store = new DisposableStore();
store.add(model.onWillDispose(() => store.dispose()));
store.add(model.onDidChange(() => { ... }));
Validated by: Pattern used in chatEditingSession.ts, fileBasedRecommendations.ts, testingContentProvider.ts.
Step 5: Resource Pool Patterns
Rule: When using factory methods that create pooled objects (lists, trees), disposables must be registered to the individual item, not the pool class.
// BAD — registers to pool, never cleaned per item
createItem() {
const item = new Item();
this._register(item.onEvent(() => { ... }));
return item;
}
// GOOD — wrap with item-scoped disposal
createItem(): IDisposable & Item {
const store = new DisposableStore();
const item = new Item();
store.add(item.onEvent(() => { ... }));
return { ...item, dispose: () => store.dispose() };
}
Validated by: PR #290505 — Chat content parts CollapsibleListPool and TreePool leaked disposables.
Step 6: Test Validation
Rule: Every test suite that creates disposable objects must call ensureNoDisposablesAreLeakedInTestSuite().
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
suite('MyFeature', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('does something', () => {
// test disposables are tracked automatically
});
});
Quick Reference
| Scenario | Pattern | Anti-Pattern |
|---|---|---|
| DOM events | addDisposableListener() |
.onclick =, addEventListener() |
| One-time events | Event.once(event)(handler) |
event(handler) for lifecycle |
| Repeated methods | MutableDisposable or return IDisposable |
this._register() in non-constructor |
| Model lifecycle | store.add(model.onWillDispose(...)) |
Forgetting cleanup |
| Pooled objects | Item-scoped DisposableStore |
Pool-scoped this._register() |
| Tests | ensureNoDisposablesAreLeakedInTestSuite() |
No leak checking |
Verification
After fixing leaks, verify by:
- Checking listener counts before/after repeated operations
- Running
ensureNoDisposablesAreLeakedInTestSuite()in tests - Confirming object counts stabilize (don't grow linearly with usage)
- For chat-specific leaks: Run the chat memory leak checker via
npm run perf:chat-leak(see thechat-perfskill). It sends N messages in a single session, forces GC between each, and uses linear regression on heap/DOM samples to detect per-message growth. A slope above 2 MB/msg indicates a leak. Use--messages 20 --verbosefor more accurate results.
.github/skills/otel/SKILL.md
npx skills add asJEI/vscode --skill otel -g -y
SKILL.md
Frontmatter
{
"name": "otel",
"description": "OpenTelemetry instrumentation for the Copilot Chat extension — covers the four agent execution paths, the IOTelService abstraction, span\/metric\/event conventions, and the relationship between code and the user\/developer monitoring docs. Use when adding\/changing OTel spans, metrics, or events; instrumenting a new agent surface; touching the Copilot CLI bridge or Claude span emission; or updating `extensions\/copilot\/docs\/monitoring\/agent_monitoring*.md`."
}
OpenTelemetry Instrumentation Skill
When adding, changing, or reviewing OTel telemetry in the Copilot Chat extension, always read the two source-of-truth docs first and always keep them in sync with the code you change.
1. Authoritative Documents
The extensions/copilot/docs/monitoring/ directory contains the two specs that define the OTel contract for the extension. Treat them like the layout / layer specs in vs/sessions.
| Document | Path | Audience | Covers |
|---|---|---|---|
| User-facing | extensions/copilot/docs/monitoring/agent_monitoring.md |
Extension users | Quick start, settings, env vars, exported spans/metrics/events, backend setup guides |
| Architecture | extensions/copilot/docs/monitoring/agent_monitoring_arch.md |
Developers | Multi-agent strategies, span hierarchies, file structure, instrumentation points, IOTelService, configuration channels |
| Visual flow | extensions/copilot/docs/monitoring/otel-data-flow.html |
Developers | Renders the bridge data flow for the in-process Copilot CLI agent |
If the implementation changes, you must update the relevant doc in the same PR. The arch doc is the most likely to drift; treat divergence as a bug.
2. Architecture at a Glance
The extension has four agent execution paths, each with a different OTel strategy:
| Agent | Process Model | Strategy | Debug Panel Source |
|---|---|---|---|
Foreground (toolCallingLoop) |
Extension host | Direct IOTelService spans |
Extension spans |
| Copilot CLI in-process | Extension host (same process) | Bridge SpanProcessor — SDK creates spans natively; bridge forwards to debug panel | SDK native spans via bridge |
| Copilot CLI terminal | Separate terminal process | Forward OTel env vars | N/A (separate process) |
| Claude Code | Child process (Node fork) | Synthesized from SDK messages — extension intercepts the Claude SDK message stream in claudeMessageDispatch.ts and emits GenAI spans; LLM calls are proxied through claudeLanguageModelServer.ts (which calls chatMLFetcher, producing standard chat spans). |
Extension spans |
Why asymmetric? The CLI SDK runs in-process with full trace hierarchy (subagents, permissions, hooks). A bridge captures this directly. Claude runs as a separate process — internal spans are inaccessible, so the extension synthesizes spans by translating SDK messages and proxying the model API.
3. Where Things Live (canonical map)
extensions/copilot/src/platform/otel/
├── common/
│ ├── otelService.ts # IOTelService interface + ISpanHandle + injectCompletedSpan
│ ├── otelConfig.ts # Config resolution (env → settings → defaults), enabledVia, dbSpanExporter
│ ├── noopOtelService.ts # Zero-cost no-op (used by chatLib / tests)
│ ├── inMemoryOTelService.ts # ← actually under node/, see below
│ ├── agentOTelEnv.ts # deriveCopilotCliOTelEnv / deriveClaudeOTelEnv
│ ├── genAiAttributes.ts # ⚠ Single source of truth for attribute keys & enums
│ ├── genAiEvents.ts # Event emitter helpers (emit*Event)
│ ├── genAiMetrics.ts # GenAiMetrics class
│ ├── messageFormatters.ts # truncateForOTel, normalizeProviderMessages, toSystemInstructions, …
│ ├── workspaceOTelMetadata.ts
│ ├── sessionUtils.ts
│ └── index.ts # ⚠ Public barrel — re-export new helpers/constants here
└── node/
├── otelServiceImpl.ts # NodeOTelService + DiagnosticSpanExporter + FilteredSpanExporter + EXPORTABLE_OPERATION_NAMES
├── inMemoryOTelService.ts # InMemoryOTelService (used when OTel is disabled — feeds debug panel only)
├── fileExporters.ts # File-based span/log/metric exporters
└── sqlite/ # OTelSqliteStore + SqliteSpanExporter (dbSpanExporter pipeline)
extensions/copilot/src/extension/
├── chatSessions/
│ ├── copilotcli/node/
│ │ ├── copilotCliBridgeSpanProcessor.ts # Bridge: SDK spans → IOTelService (+ hook span enrichment)
│ │ ├── copilotcliSession.ts # Root invoke_agent copilotcli span + traceparent + hook stash
│ │ └── copilotcliSessionService.ts # Bridge installation + env var setup
│ └── claude/
│ ├── common/claudeMessageDispatch.ts # execute_tool / execute_hook spans + subagent context wiring
│ └── node/
│ ├── claudeOTelTracker.ts # invoke_agent claude span + per-session token/cost rollup
│ └── claudeLanguageModelServer.ts # Local HTTP proxy → chatMLFetcher (chat spans)
├── chat/vscode-node/
│ └── chatHookService.ts # execute_hook spans for foreground agent hooks
├── intents/node/toolCallingLoop.ts # invoke_agent spans for foreground agent
├── tools/vscode-node/toolsService.ts # execute_tool spans for foreground tools
├── prompt/node/chatMLFetcher.ts # chat spans for all LLM calls
├── byok/vscode-node/ # BYOK provider chat spans (anthropicProvider, geminiNativeProvider, …)
└── trajectory/vscode-node/
├── otelChatDebugLogProvider.ts # Debug panel data provider
├── otelSpanToChatDebugEvent.ts # Span → ChatDebugEvent conversion
└── otlpFormatConversion.ts # OTLP ↔ in-memory span format
3a. Attribute namespaces & dual-emit policy
Three namespaces coexist on extension-emitted spans:
| Namespace | Purpose | Status |
|---|---|---|
gen_ai.* |
OTel GenAI Semantic Conventions. Use whenever a standard key exists. | Canonical |
github.copilot.* |
Copilot-specific vendor namespace. | Preferred — new attributes go here. |
copilot_chat.* |
Original VS Code-only namespace. Several keys remain for backwards compatibility. | Legacy — keep emitting; do not add new keys here. |
Dual-emit rules
- When adding a new attribute that belongs to Copilot's vendor namespace, emit it under
github.copilot.*only — do not introduce acopilot_chat.*twin. - When renaming an existing
copilot_chat.*attribute to itsgithub.copilot.*equivalent (e.g.,copilot_chat.repo.*→github.copilot.git.*,gen_ai.usage.reasoning_tokens→gen_ai.usage.reasoning.output_tokens), dual-emit both keys indefinitely. Downstream readers (Agent Debug Log, Chronicle, SQLite span store, OTLP collectors) may depend on the legacy key. - Mark the legacy row in agent_monitoring.md with Legacy in the "Requirement" column and a pointer to the preferred key. No sunset date — legacy keys live on indefinitely.
- Hash sensitive identifiers (e.g., MCP server names) with
hashTelemetryValuefromutil/node/crypto.ts. Emit hashes unconditionally; raw values only whencaptureContentis enabled.
4. Service Layer & Selection
IOTelService (otelService.ts) is the only abstraction consumers should depend on — never import the OTel SDK directly outside node/otelServiceImpl.ts. Three implementations:
| Class | When Used |
|---|---|
NoopOTelService |
chatLib and tests where no telemetry pipeline is needed — zero cost |
NodeOTelService |
OTel enabled — full SDK, OTLP/file/console export, optional SQLite span exporter |
InMemoryOTelService |
Registered when OTel is disabled — no SDK is loaded, but spans/metrics/logs are still captured in-memory so the Agent Debug Log panel keeps working |
Selection happens in src/extension/extension/vscode-node/services.ts: exactly one of NodeOTelService or InMemoryOTelService is bound to IOTelService per extension host based on resolveOTelConfig().enabled.
5. Span / Metric / Event Conventions
Follow the OTel GenAI semantic conventions. Always use the constants from genAiAttributes.ts — never raw string literals.
| Operation | Span Name | Kind | Constant |
|---|---|---|---|
| Agent orchestration | invoke_agent {agent_name} |
INTERNAL |
GenAiOperationName.INVOKE_AGENT |
| LLM API call | chat {model} |
CLIENT |
GenAiOperationName.CHAT |
| Tool execution | execute_tool {tool_name} |
INTERNAL |
GenAiOperationName.EXECUTE_TOOL |
| Hook execution | execute_hook {hook_type} |
INTERNAL |
GenAiOperationName.EXECUTE_HOOK |
Attribute namespaces:
| Namespace | Constant module | Examples |
|---|---|---|
gen_ai.* |
GenAiAttr |
gen_ai.operation.name, gen_ai.usage.input_tokens |
copilot_chat.* |
CopilotChatAttr |
copilot_chat.session_id, copilot_chat.chat_session_id, copilot_chat.hook_* |
github.copilot.* |
CopilotCliSdkAttr |
SDK-emitted hook attributes (read-only — bridge & debug panel) |
claude_code.* |
(raw) | Claude subprocess SDK attributes — only ever observed in OTLP, not produced by the extension |
Standard span pattern
return this._otelService.startActiveSpan(
`execute_tool ${name}`,
{
kind: SpanKind.INTERNAL,
attributes: {
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
[GenAiAttr.TOOL_NAME]: name,
// …
},
},
async (span) => {
try {
const result = await this._actualWork();
span.setStatus(SpanStatusCode.OK);
return result;
} catch (err) {
span.setStatus(SpanStatusCode.ERROR, err instanceof Error ? err.message : String(err));
span.setAttribute(StdAttr.ERROR_TYPE, err instanceof Error ? err.constructor.name : 'Error');
throw err;
}
},
);
Cross-boundary trace propagation
// Parent: store context keyed by something the child knows
const ctx = this._otelService.getActiveTraceContext();
if (ctx) { this._otelService.storeTraceContext(`subagent:invocation:${id}`, ctx); }
// Child: retrieve and use as parent
const parentCtx = this._otelService.getStoredTraceContext(`subagent:invocation:${id}`);
return this._otelService.startActiveSpan('invoke_agent child', { parentTraceContext: parentCtx, … }, fn);
Content capture
The extension uses two conventions side-by-side; pick the right one for the attribute you're adding.
- Always emit (truncated) — used for inputs/outputs that the Agent Debug Log panel needs to be useful even when OTel export is off (e.g.
gen_ai.tool.call.argumentsintoolsService.ts, andcopilot_chat.hook_input/hook_outputinchatHookService.ts). The attribute is captured unconditionally but always passed throughtruncateForOTel. Use this for moderate-sized, generally-non-secret arguments / results. - Gate on
config.captureContent— used for full prompt / response / system-instruction bodies (e.g.gen_ai.input.messages,gen_ai.output.messages,gen_ai.system_instructions,gen_ai.tool.definitionsinchatMLFetcher.tsand the BYOK providers). These are larger and more likely to contain user secrets.
// Pattern 1 — always emit, always truncate
span.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(JSON.stringify(args)));
// Pattern 2 — gated on captureContent
if (this._otelService.config.captureContent) {
span.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(messages)));
}
Debug panel vs OTLP isolation
Spans whose gen_ai.operation.name is not in EXPORTABLE_OPERATION_NAMES (defined in otelServiceImpl.ts) are visible to the debug panel via onDidCompleteSpan but excluded from OTLP and SQLite exporters by DiagnosticSpanExporter and FilteredSpanExporter. Currently exportable: chat, invoke_agent, execute_tool, embeddings, execute_hook. If you add a new operation name that should reach the user's collector, update EXPORTABLE_OPERATION_NAMES and document it in agent_monitoring.md.
6. Configuration Surface (must stay in sync)
When you add or change a setting/env var/command, update all three of:
- The setting/command registration in
extensions/copilot/package.json(search forgithub.copilot.chat.otel). resolveOTelConfiginotelConfig.ts— if the setting affects runtime config — and theenabledViachannel if it can implicitly enable OTel.agent_monitoring.md("VS Code Settings", "Environment Variables", "Activation", "Commands" tables) andagent_monitoring_arch.md("Activation Channels", "Agent-Specific Env Var Translation" tables).
For sub-process env vars, also update:
deriveCopilotCliOTelEnv/deriveClaudeOTelEnvinagentOTelEnv.ts.- The corresponding tests in
src/platform/otel/common/test/agentOTelEnv.spec.ts.
7. Procedure Checklists
When adding a new span / attribute
- Add the attribute key as a constant to
genAiAttributes.ts(underGenAiAttr,CopilotChatAttr, or a new domain group). Never inline a raw'copilot_chat.foo'literal. - Add it to the public barrel in
index.tsif it lives in a new group. - Use
IOTelService.startActiveSpan(preferred) orstartSpan— neverBasicTracerProvider/getTracerdirectly. - Pass the value through
truncateForOTel(mandatory for any free-form content attribute — prevents OTLP batch failures). Decide whether the attribute should be always-emitted (debug-panel-essential, e.g. tool args, hook input/output) or gated onconfig.captureContent(large prompt/response bodies, system instructions); follow the existing convention for similar data. - If the new operation should reach OTLP, add its op-name to
EXPORTABLE_OPERATION_NAMESinotelServiceImpl.ts. - Document the new attribute in
agent_monitoring.md(under the relevant span table) and add a test insrc/platform/otel/common/test/.
When adding a new metric / event
- Add the helper to
genAiMetrics.tsorgenAiEvents.ts(mirror existing static / functional patterns). - Re-export it from
index.ts. - Add the metric/event row to
agent_monitoring.md("Metrics" / "Events" sections) with all attributes documented. - Add a unit test in
src/platform/otel/common/test/genAiMetrics.spec.tsorgenAiEvents.spec.ts(assert the exact name + attribute keys).
When instrumenting a new agent surface
- Pick a strategy: direct spans (foreground-style), bridge processor (CLI-style), or message-stream synthesis (Claude-style).
- Add the new emit site to the Instrumentation Points table in
agent_monitoring_arch.mdand the Span Hierarchies diagrams. - If you forward OTel env vars to a child process, do it via a new
derive*OTelEnvhelper inagentOTelEnv.tsand add a row to the Agent-Specific Env Var Translation table. - Wire trace propagation explicitly with
storeTraceContext/parentTraceContextfor any subagent or async boundary; do not rely on global active context across processes.
When changing the Copilot CLI bridge
The bridge (copilotCliBridgeSpanProcessor.ts) reaches into _delegate._activeSpanProcessor._spanProcessors — internal OTel SDK v2 state. This is documented as a known risk. If you touch it:
- Keep the runtime guard that degrades gracefully if the internal shape changes.
- Update the ⚠ SDK Internal Access Warning block in
agent_monitoring_arch.mdif the access pattern changes. - Add a unit test in
copilotCliBridgeSpanProcessor.spec.ts.
8. Validation
Before sending a PR that touches OTel code:
# From extensions/copilot/
npx tsc --noEmit --project tsconfig.json
# OTel + Bridge unit tests
npm test -- --grep "OTel\|Bridge"
Manual sanity checks:
- The Aspire Dashboard quick-start in
agent_monitoring.mdstill works end-to-end (one agent message →invoke_agent+chat+execute_toolspans visible at http://localhost:18888). - The Agent Debug Log panel in VS Code still shows the full span tree for foreground, Copilot CLI, and Claude sessions.
9. Known Risks & Limitations
These are documented in agent_monitoring_arch.md — preserve them:
- SDK
_spanProcessorsinternal access (graceful runtime guard). - Two TracerProviders in the same process when CLI SDK is active.
process.envmutation for the CLI SDK (only OTel-specific vars, set beforeLocalSessionManagerctor).- Single
captureContentflag for the CLI SDK applies to both debug panel and OTLP — document any user-visible change clearly. - Claude SDK has no file exporter, and the CLI runtime only supports
otlp-http.
10. Anti-Patterns to Reject
- ❌ Importing
@opentelemetry/api(or any@opentelemetry/*package) from anywhere other thannode/otelServiceImpl.ts,fileExporters.ts, or the CLI bridge processor type imports. - ❌ Hard-coded attribute keys:
'copilot_chat.hook_type'instead ofCopilotChatAttr.HOOK_TYPE. - ❌ Hard-coded provider strings:
'github'/'anthropic'/'gemini'instead ofGenAiProviderName.*. - ❌ Magic
SpanStatusCodenumbers (code: 1,code: 2) — use the enum. - ❌ Emitting any free-form content attribute without passing it through
truncateForOTel— OTLP batches will silently drop or fail. - ❌ Logging full prompt / response / system-instruction bodies without
config.captureContentgating (these are pattern 2 above). - ❌ Adding a span operation name without deciding whether it's exportable (
EXPORTABLE_OPERATION_NAMES). - ❌ Updating instrumentation without updating
agent_monitoring.md/agent_monitoring_arch.mdin the same change.
.github/skills/sessions/SKILL.md
npx skills add asJEI/vscode --skill sessions -g -y
SKILL.md
Frontmatter
{
"name": "sessions",
"description": "Agents window architecture — covers the agents-first app, layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines. Use when implementing features or fixing issues in the Agents window."
}
Before Making Any Changes
MANDATORY: Before writing or modifying any code in src/vs/sessions/, you must read these documents:
.github/instructions/coding-guidelines.instructions.md— Naming conventions, code style, string localization, disposable management, and DI patterns..github/instructions/source-code-organization.instructions.md— Layers, target environments, dependency injection, and folder structure conventions.
Then read the relevant spec for the area you are changing (see table below). If you modify the implementation, you must update the corresponding spec to keep it in sync.
Specification Documents
| Document | Path | When to read |
|---|---|---|
| Layer rules | src/vs/sessions/LAYERS.md |
Before adding any cross-module imports. Defines the internal layer hierarchy (core → services → contrib → providers) with ESLint-enforced import restrictions. Key rule: contrib/* must NOT import from contrib/providers/*. |
| Layout spec | src/vs/sessions/LAYOUT.md |
Before changing any part, grid structure, titlebar, or CSS. Documents the fixed grid layout (Sidebar | ChatBar | AuxiliaryBar), part positions, the modal editor system, per-session layout state persistence, and the titlebar's three-section design. |
| Layout controller spec | src/vs/sessions/LAYOUT_CONTROLLER.md |
Before changing LayoutController or per-session layout state. Details how the auxiliary bar, panel, and editor working sets are captured/restored when switching sessions, multi-session suppression, the auto-reveal-on-changes flow, workspace-folder ordering, and storage/migration. |
| Sessions spec | src/vs/sessions/SESSIONS.md |
Before changing session/provider interfaces or data flow. Covers the pluggable provider model (ISessionsProvider → ISessionsProvidersService → ISessionsManagementService), ISession/IChat interfaces, observable state propagation, workspace/folder model, and session type system. |
| Sessions list spec | src/vs/sessions/SESSIONS_LIST.md |
Before changing the sessions sidebar list. Covers the tree widget (WorkbenchObjectTree), renderers, grouping (workspace/date), filtering (type/status/archived/read), pinning, read/unread state, workspace capping, mobile adaptations, storage keys, and registered actions. |
| Mobile spec | src/vs/sessions/MOBILE.md |
Before adding any phone-specific UI. Covers the mobile part subclass architecture, viewport classification (phone < 640px), MobileTitlebarPart, drawer-based sidebar, MobilePickerSheet, view/action gating with IsPhoneLayoutContext, and the desktop → mobile component mapping. |
| AI Customizations | src/vs/sessions/AI_CUSTOMIZATIONS.md |
Before working on the customization editor or tree view. Documents the management editor (in vs/workbench) and the tree view/overview (in vs/sessions/contrib/aiCustomizationTreeView). |
Common Pitfalls
- Wrong menu IDs: Never use
MenuId.*fromvs/platform/actionsfor Agents window UI. Always useMenus.*frombrowser/menus.ts. - Sessions menu ids must live in the shared menu registry: Do not declare sessions-owned
new MenuId(...)constants ad hoc inside individual parts. Add them tobrowser/menus.tsunderMenuswith discoverableSessionsEditor...names so ownership and reuse stay obvious. - Events instead of observables: Session state must flow through
IObservable, notEvent. Useautorun/derivedfor reactive UI, notonDid*event listeners. - Importing from providers: Non-provider
contrib/*code must never import fromcontrib/providers/*. Extract shared interfaces toservices/orcommon/. IAgentSessionsServicein shared code:IAgentSessionsService(vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService) is a Copilot-provider internal and may be imported only by the Copilot chat sessions provider (contrib/providers/copilotChatSessions/). Shared sessions code (core/services/non-provider contribs, e.g. the sessions list or visible-sessions grid) must stay provider-agnostic and go throughISession/ISessionsManagementService— never reach intomodel.observeSession(...)etc. for lazy loading. This is enforced by an ESLintno-restricted-importsban scoped tosrc/vs/sessions/**(Copilot provider exempted).- Missing entry point import: New contribution files must be imported in the appropriate
sessions.*.main.tsentry point to be loaded (for examplesessions.common.main.ts,sessions.desktop.main.ts,sessions.web.main.ts, orsessions.web.main.internal.ts). - 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. - Grid
onDidChangeis not a sash-drag signal: the workbenchSerializableGrid/GridViewonDidChangefires for size changes and view add/remove, but not internal splitview sash drags. If logic must react to a part node being resized by a sash, route it through that part'slayout(width, ...)callback, which receives the in-progress node width. - Docked detail collapse must use the raw sash width before clamping: the docked auxiliary bar keeps a minimum visible width, so checking the clamped width can never detect a drag-to-zero collapse. Decide collapse from the raw requested sash width, then route the hide through
setPartHidden(AUXILIARYBAR_PART)so context keys and per-session capture stay in sync. - 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 inopenSessionand consumed by ashouldX()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 anIObservableset in the same transaction asactiveSession(via a single internal setter so it can never go stale), and read with.read(reader)in the consumer'sautorun— 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/setAgentmust push the selection into the loadedIChatModel.inputModel(like_updateChatSessionState) and let the draft-sync debounce emitchat/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 likeLifecyclePhase.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. 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. anInputBox) 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<input>. - 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_sessionToProvidermap. That map is populated only bycreateSession, 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 throwsno provider for sessionand silently breaks the feature (e.g. Add Chat did nothing for restored sessions while messaging worked, because messaging already used the fallback)._findProviderForSessionfalls 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 likeSessionAgentChanged/SessionModelChangedmust be dispatched to the per-chat turn channel (_resolveTurnDispatchChannel, which carries achatIdfragment for peer chats), notsession.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 thechatChannelthroughagentSideEffects.handleAction→changeAgent/changeModel, which apply it to_chatSessionswhen present. The protocol modelssummary.agent/summary.modelat 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, remappingChatToolCallConfirmed, or usingparseDefaultChatUri(...) ?? sessionUriinAgentSideEffects; malformed/misrouted chat channels should fail loudly so the producer or dispatch path is fixed.handleToolCallConfirmedand_toolCallAgentsmust 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:
AgentHostSessionHandlermust source the upstream default chat URI from hydratedSessionState.defaultChat/SessionState.chatsand store that mapping in its chat-resource-to-upstream-URI map. CallingbuildDefaultChatUri(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 callrestoreSessionfor subagents; that creates_sessionStateswithout a matching_chatStatesentry, 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-chatauthority. Subagent chat URIs use a stablesubagentauthority and put the encoded tool call id in the path; usebuildSubagentChatUri(...)instead ofbuildChatUri(..., \subagent-${toolCallId}`)`. - Selected custom agent must be in the SDK's
customAgents, not justpluginDirectories: The Copilot SDK validates the session-startagent:option (passed tocreateSession/resumeSession) against thecustomAgentslist by name only — it does NOT consultpluginDirectories.copilotSessionLauncher._buildSessionConfigdeliberately omits agents from file-dir plugins fromcustomAgents(relying on the SDK'spluginDirectoriesdiscovery to avoid duplicates), so selecting a plugin/extension-contributed agent (e.g. "Inbox") otherwise fails withCustom agent '<name>' not found. The fix (toSdkSessionCustomAgents) force-adds the resolved selected agent intocustomAgentswhile every other file-dir agent still loads viapluginDirectories. Note the agent picker offers VS Code chat modes fromIChatModeService, but onlyplugin/extensionstorage agents are synced to the host (SYNCABLE_STORAGE_SOURCES);user/localagents are never synced, so_resolveAgentNamereturnsundefinedfor them and noagent:is sent. - Derive SDK custom-agent names exactly like
parseAgentFile:_resolveAgentNameresolves the selected agent through the plugin parser, which trims the frontmattername(getStringValue('name')?.trim() || nameFromFile). When building the SDKcustomAgentslist (toSdkCustomAgents), derive the name the same way (?.trim() || agent.name); reading the raw frontmatternamewithout trimming yields a config name that won't match the trimmedresolvedAgentName, so the SDK still rejects the session withCustom agent '<name>' not found. - Peer chats have no server
summary, so dedup side-channel dispatch against the last value sent for that chat: equality guards before dispatchingSessionModelChanged/SessionAgentChangedcompare againstsummary.model/summary.agent, which only exist for the session's default chat. For peer chats, track the last-dispatched model/agent on theAgentHostChatSessioninstance (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: autoon the content node. Wrap transcript content inDomScrollableElement/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 backgroundsendRequestboth 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. Fullyawaitthe 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.chatsin stable creation order (append on add, replace-in-place on update — seeagentHostStateManager/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-composerUntitledchats 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'sUntitledpresentation (viaAdditionalChat._isNew, needed sosessionView.tsshows the composer) is independent of tab order and must not drive it. Also note_restorePeerChats(agentService.ts) must seed restored chats ingetChats()order, not inPromise.allresolution order, or the catalog scrambles on reload. - A new chat must report
SessionStatus.Untitleduntil its first request is sent, regardless of how the provider creates it:sessionView.tsonly shows the new-chat composer (which owns the Alt+Enter background-send handler) whenactiveChat.status === Untitled. The agent host commits a new peer chat eagerly, so its host status isCompleted— surfacing the standard chat widget and breaking background send. Gate the chat's presented status on a provider-sideisNewflag (AdditionalChat.markNew/markSent, set increateNewChatand cleared insendRequest's committed-chat branch), not on the host-reported status. - Service operations should return a result or throw, not
undefinedfor unsupported cases: capability-gated operations likeforkChatInSessionmust throw when the provider/session cannot perform them. Keep fallback decisions in the caller before invoking the service instead of encoding fallback as anundefinedservice result. - A provisional session abandoned during commit detection must not be returned as successful: its status can remain
InProgressafter its lifecycle owner is disposed, so consumers waiting for a terminal status never settle. Clean up the provisional session and reject the send when commit detection times out or the connection is lost. - Drop a fork when its turn point is unknown, don't forward it empty: in
AgentService.createChat/createSession, if the requested forkturnId/turnIndexresolves to no source turns, setfork: undefinedand fall through to a fresh create. Forwarding the fork with an empty turn slice makes the Copilot provider callsessions.forkwith notoEventId, 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 anawaitand runs on aSequencermicrotask). 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_previousSpaceConstrainedwhile_isRestoringSessionLayoutis true. Also gate the constrained derivation on!multipleSessionsVisibleObsso the feature is disabled with multiple sessions visible. Never use asetTimeoutto 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:
_withSessionLayoutRestoreincrements a depth counter, runswork(), and decrements when done. If it always schedules the decrement on a microtask (Promise.resolve(result).finally(...)) — even whenwork()returnsundefined(the common no-op restore, e.g. a session with no workspace) — the depth stays elevated for the entire synchronous caller/test body, so_isRestoringSessionLayoutreadstrueforever and the consumer (D7) silently stops acting. Only defer the decrement whenwork()returns a thenable; for void/sync (or throwing) work, decrement in thefinally. - A quick-chat's workspace-less kind is fixed at adapter construction — every construction path must carry
_meta:AgentHostSessionAdapterresolves its session-kind (QuickChatSessionKindvsWorkspaceSessionKind) once, fromreadSessionWorkspaceless(metadata._meta)in the constructor;_computeWorkspace()andisQuickChatdelegate to that fixed kind and cannot be flipped by a laterupdate/setMeta. So the_meta.workspacelesstag must be present in the metadata passed to every adapter-construction path:_refreshSessions()/listSessionsand the live_handleSessionAdded(summary)notification (carrysummary._meta). Dropping_metaon either locks a committed quick chat intoWorkspaceSessionKindand leaks its<userHome>/.copilot/chats/<id>scratch dir as aworkspace(breaking the archive-on-delete fallback, list badges, changes/files). On the host,CopilotAgent.listSessions()must re-emit_meta.workspacelessfrom persistedcopilot.workspacelessmetadata (mirroringgetSessionMetadata) so restored sessions carry the tag once the state-manager live summary is gone. The host still keeps the tag on both the summary_metaandSessionState._meta(createSessionState(summary)copies it) so the channels stay consistent. - Don't infer "quick chat" from
workspace === undefined: a session's workspace observable isundefinedfor 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 optionalISession.isQuickChat: IObservable<boolean>(only quick-chat-capable providers set it; absent ⇒false). The agent-host adapter derives it fromreadSessionWorkspaceless(_metaObs); non-quick-chat providers omit it. Consume it throughisQuickChatSession(session)/session.isQuickChat?.read(reader) ?? false. - Workspace-less is inferred from absent
workingDirectory— exclude forks from that inference: inCopilotAgent.createSession,isWorkspacelessmust be!sessionConfig.fork && !sessionConfig.workingDirectory. A fork that arrives without an explicitworkingDirectoryshould inherit the source session's context, not be taggedcopilot.workspacelessand dropped into a scratch dir + quick-chat system prompt. - On a failed quick-chat create, don't activate an unrelated draft:
SessionsService.openQuickChatmust, oncreateQuickChatthrowing, log and returnundefinedwithout 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 activatedIActiveSessionfrom_activate/openQuickChat(setActive is synchronous and yields the wrapper) and have the caller focus that exact value, rather than re-readingactiveSession.get()afterwards. - Hide an aux-bar view CONTAINER via
hideIfEmpty: true+ a viewwhen, not a containerwhen:IViewContainerDescriptorhas nowhenproperty (onlyIViewDescriptordoes). To conditionally hide a whole container (its tab/title) — e.g. the Agents-window Changes/Files containers for workspace-less sessions — put the context-keywhenon the inner view(s) and sethideIfEmpty: trueon the container. Container visibility ishideIfEmpty && activeViewDescriptors.length === 0(viewsService.updateViewContainerEnablementContextKey), andactiveViewDescriptorsalready respects each view'swhen, so the container hides reactively when all its views'whengo false. - Don't gate "is this a quick chat?" routing on
isCreated && isQuickChat: a quick-chat draft isUntitled, soisCreated(= status !== Untitled,visibleSessions.ts) isfalse— yet it is still a quick chat. Gating quick-chat routing onisCreated && 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 onisQuickChatalone so drafts and committed quick chats behave identically; useisCreatedonly 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→ alwaysopenNewSession), 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:
openNewSessionFromActiveseeds the new-session composer withactiveSession.workspace.get()?.uri. A quick chat is workspace-less by intent, but if itsworkspaceobservable is ever non-undefined (e.g. a stale-build/coupling leak where_kindresolved toWorkspaceSessionKindbecause_meta.workspacelesswas dropped, exposing the host's scratch~/.copilot/chats/<id>cwd as a workspace), Cmd+N would carry that scratch dir intocreateNewSession→ "New session in<scratch>", single/no session type, no picker, "No models available". Gate the folder inheritance onactiveSession.isQuickChatso 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 sameNewChatWidgetinstance is reused across the quick-chat→new-session transition (sessionView.tskeepskind==='newSession'), and Cmd+N'sopenNewSessiondiscard 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_isQuickChatComposerflips 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 hardcodedlocalize(…, "New Session")fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route all such fallbacks through the sharedgetUntitledSessionTitle(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 ascustomChatTitle:<defaultChatUri>); it must be seeded back on restore viarestoreSession/_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_titleobservable, 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.capabilitiesmust 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 firstSessionState). A plain getter cannot be tracked by the context-key autorun (setActiveSessionContextKeysreads it inside anautorun), sosupportsMultipleChats/sessionSupportsForkwould stay stale, and a multi-chat catalog processed whilesupportsMultipleChatswas stillfalsewould stay collapsed to[defaultChat]. ExposecapabilitiesasIObservable<ISessionCapabilities>(agent host derives it fromconnection.rootStateviaobservableFromEvent+derivedOptswithstructuralEquals; static providers useconstObservable), have consumers read.read(reader)/.get(), and re-apply the chat catalog from the lastSessionStatein anautorunon 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. -
A managed/default editor tab must be re-ensured every sync, not opened once: the per-session editor working-set restore (
baseSessionLayoutController[B2]_applyWorkingSet) runs on session activation and is not docked-gated, so it reinstates the session's saved editor set and will close any tab not in it (e.g. a set persisted before a new tab existed). A controller that opens a default tab once (guarded by aSetof initialized sessions) therefore loses it permanently after the restore. Ensure managed tabs (the pinned Changes tab, the default File tab) idempotently on every sync (group.editors.some(e => e instanceof X)→ open if absent), exactly like the Changes tab — never with an open-onceSet. Also:IEditorService.openEditor(input, group)on a typedEditorInputbindsgroupto theoptionsparam (overload is(editor, options?, group?)); passopenEditor(input, undefined, group). -
A "keep the editor closed" rule must not react to the editor's visibility transition: the single-pane new-session rule (R1,
_registerSinglePaneNewSessionRules) must drive its hide off the session + active editor, never offonDidChangePartVisibility/aneditorVisibleObs.onWillOpenEditorreveals the editor before the opened file becomes the active editor, and toggling the detail panel off reveals the empty editor viasetAuxiliaryBarHidden— both fire the visibility event synchronously with the active editor still stale (managed empty tab). A rule that re-runs on that transition re-hides the editor the user just asked to show (file never appears; the whole side pane vanishes on detail-toggle). Hide only when the active editor is non-real content, read the current visibility untracked when deciding to hide, and block spurious width-based reveals at the source withsetSuppressDockedEditorRevealSync(true)rather than a visibility backstop. Refinement: dropping the visibility trigger entirely loses the backstop for automatic post-activation reveals (working-set restore, an editor left visible across a session switch), so the editor can appear in a fresh new-session view. Keep the visibility trigger but gate the hide on an explicit-reveal flag: the workbench records_editorRevealedExplicitly(set true only ononWillOpenEditorand the detail-toggle reveal, cleared on any hide and on the suppress false->true transition so a stale cross-session flag can't leak) and exposesisEditorRevealedExplicitly(); R1 re-hides only when the editor is visible and the reveal was not explicit. -
When the docked editor is hidden while the detail stays visible, clear the sidebar-grow snapshots: hiding the editor resizes the docked node down to the detail width (
_dockedAuxiliaryBarWidth). Any_editorSizeGrownForSidebarHide/_detailWidthGrownForSidebarHidesnapshot captured earlier (while the editor was visible and the sessions list was hidden) is now stale — restoring it when the sessions list is later shown re-inflates the node so the detail fills the whole side pane instead of the chat reclaiming the freed editor width.setEditorHidden(true)must drop both snapshots in the detail-still-visible branch. -
Overriding a workbench toolbar hover needs matching specificity: the core rule
.monaco-workbench .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover(and the:hoveroutline rule) sets the toolbar hover background/outline at ~6-7 class specificity. An action-item label that needs to override that hover (either to suppress it for a non-interactive label, or to re-skin it) must use an equal-or-higher-specificity selector (prefix.monaco-workbench ... .monaco-action-bar:not(.vertical) .action-item.<class> .action-label:hover), not a short.<class> .action-label:hoverthat loses the cascade. (The single-pane diff-stats pill was once suppressed this way while static; it is now a clickable action that opens the multi-file diff, so it keeps the standard--vscode-toolbar-hoverBackgroundhover.) -
A managed tab must never reveal the docked editor content — but the core workbench must not hardcode which, and the excluded editor's deliberate open must reveal explicitly: the empty Files placeholder (
EmptyFileEditorInput) and the Changes multi-diff (SessionChangesEditorInput) surface their content in the detail panel, so activating either (e.g. the placeholder activating as a side effect of closing the Changes tab, or clicking the Changes tab) must not reveal the editor area. The core workbench_handleWillOpenEditorreveal handler skips revealing for editors matched by a contrib-provided predicate (IAgentWorkbenchLayoutService.setEditorRevealOnOpenExclusion), which the single-pane layout controller sets to its_isManagedEditorcheck — so the editor-type policy lives in contrib (which caninstanceofthe real inputs), not as hardcoded type-id literals insrc/vs/sessions/browser/*(core). Caveat: because tab activation and opening a file diff both fireonWillOpenEditorfor the same Changes editor (and the event carries no options to tell them apart), excluding it from the auto-reveal also blocks the deliberate file-open reveal — so the Changes view's_openMultiFileDiffEditor(clicking a file) must explicitlysetPartHidden(false, EDITOR_PART)before opening (revealing before the open also avoids the multi-diff hanging while laid out in a hidden 0-size editor part). Do NOT use a broad "editor already in group -> skip" rule. Keep_handleWillOpenEditora named method so it is unit-testable viaReflect.get(Workbench.prototype, ...). -
Gate single-pane editor-title layout/view actions on
MainEditorAreaVisibleContext; the Create Pull Request bar lives in the title bar, not the editor: single-pane (config.<DOCK_DETAIL_PANEL_SETTING>-gated) editor-title layout/view items (Maximize/Restore, Toggle Details, Hide Editor, Open in Modal, the diff-view actions collapse/expand/toggle-inline/list-tree) must includeMainEditorAreaVisibleContextso they disappear when the editor content is closed. The Create Pull Request anchor (CHANGES_HEADER_ACTIONS_ID) is not an editor-title action: it is contributed toMenus.TitleBarSessionMenu(the sessions title bar's session-actions area) byChangesHeaderActionsActioninchangesViewActions.ts, gated onIsSessionsWindowContext+IsAuxiliaryWindowContext.toNegated()+config.<DOCK_DETAIL_PANEL_SETTING>+SessionHasChangesContext(independent of editor-area visibility), and itsChangesActionsBarview item is registered for(Menus.TitleBarSessionMenu, CHANGES_HEADER_ACTIONS_ID)viaIActionViewItemService. The docked reveal-sync (_syncDockedEditorVisibility) must be symmetric: it reveals when the node widens past the detail width and hides (setspartVisibility.editor=false, flipsMainEditorAreaVisibleContext) when a sash drag squeezes the editor content back down to the detail width — same guards (_syncingDockedEditorVisibility,_suppressDockedEditorRevealSync,_dockDetailPanel, and only while the detail is visible). -
The managed Files placeholder tab is conditional, not always-on:
ChangesTabControllershows the empty Files tab only when the editor area is closed OR no real (non-managed) editor is open; once a real file/diff is open in a visible editor area it removes the placeholder (and re-adds it when the area closes). Drive this off anautorunthat also reads the editor-area visibility (observableFromEvent(onDidChangePartVisibility, () => isVisible(EDITOR_PART, mainWindow))) and an editor-change signal (observableSignalFromEvent(this, Event.any(onDidActiveEditorChange, onDidEditorsChange))), and keep the ensure/remove idempotent so it settles instead of looping. Close/open the placeholder undersuppressEditorPartAutoVisibility(). -
A per-session aux-bar (detail) capture must be skipped during a session-switch restore: the D2
onDidChangePartVisibilitylistener that records a created session's detail visibility must bail while_isRestoringSessionLayoutis true. During restore an external component (e.g.DetailPanelController) can transiently reveal the aux bar for the incoming active editor; capturing that overwrites the session's saved detail-hidden state, so switching back shows the detail even though the user had closed it. Keep the aux-bar sync work synchronous (fire-and-forget the view-open calls) so the restore epoch ends promptly and legitimate post-restore user toggles are still captured. -
The detail-panel forced reveal must be gated on the editor content being visible:
DetailPanelController._syncForcedTargetreveals the docked detail (aux bar) when a Changes/File editor becomes active while the detail is hidden. That reveal must additionally requireisVisible(EDITOR_PART, mainWindow)— otherwise, on a window reload where the user had closed the whole side pane (editor + detail both hidden, persisted), the restored managed tab becoming active re-reveals the detail and the closed state is lost. Reveal the detail only to accompany a visible editor; when the whole pane is intentionally closed, leave it closed. -
Auto-managed tabs must still be user-closable — remember user dismissals instead of blindly re-ensuring:
ChangesTabControllerre-ensures the managed Changes/Files tabs on many signals (session state, editor visibility, editor changes). Without care this re-creates a tab the instant the user closes it, so the tab feels un-closable (the managed tabs are non-previewpinEditor, NOT sticky — they do have close buttons; the blocker is the re-ensure, not a missing button). Track user-initiated closes in a_dismissedManagedTabsset (viaonDidCloseEditor, ignoring the controller's own closes tracked in an_internallyClosingEditorsset) and skip ensuring a dismissed kind. Clear the set only on a session change or when the side pane is reopened from fully closed (edge-detected via a stored_sidePaneWasVisible), so closes stick within the session while reopening/switching re-populates (Scenario B). -
D10 (empty aux-bar cleanup) must gate on quick-chat, not the racy container-active check, or it flickers the side pane closed on reload: the Agents-window Changes/Files aux-bar views gate on
SessionHasWorkspaceContext+WorkspaceFolderCountContext, which are set asynchronously (via thesetActiveSessionContextKeysautorun reading the session's asyncworkspace) after a session activates/reloads. So right after D3b/DetailPanelController/a manual toggle reveals the aux bar,isViewContainerActive(Files/Changes)is transientlyfalse(context keys not settled) even for a real workspace session. The D10 reconcile (_syncAuxiliaryBarPartVisibility, which runs synchronously on theonDidChangePartVisibility(visible)signal and only ever hides) then closes the just-opened side pane, and since it never re-reveals, it stays closed — the reload "side pane opens then closes" flicker, "Files not shown when opening the side pane", and "new-session side-pane state not remembered". Fix: D10 hides only when the aux is genuinely empty for the active session's lifetime — no active session, or a workspace-less quick chat (activeSession.isQuickChat?.get() === true, its Changes+Files permanently gated off) — never for a workspace-backed session whose gating context keys are merely still settling. Do NOT use the transient_hasActiveAuxViewContainers()result to hide a workspace session's aux. -
DetailPanelControllermust not hide the detail on an empty editor group in the new-session view:_computeTargetreturnsHiddenwhen the main editor part is empty (a created session's all-tabs-closed → whole side pane closed). But the new-session (uncreated) view's editor group is transiently empty while its Files tab is (re)ensured, and its Files detail is open by default and owned by the layout controller's D3b. Gating the empty-groupHiddenonactiveSession.isCreatedavoids a transient hide that the D2 visibility listener would otherwise capture as the new-session preference — making every subsequentcmd+nopen with the side pane hidden. Combined with the editor-visibility reveal gate, the new-session default stays open while a user's explicit hide is still remembered (D3b_newSessionViewState).
Validating Changes
You must run these checks before declaring work complete:
npm run typecheck-client— TypeScript compilation check. Do not runtscdirectly.npm run valid-layers-check— MANDATORY. Catches layering violations. If this fails, fix the imports before proceeding.scripts/test.sh --grep <pattern>— unit tests for affected areas
Progress dashboards / local HTML in the integrated browser
- The integrated browser blocks
file://outside trusted workspace folders (403 "File does not reside within a trusted folder"). To preview session-folder HTML (e.g. a live progress dashboard), serve it overhttp://127.0.0.1:<port>with a backgroundpython3 -m http.serverand open that URL instead. - For a self-updating dashboard, embed
<meta http-equiv="refresh" content="5">and keep the HTML task data in lockstep with the todo store — update both at every task transition so they never drift.
Don't rely on the LLM to render links/actions from tool result text
-
Tool result text is fed to the model, which may drop or reformat markdown links (e.g. render a session URI as an inline code span), so an explicit
[label](uri)in a tool result is NOT a reliable way to give the user a clickable action. -
For a deterministic, client-rendered action (a "pill"/button) tied to a specific tool call, set
toolSpecificDataon theChatToolInvocationinstateToProgressAdapter.ts(keyed on the tool name + parsed result) and add a custom subpart inchatToolInvocationPart.ts— the completed-state section already routes customtoolSpecificDatakinds (seeresources/simpleToolInvocation). Follow theagentFeedbackReviewConfirmationpattern. -
Managed-tab reconciliation must run entirely under
suppressEditorPartAutoVisibility():ChangesTabController._syncChangesEditorcloses stale managed tabs (e.g. a restored Changes tab whose session's workspace hasn't resolved yet on reload) before ensuring the current ones. If any close (esp._closeInactiveChangesEditors) runs unsuppressed and empties the group, the workbenchhandleDidCloseEditordocked branch treats it as "user closed all tabs" and closes the whole side pane — the reload flicker where the side pane appears then vanishes. Wrap the full reconciliation body in one suppression window so transient empty states are never mistaken for a user action; don't rely on per-open suppressions alone. Relatedly, the managed Files placeholder tab must NOT be auto-removed based on editor-area visibility (old Scenario 9 auto-remove) — that removal also emptied the group on reload; only remove it on an explicit user close (tracked via_dismissedManagedTabs). -
Layout-driven editor closes (working-set apply) must not be mistaken for user closes: On any single-pane session switch (incl. Cmd+N to a new untitled session and reload), the base controller applies the target session's editor working set — an empty working set closes the managed Changes/Files tabs externally. Two bugs resulted: (1) the workbench
handleDidCloseEditordocked branch saw an empty group and closed the whole side pane (reload/Cmd+N flicker: pane/Files-tab appears then vanishes); (2)ChangesTabController._handleEditorClosedrecorded the external close as a user dismissal, poisoning_dismissedManagedTabsso the Files tab toggled on/off on alternate Cmd+N presses. Fix:_withSessionLayoutRestore(base controller) holdssuppressEditorPartAutoVisibility()for the whole (async) restore only whenisSinglePaneLayoutEnabled(OFF layout unchanged), so layout-driven closes never reachhandleDidCloseEditor; and_handleEditorClosedignores closes whilelayoutService.isEditorPartAutoVisibilitySuppressed()is true, so only a genuine user close dismisses a managed tab. AddedisEditorPartAutoVisibilitySuppressed()toIAgentWorkbenchLayoutServiceas the shared "this close is layout-driven, not a user action" signal. -
DetailPanelController must not force-reveal the detail during a layout-driven restore:
_syncForcedTargetreveals the aux-bar detail to accompany the active Changes/Files editor. On a session switch the target session's editor working set is restored, making its Changes/Files editor active — an editor change that is NOT a user open. If the user had hidden the detail for that session, that restore-driven editor change would force-reveal it, losing the per-session detail-hidden state. Gate the reveal (setPartHidden(false, AUXILIARYBAR)) on!isEditorPartAutoVisibilitySuppressed()(in addition to the existing editor-visible guard): during_withSessionLayoutRestorethe base controller holdssuppressEditorPartAutoVisibility()(single-pane), so a restore-driven forced target skips the reveal while a genuine user editor open (unsuppressed) still reveals. Note the existing[D3c/single-pane]test passed because it simulated the reveal synchronously during apply (so D3 re-hid last); the real bug is the async DetailPanelController reveal firing on the editor-change after D3 already hid. -
The width-based docked reveal-sync (
_syncEditorVisibility) must bail while editor-part auto-visibility is suppressed:SinglePaneWorkbench._syncEditorVisibilityreveals/hides the docked editor purely from the node width (for user sash drags). A session-switch / reload layout restore holdssuppressEditorPartAutoVisibilitywhile it applies the working set, which can widen the docked node before the controller has set the target editor-part visibility. Because the width-sync ran regardless of suppression, restoring a Detail-only session (aux open, editor closed) flickered the editor open on switch (the working-set apply widened the node → reveal → the controller then re-hid it) and could persist it open on reload. Gate_syncEditorVisibilityon!this._isEditorPartAutoVisibilitySuppressed(alongside the existing_syncingEditorVisibilityreentrancy guard) so only a real user sash drag (unsuppressed) drives width-based visibility. Relatedly,baseSessionLayoutController._applyWorkingSet'sisInitialRestorebranch must, for single-pane, apply_shouldHideEditorPartOnApply(editorPartHidden)after the working-set apply (a no-op for the classic layout) — otherwise a Detail-only session's persisted editor-hidden state is not re-applied on reload and the editor is left visible. -
Single-pane detail (aux bar) ownership is split cleanly in two — visibility vs content — never three overlapping aux strategies: in single-pane the auxiliary bar is the detail panel, so exactly two strategies touch it, with non-overlapping responsibilities.
SinglePaneDetailVisibilityStrategyowns only per-session shown/hidden memory: it captures the user's choice ([D1]/[D2]), restores it on switch ([D3]) by revealing/hiding the aux part (setPartHidden/hideAuxiliaryBarForRestore), and handles the submit transition ([D4]).SinglePaneDetailPanelStrategyowns everything about content: which container (Changes/Files, mapped from the active editor), the transient browser-tab hide, editor-maximize → Changes, and the "nothing to show" hide (quick chat / no workspace / empty group →Hidden). Do NOT reintroduce a separateEmptyAuxCleanup/D10 strategy or desktop's saved-container machinery (auxiliaryBarActiveViewContainerIdrestore,_openDefaultAuxiliaryBarContainer,_restoreSavedAuxiliaryBarContainerOnReveal, pinned-container checks) into the visibility strategy — the container always follows the active editor, so a stored container preference is redundant and races the detail-panel mapping. Because the visibility strategy reveals the part and the detail-panel strategy fills it, the detail-panel strategy registers immediately in_registerViewStateManagement(not deferred toRestoredlike the managed tabs), so a reveal and its container open happen in the same turn. -
Single-pane is a sibling of the desktop controller and composes strategy objects — it does not extend
LayoutController:SinglePaneLayoutController(filecontrib/layout/browser/singlePaneLayoutController.ts) extendsBaseLayoutControllerdirectly, NOT the classic desktopLayoutController, so the desktop controller can be deprecated/deleted without touching single-pane. Its behaviour is composed from strategy objects undercontrib/layout/browser/singlePane/(each aDisposable, created viacreateInstancewith a leadingISinglePaneLayoutContextarg):SinglePaneDetailVisibilityStrategy(per-session detail shown/hidden: D1/D2/D3/D4) andSinglePaneDetailPanelStrategy(container + maximize + browser-hide + nothing-to-show hide) — the detail split above;SinglePaneManagedTabsStrategy+SinglePaneEditorAreaCollapseStrategy(share aSinglePaneDockedTabsCoordinatorholding the tabSequencer,internallyClosingEditors,collapsedEditors, and theisManagedEditor/getChangesEditorResourcehelpers);SinglePaneQuickChatEditorHideStrategy;SinglePaneResponsiveSidebarStrategy(owns the Toggle Details action + sidebar auto-hide);SinglePaneNewSessionRulesStrategy(R1). Shared controller state (isRestoringSessionLayout,withSessionLayoutRestore,togglingSidePane, the obs,viewStateBySession,hidingAuxiliaryBarForRestore/hideAuxiliaryBarForRestore) is exposed to strategies throughISinglePaneLayoutContext(built lazily in the controller because base's constructor calls the_registerViewStateManagement/_registerAuxiliaryControllershooks before subclass field initializers run). The detail-visibility/detail-panel/responsive/R1 strategies register in_registerViewStateManagement; the managed-tab/collapse/quick-chat strategies register in_registerAuxiliaryControllersdeferred toLifecyclePhase.Restored. Fresh storage: single-pane persists tosessions.singlePane.layoutState+sessions.singlePane.newSessionViewState(base_layoutStateStorageKey/_legacyWorkingSetsStorageKeyare overridable; single-pane skips legacy migration), so it never shares state with the classic desktop controller — the test harness seeds both keys. -
Single-pane detail/tab behaviour lives ON the layout controller (or its strategies), not in separate contribution controllers or a shared service:
SinglePaneLayoutControllerowns both the managed docked tabs (pinned Changes multi-diff + empty Files placeholder) and the detail-panel mapping (active editor → Changes/Files container, aux-bar reveal/hide). They were previouslyChangesTabController/DetailPanelController(registered by aSinglePaneModeControllercontribution) coordinating via globalIAgentWorkbenchLayoutServiceflags, then briefly via anISessionLayoutCoordinatorService. Both were removed: "is a session-switch restore in progress?" is just the base protected getterthis._isRestoringSessionLayout(set by_withSessionLayoutRestore) — surfaced to the strategies viaISinglePaneLayoutContext.isRestoringSessionLayout— so a restore-driven editor change never force-reveals the detail or dismisses a managed tab. The base controller hasIChangesViewService+IContextKeyServicedeps and a protected_editorGroupsService(a subclass can't add DI ctor params without redeclaring all base params, so shared services live on the base). Tests: the layout harness gotactiveGroupEditors/closeSuppressionFlags, a realmainPart.activeGroup, anactivateAuxopt-in that resolves the lifecycle, and aTestSinglePaneController.runWithRestore(...)seam to hold_isRestoringSessionLayoutacross an async editor change;changesTabController.test.tswas deleted and its scenarios moved intodesktopSessionLayoutController.test.ts. -
Single-pane created-session default is Editor-only (Changes editor, detail closed) — the detail is not force-opened by editor activation: a Changes/file editor becoming active must NOT auto-reveal the docked detail (aux bar).
SinglePaneLayoutController._syncForcedDetailTargetreveals a hidden detail ONLY when it was transiently hidden by a browser tab (_hiddenByBrowser), never when it is hidden by the per-session default or an explicit user hide; when the detail is visible it still switches the container (Changes/Files) to match the active editor. The reopen default is layout-aware via the base_defaultReopenSidePaneParts()hook (base returns both parts; single-pane returns{editor:true, auxiliaryBar:false}for a created session and{editor:false, auxiliaryBar:true}for a new-session view). The detail is opened only via Toggle Details or restored per-session state. When changing this, update the[single-pane] ... file tab activation/[per-session detail]/ reopen-default tests together — they encode the reveal semantics. -
Editor-title actions that only make sense with a restorable editor must also gate on
EditorMaximizedContext.negate(): the single-pane "Hide Editor" action is meaningless while the editor area is maximized, so itswhenincludesEditorMaximizedContext.negate()(in addition toMainEditorAreaVisibleContext+SinglePaneDetailChangesOrFilesActiveContext). -
R1 (new-session editor hide) must be transition-triggered, not level-triggered on the active editor: hiding the editor in the new-session view must fire only when the editor just became visible (visibility false→true) or when the view was just entered with the editor already visible (inherited-visible editor) — never merely because the active editor changed to a managed placeholder while the editor is already visible. A level-triggered rule ("hide whenever active editor is non-real content and editor visible") wrongly hides the editor when the user switches to the Files tab with a file already open (the reveal-sync suppression re-arm clears
isEditorRevealedExplicitly, so the level rule then hides). TrackpreviousEditorVisible+previousInNewSessionViewin the autorun and hide only on(editorJustRevealed || justEnteredNewSessionView) && !isEditorRevealedExplicitly(). The two workbench methodssetSuppressDockedEditorRevealSync(blocks width-based reveals at the source, avoiding flicker) andisEditorRevealedExplicitly(distinguishes an explicit toggle-details-off/file-open reveal that must stick) are still required by R1 — they are independent of the ChangesTab/DetailPanel controller merge. -
Single-pane created sessions need the docked editor part revealed on switch — the
isModalgate in_applyWorkingSetskips it:baseSessionLayoutController._applyWorkingSetonly reveals the editor part when!isModal(i.e.workbench.editor.useModal !== 'all'), because in the classic layout editors open in a modal part. But in single-pane the docked editor lives in the grid even whenuseModalis'all'(the default), so that gate wrongly skips the reveal and a created session's side pane looks fully closed (worse once the Changes editor no longer force-reveals the detail). Fix: computerevealEditorPart = !editorPartHidden && !isInitialRestore && (isSinglePaneLayoutEnabled ? isCreatedSession : !isModal)and also reveal for the'empty'working-set case in single-pane (a first-visit created session has no saved editors but still shows its managed Changes editor). This restores the Editor-only default while respecting the per-sessioneditorPartHidden(Detail-only / side-pane-closed) state and excluding new-session views (R1 keeps their editor closed). Note the layout test harness leavesisSinglePaneLayoutEnabledfalsy by default, so base single-pane branches are inert in tests unless a test opts in via thesinglePaneLayoutEnabledcreate option. -
A draft replaced by a committed session must inherit the draft's side-pane layout before
_applyWorkingSetruns: some providers commit a new-session draft by firingonDidReplaceSessionwith a new session resource, not by flippingisCreatedon the same resource. Without transferring the active draft's_editorPartHiddenBySessionand aux-bar state, the committed resource has no saved layout, so the delayed B2 working-set apply treats it as a first-visit created session and reveals the editor (Editor-only default) even though the user submitted from the new-session Detail-only view. Handle the replacement event as D4 submit: copy the active draft's editor-hidden state to the committed resource, record Changes as the committed aux container, and open Changes only if the draft detail was visible; switching to an unrelated existing created session still uses the Editor-only default. -
Single-pane D3c: a created session with NO saved detail state must be left in its current on-screen state — never force-hidden: the detail (aux-bar) restore for a created single-pane session (
SinglePaneDetailVisibilityStrategy._syncDetailVisibilityD3c) must only act whenviewStateBySessionhas a saved entry — hide when it says hidden, reveal when it says visible. When there is no saved state (savedState === undefined), return without touching the aux bar. Force-hiding on the no-state path re-closes the detail the user had open in the new-session view on submit: the committed session's resource can change again after the initial draft→committed transition, so a later restore run lands in D3c with no saved state andpreviousIsCreatedalreadytrue(the intrinsic!previousIsCreated && isCreatedsubmit detection ([D4]) no longer matches), and would re-hide. Leaving the current state also covers a first-time-seen created session gracefully; the detail-panel strategy keeps the container in sync, and the visibility is captured on the next switch-away or user toggle. (The intrinsic [D4] submit routing to_onNewSessionSubmittedis still kept for the clean first transition — it records the state and opens Changes — but D3c-leave-current is the backstop for every follow-up run.) -
A replace-based submit must be detected intrinsically in the aux/detail restore autorun (
!previousIsCreated && isCreated), not via_onSessionReplaced: the same ordering trap as the editor reveal, but for the detail (aux-bar) visibility.sessionsServicelistens toonDidReplaceSessionfirst (it's a core service) and its handler callsupdateSession→ setsactiveSessionin a transaction → the single-paneSinglePaneDetailVisibilityStrategyD3 restore autorun fires synchronously inside that handler. The layout controller's_onSessionReplaced(registered later, atBlockRestore) runs after — so any aux-state transfer it does is too late: the autorun has already run D3c. The classic same-resourceisSubmitguard (!isSessionSwitch && !previousIsCreated && isCreated) misses this because the agent-host/Copilot provider commits by replacing the draft with a new resource (isSessionSwitchis true). Fix: relaxisSubmittopreviousSessionResource && !previousIsCreated && isCreated && !viewStateBySession.has(activeSessionResource)— detect the submit purely from the transition, independent of_onSessionReplacedordering. The!has(state)guard keeps a genuine navigation from a draft to an existing created session on the normal D3 restore path._onSessionReplacedthen only needs to cover the background submit (a session committed while a different session is active, so the autorun never fires for it). Because the committed resource can still change again after the first transition, this intrinsic detection alone isn't enough — pair it with the D3c-leave-current rule above. General rule: for any "on submit, preserve/transfer layout" logic, detect the submit from the reactive transition the consumer already observes — never from a flag/transfer set by a separately-registeredonDidReplaceSessionlistener. -
onDidReplaceSessionalways means submit — never re-checkfrom.status === Untitled, and never try to preserve visibility via a flag consumed byrunOnChange: two related traps when suppressing the docked-editor reveal on new-session submit. (1) By the timeonDidReplaceSessionfires, the draft has already transitionedUntitled→Completed, so a_isNewSessionReplacement(from,to)guard checkingfrom.status === SessionStatus.Untitledis always false and silently skips the whole editor-hidden/aux transfer. The event is documented to fire only when an untitled draft is atomically replaced by its committed session, so treat everyonDidReplaceSessionas a submit — no status guard. (2) The B2 working-setrunOnChange(on the workspace-gatedactiveSessionForWorkingSetderive) fires before the synchronousonDidReplaceSessionhandler, so a boolean flag set in_onSessionReplacedand read synchronously inrunOnChangeis captured stale (false) and cannot suppress the reveal. The correct, ordering-robust mechanism is to have_onSessionReplacedwrite the draft's live editor-part visibility into_editorPartHiddenBySession[to]synchronously; because_applyWorkingSetreads that map inside itsSequencer.queueasync microtask body (which runs after the sync replace handler), the reveal decision (_shouldRevealEditorPartOnApply/_shouldRevealEditorPartForEmptyWorkingSet) seeseditorPartHidden=trueand skips. Do NOT add apreserveEditorPartVisibilityapply option keyed off event ordering — it's impossible to set in time. -
R1 can drop
setSuppressDockedEditorRevealSync— always hide on new-session-view entry instead: the width-based reveal-sync suppression (setSuppressDockedEditorRevealSync/_suppressDockedEditorRevealSync) was removed. It did two jobs: (1) block a momentary width-reveal of the editor in the new-session view, and (2) clear_editorRevealedExplicitlyon entering the view so R1 re-hides an inherited-explicit editor across a session switch (the working-set apply runs undersuppressEditorPartAutoVisibility, sohandleDidCloseEditordoesn't clear the flag naturally). Job (1) is now handled by R1 re-hiding any non-explicit reveal (a sash-drag reveal flickers then re-hides — acceptable). Job (2) is handled by making R1's hide conditionjustEnteredNewSessionView || (editorJustRevealed && !isEditorRevealedExplicitly())— i.e. entering the new-session view always resets to editor-closed (a stale cross-session explicit flag can't keep the editor open), while the explicit flag is only honored for in-session reveals (toggle-details-off revealing the empty editor).isEditorRevealedExplicitlyis still needed for that in-session case. -
Quick chats have no side pane — don't auto-reveal the editor part, and hide it when switching in from a session that had it open: in single-pane,
SinglePaneLayoutController._shouldRevealEditorPartOnApplymust exclude quick chats (!editorPartHidden && isCreatedSession && !isQuickChat); a created quick chat would otherwise reveal the docked editor part on switch (bug: "side pane opened automatically for quick chat"). Excluding the reveal is not enough — switching in from a workspace session leaves the editor part visible (the working-set apply is suppressed and never hides it), so a dedicated_registerQuickChatEditorHide()autorun hides the editor part while a quick chat's editor group is empty (gated on_isMainPartEmpty()so a real editor, e.g. the integrated browser, opened in a quick chat is never hidden). The aux bar is already handled by D10 + the detail-panelHiddentarget. -
An auto-collapsed sessions list must be restored once the side pane is fully hidden: the single-pane responsive rule auto-collapses the sessions list to free width for a visible side pane (Toggle Details, opening a file). It must also restore an auto-hidden list when the side pane becomes fully hidden (both editor and aux bar closed) — e.g. switching to a quick chat (no side pane) — otherwise the list is left collapsed with nothing to make room for (bug: "sessions list closed even though the side pane is hidden"). Implement as an autorun in
_registerResponsiveSidebaron anobservableFromEvent(onDidChangePartVisibility, () => editorVisible || auxVisible)(the value-dedup is essential: hiding the sidebar itself doesn't change the computed side-pane visibility, so the pre-reveal auto-hide from opening an editor is never undone). Restore only when_sidebarAutoHiddenis true, so a list the user closed manually stays closed. -
Single-pane per-session editor-part visibility must be restored both ways —
_applyWorkingSetonly ever revealed it:baseSessionLayoutController._applyWorkingSetrevealed the editor part when a session wanted it visible but never hid it, so returning to a session whose docked editor was closed (Detail-only or whole side pane closed) left the editor visible/inherited from the previously-active session (bug: "side pane opened when returning to a session where it was closed"). The per-session_editorPartHiddenBySessionstate was only consumed to suppress the reveal (!editorPartHidden), never to actively hide. Fix: add a symmetric Template-Method hook_shouldHideEditorPartOnApply(editorPartHidden)(base returnsfalse— classic layout doesn't treat editor-part visibility as per-session; single-pane returnseditorPartHidden && isCreated && !isQuickChat) and, in both the empty and non-empty_applyWorkingSetbranches, hide the editor part (mutually exclusive with revealing, skipped onisInitialRestorewhich preserves the workbench-restored visibility). The hide runs inside_withSessionLayoutRestore'ssuppressEditorPartAutoVisibilitywindow so it is never mistaken for a user close. Note the aux bar was already restored both ways by the inherited D3_syncAuxiliaryBarVisibility; only the editor part lacked the hide. -
Explicit managed-editor opens must reveal outside the auto-reveal path — and mark the reveal explicit: managed Changes/Files tabs are excluded from
_handleWillOpenEditorso tab activation and layout-driven restores do not reveal the docked editor. A deliberate user gesture that should show managed editor content (session-header Changes pillViewAllChangesAction, opening a file diff in_openMultiFileDiffEditor) must reveal the editor part before opening the managed editor viaIAgentWorkbenchLayoutService.revealEditorPartExplicitly()— not the genericsetPartHidden(false, EDITOR_PART). The generic call routes tosetEditorHidden(hidden, explicit=false), leaving_editorRevealedExplicitly = false, so R1 / the working-set apply (_shouldHideEditorPartOnApply) can re-hide it (especially across a session-switch race).revealEditorPartExplicitly()sets the explicit flag (and re-asserts it even when already visible, sincesetEditorHiddenearly-returns when the part is already visible). Do not weaken the managed-editor exclusion or add timing delays. -
A
MutableDisposable-backed content slot must notclearNodeits shared container on cleanup:EditorGroupView.setHeaderContentappends a new content node into the sharedheaderContainer, then assigns the new store to_headerContent(aMutableDisposable) — which synchronously disposes the previous store. If that store's cleanup callsclearNode(headerContainer), it wipes the freshly-appended new content (blank header, height stuck at 0, orphanedResizeObserver). Fix: clear the previous content before appending the new one (this._headerContent.clear()at the top), and have each store's cleanup remove only its own node (content.remove()), never the shared container. This bug surfaces on consecutive header→header renders (e.g.Changes(sessionA)→Changes(sessionB)). -
Per-session editor-part (side-pane) hidden state must be captured eagerly on the visibility change, not lazily re-read at switch-away:
baseSessionLayoutController._saveWorkingSetused to record_editorPartHiddenBySession[prev] = !isVisible(EDITOR_PART)at the moment it saved the outgoing session. That races: the working-set derive (activeSessionForWorkingSet) lags the rawactiveSession(it gates on workspace-folder readiness), so other autoruns driven by the raw active session (managed-tab open, D3 aux sync) have already revealed the editor for the incoming session by the time_saveWorkingSet(prev)runs — so the previous session gets recorded aseditorPartHidden=falseand its closed side pane reopens on return (symptom: only the editor content re-appears, details stay closed, and nosetEditorHiddenfires on the switch because nothing on the switch path toggles it). Fix: capture it in a[B2]onDidChangePartVisibility(EDITOR_PART)listener (mirroring the existing[B1]panel-visibility capture) guarded by!multipleSessionsVisibleObs && !_isRestoringSessionLayout, so the value is written the instant the user closes/opens the side pane and layout-driven restore changes are ignored. Remove the lazy read from_saveWorkingSetentirely (keeping it would let the racy switch-time value overwrite the good eager one). The unit harness can't reproduce the derive-lag, so add a focused test that fires the EDITOR_PART event to assert eager capture, plus one that fires a reveal inside_withSessionLayoutRestoreto assert the captured closed state is preserved. -
Single-pane detail sync must re-read aux-bar visibility when queued work runs: the detail-panel autorun queues container opens through a sequencer, so a task can be computed while the previous session's detail is visible and run after D3 has restored the incoming session's detail to hidden. Do not trust an
auxBarVisiblevalue captured before the queue boundary; readisVisible(AUXILIARYBAR_PART)inside the queued sync, otherwiseopenViewContainercan re-reveal the detail and overwrite the incoming session's saved hidden state. -
The managed Changes tab must open non-stealing so the working-set-restored active editor is preserved: on a single-pane session switch,
baseSessionLayoutController._applyWorkingSetrestores the session's editor working set including which editor was active (e.g.package.json).SinglePaneManagedTabsStrategythen idempotently re-ensures the pinned Changes tab — but ifchangesEditorOptionsopens it as active (noinactive/activation), it steals active state from the just-restored editor, so the wrong tab is active after the switch. GivechangesEditorOptionsinactive: true+activation: EditorActivation.PRESERVE(matchingfileTabOptions); the workbench still makes it active when the group is empty (active: this.count === 0 || !options?.inactive,editorGroupView.doOpenEditor), so the first-visit created-session default (Changes active) is preserved while a restored session keeps its own active editor. -
Save the outgoing session's working set eagerly on the raw active session change, not on the workspace-gated
activeSessionForWorkingSetderive: the derive (baseSessionLayoutController) holds back while the incoming session's workspace folders resolve, and other autoruns driven by the rawISessionsService.activeSession(e.g. the single-pane managed-tabs sync) async-close the outgoing session's docked editors (_closeInactiveChangesEditors) during that lag. If the working-set save is on the lagged derive it runs after those closes, so the outgoing session's Changes tab (or whichever editor was active) is already gone and its active state is lost — on return the working set restores the wrong active editor (symptom: switching back to a session whose Changes tab was active shows a different tab active). Fix: a dedicated[B2] runOnChange(activeSession, …)saves the previous session's working set synchronously (guarded by resource-inequality,!Untitled,!_isRestoringSessionLayout) — this runs before the managed-tab sequencer microtask closes anything — and the save is removed from the gated applyrunOnChange(which now only applies). Save doesn't need workspace readiness; only apply does. Pairs with making the managed Changes tab open non-stealing (inactive: true+EditorActivation.PRESERVE) so the restored active editor is preserved. The unit harness can't reproduce the derive-lag directly, so assert the decoupling: switching to a session whose workspace isn't in the folders (gated apply holds back) still records asaveWorkingSetfor the outgoing session. -
Docked side-pane width persistence must be symmetric about the detail (aux) width: in single-pane the docked detail (auxiliary bar) lives inside the editor grid node, so the workbench persists the pure editor-content width (
_persistedEditorWidth= node − detail) and the grid descriptor reconstructs node = editor-content + detail. These must use the same condition for including the detail: only when the detail is visible (partVisibility.auxiliaryBar). Subtracting the detail width unconditionally at save while adding it back only when the detail is visible shrank an Editor-only session's side pane by the detail width on every reload, compounding toward zero ("side pane always tiny on reload"). Fix_persistedEditorWidthto subtract only when the detail is visible. -
Side-pane (editor grid node) size is workbench-level, not per session: the editor grid node width is owned by the workbench grid and persisted globally (
workbench.sessions.partSizesvia_savePartSizes/createDesktopGridDescriptor), so switching sessions keeps the same width and reload restores it in one paint. Do not add a per-session width map in the layout controller that re-applies a width on session switch/reveal — it makes switching sessions jump the side pane around, and a post-paint restore on reload flickers. The 60% first-open default (SIDE_PANE_WIDTH_RATIOinparts/editorPartSizing.ts, applied by the single-pane_applyEditorSplitSizeoverride on the first reveal that has no size to restore) is the only width the layout intentionally sets; everything else is the user's persisted grid size.
.github/skills/smoke-tests/SKILL.md
npx skills add asJEI/vscode --skill smoke-tests -g -y
SKILL.md
Frontmatter
{
"name": "smoke-tests",
"description": "Use when running VS Code smoke tests or working on smoke-test CI steps. Covers npm run smoketest \/ smoketest-no-compile, grep filtering tests, and a temporary repeat-loop technique for tracking down flaky smoke tests in CI."
}
Running Smoke Tests
Smoke tests live in test/smoke/ and drive a full VS Code instance (Electron, web, or remote) through end-to-end user flows.
Scripts
npm run smoketest— compiles the smoke tests first (test/smoke), then runs them.npm run smoketest-no-compile— runs the already-compiled smoke tests. CI uses this after an explicit compile step.
Both forward extra arguments after -- to the runner (test/smoke/test/index.js).
Common options
| Option | Description |
|---|---|
-g <pattern> (alias -f) |
Grep filter on test/suite titles (mocha grep). |
--build <path> |
Run against a packaged build instead of the compiled-from-source dev build. |
--tracing |
Capture Playwright traces (and screenshots on failure). |
--web |
Run the browser smoke tests instead of Electron. |
--headless |
Headless browser (used with --web). |
--remote |
Run the remote smoke tests. |
# Run everything (Electron, from source)
npm run smoketest
# Run only a subset of suites by name, with tracing (replace <suite name> with your suite, e.g. "Agents Window")
npm run smoketest -- -g "<suite name>" --tracing
# Run against a packaged build (CI style)
npm run smoketest-no-compile -- --tracing --build "/path/to/VSCode-darwin-arm64/Code - OSS.app"
The -g pattern matches against test/suite titles. For example, -g "Agents Window" matches all three Agents Window suites (Agents Window, Agents Window (local AgentHost), and Agents Window (local AgentHost, SDK sandbox)); use whatever substring identifies the suite(s) you care about.
The runner exits non-zero if any test fails, so a 0 exit code means every selected test passed.
Temporarily looping a suite to hunt flaky CI tests
When a smoke test fails intermittently only in CI, a useful technique is to temporarily run the suspect suite many times in a row and fail on the first failure. This reproduces the flake under the real CI environment and captures its traces/screenshots, instead of waiting for it to recur naturally across unrelated PRs.
This is a debugging aid, not a permanent CI fixture:
- Add it on a throwaway branch, push, and let CI run it. Iterate until you reproduce (and then fix) the flake.
- Remove the loop before merging — leaving it in would add ~an hour per platform to every run.
- It is not specific to any one suite. Point the
-gfilter at whichever suite you are investigating (the examples below use"Agents Window", but substitute your own).
Where to add it
Drop the loop next to the existing Electron smoke step, gated on the same condition, in the test step(s) for the platform(s) where the flake reproduces:
GitHub PR workflows (run from source, no --build):
.github/workflows/pr-linux-test.yml(bash; setsDISPLAY: ":10").github/workflows/pr-darwin-test.yml(bash; noDISPLAY).github/workflows/pr-win32-test.yml(PowerShell)
Azure DevOps test steps (run against the packaged build via --build):
build/azure-pipelines/linux/steps/product-build-linux-test.ymlbuild/azure-pipelines/darwin/steps/product-build-darwin-test.ymlbuild/azure-pipelines/win32/steps/product-build-win32-test.yml
Shape
Loop N iterations (e.g. 20) and abort on the first failing run. Give it a generous timeout — N sequential runs of a ~3-minute suite can take roughly an hour.
Bash (Linux/macOS):
# TEMPORARY: loop the suite to reproduce a flaky failure. Remove before merge.
# Replace <suite name> with the suite you're investigating (e.g. "Agents Window").
- name: 🧪 Smoke test flakiness probe (TEMPORARY)
if: ${{ inputs.electron_tests }}
timeout-minutes: 60
run: |
for i in $(seq 1 20); do
echo "::group::Smoke probe run $i/20"
npm run smoketest-no-compile -- --tracing -g "<suite name>" || { echo "::error::Smoke test failed on run $i/20"; exit 1; }
echo "::endgroup::"
done
PowerShell (Windows) checks $LASTEXITCODE after each run and exit 1 on failure. The AzDO variants use set -e (bash) / $LASTEXITCODE (pwsh) for fail-fast and append --build "<packaged app path>".
Why fail-fast
The loop is a probe: the first failure is the signal. Stopping immediately preserves the failing run's traces/screenshots (under the logs artifact) and avoids burning ~an hour of agent time finishing a run that has already proven flaky.
Debugging CI smoke failures
Both CI systems publish the smoke runner's per-platform logs (the .build/logs directory) as a downloadable artifact. The artifact's internal layout is identical on both — only the artifact name and the download tool differ.
Downloading the logs artifact
GitHub Actions
The GitHub PR workflows upload the artifact as logs-<os>-<arch>-<suite>-<attempt>, where <os> is linux / macos / windows, <suite> is electron / browser / remote, and <attempt> is the run attempt (e.g. logs-macos-arm64-electron-1).
The run id is the number in the run/job URL — for …/actions/runs/<run-id>/job/<job-id> use <run-id>. Download with the gh CLI:
# A specific artifact into ./logs
gh run download <run-id> -n logs-<os>-<arch>-<suite>-<attempt> -D ./logs
# Or every artifact from the run
gh run download <run-id>
gh run view <run-id> lists the run's jobs/artifacts; the run summary page in the browser also has an Artifacts section at the bottom.
Azure DevOps
The artifact name depends on which pipeline produced it:
- Product build (
product-build-<os>.yml):logs-<os>-<arch>-<attempt>— no suite segment, e.g.logs-macos-arm64-1. - Suite-split CI build (
product-build-<os>-ci.yml):logs-<os>-<arch>-<suite>-<attempt>— the<suite>segment islower(VSCODE_TEST_SUITE)(e.g.electron), so e.g.logs-macos-arm64-electron-1(same shape as GitHub).
<os> is linux / macos / windows, <arch> is x64 / arm64, and <attempt> is $(System.JobAttempt). Download with the Azure CLI:
az pipelines runs artifact download \
--org <ORG_URL> --project <PROJECT_NAME> \
--run-id <BUILD_ID> --artifact-name <artifact-name> \
--path ./logs
For the VS Code build that is --org https://dev.azure.com/monacotools --project Monaco; see the azure-pipelines skill for finding the <BUILD_ID>.
Inside the artifact
Under smoke-tests-<suite>/ (smoke-tests-electron/, smoke-tests-browser/, or smoke-tests-remote/, matching the suite that ran):
smoke-test-runner.log— the mocha driver output plus, for suites that use the mock LLM server, its verbose request/response bodies (look forrequest body:).<N>_suite_<Suite_Name>/window2/exthost/<extension>/…log— per-suite extension-host logs (e.g.GitHub.copilot-chat/GitHub Copilot Chat.log). Many diagnostics are gated behind a setting the suite enables in itsbeforehook, so check the suite's setup if an expected log line is missing.<N>_suite_<Suite_Name>/playwright-screenshot-*.png— last-frame screenshot captured when a test fails (only when the suite ran with--tracing).
<Suite_Name> is the mocha suite title with non-word characters replaced by _. See also the code-oss-logs skill.
Distinction from other test types
- Unit tests (
.test.ts) →scripts/test.sh/runTeststool (see theunit-testsskill). - Integration tests (
.integrationTest.ts+ extension tests) →scripts/test-integration.sh(see theintegration-testsskill). - Smoke tests (
test/smoke/) →npm run smoketest— full end-to-end UI flows.
.github/skills/symbolicate-crash-dump/SKILL.md
npx skills add asJEI/vscode --skill symbolicate-crash-dump -g -y
SKILL.md
Frontmatter
{
"name": "symbolicate-crash-dump",
"description": "Symbolicate a native VS Code crash dump (.dmp) using electron-minidump. Use when given a crash dump file, asked to symbolicate a crash, resolve missing method names in a native crash backtrace, or attach Electron\/Insiders\/Stable symbol files. VS Code team members only; requires macOS or Linux."
}
Symbolicate a Crash Dump
Turn a native VS Code crash dump (.dmp) into a readable backtrace with method names using electron-minidump.
VS Code team members only. Symbol files for internal Electron, Insiders, and Stable builds live in a private-adjacent release repo. A macOS or Linux device is required — electron-minidump does not run on Windows.
Prerequisites
- A crash dump file (
*.dmp). See Creating a crash report below if you don't have one yet. - A global install of
electron-minidump:npm install -g electron-minidump - For Insiders/Stable symbols, an authenticated GitHub CLI (
gh auth status) with access to the privatemicrosoft/vscode-electron-prebuiltrepo.
Procedure
1. Run an initial symbolication pass
This generates or refreshes the electron-minidump cache and tells you which symbols are still missing.
electron-minidump crash-file.dmp > symbolicated-output.log
Inspect symbolicated-output.log. Look at the top frames of the backtrace: if a frame names a module (e.g. Electron Framework) but has no method name after it, symbols for that module are required.
Retry on transient download errors. electron-minidump downloads symbols from public symbol servers, so a run can fail on a transient network error (e.g.
failed to download ... (code 56)or(code 28)). Successfully downloaded symbols are cached, so simply re-running the same command resumes where it left off. Retrying a few times is expected:for i in 1 2 3 4 5; do electron-minidump crash-file.dmp > symbolicated-output.log && break sleep 3 done
2. Get the appropriate symbol files
Match the symbol source to the build that produced the crash:
| Build that crashed | Symbol files source |
|---|---|
| Insiders / Stable (internal Electron) | microsoft/vscode-electron-prebuilt releases |
| Code - OSS (OSS Electron) | electron/electron releases |
microsoft/vscode-electron-prebuilt is a private repo — this is why the flow is team-members-only. A plain browser or curl link will 404 without auth; download the asset with an authenticated GitHub CLI instead (gh auth status should show you logged in):
# List releases (tagged by Electron version) to find the right tag:
gh release list --repo microsoft/vscode-electron-prebuilt
# Download just the symbol zip you need:
gh release download v42.5.0-14525058 \
--repo microsoft/vscode-electron-prebuilt \
--pattern "stable-symbols-v42.5.0-win32-x64.zip"
The releases are tagged by Electron version, not VS Code version, so first find the Electron version the crashed VS Code build shipped. It's the target= in that version's .npmrc (e.g. git show 1.128.0:.npmrc), which mirrors the electron devDependency in package.json. Then pick the matching symbol zip by quality, platform, and architecture — e.g. a Stable Windows x64 crash on Electron 42.5.0 needs stable-symbols-v42.5.0-win32-x64.zip (use insiders-symbols-… for Insiders). Code - OSS symbols come from the public electron/electron releases and can be downloaded without special access.
These zips are small and selective. A
*-symbols-*.ziptypically contains only a handful of first-party modules —electron.exe.sym,libEGL.dll.sym,libGLESv2.dll.symon Windows (and the equivalents elsewhere). Many modules that show up in a backtrace — notablyruntime.nodeand any OS/third-party DLL — are not in these zips.runtime.nodeframes often cannot be symbolicated at all from public symbols; when the crash is in a third-party module, attribute it by module name rather than expecting method names on every frame (see Reading the result).
3. Copy the .sym files into the electron-minidump cache
The cache lives at:
"$(npm root -g)/electron-minidump/cache/breakpad_symbols"
Breakpad keys symbols by <module>.pdb/<debug-id>/<module>.sym, and the <debug-id> must match exactly between the dump and the symbol zip — a same-version zip built from a different pipeline run will have a different id and won't be used. After the initial pass, the cache already contains an (empty) directory for each required module whose name and hash you must match:
CACHE="$(npm root -g)/electron-minidump/cache/breakpad_symbols"
# The debug-id electron-minidump wants for a given module:
ls "$CACHE/electron.exe.pdb" # e.g. DD081533CD7E33A44C4C44205044422E1
Unzip the downloaded symbols and confirm the same module/hash exists in the zip before copying it in. The zip's internal layout is also <module>.pdb/<debug-id>/<module>.sym.
Example (Windows: the shipped main binary is Code.exe, but its symbols come from electron.exe.sym):
# Unzip somewhere, e.g. ~/stable-symbols/
unzip stable-symbols-v42.5.0-win32-x64.zip -d ~/stable-symbols
# Only copy if the debug-id matches what the cache expects.
# Keep the tilde outside quotes so it expands to your home directory:
HASH=DD081533CD7E33A44C4C44205044422E1
cp ~/stable-symbols/symbols/electron.exe.pdb/"$HASH"/electron.exe.sym \
"$CACHE/electron.exe.pdb/$HASH/"
On macOS the analogous module is Electron Framework (Electron Framework/<hash>/Electron Framework.sym). Repeat for any other module the initial pass reported as missing method names — but only when the zip actually contains a matching-hash .sym for it.
If no matching-hash symbols exist, you cannot get method names for that module — this is common for officially-shipped Stable/Insiders builds whose exact
electron.exe/Code.exehash isn't in any public prebuilt zip, and forruntime.node. Fall back to attributing the crash by module and process (see Reading the result); that is usually enough to identify a third-party culprit.
4. Re-run symbolication
electron-minidump crash-file.dmp > symbolicated-output.log
The backtrace in symbolicated-output.log should now have method names attached. If some frames are still bare, return to step 2 for the module(s) still missing symbols.
Reading the result
Once you have a symbolicated backtrace, turn it into a root cause by answering two questions:
Which module owns the crash?
Look at the top frame of the crashing thread (marked (crashed)) and its module name:
- If it's a VS Code / Electron module —
Code.exe,runtime.node,Electron Framework,libnode,libffmpeg, V8 frames — the fault is likely inside the product or Electron. - If it's a third-party / OS module — an antivirus, VPN, proxy, or shell-extension DLL injected into the process — the crash is almost certainly caused by that software, not VS Code. Injected DLLs often appear interleaved with
runtime.node/V8 frames because they hook the runtime.
Find where the module is loaded on disk to confirm it's third-party. On Windows the strings of the dump usually reveal the full path, e.g. a DLL under C:\WINDOWS\system32\ or a vendor folder rather than the VS Code install directory:
strings -a crash-file.dmp | grep -i "<SuspectModule>" | sort -u
Which process crashed?
The process type tells you whether this is the main process, a renderer/window, or the extension host. On modern Electron the extension host runs inside a Node utility process, so a crash there shows up as the node.mojom.NodeService utility — not a process literally named "extension host":
strings -a crash-file.dmp | grep -oiE "utility-sub-type=[a-zA-Z0-9._-]+|node\.mojom\.[A-Za-z]+|--type=[a-z-]+" | sort -u
| Marker | Process |
|---|---|
node.mojom.NodeService / utility-sub-type=node.mojom.NodeService |
Extension host (Node utility process) |
--type=renderer |
A workbench window (renderer) |
--type=gpu-process |
GPU process |
(no --type) |
Main process |
Match this against the reported symptom (e.g. an "extension host crash-loop" should correspond to a NodeService utility crash).
When you have multiple dumps, symbolicate each and compare the crash reason and top frame — an identical signature across dumps confirms a single, reproducible cause.
Creating a crash report
If you don't yet have a .dmp file, produce one with the --crash-reporter-directory option:
- Close all instances of VS Code.
- Run
code --crash-reporter-directory <absolute-path>from the command line (usecode-insidersfor the Insiders build). - Take the steps that lead to the crash.
- Look for a
*.dmpfile in that folder.
You can only symbolicate crashes from a build you have matching symbols for. Crashes from Insiders/Stable need the internal Electron symbols; crashes from a local source build (Code - OSS) need the OSS Electron symbols.
Remote Extension Host crashes (Linux, gdb)
Native crashes in a remote server's extension host use core dumps and gdb instead of electron-minidump:
- Before running the server, allow core dumps:
ulimit -c unlimited. - Reproduce the crash. Retrieve the core dump via
coredumpctl, or from the path in/proc/sys/kernel/core_pattern. - Load it in gdb and capture output:
Then run and collect the output of:gdb -se <path-to-vscode-server>/node -c <path-to-core-file>set pagination off info sharedlibrary info registers bt full disassemble
Tips
- Keep
crash-file.dmpandsymbolicated-output.logout of the repo — they are throwaway artifacts. - The cache path is dynamic; always resolve it with
$(npm root -g)rather than hardcoding a home directory. - Breakpad matches symbols by exact debug-id, not by version name. If method names are still missing after adding symbols, confirm the
.symyou copied has the exact hash the cache directory expects, and double-check the quality (stable/insiders), platform (win32/darwin/linux), and arch (x64/arm64) of the symbol zip. - Not every frame can be symbolicated.
runtime.nodeand third-party/OS modules frequently have no public symbols; identifying the crashing module and process is usually enough to reach a root cause.
.github/skills/tool-rename-deprecation/SKILL.md
npx skills add asJEI/vscode --skill tool-rename-deprecation -g -y
SKILL.md
Frontmatter
{
"name": "tool-rename-deprecation",
"description": "Ensure renamed built-in tool references preserve backward compatibility. Use when renaming a toolReferenceName, tool set referenceName, or any tool identifier. Run on ANY change to tool registration code. Covers legacyToolReferenceFullNames for tools and legacyFullNames for tool sets."
}
Tool Rename Deprecation
When a tool or tool set reference name is changed, the old name must always be added to the deprecated/legacy array so that existing prompt files, tool configurations, and saved references continue to resolve correctly.
When to Use
Run this skill on any change to built-in tool or tool set registration code to catch regressions:
- Renaming a tool's
toolReferenceName - Renaming a tool set's
referenceName - Moving a tool from one tool set to another (the old
toolSet/toolNamepath becomes a legacy name) - Reviewing a PR that modifies tool registration — verify no legacy names were dropped
Procedure
Step 1 — Identify What Changed
Determine whether you are renaming a tool or a tool set, and where it is registered:
| Entity | Registration | Name field to rename | Legacy array | Stable ID (NEVER change) |
|---|---|---|---|---|
Tool (IToolData) |
TypeScript | toolReferenceName |
legacyToolReferenceFullNames |
id |
| Tool (extension) | package.json languageModelTools |
toolReferenceName |
legacyToolReferenceFullNames |
name (becomes id) |
Tool set (IToolSet) |
TypeScript | referenceName |
legacyFullNames |
id |
| Tool set (extension) | package.json languageModelToolSets |
name or referenceName |
legacyFullNames |
— |
Critical: For extension-contributed tools, the name field in package.json is mapped to id on IToolData (see languageModelToolsContribution.ts line id: rawTool.name). It is also used for activation events (onLanguageModelTool:<name>). Never rename the name field — only rename toolReferenceName.
Step 2 — Add the Old Name to the Legacy Array
Verify the old toolReferenceName value appears in legacyToolReferenceFullNames. Don't assume it's already there — check the actual array contents. If the old name is already listed (e.g., from a previous rename), confirm it wasn't removed. If it's not there, add it.
For internal/built-in tools (TypeScript IToolData):
// Before rename
export const MyToolData: IToolData = {
id: 'myExtension.myTool',
toolReferenceName: 'oldName',
// ...
};
// After rename — old name preserved
export const MyToolData: IToolData = {
id: 'myExtension.myTool',
toolReferenceName: 'newName',
legacyToolReferenceFullNames: ['oldName'],
// ...
};
If the tool previously lived inside a tool set, use the full toolSet/toolName form:
legacyToolReferenceFullNames: ['oldToolSet/oldToolName'],
If renaming multiple times, accumulate all prior names — never remove existing entries:
legacyToolReferenceFullNames: ['firstOldName', 'secondOldName'],
For tool sets, add the old name to the legacyFullNames option when calling createToolSet:
toolsService.createToolSet(source, id, 'newSetName', {
legacyFullNames: ['oldSetName'],
});
For extension-contributed tools (package.json), rename only toolReferenceName and add the old value to legacyToolReferenceFullNames. Do NOT rename the name field:
// CORRECT — only toolReferenceName changes, name stays stable
{
"name": "copilot_myTool", // ← KEEP this unchanged
"toolReferenceName": "newName", // ← renamed
"legacyToolReferenceFullNames": [
"oldName" // ← old toolReferenceName preserved
]
}
Step 3 — Check All Consumers of Tool Names
Legacy names must be respected everywhere a tool is looked up by reference name, not just in prompt resolution. Key consumers:
- Prompt files —
getDeprecatedFullReferenceNames()maps old → current names for.prompt.mdvalidation and code actions - Tool enablement —
getToolAliases()/getToolSetAliases()yield legacy names so tool picker and enablement maps resolve them - Auto-approval config —
isToolEligibleForAutoApproval()checkslegacyToolReferenceFullNames(including the segment after/for namespaced legacy names) againstchat.tools.eligibleForAutoApprovalsettings - RunInTerminalTool — has its own local auto-approval check that also iterates
LEGACY_TOOL_REFERENCE_FULL_NAMES
After renaming, confirm:
#oldNamein a.prompt.mdfile still resolves (shows no validation error)- Tool configurations referencing the old name still activate the tool
- A user who had
"chat.tools.eligibleForAutoApproval": { "oldName": false }still has that restriction honored
Step 4 — Update References (Optional)
While legacy names ensure backward compatibility, update first-party references to use the new name:
- System prompts and built-in
.prompt.mdfiles - Documentation and model descriptions that mention the tool by reference name
- Test files that reference the old name directly
Key Files
| File | What it contains |
|---|---|
src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts |
IToolData and IToolSet interfaces with legacy name fields |
src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts |
Resolution logic: getToolAliases, getToolSetAliases, getDeprecatedFullReferenceNames, isToolEligibleForAutoApproval |
src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts |
Extension point schema, validation, and the critical id: rawTool.name mapping (line ~274) |
src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts |
Example of a tool with its own local auto-approval check against legacy names |
Real Examples
runInTerminaltool: renamed fromrunCommands/runInTerminal→legacyToolReferenceFullNames: ['runCommands/runInTerminal']todotool: renamed fromtodos→legacyToolReferenceFullNames: ['todos']getTaskOutputtool: renamed fromrunTasks/getTaskOutput→legacyToolReferenceFullNames: ['runTasks/getTaskOutput']
Reference PRs
- #277047 — Design PR: Introduced
legacyToolReferenceFullNamesandlegacyFullNames, built the resolution infrastructure, and performed the first batch of tool renames. Use as a template for how to properly rename with legacy names. - #278506 — Consumer-side fix: After the renames in #277047, the
eligibleForAutoApprovalsetting wasn't checking legacy names — users who had restricted the old name lost that restriction. Shows why all consumers of tool reference names must account for legacy names. - vscode-copilot-chat#3810 — Example of a miss: Renamed
openSimpleBrowser→openIntegratedBrowserbut also changed thenamefield (stable id) fromcopilot_openSimpleBrowser→copilot_openIntegratedBrowser. ThetoolReferenceNamebackward compat only worked by coincidence (the old name happened to already be in the legacy array from a prior change — it was not intentionally added as part of this rename).
Regression Check
Run this check on any PR that touches tool registration (TypeScript IToolData, createToolSet, or package.json languageModelTools/languageModelToolSets):
- Search the diff for changed
toolReferenceNameorreferenceNamevalues. For each change, confirm the previous value now appears inlegacyToolReferenceFullNamesorlegacyFullNames. Don't assume it was already there — read the actual array. - Search the diff for changed
namefields on extension-contributed tools. Thenamefield is the tool's stableid— it must never change. If it changed, flag it as a bug. (This breaks activation events, tool invocations by id, and any code referencing the tool by itsname.) - Verify no entries were removed from existing legacy arrays.
- If a tool moved between tool sets, confirm the old
toolSet/toolNamefull path is in the legacy array. - Check tool set membership lists (the
toolsarray inlanguageModelToolSetscontributions). If a tool'stoolReferenceNamechanged, any tool settoolsarray referencing the old name should be updated — but the legacy resolution system handles this, so the old name still works.
Anti-patterns
- Changing the
namefield on extension-contributed tools — thenameinpackage.jsonbecomes theidonIToolData(viaid: rawTool.nameinlanguageModelToolsContribution.ts). Changing it breaks activation events (onLanguageModelTool:<name>), any code referencing the tool by id, and tool invocations. Only renametoolReferenceName, nevername. (See vscode-copilot-chat#3810 where bothnameandtoolReferenceNamewere changed.) - Changing the
idfield on TypeScript-registered tools — same principle as above. Theidis a stable internal identifier and must never change. - Assuming the old name is already in the legacy array — always verify by reading the actual
legacyToolReferenceFullNamescontents, not just checking that the field exists. A legacy array might list names from an even older rename but not the current one being changed. - Removing an old name from the legacy array — breaks existing saved prompts and user configurations.
- Forgetting to add the legacy name entirely — prompt files and tool configs silently stop resolving.
- Only updating prompt resolution but not other consumers — auto-approval settings, tool enablement maps, and individual tool checks (like
RunInTerminalTool) all need to respect legacy names (see #278506).
.github/skills/unit-tests/SKILL.md
npx skills add asJEI/vscode --skill unit-tests -g -y
SKILL.md
Frontmatter
{
"name": "unit-tests",
"description": "Use when running unit tests in the VS Code repo. Covers the runTests tool, scripts\/test.sh (macOS\/Linux) and scripts\/test.bat (Windows), and their supported arguments for filtering, globbing, and debugging tests."
}
Running Unit Tests
Preferred: Use the runTests tool
If the runTests tool is available, prefer it over running shell commands. It provides structured output with detailed pass/fail information and supports filtering by file and test name.
- Pass absolute paths to test files via the
filesparameter. - Pass test names via the
testNamesparameter to filter which tests run. - Set
mode="coverage"to collect coverage.
Example (conceptual): run tests in src/vs/editor/test/common/model.test.ts with test name filter "should split lines".
Fallback: Shell scripts
When the runTests tool is not available (e.g. in CLI environments), use the platform-appropriate script from the repo root:
- macOS / Linux:
./scripts/test.sh [options] - Windows:
.\scripts\test.bat [options]
These scripts download Electron if needed and launch the Mocha test runner.
Commonly used options
Bare file paths - Run tests from specific files
Pass source file paths directly as positional arguments. The test runner automatically treats bare .ts/.js positional arguments as --run values.
./scripts/test.sh src/vs/editor/test/common/model.test.ts
.\scripts\test.bat src\vs\editor\test\common\model.test.ts
Multiple files:
./scripts/test.sh src/vs/editor/test/common/model.test.ts src/vs/editor/test/common/range.test.ts
--run <file> - Run tests from a specific file (explicit form)
Accepts a source file path (starting with src/). The runner strips the src/ prefix and the .ts/.js extension automatically to resolve the compiled module.
./scripts/test.sh --run src/vs/editor/test/common/model.test.ts
Multiple files can be specified by repeating --run:
./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --run src/vs/editor/test/common/range.test.ts
--grep <pattern> (aliases: -g, -f) - Filter tests by name
Runs only tests whose full title matches the pattern (passed to Mocha's --grep).
./scripts/test.sh --grep "should split lines"
Combine with --run to filter tests within a specific file:
./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --grep "should split lines"
--runGlob <pattern> (aliases: --glob, --runGrep) - Run tests matching a glob
Runs all test files matching a glob pattern against the compiled output directory. Useful for running all tests under a feature area.
./scripts/test.sh --runGlob "**/editor/test/**/*.test.js"
Note: the glob runs against compiled .js files in the output directory, not source .ts files.
--coverage - Generate a coverage report
./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --coverage
--timeout <ms> - Set test timeout
Override the default Mocha timeout for long-running tests.
./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --timeout 10000
Integration tests
Integration tests (files ending in .integrationTest.ts or located in extensions/) are not run by scripts/test.sh. Use scripts/test-integration.sh (or scripts/test-integration.bat) instead. See the integration-tests skill for details.
Compilation requirement
Tests run against compiled JavaScript output. Ensure the VS Code - Build watch task is running or that compilation has completed before running tests. Test failures caused by stale output are a common pitfall.
.github/skills/update-screenshots/SKILL.md
npx skills add asJEI/vscode --skill update-screenshots -g -y
SKILL.md
Frontmatter
{
"name": "update-screenshots",
"description": "Download screenshot baselines from the latest CI run and commit them. Use when asked to update, accept, or refresh component screenshot baselines from CI, or after the screenshot-test GitHub Action reports differences. This skill should be run as a subagent."
}
Update Component Screenshots from CI
Screenshot baselines are no longer stored in the repository. They are managed by an external screenshot service (hediet-screenshots.azurewebsites.net). The CI workflow uploads screenshots to this service and diffs them automatically.
When the Checking Component Screenshots GitHub Action detects changes, it posts a PR comment with before/after comparisons. No manual baseline updates are needed — the screenshots on the main branch commit become the new baselines automatically after merge.
What Changed
- Baseline images were removed from
test/componentFixtures/.screenshots/baseline/. - Git LFS is no longer used for screenshot storage.
- The screenshot service stores images keyed by commit SHA and handles diffing.
If Screenshots Need Investigation
- Check the PR comment posted by the CI workflow for visual diffs.
- Download the
screenshotsartifact from the CI run for the raw captured images:
gh run download <run-id> --name screenshots --dir .tmp/screenshots
- Compare locally if needed. The artifact contains the full set of captured screenshots.
.github/skills/ux-css-layout/SKILL.md
npx skills add asJEI/vscode --skill ux-css-layout -g -y
SKILL.md
Frontmatter
{
"name": "ux-css-layout",
"description": "VS Code CSS conventions, file organization, class naming, standard sizes, SplitView\/Grid layout, scrollable content, responsive layout, and text overflow\/ellipsis patterns. Use when writing CSS, building layouts, or fixing text truncation issues."
}
This skill covers CSS file organization, naming, standard sizes, programmatic layout (SplitView, Grid, scrollable), responsive patterns, and text overflow handling.
1. File Organization
CSS files are co-located with their TypeScript components:
src/vs/base/browser/ui/button/
button.ts
button.css
src/vs/workbench/contrib/myFeature/browser/
myFeature.ts
media/
myFeature.css
Import CSS directly in the TS file:
import './media/myFeature.css';
// or for base widgets:
import './button.css';
Workbench-level global styles live in src/vs/workbench/browser/media/.
2. Class Naming
monaco-prefix for all major components:.monaco-workbench,.monaco-split-view2,.monaco-scrollable-element- Modifier classes:
.monaco-split-view2.vertical,.monaco-split-view2.horizontal - State classes:
.visible,.focused,.active,.highlight - Feature-specific classes use kebab-case without prefix:
.my-feature,.outline-pane,.welcome-view-content
3. Standard Sizes
| Element | Size |
|---|---|
| Part title height | 35px |
| Title padding (horizontal) | 8px |
| Title label inner padding | 12px |
| Action area padding | 5px |
| Action icon size | 16px |
| Body font-size | 13px (workbench), 11px (HTML body) |
| Line height | 1.4em |
| Validation message font-size | 12px (line-height: 17px) |
For
padding/margin/gap,border-radius,font-size/font-weight, codicon size and border width, prefer the design-system size tokens over raw px — see §10 Design-System Size Tokens. Canonical reference:.github/instructions/design-tokens.instructions.md(auto-injected forsrc/vs/**/*.css).
4. CSS Selector Quality
| Anti-pattern (flagged) | Correct pattern |
|---|---|
ID selectors for styling (#my-widget) |
Class selectors (.my-widget) |
| Overly specific selectors | Minimal specificity needed |
| Styles in the wrong file | Co-located with the component |
Missing min-width: 0 on flex children |
Prevents truncation issues |
Forgetting pointer-events: none on hidden overlays |
Prevents click-through bugs |
5. SplitView Layout
File: src/vs/base/browser/ui/splitview/splitview.ts
For splitting views with draggable sashes (either horizontal or vertical):
const splitView = new SplitView(container, {
orientation: Orientation.VERTICAL,
proportionalLayout: true,
styles: { separatorBorder: asCssVariable(sashBorder) }
});
// Each view implements IView:
const myView: IView = {
element: myDomNode,
minimumSize: 100,
maximumSize: Number.POSITIVE_INFINITY,
onDidChange: Event.None,
layout(size, offset) { /* resize content */ }
};
splitView.addView(myView, Sizing.Distribute);
Use LayoutPriority.High / .Low to control which views resize first when space is constrained. Use snap: true to allow views to snap closed.
6. Grid Layout
File: src/vs/base/browser/ui/grid/grid.ts
For 2D layouts (used by editor groups):
const grid = new Grid(initialView);
grid.addView(newView, Sizing.Distribute, referenceView, Direction.Right);
7. Scrollable Content
Three classes for different needs:
| Class | When to Use |
|---|---|
SmoothScrollableElement |
Animated scrolling (SplitView, ListView) |
DomScrollableElement |
Wrap existing DOM content (hovers, menus, breadcrumbs) |
ScrollableElement |
Basic single-direction scrollbar |
const scrollable = new DomScrollableElement(contentNode, {
horizontal: ScrollbarVisibility.Auto,
vertical: ScrollbarVisibility.Auto
});
this._register(scrollable);
container.appendChild(scrollable.getDomNode());
scrollable.scanDomNode(); // call after content changes
8. Responsive Layout
VS Code does not use CSS media queries. Instead, it uses a programmatic constraint-based layout system:
IView.minimumSize/maximumSize— views declare their size constraints.SplitViewandGriddistribute space according to constraints andLayoutPriority.ResizeObserveris used for container-aware sizing (e.g., editor auto-layout).- The window is treated as a fixed viewport; space is distributed via sash-based resizing.
When building a responsive component:
- Set
minimumSize/maximumSizeappropriately. - Use
LayoutPriority.Lowfor panels that should collapse first. - Use
snap: truefor panels that should snap closed when too small. - Fire
onDidChangewhen your constraints change dynamically.
9. Text Overflow & Ellipsis
All text labels that can be truncated by a resizable container must use the ellipsis pattern. Clipped text without an ellipsis is a visual bug.
Standard Ellipsis Pattern (CSS)
The three-property combo is required — all three must be present:
.my-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
Common Locations That Need Ellipsis
| Element | Why |
|---|---|
Part title labels (h2, breadcrumbs) |
Sidebar/panel can be resized narrower than the title |
| View pane header titles | View containers can be narrow |
| List/tree row labels | Rows have a fixed width from the list container |
| Tab labels (editor tabs) | Many tabs shrink to fit |
| Button labels in welcome views | Buttons have max-width constraints |
| Status bar items | Many items compete for horizontal space |
| Notification message text | Notification toast/center has fixed width |
| Tooltip/hover headings | Hovers have max-width |
| Dropdown/select items | Select boxes have bounded width |
| Badge text / descriptions | Auxiliary text in constrained columns |
Flex Container Gotchas
Flex children default to min-width: auto, which prevents text-overflow: ellipsis from working because the flex item refuses to shrink below its content width. Fix this by setting min-width: 0 on the flex child:
/* WRONG — ellipsis will NOT trigger inside a flex container */
.flex-parent {
display: flex;
}
.flex-parent > .label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* CORRECT — add min-width: 0 so the flex item can shrink */
.flex-parent > .label {
min-width: 0; /* ← this is the fix */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
This pattern is used throughout VS Code — for example, .monaco-icon-label-container sets min-width: 0 and flex: 1 to allow label text to truncate.
Fixed vs Flexible Elements
When a row has both fixed-size elements (icons, action buttons) and flexible text:
.row {
display: flex;
align-items: center;
}
.row > .icon {
flex-shrink: 0; /* icon never shrinks */
width: 16px;
}
.row > .label {
flex: 1; /* label takes remaining space */
min-width: 0; /* allows shrinking below content width */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row > .actions {
flex-shrink: 0; /* action buttons never shrink */
}
This is the standard pattern for tree rows, list items, tab labels, and view pane headers.
Hover for Full Text
When text is truncated with ellipsis, the full text must be accessible via hover tooltip. Use IHoverService.setupDelayedHover() with the full untruncated text so users can read it:
this._register(this.hoverService.setupDelayedHover(labelElement, {
content: fullLabelText,
}));
For IconLabel and list/tree renderers, this is handled automatically. For custom DOM, you must add it manually.
Anti-Patterns (NEVER DO)
- Never let text clip without an ellipsis — if
overflow: hiddenis set,text-overflow: ellipsismust also be set. - Never rely on a fixed pixel width for text that could be localized — localized strings vary in length.
- Never use
text-overflow: ellipsiswithoutoverflow: hiddenandwhite-space: nowrap— all three are required. - Never forget
min-width: 0on flex children that need to truncate. - Never truncate text without providing a hover/tooltip for the full string.
10. Design-System Size Tokens (spacing, radius, font, codicon, stroke)
VS Code ships a design-system size ramp, registered in
src/vs/platform/theme/common/sizes/baseSizes.ts (agents font ramp in
src/vs/sessions/common/sizes.ts) and emitted as --vscode-* CSS variables.
When writing or editing CSS, prefer the token var over a raw px value wherever a
token exists. The full tables + rationale live in the auto-injected
.github/instructions/design-tokens.instructions.md (canonical source — keep
this section in sync with it). This section captures the decision logic for
deeper styling tasks.
Every
--vscode-*size var you reference must already exist inbuild/lib/stylelint/vscode-known-variables.json("sizes"array, alphabetically sorted) or stylelint/hygiene fails. Adding a new token means adding it both inbaseSizes.tsand that JSON file.
Spacing — padding, margin, gap
Scale (px): 0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 28, 32, 36, 40 →
--vscode-spacing-sizeNone, --vscode-spacing-size20 … --vscode-spacing-size400
(token number = px × 10, so size200 = 20px).
What matters is the value, not the token. Adopting the var() is optional —
a raw px value is fine as long as it lands on the scale. What breaks rhythm is
an off-scale value (3, 5, 7, 14, 26px…). Snap off-scale values to the nearest
scale value, ties round up (5px → 6px, 3px → 4px, 1px → 2px,
26px → 28px). Each length of a shorthand is checked independently
(0 5px → 0 6px). Leave auto, %, em/rem, var()/calc() untouched.
Corner radius — border-radius
| px | Variable | Use |
|---|---|---|
| 2 | --vscode-cornerRadius-xSmall |
very compact elements |
| 4 | --vscode-cornerRadius-small |
controls (buttons, inputs) |
| 6 | --vscode-cornerRadius-medium |
base / inner surfaces |
| 8 | --vscode-cornerRadius-large |
prominent / outer surfaces |
| 12 | --vscode-cornerRadius-xLarge |
very prominent surfaces |
| 9999 | --vscode-cornerRadius-circle |
fully rounded (pills, dots) |
Snap map for off-scale literals (ties round up):
2→xSmall, 3,4→small, 5,6→medium, 7,8→large, 10,11,12→xLarge,
14,16,18,20→xLarge, 999→circle.
- Pills (radius ≈ half the element height — e.g.
28h/14r,36h/18r,22×22/11r) →--vscode-cornerRadius-circle, not xLarge. The literal-nearest token would square them and lose the fully-rounded intent. - Leave untouched:
50%,0,0px,inherit, anycalc()/var(). Preserve!important.
Font size & weight
Generic UI ramp — pair a size token with a weight token (mirrors the
agents ramp; "Strong" = matching size token + semiBold, never a separate size):
| px | Size var | Weight |
|---|---|---|
| 26 | --vscode-fontSize-heading1 |
semiBold |
| 18 | --vscode-fontSize-heading2 |
semiBold |
| 13 | --vscode-fontSize-heading3 |
semiBold |
| 13 | --vscode-fontSize-body1 |
regular |
| 11 | --vscode-fontSize-body2 |
regular |
| 12 | --vscode-fontSize-label1 |
regular |
| 11 | --vscode-fontSize-label2 |
regular |
| 10 | --vscode-fontSize-label3 |
regular |
Generic weights: --vscode-fontWeight-regular (400),
--vscode-fontWeight-semiBold (600).
Deprecated — --vscode-bodyFontSize (13) → --vscode-fontSize-body1,
--vscode-bodyFontSize-small (12) → --vscode-fontSize-label1,
--vscode-bodyFontSize-xSmall (11) → --vscode-fontSize-body2.
Agents window (src/vs/sessions/**) ramp — identical values, agents--prefixed:
| px | Size var | Weight |
|---|---|---|
| 26 | --vscode-agents-fontSize-heading1 |
semiBold |
| 18 | --vscode-agents-fontSize-heading2 |
semiBold |
| 13 | --vscode-agents-fontSize-heading3 |
semiBold |
| 13 | --vscode-agents-fontSize-body1 |
regular |
| 11 | --vscode-agents-fontSize-body2 |
regular |
| 12 | --vscode-agents-fontSize-label1 |
regular |
| 11 | --vscode-agents-fontSize-label2 |
regular |
| 10 | --vscode-agents-fontSize-label3 |
regular |
Both weight ramps are two weights only: regular (400) and
semiBold (600) — generic --vscode-fontWeight-*, agents
--vscode-agents-fontWeight-*.
- No medium (500).
font-weight: 500is off the ramp — snap tosemiBold. Likewise700/bold→ round to the nearer of 400/600. - "Strong" is not a separate size. "Body 1 Strong" = the matching
--vscode-fontSize-*(or--vscode-agents-fontSize-*) size token +semiBold. Never add a strong size. normal≡ 400 →regular. Leaveinherit,lighter,bolder,var()/calc()untouched.
Codicon size — icon font-size
Codicons are only ever 16px or 12px — never 14px or any in-between value.
| px | Variable | Use |
|---|---|---|
| 16 | --vscode-codiconFontSize (base) |
default icon size |
| 12 | --vscode-codiconFontSize-compact |
dense/inline chrome |
Compact-glyph convention: when sizing an icon at the compact 12px size, also
swap the registered glyph to its *Compact variant (e.g. Codicon.close →
Codicon.closeCompact, Codicon.add → Codicon.addCompact). CSS font-size
alone only scales the icon — it does not change to the visually-optimized
compact glyph; that requires changing the registered icon (Action2 icon: /
renderIcon). Only swap the glyph when no CSS selector targets the original
glyph class (e.g. .codicon-close); selectors keyed on the glyph class
(.codicon-add, .codicon-chevron-down) break when the class becomes
-compact, so update those selectors too (or size via a glyph-independent
wrapper class like .monaco-button). Some icons (settings/sliders, agent, vm,
info, lock, plus) have no compact variant — keep the regular glyph at 12px.
Stroke — border width
A single stroke thickness: 1px → --vscode-strokeThickness. Applies to the
border: 1px solid <color> shorthand and border-width: 1px. Other widths have
no token — leave them.
/* prefer */ border: var(--vscode-strokeThickness) solid var(--vscode-widget-border);
/* avoid */ border: 1px solid var(--vscode-widget-border);
Key Files
| Area | File |
|---|---|
| SplitView | src/vs/base/browser/ui/splitview/splitview.ts |
| Grid | src/vs/base/browser/ui/grid/grid.ts |
| Scrollbar | src/vs/base/browser/ui/scrollbar/scrollableElement.ts |
| Global workbench styles | src/vs/workbench/browser/media/style.css |
.github/skills/ux-theming/SKILL.md
npx skills add asJEI/vscode --skill ux-theming -g -y
SKILL.md
Frontmatter
{
"name": "ux-theming",
"description": "VS Code theming, color tokens, widget styles, focus indicators, and high-contrast theme support. Use when registering colors, styling widgets with theme tokens, or ensuring HC\/focus compliance."
}
This skill covers color registration, CSS variable usage, widget style patterns, focus indicators, and high-contrast theme requirements.
1. Registering Colors
File: src/vs/platform/theme/common/colorUtils.ts
export const myWidgetBackground = registerColor('myWidget.background',
{ light: '#ffffff', dark: '#252526', hcDark: Color.black, hcLight: Color.white },
nls.localize('myWidgetBackground', "Background color of My Widget."));
Rules:
- Provide defaults for all four theme types:
light,dark,hcDark,hcLight. - HC themes must use solid colors (avoid transparency) and set explicit borders via
contrastBorder. - Use color transforms for derived colors:
transparent(),darken(),lighten(),oneOf(). - Reference existing colors when possible instead of hardcoding hex values.
2. Color Categories
| File | Colors |
|---|---|
src/vs/platform/theme/common/colors/baseColors.ts |
foreground, focusBorder, contrastBorder, text links |
src/vs/platform/theme/common/colors/editorColors.ts |
Editor widgets, find match, errors/warnings |
src/vs/platform/theme/common/colors/inputColors.ts |
Input, toggle, validation |
src/vs/platform/theme/common/colors/listColors.ts |
List/tree selection, focus, hover, drop |
src/vs/platform/theme/common/colors/miscColors.ts |
Badge, scrollbar, progress bar, sash |
src/vs/workbench/common/theme.ts |
Tabs, sidebar, status bar, panels, editor groups, banner |
3. Using Colors in CSS
Colors are injected as CSS custom properties on .monaco-workbench:
Color ID: editor.background
CSS variable: --vscode-editor-background
Usage: var(--vscode-editor-background)
Conversion functions in colorUtils.ts:
asCssVariable(colorId)→'var(--vscode-editor-background)'asCssVariableName(colorId)→'--vscode-editor-background'
In CSS files, reference directly:
.my-widget {
background-color: var(--vscode-editor-background);
color: var(--vscode-foreground);
border: 1px solid var(--vscode-contrastBorder);
}
4. Widget Styles Pattern
File: src/vs/platform/theme/browser/defaultStyles.ts
Every widget type has a default style object and an override factory:
// Use defaults:
const button = new Button(container, defaultButtonStyles);
// Override specific colors:
const button = new Button(container, getButtonStyles({
buttonBackground: myCustomBackgroundColor
}));
Available defaults: defaultButtonStyles, defaultInputBoxStyles, defaultCheckboxStyles, defaultToggleStyles, defaultDialogStyles, defaultListStyles, defaultSelectBoxStyles, defaultMenuStyles, defaultProgressBarStyles, defaultCountBadgeStyles, defaultBreadcrumbsWidgetStyles, defaultKeybindingLabelStyles, defaultFindWidgetStyles.
5. Focus Indicators
Defined in src/vs/workbench/browser/media/style.css:
.my-widget:focus {
outline-width: 1px;
outline-style: solid;
outline-offset: -1px;
outline-color: var(--vscode-focusBorder);
}
Rules:
- Use
var(--vscode-focusBorder)— never hardcode a focus color. - Default
outline-offset: -1px(inset). Exception: checkboxes use2px. - Active elements suppress focus ring:
.my-widget:active { outline: 0 !important; } - Use
.synthetic-focusclass for programmatic focus indication. - Toggle buttons use
border: 1px dashed var(--vscode-focusBorder)instead of outline.
Focus Trapping
Modal dialogs must trap focus within the dialog until dismissed. Use dom.trackFocus() and handle Tab/Shift+Tab cycling.
6. High Contrast Theme Rules
- Always provide
hcDarkandhcLightdefaults when registering colors. - HC backgrounds:
Color.black(hcDark),Color.white(hcLight). - HC borders: reference
contrastBorder— it isnullin normal themes, visible in HC. - HC focus: use
activeContrastBorder(derived fromfocusBorder). - In CSS, use
.hc-black/.hc-lightclass selectors for HC-specific overrides:.hc-black .my-widget { border: 1px solid var(--vscode-contrastBorder); } - In TypeScript, check
isHighContrast(theme.type)for runtime behavior changes. - Box shadows must be removed or replaced in HC mode (shadows are invisible/distracting with high contrast borders):
.my-widget { box-shadow: 0 1px 3px var(--vscode-widget-shadow); } .vscode-high-contrast .my-widget { box-shadow: none; border: 1px solid var(--vscode-contrastBorder); }
7. No Hardcoded Visual Values
Reviewers will always flag hardcoded colors, shadows, sizes that should use theme tokens or CSS variables.
| Hardcoded (flagged) | Correct |
|---|---|
rgba(0, 0, 0, 0.12) |
var(--vscode-widget-shadow) or theme-aware variable |
#252526 |
var(--vscode-editor-background) |
color: white |
var(--vscode-button-foreground) |
border: 1px solid #ccc |
var(--vscode-editorWidget-border) |
border: 1px solid … (width) |
var(--vscode-strokeThickness) for the 1px width |
border-radius: 6px |
var(--vscode-cornerRadius-medium) (radius ramp) |
padding: 8px 12px (off-scale) |
spacing ramp (--vscode-spacing-size*) |
font-size: 14px (arbitrary) |
size ramp (--vscode-fontSize-*, agents --vscode-agents-fontSize-*) |
font-weight: 500 |
--vscode-fontWeight-semiBold (agents --vscode-agents-fontWeight-semiBold; no 500) |
codicon font-size: 14px |
--vscode-codiconFontSize (16) / -compact (12) |
Rule: If a value relates to color, shadow, or border — it must come from a CSS variable or registered color token. The only exception is 0 (zero) values and purely structural measurements like 100%.
Size, spacing, radius, font and stroke values have their own design-system
size tokens (and decision logic — snap maps, the pill→circle rule, and the
compact-glyph convention). Those live in the ux-css-layout skill (§10
Design-System Size Tokens) and the auto-injected
.github/instructions/design-tokens.instructions.md. Reach for those when a flag
is about how big / how round / how bold something is rather than what color.
Key Files
| Area | File |
|---|---|
| Color registration | src/vs/platform/theme/common/colorUtils.ts |
| Color registry (barrel) | src/vs/platform/theme/common/colorRegistry.ts |
| Base colors | src/vs/platform/theme/common/colors/baseColors.ts |
| Workbench colors | src/vs/workbench/common/theme.ts |
| Default widget styles | src/vs/platform/theme/browser/defaultStyles.ts |
| Global workbench styles | src/vs/workbench/browser/media/style.css |
.github/skills/vscode-dev-workbench/SKILL.md
npx skills add asJEI/vscode --skill vscode-dev-workbench -g -y
SKILL.md
Frontmatter
{
"name": "vscode-dev-workbench",
"description": "Use when the user wants to run the vscode.dev server locally and exercise the VS Code workbench or Agents window in the integrated browser against the local `microsoft\/vscode` sources. Covers starting the dev server, the `vscode-quality=dev` URL, browser-driven interaction patterns, and optionally wiring up a local mock agent host for the Agents window."
}
Running vscode.dev Against Local VS Code Sources
The vscode-dev repo is the vscode.dev server. When run locally with ?vscode-quality=dev, it serves the VS Code web workbench (or Agents window at /agents) from the sibling microsoft/vscode checkout. This is the fastest way to validate web-only changes to the workbench without shipping an Insiders build.
Layout assumption
vscode-dev and vscode must be sibling folders:
<workRoot>/
vscode/ # microsoft/vscode checkout
vscode-dev/ # microsoft/vscode-dev checkout
If your paths differ, check server/ in vscode-dev for the source root resolution — the /vscode-sources/* route maps to ../vscode.
Start the dev server
Critical: Run npm run dev from the vscode-dev folder, NOT from vscode. The vscode repo has no dev script and will fail with npm error Missing script: "dev". Terminal tools that simplify/strip leading cd into separate commands will silently keep the cwd of a previous terminal — always use an absolute pushd or verify with pwd before npm run dev.
cd /path/to/vscode-dev # NOT /path/to/vscode
npm run dev # runs watch + nodemon; serves https://127.0.0.1:3000
If you're driving this through an agent/terminal tool, prefer:
pushd /absolute/path/to/vscode-dev >/dev/null && pwd && npm run dev
On first start you may see one crash like Cannot find module './indexes' — it's the watcher racing the first build. nodemon restarts automatically once out/ finishes compiling. The server is ready when curl -sk -o /dev/null -w "%{http_code}" https://127.0.0.1:3000/ returns 200.
URLs
https://127.0.0.1:3000/?vscode-quality=dev— main workbench, local dev sourceshttps://127.0.0.1:3000/agents?vscode-quality=dev— Agents window, local dev sourceshttps://127.0.0.1:3000/?vscode-version=<commit>— pinned production commit- Add
&vscode-log=tracefor verbose client logging
Interacting via the integrated browser
Use open_browser_page and the standard browser tools.
Enter inserts a newline in the chat input
The chat input is a Monaco editor — page.keyboard.press('Enter') inserts a newline. To send, click the Send button (a[aria-label^="Send"]) or use the send keybinding.
Hard-reloading after a rebuild
The service worker caches client assets aggressively. A plain reload can still serve stale modules:
await page.evaluate(async () => {
const regs = await navigator.serviceWorker?.getRegistrations() ?? [];
await Promise.all(regs.map(r => r.unregister()));
const keys = await caches?.keys() ?? [];
await Promise.all(keys.map(k => caches.delete(k)));
});
await page.reload({ waitUntil: 'domcontentloaded' });
Simulating mobile (only when explicitly requested)
The integrated browser panel clamps width, so page.setViewportSize() and CDP setDeviceMetricsOverride narrow the viewport only as far as the panel allows. User-Agent override and touch emulation work fine:
const client = await page.context().newCDPSession(page);
await client.send('Emulation.setUserAgentOverride', {
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
platform: 'iPhone'
});
await client.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 });
await client.send('Emulation.setDeviceMetricsOverride', {
width: 393, height: 852, deviceScaleFactor: 3, mobile: true,
screenOrientation: { type: 'portraitPrimary', angle: 0 }
});
await page.reload();
For a true mobile viewport, drive a standalone Playwright script with devices['iPhone 14 Pro'] instead of the integrated browser. If a mobile-responsive overlay intercepts pointer events during automation, fall back to { force: true } on click().
Known-noise console messages (ignore)
Canceled: CanceledatclipboardService.js— cancelled permission probes on hover.NotAllowedError: Failed to execute 'write' on 'Clipboard'— web clipboard requires a user gesture.[WebTunnelAgentHost] Failed to list tunnels— only fires when not signed in.The web worker extension host is started in a same-origin iframe!— expected in dev.Unrecognized feature: 'local-network-access'— dev manifest warning.[LEAKED DISPOSABLE]stacks — GC-based tracker; only real if reproducible across reloads.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Cannot find module './indexes' on first run |
nodemon started before TS compile finished | Wait; it auto-restarts |
Session not found: <uuid> when sending chat |
Reopened a cloud/tunnel-backed session | Start a fresh session (⌘N) in the Agents window |
| Workspace picker opens native dialog and hangs automation | Select Folder… needs a real file dialog |
Pick a workspace URL scheme instead, or skip in automation |
Stale UI after editing vscode/ sources |
Service worker cache | Unregister SWs + clear caches (snippet above) |
Testing the Agents window against a local mock agent host
If the scenario touches the Agents window (/agents route), you almost always need the mock agent host running. Without it, the Agents window will sit on the sign-in / tunnel-discovery screen and block any real interaction. Start it in addition to the dev server — it's a second terminal, not a replacement.
vscode-dev supports a ?mock-agent-host=ws://… URL parameter that short-circuits tunnel discovery and wires the Agents window to a raw WebSocket. Pair it with the mock agent host binary from microsoft/vscode:
cd /path/to/vscode
node out/vs/platform/agentHost/node/agentHostServerMain.js \
--enable-mock-agent --quiet --without-connection-token --port 8765
# Listens on ws://localhost:8765
Prerequisite: out/ in the vscode repo must be populated by the VS Code - Build task (or npm run watch). If out/vs/platform/agentHost/node/agentHostServerMain.js is missing, start that task first.
--enable-mock-agent registers the ScriptedMockAgent from src/vs/platform/agentHost/test/node/mockAgent.ts with one pre-existing session. Seed additional sessions via the VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS env var, using a comma-separated list of session URIs (for example, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS=mock://pre-1,mock://pre-2). Scripted prompts include hello, use-tool, error, permission, write-file, run-safe-command, slow, client-tool, subagent, etc. (see mockAgent.ts for the full list).
Then open:
https://127.0.0.1:3000/agents?vscode-quality=dev&mock-agent-host=ws://localhost:8765&vscode-log=trace
Expect these logs in order:
[MockAgentHost] Using local mock agent host at ws://localhost:8765/[WebTunnelAgentHost] Found 1 tunnel(s) with agent host support[WebTunnelAgentHost] Connecting to tunnel 'mock-agent-host' (mock)[WebTunnelAgentHost] Protocol handshake completed with tunnel:mock[RemoteAgentHost] Registered agent mock from tunnel:mock as remote-tunnel__mock-mock
This bypasses GitHub auth and the /agents/api/hosts endpoint entirely, so it works offline. The fake tunnel on the vscode-dev side must advertise a protocolvN tag ≥ TUNNEL_MIN_PROTOCOL_VERSION in src/vs/platform/agentHost/common/tunnelAgentHost.ts (currently 5); otherwise WebTunnelAgentHostService filters it out and you'll see Found 0 tunnel(s) with agent host support.
extensions/copilot/.agents/skills/anthropic-sdk-upgrader/SKILL.md
npx skills add asJEI/vscode --skill anthropic-sdk-upgrader -g -y
SKILL.md
Frontmatter
{
"name": "anthropic-sdk-upgrader",
"model": "opus",
"description": "Use this agent when the user needs to upgrade Anthropic SDK packages. This includes: upgrading @anthropic-ai\/sdk or @anthropic-ai\/claude-agent-sdk to newer versions, migrating between SDK versions, resolving SDK-related dependency conflicts, updating SDK types and interfaces, or asking about SDK upgrade procedures. Examples: 'Upgrade the Anthropic SDK to the latest version', 'Help me migrate to the latest claude-agent-sdk', 'What's the process for upgrading Anthropic packages?'"
}
You are an expert at upgrading Anthropic SDK packages in the vscode-copilot-chat project.
Packages
| Package | Description |
|---|---|
@anthropic-ai/claude-agent-sdk |
Official Claude Agent SDK - provides the core agent runtime, tools, hooks, sessions, and message streaming |
@anthropic-ai/sdk |
Anthropic API SDK - provides base types, API client, and message structures used by the agent SDK |
Upgrade Process
Follow these steps exactly:
1. Check Current Versions and Changelog
Before upgrading, review the current versions in package.json and check the release notes:
- Claude Agent SDK Releases: https://github.com/anthropics/claude-agent-sdk-typescript/releases
- Anthropic SDK Releases: https://github.com/anthropics/anthropic-sdk-typescript/releases
2. Summarize All Changes
Create a consolidated summary of changes between the current version and the target version. Group changes by category, not by individual version:
Summary Format:
### `@anthropic-ai/package-name` (oldVersion → newVersion)
#### Features
- **Category:** Description of new feature or capability
#### Bug Fixes
- Description of what was fixed
#### Breaking Changes
- **Old API → New API**: Description of what changed and how to migrate
How to Create the Summary:
- Read the GitHub Release Notes: Go through each release between your versions
- Consolidate by Category: Group all features together, all bug fixes together, etc.
- Identify Breaking Changes: Look for:
- Removed or renamed exports
- Changed function signatures
- Modified type definitions
- Deprecated APIs that have been removed
- Document Migration Steps: For breaking changes, include the old and new patterns
- Check Peer Dependencies: Note if the new version requires different peer dependencies
3. List Important Changes
Categorize changes by impact level:
Critical (Must Address Before Merge):
- Breaking API changes that will cause compilation errors
- Removed types or functions currently in use
- Changed behavior of core functionality (sessions, streaming, tools)
Important (Should Address):
- Deprecated APIs that should be migrated
- New recommended patterns replacing old ones
- Performance improvements that require code changes
Nice to Have (Can Address Later):
- New optional features
- Additional type exports
- Enhanced error messages
4. Update Package Versions
# Update to latest
npm install @anthropic-ai/claude-agent-sdk @anthropic-ai/sdk
5. Detect API Surface Changes
After updating, diff the old and new type definitions to detect API changes that may not cause compilation errors but are important to know about (new parameters, new functions, deprecated APIs, etc.).
Steps:
-
Snapshot before upgrading: Before running
npm installin step 4, copy the current type definitions to a temp directory:mkdir -p /tmp/anthropic-sdk-old cp -r node_modules/@anthropic-ai/sdk/*.d.ts node_modules/@anthropic-ai/sdk/resources/*.d.ts /tmp/anthropic-sdk-old/ 2>/dev/null cp -r node_modules/@anthropic-ai/claude-agent-sdk/*.d.ts /tmp/anthropic-sdk-old/ 2>/dev/nullImportant: This snapshot must be taken before step 4's
npm install. -
Diff the type definitions: After
npm install, compare the old and new.d.tsfiles:# Diff the Anthropic SDK types for f in node_modules/@anthropic-ai/sdk/*.d.ts node_modules/@anthropic-ai/sdk/resources/*.d.ts; do base=$(basename "$f") if [ -f "/tmp/anthropic-sdk-old/$base" ]; then diff -u "/tmp/anthropic-sdk-old/$base" "$f" else echo "+++ NEW FILE: $f" fi done # Diff the Agent SDK types for f in node_modules/@anthropic-ai/claude-agent-sdk/*.d.ts; do base=$(basename "$f") if [ -f "/tmp/anthropic-sdk-old/$base" ]; then diff -u "/tmp/anthropic-sdk-old/$base" "$f" else echo "+++ NEW FILE: $f" fi done -
Analyze the diff and produce a report with the following categories:
New Exports — Functions, classes, types, or constants that were added:
- New exported functions or methods
- New type/interface definitions
- New enum values
New Parameters — Optional or required parameters added to existing functions:
- New optional fields on existing option/config types
- New required parameters (these are breaking changes — flag them as critical)
- New overloads of existing functions
Changed Signatures — Modifications to existing function/method signatures:
- Parameter type changes (e.g.,
string→string | string[]) - Return type changes
- Generic type parameter changes
Removed or Renamed — Items that were removed or renamed:
- Removed exports (breaking — flag as critical)
- Renamed types/functions (breaking — flag as critical)
- Removed fields from interfaces
Deprecations — Items newly marked as
@deprecated:- Functions or types with new
@deprecatedJSDoc tags
-
Cross-reference with our usage: For each change found, check whether the codebase currently uses the affected API:
# Example: if `createSession` gained a new parameter, check our usage grep -rn "createSession" src/extension/agents/claude/Flag changes that affect APIs we actively use as higher priority.
-
Summarize opportunities: Identify new APIs or parameters that could improve the codebase. These become candidates for follow-up work after the upgrade is complete.
-
Clean up:
rm -rf /tmp/anthropic-sdk-old
6. Fix Compilation Errors
After updating, check for compilation errors:
npm run compile
Address any type errors in the following key files:
src/extension/agents/claude/node/claudeCodeAgent.ts- Session and message handlingsrc/extension/agents/claude/node/claudeCodeSdkService.ts- SDK wrappersrc/extension/agents/claude/node/sessionParser/claudeCodeSessionService.ts- Session persistencesrc/extension/agents/claude/common/claudeTools.ts- Tool type definitionssrc/extension/agents/claude/node/hooks/*.ts- Hook implementationssrc/extension/agents/claude/vscode-node/slashCommands/*.ts- Slash command handlerssrc/extension/agents/claude/node/toolPermissionHandlers/*.ts- Permission handlers
7. Run Tests
After upgrading, run the Claude-related unit tests to verify nothing is broken:
# Run all Claude agent tests
npm run test:unit -- --testPathPattern="agents/claude"
Fix any test failures before proceeding. Common test files to check:
src/extension/agents/claude/node/test/claudeCodeAgent.spec.tssrc/extension/agents/claude/node/test/claudeCodeSessionService.spec.tssrc/extension/agents/claude/node/sessionParser/test/*.spec.ts
8. Update Documentation
If needed, update documentation in the codebase:
- Update
src/extension/agents/claude/AGENTS.mdif any architectural changes occurred - Update type definitions in
common/claudeTools.tsif tools changed - Document any new features or capabilities added
- Update the "Official Claude Agent SDK Documentation" links if URLs changed
9. Commit with a Detailed Message
Create a commit message that documents the upgrade clearly. Include:
- Package version changes - Both old and new versions
- Features - Notable new capabilities added
- Bug fixes - Important fixes included
- Breaking changes - What changed and how it was addressed in the code
Example commit message:
Update Anthropic SDK packages
### `@anthropic-ai/sdk` (0.71.2 → 0.72.1)
#### Features
- Structured Outputs support in Messages API
- MCP SDK helper functions
#### Breaking Changes
- `output_format` → `output_config` parameter migration
### `@anthropic-ai/claude-agent-sdk` (0.2.5 → 0.2.31)
#### Features
- **Query interface:** Added `close()` method, `reconnectMcpServer()`, `toggleMcpServer()` methods
- **Sessions:** Added `listSessions()` function for discovering resumable sessions
- **MCP:** Added `config`, `scope`, `tools` fields and `disabled` status to `McpServerStatus`
#### Bug Fixes
- Fixed `mcpServerStatus()` to include tools from SDK and dynamically-added MCP servers
- Fixed PermissionRequest hooks in SDK mode
#### Breaking Changes
- `KillShellInput` → `TaskStopInput`: Updated type mapping in claudeTools.ts
Troubleshooting Common Issues
Type Errors After Upgrade:
- Check if types were renamed (common:
Message→ContentBlock, etc.) - Look for removed type exports that need new imports
- Verify generic type parameters haven't changed
Session Loading Failures:
- Session file format may have changed between major versions
- Check
ClaudeCodeSessionServicefor compatibility issues - May need to clear old session files during major upgrades
Hook Registration Failures:
- Hook event names may have changed
- Check
HookEventtype for valid event strings - Verify hook callback signatures match new SDK expectations
Tool Execution Errors:
- Tool input schemas may have changed
- Check tool result handling for new error types
- Verify tool confirmation flow hasn't changed
extensions/copilot/.agents/skills/launch/SKILL.md
npx skills add asJEI/vscode --skill launch -g -y
SKILL.md
Frontmatter
{
"name": "launch",
"metadata": {
"allowed-tools": "Bash(npx @playwright\/cli:*)"
},
"description": "Launch and automate VS Code Insiders with the Copilot Chat extension using @playwright\/cli via Chrome DevTools Protocol. Use when you need to interact with the VS Code UI, automate the chat panel, test the extension UI, or take screenshots. Triggers include 'automate VS Code', 'interact with chat', 'test the UI', 'take a screenshot', 'launch with debugging'."
}
VS Code Extension Automation
Automate VS Code Insiders with the Copilot Chat extension using @playwright/cli. VS Code is built on Electron/Chromium and exposes a Chrome DevTools Protocol (CDP) port that @playwright/cli can attach to, enabling the same snapshot-interact workflow used for web pages.
Prerequisites
@playwright/cliis available via devDependencies. Runnpm installat the repo root, then usenpx @playwright/clito invoke commands. Alternatively, install globally withnpm install -g @playwright/cli.code-insidersis required. This extension uses 58 proposed VS Code APIs and targetsvscode ^1.110.0-20260223. VS Code Stable will not activate it — you must use VS Code Insiders.- The extension must be compiled first. Use
npm run compilefor a one-shot build, ornpm run watchfor iterative development. - CSS selectors are internal implementation details. Selectors like
.interactive-input-part,.monaco-editor, and.view-lineare VS Code internals that may change across versions. If automation breaks after a VS Code update, re-snapshot and check for selector changes.
Core Workflow
- Build the extension
- Launch VS Code Insiders with the extension and remote debugging enabled
- Attach npx @playwright/cli to the CDP port
- Snapshot to discover interactive elements
- Interact using element refs
- Re-snapshot after navigation or state changes
📸 Take screenshots for a paper trail. Use
npx @playwright/cli screenshot --filename=<path>at key moments — after launch, before/after interactions, and when something goes wrong. Screenshots provide visual proof of what the UI looked like and are invaluable for debugging failures or documenting what was accomplished.Save screenshots inside
.vscode-ext-debug/screenshots/(gitignored) using a timestamped subfolder so each run is isolated and nothing gets overwritten:# Create a timestamped folder for this run's screenshots SCREENSHOT_DIR=".vscode-ext-debug/screenshots/$(date +%Y-%m-%dT%H-%M-%S)" mkdir -p "$SCREENSHOT_DIR" # Windows (PowerShell): # $screenshotDir = ".vscode-ext-debug\screenshots\$(Get-Date -Format 'yyyy-MM-ddTHH-mm-ss')" # New-Item -ItemType Directory -Force -Path $screenshotDir # Save a screenshot npx @playwright/cli screenshot --filename="$SCREENSHOT_DIR/after-launch.png"
# Build and launch with the extension
npm run compile
# Use a PERSISTENT user-data-dir so auth state is preserved across sessions.
# .vscode-ext-debug is relative to the project root — works in worktrees and is gitignored.
code-insiders --extensionDevelopmentPath="$PWD" --remote-debugging-port=9223 --user-data-dir="$PWD/.vscode-ext-debug"
# On Windows (PowerShell):
# code-insiders --extensionDevelopmentPath="$PWD" --remote-debugging-port=9223 --user-data-dir="$PWD\.vscode-ext-debug"
# Wait for VS Code to start, retry until attached
for i in 1 2 3 4 5; do npx @playwright/cli attach --cdp=http://127.0.0.1:9223 2>/dev/null && break || sleep 3; done
# Verify you're connected to the right target (not about:blank)
# If `tab-list` shows the wrong target, run `npx @playwright/cli close` and reattach
npx @playwright/cli tab-list
npx @playwright/cli snapshot
Attaching
# Attach to a specific CDP port
npx @playwright/cli attach --cdp=http://127.0.0.1:9223
After attach, all subsequent commands target the connected app without needing to reattach.
Tab Management
VS Code uses multiple webviews internally. Use tab commands to list and switch between them:
# List all available targets (windows, webviews, etc.)
npx @playwright/cli tab-list
# Switch to a specific tab by index
npx @playwright/cli tab-select 2
Launching VS Code Extensions for Debugging
To debug a VS Code extension via npx @playwright/cli, launch VS Code Insiders with --extensionDevelopmentPath pointing to your extension source and --remote-debugging-port for CDP. Use --user-data-dir to avoid conflicting with an already-running VS Code instance.
# Build the extension first (from the repo root)
npm run compile
# Launch VS Code Insiders with the extension and CDP
# IMPORTANT: Use a persistent directory (not /tmp) so auth state is preserved.
# .vscode-ext-debug is relative to the project root — works in worktrees and is gitignored.
code-insiders \
--extensionDevelopmentPath="$PWD" \
--remote-debugging-port=9223 \
--user-data-dir="$PWD/.vscode-ext-debug"
# Wait for VS Code to start, retry until attached
for i in 1 2 3 4 5; do npx @playwright/cli attach --cdp=http://127.0.0.1:9223 2>/dev/null && break || sleep 3; done
# Verify you're connected to the right target (not about:blank)
# If `tab-list` shows the wrong target, run `npx @playwright/cli close` and reattach
npx @playwright/cli tab-list
npx @playwright/cli snapshot
Key flags:
--extensionDevelopmentPath=<path>— loads your extension from source (must be compiled first). Use$PWDwhen running from the repo root.--remote-debugging-port=9223— enables CDP (use 9223 to avoid conflicts with other apps on 9222)--user-data-dir=<path>— uses a separate profile so it starts a new process instead of sending to an existing VS Code instance. Always use a persistent path (e.g.,$PWD/.vscode-ext-debug) rather than/tmp/...so authentication, settings, and extension state survive across sessions.
Without --user-data-dir, VS Code detects the running instance, forwards the args to it, and exits immediately — you'll see "Sent env to running instance. Terminating..." and CDP never starts.
⚠️ Authentication is required. The Copilot Chat extension needs an authenticated GitHub session to function. Using a temp directory (e.g.,
/tmp/...) creates a fresh profile with no auth — the agent will hit a "Sign in to use Copilot" wall and model resolution will fail with "Language model unavailable."Always use a persistent
--user-data-dirlike$PWD/.vscode-ext-debug. On first use, launch once and sign in manually. Subsequent launches will reuse the auth session.
Restarting After Code Changes
After making changes to the extension source code, you must restart VS Code to pick up the new build. The extension host loads the compiled bundle at startup — changes are not hot-reloaded.
Restart Workflow
- Recompile the extension
- Kill the running VS Code instance (the one using your debug user-data-dir)
- Relaunch VS Code with the same flags
# 1. Recompile
npm run compile
# 2. Kill the VS Code instance tied to this project's debug profile, then relaunch
# macOS / Linux:
kill $(ps ax -ww -o pid,command | grep "$PWD/.vscode-ext-debug" | grep -v grep | awk '{print $1}' | head -1)
# Windows (PowerShell):
# Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*$PWD\.vscode-ext-debug*" } | ForEach-Object { Stop-Process -Id $_.ProcessId }
# 3. Relaunch
code-insiders \
--extensionDevelopmentPath="$PWD" \
--remote-debugging-port=9223 \
--user-data-dir="$PWD/.vscode-ext-debug"
# 4. Reattach npx @playwright/cli
for i in 1 2 3 4 5; do npx @playwright/cli attach --cdp=http://127.0.0.1:9223 2>/dev/null && break || sleep 3; done
npx @playwright/cli snapshot
Tip: If you're iterating frequently, run
npm run watchin a separate terminal so compilation happens automatically. You still need to kill and relaunch VS Code to load the new bundle.
Interacting with Monaco Editor (Chat Input, Code Editors)
VS Code uses Monaco Editor for all text inputs including the Copilot Chat input. Monaco editors appear as textboxes in the accessibility snapshot but require specific npx @playwright/cli commands to interact with.
What Works
fill <ref> — The Best Approach
The fill command with a snapshot ref handles focus and input in one step:
# Snapshot to find the chat input ref
npx @playwright/cli snapshot
# Look for: textbox "The editor is not accessible..." [ref=e51]
# Fill directly using the ref — handles focus automatically
npx @playwright/cli fill e51 "Hello from George!"
# Send the message
npx @playwright/cli press Enter
# Wait for the response to complete before re-snapshotting.
# Poll until the "Stop generating" button disappears:
for i in $(seq 1 30); do
npx @playwright/cli snapshot 2>/dev/null | grep -q "Stop generating" || break
sleep 1
done
npx @playwright/cli snapshot
This is the simplest and most reliable method. It works for both the main editor chat input and the sidebar chat panel.
Tip: If
fillsilently drops text (the editor stays empty), the ref may be stale or the editor not yet ready. Re-snapshot to get a fresh ref and try again. You can verify text was entered using the snippet in "Verifying Text in Monaco" below.
type — After Focus
If focus is already on a Monaco editor, type works:
# Focus first (via fill with empty string, or JS mouse events)
npx @playwright/cli fill e51 ""
# Then type works for subsequent input
npx @playwright/cli type "More text here"
press — Individual Keystrokes
Always works when focus is on a Monaco editor. Useful for special keys, keyboard shortcuts, and as a universal fallback for typing text character by character:
# Type text character by character (works on all builds)
npx @playwright/cli press H
npx @playwright/cli press e
npx @playwright/cli press l
npx @playwright/cli press l
npx @playwright/cli press o
npx @playwright/cli press Space # Use "Space" for spaces
# Select all
# macOS:
npx @playwright/cli press Meta+a
# Linux / Windows:
npx @playwright/cli press Control+a
npx @playwright/cli press Backspace # Delete selection
npx @playwright/cli press Enter # Send message / new line
# Send to new chat
# macOS:
npx @playwright/cli press Meta+Shift+Enter
# Linux / Windows:
npx @playwright/cli press Control+Shift+Enter
What Does NOT Work
| Method | Result | Reason |
|---|---|---|
click <ref> on editor |
"Element blocked by another element" | Monaco overlays a transparent div over the textarea |
Setting textarea.value + dispatching input event via eval |
No effect | Monaco doesn't read from the textarea's value property |
Fallback: Focus via JavaScript Mouse Events
If fill doesn't work (e.g., ref is stale), you can focus the editor via JavaScript:
npx @playwright/cli eval '
(() => {
const inputPart = document.querySelector(".interactive-input-part");
const editor = inputPart.querySelector(".monaco-editor");
const rect = editor.getBoundingClientRect();
const x = rect.x + rect.width / 2;
const y = rect.y + rect.height / 2;
editor.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: x, clientY: y }));
editor.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientX: x, clientY: y }));
editor.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }));
return "activeElement: " + document.activeElement?.className;
})()'
# After JS focus, type and press work
npx @playwright/cli type "Text after JS focus"
After JS mouse events, document.activeElement becomes a DIV with class native-edit-context — this is VS Code's native text editing surface.
Verifying Text in Monaco
Monaco renders text in .view-line elements, not the textarea:
npx @playwright/cli eval '
(() => {
const inputPart = document.querySelector(".interactive-input-part");
return Array.from(inputPart.querySelectorAll(".view-line")).map(vl => vl.textContent).join("|");
})()'
Clearing Monaco Input
# macOS:
npx @playwright/cli press Meta+a
# Linux / Windows:
npx @playwright/cli press Control+a
npx @playwright/cli press Backspace
Troubleshooting
"Connection refused" or "Cannot connect"
- Make sure VS Code Insiders was launched with
--remote-debugging-port=9223 - If VS Code was already running, quit and relaunch with the flag
- Check that the port isn't in use by another process:
- macOS / Linux:
lsof -i :9223 - Windows:
netstat -ano | findstr 9223
- macOS / Linux:
Elements not appearing in snapshot
- VS Code uses multiple webviews. Use
npx @playwright/cli tab-listto list targets and switch to the right one withnpx @playwright/cli tab-select <index>
Cannot type in Monaco inputs
- Standard
clickdoesn't work on Monaco editors — see the "Interacting with Monaco Editor" section above for the full compatibility matrix fill <ref>is the best approach; individualpresscommands work everywhere;typeworks after focus is established
Screenshots fail with "Permission denied" (macOS)
If npx @playwright/cli screenshot returns "Permission denied", your terminal needs Screen Recording permission. Grant it in System Settings → Privacy & Security → Screen Recording. As a fallback, use the eval verification snippet to confirm text was entered — this doesn't require screen permissions.
Cleanup
Always kill the debug VS Code instance when you're done. Leaving it running wastes resources and holds the CDP port.
# Disconnect npx @playwright/cli
npx @playwright/cli close
# Kill the debug VS Code instance
# macOS / Linux:
kill $(ps ax -ww -o pid,command | grep "$PWD/.vscode-ext-debug" | grep -v grep | awk '{print $1}' | head -1)
# Windows (PowerShell):
# Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*$PWD\.vscode-ext-debug*" } | ForEach-Object { Stop-Process -Id $_.ProcessId }
src/vs/sessions/skills/act-on-feedback/SKILL.md
npx skills add asJEI/vscode --skill act-on-feedback -g -y
SKILL.md
Frontmatter
{
"name": "act-on-feedback",
"description": "Act on user feedback attached to the current session. Use when the user submits feedback on the session's changes via the Submit Feedback button."
}
Act on Feedback
The user has provided feedback on the current session's changes.
- Use the
listCommentstool to retrieve the user's feedback comments for this session - If specific feedback comments were attached to this message, act on only those attached comments (match them by their comment id); otherwise act on all of the listed comments, or whatever subset the user specified in their message
- Understand the intent behind each piece of feedback you are acting on
- Make the requested changes to address the feedback
- When feedback has been tackled, use the
resolveCommentstool to mark those comments as resolved, or thedeleteCommentstool to delete them - Verify your changes are consistent with the rest of the codebase
src/vs/sessions/skills/code-review/SKILL.md
npx skills add asJEI/vscode --skill code-review -g -y
SKILL.md
Frontmatter
{
"name": "code-review",
"description": "Perform a code review of the current session's changes. Use when the user requests a code review via the Run Code Review button in the Changes toolbar."
}
Code Review
You are a coding agent acting as a code reviewer. Review the current session's changed files and surface concrete, actionable issues as inline comments on the code.
Workflow
- Determine the set of changed files in the current session (e.g.
git status,git diff). - For each changed file, read the relevant ranges and review them against the rest of the codebase:
- Correctness and edge cases
- Bugs, regressions, and missing error handling
- Security and data-handling issues
- Code clarity, naming, and consistency with surrounding code
- Tests and documentation gaps that the change introduces
- For every issue you find, use the
addCommenttool to attach a comment to the exact file URI and line range. Each comment should:- Explain what is wrong and why it matters
- Be specific to that range - do not leave a single summary comment per file
- Prefer fewer, higher-signal comments over many minor stylistic nits. Do not comment on things that are already correct.
- Do not modify files. Do not run commits, pushes, or other write operations. Your only output is review comments.
- When you have finished reviewing every changed file, stop and let the user act on the comments.
src/vs/sessions/skills/commit/SKILL.md
npx skills add asJEI/vscode --skill commit -g -y
SKILL.md
Frontmatter
{
"name": "commit",
"description": "Commit staged or unstaged changes with an AI-generated commit message that matches the repository's existing commit style. Use when the user asks to 'commit', 'commit changes', 'create a commit', 'save my work', or 'check in code'."
}
Commit Changes
Help the user commit code changes with a well-crafted commit message derived from the diff, following the conventions already established in the repository.
Guidelines
- Never amend existing commits without asking.
- Never force-push or push without explicit user approval.
- Never skip pre-commit hooks (do not use
--no-verify). - Never skip signing commits (do not use
--no-gpg-sign). - Never revert, reset, or discard user changes unless the user explicitly asked for that.
- Check for obvious secrets or generated artifacts that should not be committed. If something looks risky - ask the user.
- When in doubt about staging, convention, or message content — ask the user.
Workflow
1. Discover the repository's commit convention
Run the following to sample recent commits and the user's own commits:
# Recent repo commits (for overall style)
git log --oneline -20
# User's recent commits (for personal style)
git log --oneline --author="$(git config user.name)" -10
Analyse the output to determine the commit message convention used in the repository (e.g. Conventional Commits, Gitmoji, ticket-prefixed, free-form). All generated messages must follow the detected convention.
2. Check repository status
git status --short
- If there are no changes (working tree clean, nothing staged), inform the user and stop.
- If there are staged changes, proceed with those and do not stage any unstaged changes.
- If there are only unstaged changes, stage everything (
git add -A), and proceed with those.
3. Generate the commit message
Obtain the full diff of what will be committed:
git diff --cached --stat
git diff --cached
Using the diff and the commit convention detected in step 1, draft a commit message with:
- A subject line (≤ 72 characters) that summarises the change, following the repository's convention.
- An optional body that explains why the change was made, only when the diff is non-trivial.
- Reference issue/ticket numbers when they appear in branch names or related context.
- Focus on the intent of the change, not a file-by-file inventory.
4. Commit
Construct the git commit command with the generated message.
Execute the commit:
git commit -m "<subject>" -m "<body>"
5. Confirm
After the commit:
- Run
git status --shortto confirm the commit completed. - Run
git log --oneline -1to show the new commit. - If pre-commit hooks changed files or blocked the commit, summarize exactly what happened.
- If hooks rewrote files after the commit attempt, do not amend automatically. Tell the user what changed and ask whether they want you to stage and commit those follow-up edits.
src/vs/sessions/skills/create-draft-pr/SKILL.md
npx skills add asJEI/vscode --skill create-draft-pr -g -y
SKILL.md
Frontmatter
{
"name": "create-draft-pr",
"description": "Create a draft pull request for the current session. Use when the user wants to open a draft PR with the session's changes."
}
Create Draft Pull Request
Use the GitHub MCP server to create a draft pull request — do NOT use the gh CLI.
- Run the compile and hygiene tasks (fixing any errors)
- If there are any uncommitted changes, use the
/commitskill to commit them - Review all changes in the current session
- Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …")
- Write a description covering what changed, why, and anything reviewers should know
- Create the draft pull request
src/vs/sessions/skills/create-pr/SKILL.md
npx skills add asJEI/vscode --skill create-pr -g -y
SKILL.md
Frontmatter
{
"name": "create-pr",
"description": "Create a pull request for the current session. Use when the user wants to open a PR with the session's changes."
}
Create Pull Request
Use the GitHub MCP server to create a pull request — do NOT use the gh CLI.
- Run the compile and hygiene tasks (fixing any errors)
- If there are any uncommitted changes, use the
/commitskill to commit them - Review all changes in the current session
- Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …")
- Write a description covering what changed, why, and anything reviewers should know
- Create the pull request, passing
show_ui=falseso the PR is created without opening a confirmation UI
src/vs/sessions/skills/fix-ci/SKILL.md
npx skills add asJEI/vscode --skill fix-ci -g -y
SKILL.md
Frontmatter
{
"name": "fix-ci",
"description": "Fix the failed CI checks for the current session. Use when the user requests a CI fix via the Fix Checks button in the Changes toolbar."
}
Fix CI
Please fix the failed CI checks for this session immediately.
Use the failed check information provided with this message, including annotations and check output, to identify the root causes and make the necessary code changes.
Workflow
- Read the failed check information attached to this message. It includes a link to the pull request, and for each failed check the check name, status, conclusion, a details URL, and any annotations or output.
- Use the annotations and output to identify the root cause of each failure (e.g. compile errors, lint/hygiene violations, failing tests).
- Make the necessary code changes to resolve the failures. Focus on resolving these CI failures and avoid unrelated changes unless they are required to fix the checks.
- Validate your changes locally where possible (e.g. compile, lint, run the relevant tests) to confirm the failures are addressed.
src/vs/sessions/skills/generate-run-commands/SKILL.md
npx skills add asJEI/vscode --skill generate-run-commands -g -y
SKILL.md
Frontmatter
{
"name": "generate-run-commands",
"description": "Generate or modify run commands for the current session. Use when the user wants to set up or update run commands that appear in the session's Run button."
}
Generate Run Commands
Help the user set up run commands for the current Agent Session workspace. Run commands appear in the session's Run button in the title bar.
Understanding the task schema
A run command is a tasks.json task with:
"inAgents": true— required: makes the task appear in the Agents run button"runOptions": { "runOn": "worktreeCreated" }— optional: auto-runs the task whenever a new worktree is created (use for setup/install commands)
{
"tasks": [
{
"label": "Install dependencies",
"type": "shell",
"command": "npm install",
"inAgents": true,
"runOptions": { "runOn": "worktreeCreated" }
},
{
"label": "Start dev server",
"type": "shell",
"command": "npm run dev",
"inAgents": true
}
]
}
Decision logic
First, read the existing .vscode/tasks.json to check for existing run commands (inAgents: true tasks).
If run commands already exist: treat this as a modify request — ask the user what they'd like to change (add, remove, or update a command).
If no run commands exist: try to infer the right commands from the workspace:
- Check
package.json,Makefile,pyproject.toml,Cargo.toml,go.mod,.nvmrc, or other project files to understand the stack and common commands. - If it's clear what the setup command is (e.g.,
npm install,pip install -r requirements.txt), add it with"runOptions": { "runOn": "worktreeCreated" }— no need to ask. - If it's clear what the primary run/dev command is (e.g.,
npm run dev,cargo run), add it with just"inAgents": true. - Only ask the user if the commands are ambiguous (e.g., multiple equally valid options, no recognizable project structure, or the project uses a non-standard setup).
Writing the file
Always write to .vscode/tasks.json in the workspace root. If the file already exists, merge — do not overwrite unrelated tasks.
After writing, briefly confirm what was added and how to trigger it from the Run button.
src/vs/sessions/skills/merge/SKILL.md
npx skills add asJEI/vscode --skill merge -g -y
SKILL.md
Frontmatter
{
"name": "merge",
"description": "Merge changes from the topic branch to the merge base branch. Use when the user wants to merge their session's work back to the base branch."
}
Merge Changes
Merge the topic branch (checked out in the current worktree) into the merge base branch (checked out in the main worktree). The context block appended to the prompt contains the source branch, target branch, and main worktree path.
Guidelines
- Never force-push (
--force,--force-with-lease) without explicit user approval. - Never skip pre-push hooks (do not use
--no-verify). - Never rewrite or drop commits without asking the user.
- When in doubt about conflict resolution — ask the user.
Workflow
1. Commit uncommitted changes in the current worktree
Check for uncommitted changes in the current worktree:
git status --porcelain
If there are uncommitted changes, use the /commit skill to commit them before continuing.
2. Merge the topic branch into the base branch
Use git -C <main-worktree-path> to run commands against the main worktree without leaving the current worktree.
git -C <main-worktree-path> merge <topic-branch>
3. Handle merge conflicts
If the merge reports conflicts:
3.1. List conflicted files:
git -C <main-worktree-path> diff --name-only --diff-filter=U
3.2. For each conflicted file, read the file content, resolve the conflict by preserving the intent of both sides, and stage the resolved file:
git -C <main-worktree-path> add <resolved-file>
3.3. When in doubt on how to resolve a merge conflict, ask the user for guidance. If the user wants to abort, run:
git -C <main-worktree-path> merge --abort
3.4. Once all conflicts are resolved and staged, commit the merge:
git -C <main-worktree-path> commit --no-edit
Validation
After the merge completes, verify the result:
- Confirm the main worktree is clean:
git -C <main-worktree-path> status --porcelain
- Confirm the topic branch is an ancestor of the base branch (i.e. all commits are merged):
git -C <main-worktree-path> merge-base --is-ancestor <topic-branch> HEAD
src/vs/sessions/skills/sync-upstream/SKILL.md
npx skills add asJEI/vscode --skill sync-upstream -g -y
SKILL.md
Frontmatter
{
"name": "sync-upstream",
"description": "Update a stale session branch by rebasing onto the latest origin. Use when the upstream has moved significantly and the session needs to catch up, resolving conflicts by preserving upstream changes and adapting session work to fit."
}
Update Branch
Rebase the current session branch onto the latest upstream so the work stays grounded in origin.
Workflow
- If there are uncommitted changes, use the
/commitskill to commit them first. - Fetch the latest upstream and rebase onto it:
Use the appropriate base branch if it is notgit fetch origin git rebase origin/mainmain.
Conflict Resolution
When conflicts arise, upstream always wins:
- Never alter upstream logic, APIs, or patterns to accommodate session changes.
- Adapt session work to fit the new upstream — rename, restructure, or rewrite as needed while preserving the session's goals.
- After resolving each conflict,
git addthe files andgit rebase --continue.
Validation
After the rebase completes, verify the result still compiles and meets the session's objectives. If session changes no longer make sense against the updated upstream, explain what changed and propose a revised approach.
src/vs/sessions/skills/sync/SKILL.md
npx skills add asJEI/vscode --skill sync -g -y
SKILL.md
Frontmatter
{
"name": "sync",
"description": "Sync the current session branch with its upstream branch, or publish the current session branch to a remote. Use when the user asks to sync a branch, pull latest changes, rebase onto upstream, push current branch, publish branch, or set upstream."
}
Sync Changes
Sync the current session branch with its upstream branch, or publish the current session branch to a remote. Use when the user asks to sync a branch, pull latest changes, rebase onto upstream, push current branch, publish branch, or set upstream.
Guidelines
- Never force-push (
--force,--force-with-lease) without explicit user approval. - Never skip pre-push hooks (do not use
--no-verify). - Never rewrite or drop commits during rebase without asking the user.
- When in doubt about conflict resolution — ask the user.
Workflow
- Check for uncommitted changes first. If there are uncommitted changes, use the
/commitskill to commit them before continuing. - Check whether the current session branch has an upstream branch.
- If the current session branch has an upstream branch:
3.1. Fetch the upstream remote first so tracking refs are up to date.
3.2. Check ahead/behind counts. If the branch is already in sync (0 ahead, 0 behind), stop and report that no sync is needed.git fetch <upstream-remote>
3.3. If behind, rebase onto the upstream tracking branch.git rev-list --left-right --count HEAD...@{u}
3.4. If there are merge conflicts, resolve them by preserving the intent of both sides. Stage the resolved files and continue the rebase.git rebase @{u}
If conflict resolution is unclear, ask the user how to proceed. If the user wants to stop the rebase, abort it:git add <resolved-files> git rebase --continue
3.5. If the branch has local commits (ahead > 0), push them to the remote after a successful rebase.git rebase --abort
If the push is rejected because the rebase rewrote history, explain the situation to the user and ask for approval before force-pushing.git push - If the current session branch does not have an upstream branch:
4.1. Determine the remote to publish to.
- If there is only one remote, use it.
- If there are multiple remotes, use the #tool:vscode/askQuestions tool to ask which remote to use. 4.2. Publish the current branch and set upstream in one step.
git push -u <remote> HEAD
Validation
After the workflow completes, validate the result with explicit checks:
- Verify the working tree is clean:
git status --porcelain - Verify sync state (ahead/behind counts are both 0):
git rev-list --left-right --count HEAD...@{u} - If the branch was newly published, verify the upstream branch is configured:
git rev-parse --abbrev-ref --symbolic-full-name @{u}
src/vs/sessions/skills/troubleshoot/SKILL.md
npx skills add asJEI/vscode --skill troubleshoot -g -y
SKILL.md
Frontmatter
{
"name": "troubleshoot",
"description": "Investigate unexpected behavior in the current Copilot CLI agent session by analyzing its event log. Use when the user asks why something happened, why a request was slow, why a tool was or was not used, or why instructions\/skills\/agents did not load."
}
Troubleshoot
Purpose
This skill investigates and explains unexpected agent behavior in the current Copilot CLI agent session using its on-disk event log.
Use this skill for questions like:
- Why did this request take so long?
- Why was a tool called (or not called)?
- Why did an instruction/skill/agent file not load?
- Why did a tool call fail?
- Why did the model not follow expectations?
Base every conclusion on evidence from the event log. Do not guess.
Locating the Session Log
The skill runs inside the session's agent, so the log is on the same machine. It lives outside the workspace — read it via the terminal (run_in_terminal), never grep_search.
- If a
Session log:path is provided with this message, use it. It may point to a session other than the current one (via#session) and may be comma-separated paths — investigate all of them and compare. - Sticky reference: if no path is on the current message but an earlier turn in this conversation already established a target (a
Session log:path, or a#session:reference), keep analyzing that same session for the follow-up. Do not fall back to self-discovery here — the newest log is the current session, which is the wrong one. Switch only if the user references a new session. - Otherwise, self-discover it: pick the most recently modified
events.jsonlunder${XDG_STATE_HOME:-$HOME}/.copilot/session-state/<sessionId>/— this skill is appending to the current session's log, so it's reliably newest. HonorXDG_STATE_HOME, else$HOME. - If none exists there, this isn't a Copilot CLI session — tell the user the skill supports Copilot CLI sessions only, and stop.
Data Source — events.jsonl
Each line is a JSON object sharing one envelope:
{ "type": "...", "id": "...", "parentId": "...", "agentId": "...", "timestamp": "ISO-8601", "data": { } }
type— the event kind (see below).id— unique event id.parentId— the chronologically preceding event, not a logical parent. It is a flat back-pointer over every event, not the user → turn → tool-call hierarchy. Do not treat it as a logical parent.agentId— present for sub-agent events; absent for the main agent and session-level events.timestamp— ISO-8601 time. Compute durations by differencing timestamps (e.g. a tool's start vs. its completion, a turn'sassistant.turn_startvs. theassistant.message).data— type-specific payload.
Event types (data.* fields)
session.start(once) —selectedModel,reasoningEffort.user.message—content;transformedContent(expanded prompt, when it differs).assistant.turn_start/assistant.turn_end—turnId; measure a turn's duration vs the followingassistant.message.assistant.message—model,outputTokens,content,reasoningText(thinking, when present),turnId,parentToolCallId(set ⇒ sub-agent turn spawned by that tool call).tool.execution_start—toolName,toolCallId,arguments,parentToolCallId(set ⇒ nested/sub-agent).tool.execution_complete—toolCallId(matches start),success,result. Pair with its start bytoolCallIdfor the full call + duration.session.shutdown(once, at end) —modelMetrics[*].usage,totalNanoAiu. Absent mid-session.- Other (
hook.*,permission.*,system.message) — inspectdataas needed.
Reconstructing the flow
Iterate records in order and rebuild the logical tree from context:
session.startis the root.- a
user.messagebegins a turn. - an
assistant.messageanswers the currentuser.message— unless it hasdata.parentToolCallId, in which case it belongs to the sub-agent spawned by that tool call. - a
tool.execution_startbelongs to the currentassistant.message— unless it hasdata.parentToolCallId, in which case it is nested under that parent tool call. - pair each
tool.execution_startwith itstool.execution_completebytoolCallId.
Secondary Source — Agent Host Wire Log (protocol communication)
events.jsonl is the primary source and answers almost every question on its own. A separate log captures the transport/protocol between VS Code and the agent host process — the JSON-RPC-style frames that drive sessions.
Use the wire log only when the symptom points at the agent host / transport itself, not the model or a tool. Reach for it when:
- the agent host won't start, or the session never begins;
- requests hang or time out, or the agent appears stuck "connecting" / unresponsive;
createSession/subscribefails, or expected updates/notifications never arrive;- you see RPC / protocol / connection errors.
Do not open it for ordinary "why did the model/tool do X" questions — events.jsonl already answers those. If events.jsonl fully explains the behavior, stop there and don't read the wire log.
It is written by VS Code on the client machine, so it is only reachable for a local agent host (VS Code and the agent on the same machine). For a remote agent host it lives on the client, not the host this skill runs on — skip it there.
Location
<VS Code user-data dir>/logs/<session-timestamp>/ahp/ahp-<timestamp>-<connectionId>.jsonl
The VS Code user-data dir depends on the build:
- Windows:
%APPDATA%\Code(Insiders:Code - Insiders; OSS/dev may useCode - OSSor a custom--user-data-dir) - macOS:
~/Library/Application Support/Code - Linux:
~/.config/Code
A new logs/<timestamp>/ folder is created per VS Code session — pick the most recently modified ahp/ahp-*.jsonl.
Named by connection id, not session id — one log multiplexes all sessions on that connection (no per-session file). Long connections rotate into ahp-….1.jsonl, .2.jsonl, … (max 5); the active one is the most recently modified (the locate commands pick it). Read the newest log for connection-level health; to isolate one session, filter by its id (the session-state/<sessionId>/ folder name, which appears in frames' params/result).
If no ahp/ folder exists, the wire log is disabled (it is off by default). Tell the user to set "chat.agentHost.ahpJsonlLoggingEnabled": true, restart VS Code, reproduce the issue, then re-run.
Format
Each line is a JSON-RPC frame plus an _ahpLog envelope:
{ "jsonrpc": "2.0", "id": 12, "method": "createSession", "params": {}, "_ahpLog": { "ts": "ISO-8601", "dir": "c2s", "connectionId": "…", "transport": "local" } }
_ahpLog.dir— direction:c2s= VS Code → host (requests/notifications),s2c= host → VS Code (results/errors/actions/notifications)._ahpLog.transport—local/websocket/ssh/wsl.- Requests/notifications carry
method+params; responses carryresultorerror— pair a response to its request byid. _ahpLog.truncated: truemarks frames whose large payloads were elided.
Triage: look for error frames, requests (c2s with an id) that have no matching s2c response (hangs/timeouts), or a missing s2c after createSession / subscribe. Apply the same streaming / jq / node rules as for events.jsonl.
Locating it (terminal)
- macOS/Linux:
ls -t ~/Library/Application\ Support/Code*/logs/*/ahp/ahp-*.jsonl ~/.config/Code*/logs/*/ahp/ahp-*.jsonl 2>/dev/null | head -n 1 - Windows (PowerShell):
Get-ChildItem "$env:APPDATA\Code*\logs\*\ahp\ahp-*.jsonl" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
Tooling Strategy (important)
The event log is outside the workspace, so grep_search cannot read it. Use run_in_terminal.
Do a single triage pass first (important for efficiency)
Each terminal command is a separate round-trip, so don't run one command per question. Do one streaming pass (constant memory — events.jsonl can be hundreds of MB, so never load it whole) that prints, in a single read:
- the event counts by type,
- every tool call with success/failure and duration (pair each
tool.execution_startwith itstool.execution_completebytoolCallId), - the user messages.
Use a streaming jq program (macOS/Linux) or a streaming Node readline pass (Windows) — the per-query examples below are the building blocks. Then run targeted follow-ups only to drill in.
macOS / Linux / WSL / Git Bash (grep / jq)
- Locate the newest log:
ls -t "${XDG_STATE_HOME:-$HOME}"/.copilot/session-state/*/events.jsonl | head -n 1 - Check size first:
ls -lh <logPath> - Errors:
grep '"success":false' <logPath>(tool failures) andgrep '"type":"tool.execution_complete"' <logPath> - Count events by type (streaming):
jq -nr 'reduce inputs as $i ({}; .[$i.type] += 1) | to_entries | sort_by(-.value)[] | "\(.value) \(.key)"' <logPath> - Tool calls:
jq -c 'select(.type=="tool.execution_start") | {tool:.data.toolName, id:.data.toolCallId}' <logPath> - User messages:
jq -c 'select(.type=="user.message") | .data.content' <logPath> - Assistant turns:
jq -c 'select(.type=="assistant.message") | {model:.data.model, out:.data.outputTokens}' <logPath>
Windows (PowerShell + Node.js)
Do not parse the log with PowerShell JSON. Get-Content … | ForEach-Object { ConvertFrom-Json } (or any per-line ConvertFrom-Json) deserializes one object at a time and is slow even on small logs. Use Select-String for plain-text matches and a streaming node pass for anything that needs JSON. Use jq instead if it is available.
- Locate the newest log:
$stateHome = if ($env:XDG_STATE_HOME) { $env:XDG_STATE_HOME } else { $HOME } Get-ChildItem (Join-Path $stateHome '.copilot\session-state\*\events.jsonl') | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - Check size first:
(Get-Item <logPath>).Length - Plain-text matches (fast, streaming, no parsing):
Select-String '"success":false' <logPath> - JSON queries — a streaming
nodepass (constant memory, safe for hundreds-of-MB logs; pass the log path as an argument so it stays quote-safe). Use this shape and change only the per-lineif (…)to select what you need (tool failures, tool calls, assistant turns, …):node -e "const rl=require('readline').createInterface({input:require('fs').createReadStream(process.argv[1])});rl.on('line',l=>{if(!l)return;let x;try{x=JSON.parse(l)}catch{return}if(x.type==='tool.execution_complete'&&x.data.success===false)console.log(x.data.toolCallId,JSON.stringify(x.data.result))})" "<logPath>"For aggregates (e.g. counts by type), accumulate into an object as you go and print it onrl.on('close', …). - For a very large log, narrow with
Select-Stringfirst to find the line, then read that small slice withread_file.
General rules
- One streaming pass, not many. Each command is a round-trip; prefer the single triage pass, then targeted follow-ups. Never load a whole (hundreds-of-MB) log into memory (
readFileSync/Get-Content) — stream (jq/ the readline pass) or narrow withgrep/Select-Stringfirst. Check size first if unsure (ls -lh/(Get-Item).Length). - Never deserialize JSON line-by-line in the shell (PowerShell
ConvertFrom-Jsonin a loop) — slow. Use onejqfilter or onenodepass. - Use
read_fileonly for small targeted ranges, never an entire log.
Investigation Workflow
- Locate
events.jsonland run the single triage pass. - Match the symptom: errors →
success:false; latency → gaps betweenassistant.turn_startand itsassistant.message, or a tool's start↔complete; tool / model / input → the relevant event type; agent-host / transport failure (local) → also the Wire Log. - Read only the relevant slices, then determine the most likely root cause (order contributing factors by impact) and give concrete next steps.
Response Guidelines
Cover what happened and why (root cause), key evidence (paraphrased — quote only a telling detail like an error message, never raw dumps), and how to fix it. A short combined explanation is fine; add structure (headers, bullets, tables) only for complex, multi-factor issues. Keep paragraphs short.
Abstraction: don't narrate your investigation or use internal terms (events.jsonl, tool.execution_start, parentId, "envelope"). Refer to "the session log" abstractly. Focus on what happened and why, not how you found it.
Example — instead of "I read events.jsonl and saw a tool.execution_complete with success:false…", say:
The "testing" skill was found but not loaded because the folder name and the name in the skill file differ (
testingvstesting2). Fix: make them match (rename the folder or change the name), then start a new session.
Important Rules
- Base every claim on log evidence — never assume causality.
- Search via
run_in_terminal; nevergrep_search, and neverread_filea whole (possibly huge) log — narrow first, then read small ranges. - If no Copilot CLI session log exists, say so and stop.
src/vs/sessions/skills/update-pr/SKILL.md
npx skills add asJEI/vscode --skill update-pr -g -y
SKILL.md
Frontmatter
{
"name": "update-pr",
"description": "Update the pull request for the current session. Use when the user wants to push new changes to an existing PR."
}
Update Pull Request
Update the existing pull request for the current session. The context block appended to the prompt contains the pull request information.
- Check whether the pull request has any commits that are not yet present on the current branch (incoming changes). If there are any incoming changes, pull them into the current branch and resolve any merge conflicts
- Run the compile and hygiene tasks (fixing any errors)
- If there are any uncommitted changes, use the
/commitskill to commit them - If the outgoing changes introduce significant changes to the pull request, update the pull request title and description to reflect those changes
- Update the pull request with the new commits and information
src/vs/sessions/skills/update-skills/SKILL.md
npx skills add asJEI/vscode --skill update-skills -g -y
SKILL.md
Frontmatter
{
"name": "update-skills",
"description": "Create or update repository skills and instructions when major learnings are discovered during a session. Use when the user says \"learn!\", when a significant pattern or pitfall is identified, or when reusable domain knowledge should be captured for future sessions."
}
Update Skills & Instructions
When a major repository learning is discovered — a recurring pattern, a non-obvious pitfall, a crucial architectural constraint, or domain knowledge that would save future sessions significant time — capture it as a skill or instruction so it persists across sessions.
When to Use
- The user explicitly says "learn!" or asks to capture a learning
- You discover a significant pattern or constraint that cost meaningful debugging time
- You identify reusable domain knowledge that isn't documented anywhere in the repo
- A correction from the user reveals a general principle worth preserving
Decision: Skill vs Instruction vs Learning
Add a learning to an existing instruction when:
- The insight is small (1-4 sentences) and fits naturally into an existing instruction file
- It refines or extends an existing guideline
- Follow the pattern in
.github/instructions/learnings.instructions.md
Create or update a skill (.github/skills/{name}/SKILL.md or .agents/skills/{name}/SKILL.md) when:
- The knowledge is substantial (multi-step procedure, detailed guidelines, or rich examples)
- It covers a distinct domain area (e.g., "how to debug X", "patterns for Y")
- Future sessions should be able to invoke it by name
Create or update an instruction (.github/instructions/{name}.instructions.md) when:
- The rule should apply automatically based on file patterns (
applyTo) or globally - It's a coding convention, architectural constraint, or process rule
- It doesn't need to be invoked on demand
Procedure
1. Identify the Learning
Reflect on what went wrong or what was discovered:
- What was the problem or unexpected behavior?
- Why was it a problem? (root cause, not symptoms)
- How was it fixed or what's the correct approach?
- Can it be generalized beyond this specific instance?
2. Check for Existing Files
Before creating new files, search for existing skills and instructions that might be the right home:
# Check existing skills
ls .github/skills/ .agents/skills/ 2>/dev/null
# Check existing instructions
ls .github/instructions/ 2>/dev/null
# Search for related content
grep -r "related-keyword" .github/skills/ .github/instructions/ .agents/skills/
3a. Add to Existing File
If an appropriate file exists, add the learning to its ## Learnings section (create the section if it doesn't exist). Each learning should be 1-4 sentences.
3b. Create a New Skill
If the knowledge warrants a standalone skill:
- Choose the location:
.github/skills/{name}/SKILL.mdfor project-level skills (committed to repo).agents/skills/{name}/SKILL.mdfor agent-specific skills
- Create the directory and SKILL.md with frontmatter:
---
name: {skill-name}
description: {One-line description of when and why to use this skill.}
---
# {Skill Title}
{Body with guidelines, procedures, examples, and learnings.}
- The
namefield must match the parent folder name exactly. - Include concrete examples — skills with examples are far more useful than abstract rules.
3c. Create a New Instruction
If the knowledge should apply automatically:
---
description: {When these instructions should be loaded}
applyTo: '{glob pattern}' # optional — auto-load when matching files are attached
---
{Content of the instruction.}
4. Quality Checks
Before saving:
- Is the learning general enough to help future sessions, not just this one?
- Is it specific enough to be actionable, not just a vague principle?
- Does it include a concrete example of right vs wrong?
- Does it avoid duplicating knowledge already captured elsewhere?
- Is the description clear enough that the agent will know when to invoke/apply it?
5. Inform the User
After creating or updating the file:
- Summarize what was captured and where
- Explain why this location was chosen
- Note if any existing content was updated vs new content created
extensions/copilot/.agents/skills/github-copilot-upgrader/SKILL.md
npx skills add asJEI/vscode --skill github-copilot-upgrader -g -y
SKILL.md
Frontmatter
{
"name": "github-copilot-upgrader",
"model": "Claude Opus 4.6",
"description": "Use this to update the Github Copilot CLI\/SDK"
}
You are an expert at upgrading the @github/copilot npm package in the vscode-copilot-chat project.
Upgrade Process
You must create a TODO list of all items that are to be completed. You MUST create a TODO markdown file before commencing any of the work. Update this file after each step is completed. Complete all TODO items in sequence without stopping to ask for confirmation, only stop if you encounter any ambiguous decision that requires user input. The TODO is your primary tracking mechanism. Before each step you MUST read the TODO to determine what to do next.
At a minimum your TODO must contain the following:
- Snapshot old type definitions
- Update the package
- Compare differences in type definitions and document them
- Compile,
- fix
- test
- Repease steps Compile, fix and tests until all tests are passing
- Run integration tests
- Repeate Compile, Fix, Test, until all tests are passing
- Create a summary
Note:
- Do not run any integration test.
Follow these steps exactly:
1. Snapshot of old type definitions
Take a snapshot of node_modules/@github/copilot/sdk/index.d.ts to compare against after the upghttps://github.com/microsoft/vscode/issues/291457rade.
2. Update the package using command npm install @github/copilot@latest
After this you MSUT run npm run postinstall
3. Compare differences in type definitions
- Use mode=background for comparing the files and when done, just let me know its done
- This is what you need to do in the background task:
- Analyze the differences between the old and new index.d.ts files to identify any API changes, new features, or breaking changes.
- Document the changes in a clear and organized manner, create the documentation in in .build/upgrade-notes.md
4. Compile, fix and test
4.1 Compile
- Run the following commands to identify any type errors caused by the upgrade:
- You must perform a deep analysis of the compilation errors before attempting to resolve them.
- Ensure there are no compilation errors before proceeding to run the tests.
npm run postinstall
npm run compile
npx tsc --noEmit --project tsconfig.json
4.2 Run Tests
- Use the following command to run test
npm run test:unit
- Do NOT change the behavour of the code just to make the tests pass. If the upgrade causes a test to fail, you must analyze the failure and determine if it is due to a legitimate issue caused by the upgrade or if it is a problem with the test itself.
- Ensure all tests are passing before proceeding to the next step.
5. Summarize the changes
- After successfully upgrading the @github/copilot package and ensuring that all tests are passing, you must create a summary of the changes that were made during the upgrade process.
- Give a summary of the changes in the code base
- Give a summary of the changes in the tests
- Give a summary of the differenes in the type definitions between the old and new versions of the @github/copilot package.
- Focus on the new API or features that were added, any breaking changes that were introduced, and any deprecated features that were removed.
- Document the summary in a clear and organized manner, create the documentation in in .build/upgrade-notes.md


