mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-12 11:39:57 +01:00
Merge branch 'main' into eli/agents/button-style-update-circle-outline
This commit is contained in:
@@ -47,6 +47,7 @@ Then read the relevant spec for the area you are changing (see table below). If
|
||||
- **Line-number decoration tooltips belong in Monaco decoration options**: A `lineNumberClassName` node is regenerated as the editor renders and scrolls, so DOM-managed hovers can silently attach to a stale or never-decorated element. Set the localized `lineNumberHoverMessage` with the same decoration instead; Monaco's glyph hover controller follows the rendered line-number lifecycle.
|
||||
- **Compact multi-diff control alignment**: The file-header twistie, unchanged-region expand control, and fold control form one visual column in the Agents editor. Remove the header content's left padding and use the same small inset for both unchanged-region controls; do not let the shared multi-diff defaults leave each control at a separate horizontal offset.
|
||||
- **Embedded multi-diff gutters need a shared minimum width**: Each embedded editor otherwise sizes line numbers from its own largest line number, causing the content and nearby feedback glyph to appear to drift between file entries. Set a common `lineNumbersMinChars` width for the compact multi-diff; it remains stable through three-digit line numbers and grows only when a file exceeds that reserved capacity.
|
||||
- **Editor-content overlays must anchor to the inset pane, not the full editor group**: In single-pane mode the editor group spans both the editor and docked detail panel, while `EditorGroupView.editorPaneContainer` bounds only the editor content. Mount submit/navigation overlays to that pane container so their bottom-right position stays inside the diff when the detail panel is visible or resized.
|
||||
- **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.
|
||||
- **Sticky prompt navigation must match the rail/title reveal**: Previous/Next and the sticky title must all reveal the prompt (request) row aligned to the top via the shared `reveal(requestId)` — the same path the dock/ruler rail uses. Do not align the following response to keep the header pinned, and do not add a "navigation pin" that forces the header to stay visible after a jump: the header is a `top:0` overlay, so it would cover the freshly top-aligned prompt (the prompt shows only a sliver). Let the header follow scroll tracking (it hides once the prompt is at the top), consistent with the dock. Use the chat request-bubble hover background (`--vscode-chat-requestBubbleHoverBackground`, toolbar hover as fallback), composited over the opaque panel base, for the sticky title affordance rather than an underline.
|
||||
- **Sticky prompt header transition is a label *roll*, not a moving band**: on prompt change the label text rolls (WAAPI slide+fade of absolutely-positioned line elements inside an `overflow:hidden` clip viewport), while the opaque band stays fixed. Do NOT translate the whole band to get an Explorer-style push-off: the band would move above the transcript top (its container `.interactive-session` is `overflow:visible`, so it'd overlap the session header) and clipping it would cut the band's soft drop-shadow. The roll gives the "header gives way to the next" feel with none of that risk. Gate the roll on the header already being visible (snap on first appearance/jumps) and honor `prefers-reduced-motion`.
|
||||
@@ -96,6 +97,7 @@ Then read the relevant spec for the area you are changing (see table below). If
|
||||
- **A reused new-session composer must re-seed its workspace draft when it swaps out of quick-chat mode**: the session-type picker hides itself when it has no folder types (`sessionTypePicker` `_folderSessionTypes.length === 0`), which is the case whenever the composer has **no active session** (`refresh(undefined)` clears the types). A *freshly opened* new-session composer avoids this by seeding a workspace draft from the restored folder in its constructor — but the same `NewChatWidget` instance is **reused** across the quick-chat→new-session transition (`sessionView.ts` keeps `kind==='newSession'`), and Cmd+N's `openNewSession` discard branch only `_activate(undefined)`, leaving the reused composer session-less → picker hidden. Fix by re-running the constructor's seed (`_seedWorkspaceDraft()`) from an autorun when `_isQuickChatComposer` flips **true→false with no active session**, so the reused composer matches a fresh one (folder + visible picker). Don't assume the constructor-time restore covers a reused composer.
|
||||
- **Every untitled-session-title fallback must be quick-chat aware**: an untitled session's title observable is `''`, so a hardcoded `localize(…, "New Session")` fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route **all** such fallbacks through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`, boolean param so each caller controls reader-tracked `.read(reader)` vs `.get()`). There are ≥5 sites — titlebar (`sessionsTitleBarWidget`), session header (×2: title + rename placeholder), list-row hover (`sessionHoverContent`), sessions picker (`sessionsActions`) — keep them on the helper; never hardcode "New Session". (The Cmd+N *action* title stays "New Session" — that action creates a session, unrelated to a session's own title.)
|
||||
- **`NeedsInput` is still an active turn for live turn UI**: agent-host tool and input confirmations intentionally transition a running chat from `InProgress` to `NeedsInput` without ending `activeTurn`. Live status surfaces such as the chat input pills must use `isActiveSessionStatus` so they do not disappear until the next output returns the chat to `InProgress`.
|
||||
- **Chat file pills must not hardcode an editor**: open their resource through the shared `chat.editorAssociations` resolution path so both completed-response pills and the session input toolbar respect the configured editor. Do not invoke `markdown.showPreview` directly.
|
||||
- **Agent-host-only exclusions for built-in client tools belong in `ClientToolSetsContribution`, not the global tool registration**: `AgentHostActiveClientService.getClientTools` advertises enabled members of every non-deprecated tool set, including extension-contributed sets. Omit an unsupported built-in tool from the client tool sets so normal Copilot chat can continue using it; do not treat this contribution as the sole Agent Host allowlist.
|
||||
- **Non-interactive MCP authentication probes must not create dynamic authentication providers**: Provider creation can prompt for manual client registration when dynamic registration is unsupported. With `allowInteraction: false`, only inspect existing providers and sessions; defer metadata discovery and provider creation until the user invokes the `mcpAuthenticationRequired` action.
|
||||
- **Use structured maps for the state that is actually multi-keyed, not for an incidental cache**: If MCP tracking is addressed by session + server, model that source of truth directly with `NKeyMap`. Do not add a separate `NKeyMap` that merely caches serialized storage keys while leaving the real tracking state in nested or synchronized maps.
|
||||
@@ -109,6 +111,10 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru
|
||||
|
||||
- **Definitive session deletion and temporary list eviction are different operations**: deletion clears durable provenance and pending state; filtering a still-existing session only removes its visible list entry. Keep the list-removal helper side-effect-free, and let each caller explicitly update its mutation generation instead of passing an "already incremented" boolean.
|
||||
|
||||
- **Durable user intent must never be discarded on `onDidChangeSessions.removed`**: pins, manual sort keys, and group membership (`SessionsListModelService`, `SessionGroupsService`) are cleared only on `ISessionsManagementService.onDidDeleteSession` (or archive), never on the provider's `removed` delta. `removed` is an *eviction*, not a deletion: `BaseAgentHostSessionsProvider._refreshSessions` reconciles against one listing that the host aggregates across all its agents, and an agent that cannot answer yet returns `[]` instead of failing (`CodexAgent.listSessions` returns `[]` for a missing `_githubToken`, a not-yet-downloaded SDK, or a failed `thread/list`; `ClaudeAgent.listSessions` does the same). Persisting the removal turned a ~300 ms startup race into permanent loss of the user's pins and groups. Runtime-only consumers of `removed` (terminals, grid slots, layout) are fine as-is — only *persisted* state needs the delete event.
|
||||
|
||||
- **`_refreshSessions` must not evict a cached session whose agent contributed no rows**: a listing with zero rows for an agent means "unknown", not "empty", so scope eviction to `listedAgentProviders` (the set of `AgentSession.provider(...)` schemes actually present in the response) and compare against `adapter.agentProvider`. Real deletions still arrive through `deleteSessions` and the `sessionRemoved` notification; the only cost is that an agent's *last* session, deleted elsewhere, lingers until it lists something again.
|
||||
|
||||
- **Keep session-list refresh filtering linear**: when retention pruning needs the complete backend key set, collect those keys while filtering entries in the original loop, then reconcile last-seen/pruning afterward. Do not introduce a candidate-map/filter/map pipeline when one loop plus one reconciliation call expresses the lifecycle more clearly.
|
||||
|
||||
- **Centralize session workspace filtering behind a semantic predicate**: refresh, add-notification, and summary-update paths should call one `_isSessionInWorkspace(entry)`-style helper. Keep key construction, working-directory parsing, pending-local lookup, and provenance checks out of each caller so the high-level list flow stays readable and all paths apply identical rules.
|
||||
|
||||
@@ -57,12 +57,22 @@ jobs:
|
||||
# Run the Electron smoke test once per iteration. continueOnError lets every
|
||||
# iteration run even if some fail, and gives each run its own timeline record
|
||||
# so the SmokeFlaky function can tally passes vs. failures.
|
||||
#
|
||||
# The smoke runner wipes .build/logs/smoke-tests-electron on startup, so a
|
||||
# failing iteration's diagnostics would be destroyed by the next one and the
|
||||
# published artifact would only ever hold the last iteration. Set the failed
|
||||
# run aside under a name the runner does not touch.
|
||||
- ${{ each i in parameters.iterations }}:
|
||||
- script: |
|
||||
set -e
|
||||
APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH)
|
||||
APP_NAME="`ls $APP_ROOT | head -n 1`"
|
||||
npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME"
|
||||
status=0
|
||||
npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME" || status=$?
|
||||
if [ $status -ne 0 ] && [ -d .build/logs/smoke-tests-electron ]; then
|
||||
mv .build/logs/smoke-tests-electron ".build/logs/failed-iteration-${{ i }}"
|
||||
fi
|
||||
exit $status
|
||||
displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)"
|
||||
continueOnError: true
|
||||
timeoutInMinutes: 20
|
||||
|
||||
@@ -100,10 +100,20 @@ jobs:
|
||||
# Run the Electron smoke test once per iteration. continueOnError lets every
|
||||
# iteration run even if some fail, and gives each run its own timeline record
|
||||
# so the SmokeFlaky function can tally passes vs. failures.
|
||||
#
|
||||
# The smoke runner wipes .build/logs/smoke-tests-electron on startup, so a
|
||||
# failing iteration's diagnostics would be destroyed by the next one and the
|
||||
# published artifact would only ever hold the last iteration. Set the failed
|
||||
# run aside under a name the runner does not touch.
|
||||
- ${{ each i in parameters.iterations }}:
|
||||
- script: |
|
||||
set -e
|
||||
npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)"
|
||||
status=0
|
||||
npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" || status=$?
|
||||
if [ $status -ne 0 ] && [ -d .build/logs/smoke-tests-electron ]; then
|
||||
mv .build/logs/smoke-tests-electron ".build/logs/failed-iteration-${{ i }}"
|
||||
fi
|
||||
exit $status
|
||||
env:
|
||||
TMPDIR: $(Agent.TempDirectory)
|
||||
LD_PRELOAD: $(VSCODE_SMOKE_LD_PRELOAD)
|
||||
|
||||
@@ -97,6 +97,7 @@ extends:
|
||||
|
||||
testSteps:
|
||||
- checkout: self
|
||||
path: s
|
||||
lfs: true
|
||||
retryCountOnTaskFailure: 3
|
||||
- template: copilot/setup-steps.yml
|
||||
|
||||
@@ -60,8 +60,19 @@ jobs:
|
||||
# Run the Electron smoke test once per iteration. continueOnError lets every
|
||||
# iteration run even if some fail, and gives each run its own timeline record
|
||||
# so the SmokeFlaky function can tally passes vs. failures.
|
||||
#
|
||||
# The smoke runner wipes .build\logs\smoke-tests-electron on startup, so a
|
||||
# failing iteration's diagnostics would be destroyed by the next one and the
|
||||
# published artifact would only ever hold the last iteration. Set the failed
|
||||
# run aside under a name the runner does not touch.
|
||||
- ${{ each i in parameters.iterations }}:
|
||||
- powershell: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)"
|
||||
- powershell: |
|
||||
npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)"
|
||||
$status = $LASTEXITCODE
|
||||
if ($status -ne 0 -and (Test-Path .build\logs\smoke-tests-electron)) {
|
||||
Move-Item .build\logs\smoke-tests-electron ".build\logs\failed-iteration-${{ i }}"
|
||||
}
|
||||
exit $status
|
||||
displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)"
|
||||
continueOnError: true
|
||||
timeoutInMinutes: 20
|
||||
|
||||
+134
-25
@@ -4,8 +4,19 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { ensureNpmPackage, type EnsureNpmPackageOptions } from './npmPackage.ts';
|
||||
import { ensureNpmPackage, materializeNpmPackageVersion, type EnsureNpmPackageOptions } from './npmPackage.ts';
|
||||
|
||||
/**
|
||||
* Options for {@link prepareBuiltInCopilotRipgrepShim}. Extends the npm packing
|
||||
* options with an override for the extension lockfile used to verify natives
|
||||
* fetched for the pinned version (defaults to the repo's copy; overridable in
|
||||
* tests).
|
||||
*/
|
||||
export interface PrepareBuiltInCopilotOptions extends EnsureNpmPackageOptions {
|
||||
extensionLockfilePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The platforms that @github/copilot ships platform-specific packages for.
|
||||
@@ -105,6 +116,10 @@ function getCopilotOptionalNativePayloadFiles(platform: string): string[] {
|
||||
'prebuilds/*/Copilot Computer Use.app/**',
|
||||
'prebuilds/*/CopilotComputerUse.exe',
|
||||
'prebuilds/*/keytar.node',
|
||||
// macOS voice media-pause helper (MediaRemote adapter). Optional and
|
||||
// nested under prebuilds; keep it out of the product so universal
|
||||
// merge does not need to special-case the framework binary tree.
|
||||
'prebuilds/*/mediaremote-adapter/**',
|
||||
];
|
||||
|
||||
if (platform !== 'win32') {
|
||||
@@ -232,7 +247,7 @@ export function ensureCopilotPlatformPackage(platform: string, arch: string, nod
|
||||
* Failures throw to fail the build because built-in packaging must guarantee
|
||||
* this artifact is present.
|
||||
*/
|
||||
export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, builtInCopilotExtensionDir: string, appNodeModulesDir: string): void {
|
||||
export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, builtInCopilotExtensionDir: string, appNodeModulesDir: string, options: PrepareBuiltInCopilotOptions = {}): void {
|
||||
const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch);
|
||||
const platformArch = `${nodePlatform}-${nodeArch}`;
|
||||
const copilotPackagePlatformArch = toCopilotPackagePlatformArch(platform, arch);
|
||||
@@ -244,7 +259,7 @@ export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string,
|
||||
if (!fs.existsSync(copilotSdkBase)) {
|
||||
throw new Error(`[prepareBuiltInCopilotRipgrepShim] Copilot SDK directory not found at ${copilotSdkBase}`);
|
||||
}
|
||||
materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch, tgrepPlatformArch, copilotBase, appNodeModulesDir);
|
||||
materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch, tgrepPlatformArch, copilotBase, appNodeModulesDir, options);
|
||||
pruneNonTargetCopilotSdkPrebuilds(copilotPackagePlatformArch, path.join(copilotSdkBase, 'prebuilds'), copilotPlatforms);
|
||||
pruneNonTargetCopilotSdkPrebuilds(tgrepPlatformArch, path.join(copilotSdkBase, path.join('tgrep', 'bin')), copilotTgrepPlatforms);
|
||||
pruneNonTargetCopilotSdkPrebuilds(tgrepPlatformArch, path.join(copilotBase, path.join('tgrep', 'bin')), copilotTgrepPlatforms);
|
||||
@@ -277,37 +292,131 @@ export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string,
|
||||
}
|
||||
}
|
||||
|
||||
function materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch: string, tgrepPlatformArch: string, copilotBase: string, appNodeModulesDir: string): void {
|
||||
function materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch: string, tgrepPlatformArch: string, copilotBase: string, appNodeModulesDir: string, options: PrepareBuiltInCopilotOptions = {}): void {
|
||||
if (!copilotPlatforms.includes(copilotPackagePlatformArch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const platformPackageDir = path.join(appNodeModulesDir, '@github', `copilot-${copilotPackagePlatformArch}`);
|
||||
if (!fs.existsSync(platformPackageDir)) {
|
||||
throw new Error(`[prepareBuiltInCopilotRipgrepShim] Copilot platform package not found at ${platformPackageDir}`);
|
||||
// The SDK JavaScript shipped inside the built-in extension and the native
|
||||
// `runtime.node` it loads MUST be the same @github/copilot version: the JS
|
||||
// calls native functions the binary may not export (e.g. a newer CLI that
|
||||
// removed one), which throws at load. Source the native from a platform
|
||||
// package matching the EXTENSION's version rather than whatever app-root
|
||||
// currently has — the extension is intentionally pinned to a fixed CLI
|
||||
// version for the extension host while the agent host (app-root) keeps
|
||||
// updating, so the two versions diverge by design.
|
||||
const extVersion = readCopilotPackageVersion(copilotBase);
|
||||
const { dir: platformPackageDir, cleanup } = resolveVersionMatchedCopilotPlatformPackage(copilotPackagePlatformArch, extVersion, appNodeModulesDir, options);
|
||||
try {
|
||||
const sdkPrebuildsTarget = path.join(copilotBase, 'sdk', 'prebuilds', copilotPackagePlatformArch);
|
||||
copyRequiredDirectory(
|
||||
path.join(platformPackageDir, 'prebuilds', copilotPackagePlatformArch),
|
||||
sdkPrebuildsTarget,
|
||||
`Copilot SDK native prebuilds for ${copilotPackagePlatformArch}`
|
||||
);
|
||||
// Built-in materialization copies the whole prebuilds tree (not the gulp
|
||||
// exclude globs above), so drop mediaremote-adapter explicitly afterward.
|
||||
fs.rmSync(path.join(sdkPrebuildsTarget, 'mediaremote-adapter'), { recursive: true, force: true });
|
||||
|
||||
if (!copilotTgrepPlatforms.includes(tgrepPlatformArch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tgrepSource = path.join(platformPackageDir, 'tgrep', 'bin', tgrepPlatformArch);
|
||||
copyRequiredDirectory(
|
||||
tgrepSource,
|
||||
path.join(copilotBase, 'tgrep', 'bin', tgrepPlatformArch),
|
||||
`Copilot tgrep for ${tgrepPlatformArch}`
|
||||
);
|
||||
copyRequiredDirectory(
|
||||
tgrepSource,
|
||||
path.join(copilotBase, 'sdk', 'tgrep', 'bin', tgrepPlatformArch),
|
||||
`Copilot SDK tgrep for ${tgrepPlatformArch}`
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a `@github/copilot-{platform}` package directory whose version
|
||||
* matches `extVersion`, so the native copied into the built-in extension always
|
||||
* matches the extension's own SDK JavaScript.
|
||||
*
|
||||
* Prefers the app-root package when it already matches (no extra work), and
|
||||
* otherwise fetches the exact extension version into a temp dir. The extension
|
||||
* is pinned to a fixed CLI version for the extension host while the agent host
|
||||
* (app-root) keeps updating, so app-root will normally NOT match and the fetch
|
||||
* is the expected path once the two versions diverge. The fetched tarball is
|
||||
* verified against the SHA-512 the extension lockfile pins for that version
|
||||
* before extraction; resolution fails closed if that integrity is missing.
|
||||
*/
|
||||
function resolveVersionMatchedCopilotPlatformPackage(copilotPackagePlatformArch: string, extVersion: string, appNodeModulesDir: string, options: PrepareBuiltInCopilotOptions): { dir: string; cleanup: () => void } {
|
||||
const noop = () => { };
|
||||
const packageName = `@github/copilot-${copilotPackagePlatformArch}`;
|
||||
|
||||
const appRootDir = path.join(appNodeModulesDir, '@github', `copilot-${copilotPackagePlatformArch}`);
|
||||
if (readOptionalPackageVersion(appRootDir) === extVersion) {
|
||||
return { dir: appRootDir, cleanup: noop };
|
||||
}
|
||||
|
||||
copyRequiredDirectory(
|
||||
path.join(platformPackageDir, 'prebuilds', copilotPackagePlatformArch),
|
||||
path.join(copilotBase, 'sdk', 'prebuilds', copilotPackagePlatformArch),
|
||||
`Copilot SDK native prebuilds for ${copilotPackagePlatformArch}`
|
||||
);
|
||||
const integrity = resolvePinnedPlatformPackageIntegrity(packageName, extVersion, options);
|
||||
const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-native-'));
|
||||
try {
|
||||
const stagedPackageDir = path.join(staged, `copilot-${copilotPackagePlatformArch}`);
|
||||
materializeNpmPackageVersion(packageName, extVersion, stagedPackageDir, integrity, options);
|
||||
console.log(`[prepareBuiltInCopilotRipgrepShim] ${packageName} in app-root does not match the built-in extension's @github/copilot@${extVersion}; using the version-matched package instead.`);
|
||||
return { dir: stagedPackageDir, cleanup: () => fs.rmSync(staged, { recursive: true, force: true }) };
|
||||
} catch (err) {
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!copilotTgrepPlatforms.includes(tgrepPlatformArch)) {
|
||||
return;
|
||||
/**
|
||||
* Reads the `sha512-...` integrity the built-in extension's lockfile pins for
|
||||
* `packageName` at `extVersion`. Fails closed: a missing lockfile, entry,
|
||||
* version mismatch, or integrity means the fetched native cannot be verified,
|
||||
* so the build must stop rather than ship an unverified binary.
|
||||
*/
|
||||
function resolvePinnedPlatformPackageIntegrity(packageName: string, extVersion: string, options: PrepareBuiltInCopilotOptions): string {
|
||||
const lockfilePath = options.extensionLockfilePath ?? path.join(import.meta.dirname, '..', '..', 'extensions', 'copilot', 'package-lock.json');
|
||||
|
||||
let lock: { packages?: Record<string, { version?: string; integrity?: string }> };
|
||||
try {
|
||||
lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`[prepareBuiltInCopilotRipgrepShim] Could not read ${lockfilePath} to verify ${packageName}@${extVersion}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const tgrepSource = path.join(platformPackageDir, 'tgrep', 'bin', tgrepPlatformArch);
|
||||
copyRequiredDirectory(
|
||||
tgrepSource,
|
||||
path.join(copilotBase, 'tgrep', 'bin', tgrepPlatformArch),
|
||||
`Copilot tgrep for ${tgrepPlatformArch}`
|
||||
);
|
||||
copyRequiredDirectory(
|
||||
tgrepSource,
|
||||
path.join(copilotBase, 'sdk', 'tgrep', 'bin', tgrepPlatformArch),
|
||||
`Copilot SDK tgrep for ${tgrepPlatformArch}`
|
||||
);
|
||||
const entry = lock.packages?.[path.posix.join('node_modules', packageName)];
|
||||
if (!entry) {
|
||||
throw new Error(`[prepareBuiltInCopilotRipgrepShim] ${packageName} is not recorded in ${lockfilePath}; refusing to fetch an unverifiable native.`);
|
||||
}
|
||||
if (entry.version !== extVersion) {
|
||||
throw new Error(`[prepareBuiltInCopilotRipgrepShim] ${packageName} is pinned to ${entry.version} in ${lockfilePath} but the built-in extension is @github/copilot@${extVersion}; refusing to fetch an unverifiable native.`);
|
||||
}
|
||||
if (!entry.integrity) {
|
||||
throw new Error(`[prepareBuiltInCopilotRipgrepShim] ${packageName}@${extVersion} has no integrity in ${lockfilePath}; refusing to fetch an unverifiable native.`);
|
||||
}
|
||||
return entry.integrity;
|
||||
}
|
||||
|
||||
function readCopilotPackageVersion(copilotBase: string): string {
|
||||
const version = readOptionalPackageVersion(copilotBase);
|
||||
if (!version) {
|
||||
throw new Error(`[prepareBuiltInCopilotRipgrepShim] Could not read a version from ${path.join(copilotBase, 'package.json')}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function readOptionalPackageVersion(packageDir: string): string | undefined {
|
||||
try {
|
||||
const version = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')).version;
|
||||
return typeof version === 'string' ? version : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function copyRequiredDirectory(source: string, target: string, description: string): void {
|
||||
|
||||
@@ -692,6 +692,10 @@
|
||||
"name": "vs/sessions/contrib/codeReview",
|
||||
"project": "vscode-sessions"
|
||||
},
|
||||
{
|
||||
"name": "vs/sessions/contrib/customViewTest",
|
||||
"project": "vscode-sessions"
|
||||
},
|
||||
{
|
||||
"name": "vs/sessions/contrib/fileTreeView",
|
||||
"project": "vscode-sessions"
|
||||
|
||||
@@ -54,6 +54,33 @@ export function ensureNpmPackage(packageName: string, nodeModulesRoot = 'node_mo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes a SPECIFIC version of an npm package into `targetDir`, replacing
|
||||
* any existing contents. Unlike {@link ensureNpmPackage}, the version is passed
|
||||
* explicitly rather than read from the adjacent lockfile — use for build-time
|
||||
* payloads whose required version does not match that lockfile. Pass
|
||||
* `expectedIntegrity` (the `sha512-...` recorded for that version in the
|
||||
* relevant lockfile) to verify the fetched tarball before extraction.
|
||||
*/
|
||||
export function materializeNpmPackageVersion(packageName: string, version: string, targetDir: string, expectedIntegrity: string | undefined, options: EnsureNpmPackageOptions = {}): void {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-npm-package-'));
|
||||
try {
|
||||
const tarballPath = (options.packPackage ?? packNpmPackage)(packageName, version, tempDir);
|
||||
verifyNpmIntegrity(tarballPath, expectedIntegrity);
|
||||
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
extract({ file: tarballPath, cwd: targetDir, strip: 1, sync: true });
|
||||
console.log(`[materializeNpmPackageVersion] Materialized ${packageName}@${version} in ${targetDir}`);
|
||||
} catch (err) {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
throw new Error(`[materializeNpmPackageVersion] Failed to materialize ${packageName}@${version}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function packNpmPackage(packageName: string, version: string, tempDir: string): string {
|
||||
execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['pack', `${packageName}@${version}`, '--pack-destination', tempDir, '--silent'], { stdio: 'pipe', shell: process.platform === 'win32' });
|
||||
|
||||
|
||||
@@ -1022,6 +1022,10 @@
|
||||
"--part-border-color",
|
||||
"--pane-header-size",
|
||||
"--model-hover-surface-background",
|
||||
"--modern-ui-editor-tab-action-active-background",
|
||||
"--modern-ui-editor-tab-action-hover-background",
|
||||
"--modern-ui-tab-active-background",
|
||||
"--modern-ui-tab-hover-background",
|
||||
"--scroll-shadow-surface",
|
||||
"--vscode-chat-list-background",
|
||||
"--vscode-editorCodeLens-fontFamily",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -11,6 +12,25 @@ import { suite, test } from 'node:test';
|
||||
import { create } from 'tar';
|
||||
import { copilotPlatforms, ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getMxcExcludeFilter, prepareBuiltInCopilotRipgrepShim } from '../copilot.ts';
|
||||
|
||||
/**
|
||||
* Builds a fake `@github/copilot-win32-x64@1.0.73` tarball on disk and returns
|
||||
* its path plus the `sha512-...` integrity of its bytes, so a test can pin that
|
||||
* integrity in a lockfile the build verifies against.
|
||||
*/
|
||||
function createPinnedCopilotWin32Tarball(dir: string): { tarball: string; integrity: string } {
|
||||
const stage = fs.mkdtempSync(path.join(dir, 'pkg-'));
|
||||
const packageRoot = path.join(stage, 'package');
|
||||
fs.mkdirSync(path.join(packageRoot, 'prebuilds', 'win32-x64'), { recursive: true });
|
||||
fs.mkdirSync(path.join(packageRoot, 'tgrep', 'bin', 'win32-x64'), { recursive: true });
|
||||
fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: '1.0.73' }));
|
||||
fs.writeFileSync(path.join(packageRoot, 'prebuilds', 'win32-x64', 'runtime.node'), 'EXT-NATIVE-1.0.73');
|
||||
fs.writeFileSync(path.join(packageRoot, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), 'EXT-TGREP-1.0.73');
|
||||
const tarball = path.join(stage, 'copilot-win32-x64.tgz');
|
||||
create({ file: tarball, cwd: stage, gzip: true, sync: true }, ['package']);
|
||||
const integrity = 'sha512-' + createHash('sha512').update(fs.readFileSync(tarball)).digest('base64');
|
||||
return { tarball, integrity };
|
||||
}
|
||||
|
||||
suite('copilot', () => {
|
||||
test('keeps the public copilot platform package include list scoped to the selected package', () => {
|
||||
const files = getCopilotRuntimePrebuildFiles('linux', 'x64');
|
||||
@@ -30,6 +50,7 @@ suite('copilot', () => {
|
||||
'!node_modules/@github/copilot-linux-x64/prebuilds/*/Copilot Computer Use.app/**',
|
||||
'!node_modules/@github/copilot-linux-x64/prebuilds/*/CopilotComputerUse.exe',
|
||||
'!node_modules/@github/copilot-linux-x64/prebuilds/*/keytar.node',
|
||||
'!node_modules/@github/copilot-linux-x64/prebuilds/*/mediaremote-adapter/**',
|
||||
'!node_modules/@github/copilot-linux-x64/prebuilds/*/cli-native.node',
|
||||
]);
|
||||
assertCopilotPlatformPackageIncludes(files, 'node_modules/@github/copilot-linux-x64', [
|
||||
@@ -60,6 +81,7 @@ suite('copilot', () => {
|
||||
'!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/Copilot Computer Use.app/**',
|
||||
'!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/CopilotComputerUse.exe',
|
||||
'!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/keytar.node',
|
||||
'!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/mediaremote-adapter/**',
|
||||
'!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/cli-native.node',
|
||||
]);
|
||||
assertCopilotPlatformPackageIncludes(files, 'node_modules/@github/copilot-linuxmusl-x64', [
|
||||
@@ -87,6 +109,7 @@ suite('copilot', () => {
|
||||
'!node_modules/@github/copilot-win32-x64/prebuilds/*/Copilot Computer Use.app/**',
|
||||
'!node_modules/@github/copilot-win32-x64/prebuilds/*/CopilotComputerUse.exe',
|
||||
'!node_modules/@github/copilot-win32-x64/prebuilds/*/keytar.node',
|
||||
'!node_modules/@github/copilot-win32-x64/prebuilds/*/mediaremote-adapter/**',
|
||||
]);
|
||||
assertCopilotPlatformPackageIncludes(getCopilotRuntimePrebuildFiles('win32', 'x64'), 'node_modules/@github/copilot-win32-x64', [
|
||||
'index.js',
|
||||
@@ -115,6 +138,7 @@ suite('copilot', () => {
|
||||
'!node_modules/@github/copilot-win32-arm64/prebuilds/*/Copilot Computer Use.app/**',
|
||||
'!node_modules/@github/copilot-win32-arm64/prebuilds/*/CopilotComputerUse.exe',
|
||||
'!node_modules/@github/copilot-win32-arm64/prebuilds/*/keytar.node',
|
||||
'!node_modules/@github/copilot-win32-arm64/prebuilds/*/mediaremote-adapter/**',
|
||||
]);
|
||||
assertOptionalCopilotNativeDependenciesExcluded(getCopilotRuntimePrebuildFiles('win32', 'x64'), 'node_modules/@github/copilot-win32-x64');
|
||||
assertCopilotStandaloneExecutableExcluded(getCopilotRuntimePrebuildFiles('win32', 'arm64'), 'node_modules/@github/copilot-win32-arm64');
|
||||
@@ -186,10 +210,15 @@ suite('copilot', () => {
|
||||
|
||||
fs.mkdirSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64'), { recursive: true });
|
||||
fs.writeFileSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64', 'runtime.node'), '');
|
||||
fs.writeFileSync(path.join(extensionCopilotDir, 'package.json'), JSON.stringify({ version: '1.0.73' }));
|
||||
fs.mkdirSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty'), { recursive: true });
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'package.json'), JSON.stringify({ version: '1.0.73' }));
|
||||
fs.mkdirSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'mediaremote-adapter', 'MediaRemoteAdapter.framework'), { recursive: true });
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'runtime.node'), '');
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty.node'), '');
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'), '');
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'mediaremote-adapter', 'mediaremote-adapter.pl'), '');
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'mediaremote-adapter', 'MediaRemoteAdapter.framework', 'MediaRemoteAdapter'), '');
|
||||
fs.mkdirSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64'), { recursive: true });
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), '');
|
||||
fs.mkdirSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64'), { recursive: true });
|
||||
@@ -200,6 +229,7 @@ suite('copilot', () => {
|
||||
assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'runtime.node')));
|
||||
assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'conpty.node')));
|
||||
assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe')));
|
||||
assert(!fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'mediaremote-adapter')));
|
||||
assert(!fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64')));
|
||||
assert(fs.existsSync(path.join(extensionCopilotDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe')));
|
||||
assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'tgrep', 'bin', 'win32-x64', 'tgrep.exe')));
|
||||
@@ -209,6 +239,87 @@ suite('copilot', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('materializes a version-matched native when app-root diverges from the pinned extension', () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-sdk-pinned-test-'));
|
||||
try {
|
||||
const builtInCopilotExtensionDir = path.join(repoRoot, 'extensions', 'copilot');
|
||||
const extensionCopilotDir = path.join(builtInCopilotExtensionDir, 'node_modules', '@github', 'copilot');
|
||||
const appNodeModulesDir = path.join(repoRoot, 'node_modules');
|
||||
const platformPackageDir = path.join(appNodeModulesDir, '@github', 'copilot-win32-x64');
|
||||
|
||||
// Extension pinned at 1.0.73.
|
||||
fs.mkdirSync(path.join(extensionCopilotDir, 'sdk'), { recursive: true });
|
||||
fs.writeFileSync(path.join(extensionCopilotDir, 'package.json'), JSON.stringify({ version: '1.0.73' }));
|
||||
|
||||
// App-root updated ahead of the pinned extension — its (mismatched) native must NOT be used.
|
||||
fs.mkdirSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64'), { recursive: true });
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'package.json'), JSON.stringify({ version: '9.9.9-canary' }));
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'runtime.node'), 'CANARY-NATIVE');
|
||||
fs.mkdirSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64'), { recursive: true });
|
||||
fs.writeFileSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), 'CANARY-TGREP');
|
||||
|
||||
fs.mkdirSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64'), { recursive: true });
|
||||
fs.writeFileSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64', 'rg.exe'), '');
|
||||
|
||||
// Pin the fetched tarball's integrity in the extension lockfile the build verifies against.
|
||||
const { tarball, integrity } = createPinnedCopilotWin32Tarball(repoRoot);
|
||||
const extensionLockfilePath = path.join(builtInCopilotExtensionDir, 'package-lock.json');
|
||||
fs.writeFileSync(extensionLockfilePath, JSON.stringify({
|
||||
packages: { 'node_modules/@github/copilot-win32-x64': { version: '1.0.73', integrity } }
|
||||
}));
|
||||
|
||||
const packCalls: { packageName: string; version: string }[] = [];
|
||||
prepareBuiltInCopilotRipgrepShim('win32', 'x64', builtInCopilotExtensionDir, appNodeModulesDir, {
|
||||
extensionLockfilePath,
|
||||
packPackage: (packageName, version) => {
|
||||
packCalls.push({ packageName, version });
|
||||
return tarball;
|
||||
}
|
||||
});
|
||||
|
||||
// The version-matched (1.0.73) native was fetched and used — not app-root's canary.
|
||||
assert.deepStrictEqual(packCalls, [{ packageName: '@github/copilot-win32-x64', version: '1.0.73' }]);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'runtime.node'), 'utf8'),
|
||||
'EXT-NATIVE-1.0.73'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(extensionCopilotDir, 'sdk', 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), 'utf8'),
|
||||
'EXT-TGREP-1.0.73'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses to ship a fetched native that does not match the pinned extension lockfile integrity', () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-sdk-integrity-test-'));
|
||||
try {
|
||||
const builtInCopilotExtensionDir = path.join(repoRoot, 'extensions', 'copilot');
|
||||
const extensionCopilotDir = path.join(builtInCopilotExtensionDir, 'node_modules', '@github', 'copilot');
|
||||
const appNodeModulesDir = path.join(repoRoot, 'node_modules');
|
||||
|
||||
fs.mkdirSync(path.join(extensionCopilotDir, 'sdk'), { recursive: true });
|
||||
fs.writeFileSync(path.join(extensionCopilotDir, 'package.json'), JSON.stringify({ version: '1.0.73' }));
|
||||
fs.mkdirSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64'), { recursive: true });
|
||||
fs.writeFileSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64', 'rg.exe'), '');
|
||||
|
||||
const { tarball } = createPinnedCopilotWin32Tarball(repoRoot);
|
||||
const extensionLockfilePath = path.join(builtInCopilotExtensionDir, 'package-lock.json');
|
||||
// Lockfile pins a DIFFERENT (tampered) integrity than the fetched tarball.
|
||||
fs.writeFileSync(extensionLockfilePath, JSON.stringify({
|
||||
packages: { 'node_modules/@github/copilot-win32-x64': { version: '1.0.73', integrity: `sha512-${'A'.repeat(88)}` } }
|
||||
}));
|
||||
|
||||
assert.throws(() => prepareBuiltInCopilotRipgrepShim('win32', 'x64', builtInCopilotExtensionDir, appNodeModulesDir, {
|
||||
extensionLockfilePath,
|
||||
packPackage: () => tarball
|
||||
}), /integrity mismatch/);
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('strips all copilot platform packages for unsupported armhf builds', () => {
|
||||
assert.deepStrictEqual(
|
||||
getCopilotExcludeFilter('linux', 'armhf'),
|
||||
@@ -281,6 +392,8 @@ function assertOptionalCopilotNativeDependenciesExcluded(patterns: string[], pac
|
||||
assert(!matchesGlob(`${packageDir}/prebuilds/win32-x64/CopilotComputerUse.exe`, patterns), 'CopilotComputerUse.exe');
|
||||
assert(patterns.includes(`!${packageDir}/prebuilds/*/keytar.node`), 'keytar.node');
|
||||
assert(!matchesGlob(`${packageDir}/prebuilds/linux-x64/keytar.node`, patterns), 'keytar.node');
|
||||
assert(patterns.includes(`!${packageDir}/prebuilds/*/mediaremote-adapter/**`), 'mediaremote-adapter');
|
||||
assert(!matchesGlob(`${packageDir}/prebuilds/darwin-arm64/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter`, patterns), 'mediaremote-adapter');
|
||||
|
||||
if (!packageDir.includes('win32')) {
|
||||
assert(patterns.includes(`!${packageDir}/prebuilds/*/cli-native.node`), 'cli-native.node');
|
||||
|
||||
@@ -98,6 +98,9 @@ export default {
|
||||
{
|
||||
test: /\.ttf$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
publicPath: isStaticComponentExplorerBuild ? '../' : '/',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Built-in theme JSON files use JSONC (comments / trailing
|
||||
|
||||
@@ -5618,7 +5618,7 @@
|
||||
},
|
||||
{
|
||||
"command": "github.copilot.chat.checkoutPullRequestReroute",
|
||||
"when": "chatSessionType == copilot-cloud-agent && !github.vscode-pull-request-github.activated && gitOpenRepositoryCount != 0",
|
||||
"when": "chatSessionType == copilot-cloud-agent && chatSessionPullRequest != 'none' && !github.vscode-pull-request-github.activated && gitOpenRepositoryCount != 0",
|
||||
"group": "navigation@0"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ export type ToolJsonSchema = {
|
||||
properties?: Record<string, ToolJsonSchema>;
|
||||
items?: ToolJsonSchema;
|
||||
required?: string[];
|
||||
enum?: string[];
|
||||
enum?: unknown[];
|
||||
|
||||
// Add support for JSON Schema composition keywords
|
||||
anyOf?: ToolJsonSchema[];
|
||||
@@ -104,8 +104,11 @@ function transformConcrete(schema: ToolJsonSchema): Schema {
|
||||
transformed.description = schema.description;
|
||||
}
|
||||
|
||||
if (schema.enum) {
|
||||
transformed.enum = schema.enum;
|
||||
if (type === 'string' && schema.enum) {
|
||||
const values = schema.enum.filter((value): value is string => typeof value === 'string');
|
||||
if (values.length > 0) {
|
||||
transformed.enum = values;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'object' && schema.properties) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import type { Content, FunctionCall, FunctionResponse, Part } from '@google/genai';
|
||||
import { Raw } from '@vscode/prompt-tsx';
|
||||
import type { LanguageModelChatMessage } from 'vscode';
|
||||
import type { LanguageModelChatMessage, LanguageModelChatMessage2 } from 'vscode';
|
||||
import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes';
|
||||
import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart, LanguageModelToolResultPart2 } from '../../../vscodeTypes';
|
||||
|
||||
@@ -120,7 +120,7 @@ function apiContentToGeminiContent(content: (LanguageModelTextPart | LanguageMod
|
||||
return convertedContent;
|
||||
}
|
||||
|
||||
export function apiMessageToGeminiMessage(messages: LanguageModelChatMessage[]): { contents: Content[]; systemInstruction?: Content } {
|
||||
export function apiMessageToGeminiMessage(messages: Array<LanguageModelChatMessage | LanguageModelChatMessage2>): { contents: Content[]; systemInstruction?: Content } {
|
||||
const contents: Content[] = [];
|
||||
let systemInstruction: Content | undefined;
|
||||
|
||||
@@ -131,8 +131,7 @@ export function apiMessageToGeminiMessage(messages: LanguageModelChatMessage[]):
|
||||
if (message.role === LanguageModelChatMessageRole.System) {
|
||||
// Gemini uses system instruction separately
|
||||
const systemText = message.content
|
||||
.filter((p): p is LanguageModelTextPart => p instanceof LanguageModelTextPart)
|
||||
.map(p => p.value)
|
||||
.map(part => part instanceof LanguageModelTextPart ? part.value : '')
|
||||
.join('');
|
||||
|
||||
if (systemText.trim()) {
|
||||
|
||||
+35
@@ -179,6 +179,41 @@ describe('GeminiFunctionDeclarationConverter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit non-string enums from nested schemas', () => {
|
||||
const result = toGeminiFunction('nestedEnumFunction', 'Function with nested non-string enums', {
|
||||
type: 'object',
|
||||
properties: {
|
||||
values: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
enum: [true]
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
enum: [1, 2]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.parameters!.properties!['values']).toEqual({
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
enabled: { type: Type.BOOLEAN },
|
||||
count: { type: Type.INTEGER }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nullable anyOf schemas', () => {
|
||||
const result = toGeminiFunction('nullableAnyOfFunction', 'Function with nullable anyOf', {
|
||||
type: 'object',
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
import { Raw } from '@vscode/prompt-tsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LanguageModelChatMessage } from 'vscode';
|
||||
import type { LanguageModelChatMessage, LanguageModelChatMessage2 } from 'vscode';
|
||||
import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes';
|
||||
import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelToolResultPart, LanguageModelTextPart as LMText } from '../../../../vscodeTypes';
|
||||
import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart, LanguageModelTextPart as LMText } from '../../../../vscodeTypes';
|
||||
import { apiMessageToGeminiMessage } from '../geminiMessageConverter';
|
||||
|
||||
describe('GeminiMessageConverter', () => {
|
||||
@@ -80,6 +80,27 @@ describe('GeminiMessageConverter', () => {
|
||||
expect(result.contents[0].parts![1].text).toBe('Hello!');
|
||||
});
|
||||
|
||||
it('should attach a thought signature to the following function call', () => {
|
||||
const messages: Array<LanguageModelChatMessage | LanguageModelChatMessage2> = [{
|
||||
role: LanguageModelChatMessageRole.Assistant,
|
||||
content: [
|
||||
new LanguageModelThinkingPart('', undefined, { signature: 'thought-signature' }),
|
||||
new LanguageModelToolCallPart('call-1', 'default_api:view', { path: 'README.md' }),
|
||||
],
|
||||
name: undefined,
|
||||
}];
|
||||
|
||||
const result = apiMessageToGeminiMessage(messages);
|
||||
|
||||
expect(result.contents[0].parts).toEqual([{
|
||||
functionCall: {
|
||||
name: 'default_api:view',
|
||||
args: { path: 'README.md' },
|
||||
},
|
||||
thoughtSignature: 'thought-signature',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('should extract functionResponse parts from model message into subsequent user message and prune empty model', () => {
|
||||
// Simulate a model message that (incorrectly) contains only a tool result part
|
||||
const toolResult = new LanguageModelToolResultPart('myTool_12345', [new LanguageModelTextPart('{"foo":"bar"}')]);
|
||||
|
||||
@@ -7,22 +7,17 @@ import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const COPILOT_HOME_DIRECTORY = '.copilot';
|
||||
const APP_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'ide');
|
||||
const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state');
|
||||
|
||||
export function getCopilotHome(): string {
|
||||
const xdgHome = process.env.XDG_STATE_HOME;
|
||||
return xdgHome ? join(xdgHome, COPILOT_HOME_DIRECTORY) : join(homedir(), COPILOT_HOME_DIRECTORY);
|
||||
return process.env.COPILOT_HOME || join(homedir(), COPILOT_HOME_DIRECTORY);
|
||||
}
|
||||
|
||||
export function getCopilotCliStateDir(): string {
|
||||
const xdgHome = process.env.XDG_STATE_HOME;
|
||||
return xdgHome ? join(xdgHome, APP_DIRECTORY) : join(homedir(), APP_DIRECTORY);
|
||||
return join(getCopilotHome(), 'ide');
|
||||
}
|
||||
|
||||
export function getCopilotCLISessionStateDir(): string {
|
||||
const xdgHome = process.env.XDG_STATE_HOME;
|
||||
return xdgHome ? join(xdgHome, SESSION_STATE_DIRECTORY) : join(homedir(), SESSION_STATE_DIRECTORY);
|
||||
return join(getCopilotHome(), 'session-state');
|
||||
}
|
||||
|
||||
export function getCopilotCLISessionDir(sessionId: string): string {
|
||||
|
||||
@@ -107,6 +107,7 @@ class CopilotCLIResponseStreamRouter {
|
||||
push: (part: vscode.ExtendedChatResponsePart): void => { this._call('push', [part]); },
|
||||
thinkingProgress: (thinkingDelta: vscode.ThinkingDelta): void => { this._call('thinkingProgress', [thinkingDelta]); },
|
||||
hookProgress: (hookType: vscode.ChatHookType, stopReason?: string, systemMessage?: string): void => { this._call('hookProgress', [hookType, stopReason, systemMessage]); },
|
||||
voiceProgress: (id: string, value: string): void => { this._call('voiceProgress', [id, value]); },
|
||||
textEdit: (target: vscode.Uri, editsOrDone: vscode.TextEdit | vscode.TextEdit[] | true): void => { this._call('textEdit', [target, editsOrDone]); },
|
||||
notebookEdit: (target: vscode.Uri, editsOrDone: vscode.NotebookEdit | vscode.NotebookEdit[] | true): void => { this._call('notebookEdit', [target, editsOrDone]); },
|
||||
workspaceEdit: (edits: vscode.ChatWorkspaceFileEdit[]): void => { this._call('workspaceEdit', [edits]); },
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getCopilotCliStateDir,
|
||||
getCopilotCLISessionStateDir,
|
||||
getCopilotHome,
|
||||
} from '../cliHelpers';
|
||||
|
||||
const originalCopilotHome = process.env.COPILOT_HOME;
|
||||
const originalXdgStateHome = process.env.XDG_STATE_HOME;
|
||||
|
||||
function setEnv(
|
||||
name: 'COPILOT_HOME' | 'XDG_STATE_HOME',
|
||||
value: string | undefined,
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setEnv('COPILOT_HOME', originalCopilotHome);
|
||||
setEnv('XDG_STATE_HOME', originalXdgStateHome);
|
||||
});
|
||||
|
||||
describe('Copilot CLI state directories', () => {
|
||||
it('uses COPILOT_HOME', () => {
|
||||
setEnv('COPILOT_HOME', '/tmp/copilot-home');
|
||||
setEnv('XDG_STATE_HOME', '/tmp/xdg-state');
|
||||
|
||||
expect(getCopilotHome()).toBe('/tmp/copilot-home');
|
||||
expect(getCopilotCliStateDir()).toBe(join('/tmp/copilot-home', 'ide'));
|
||||
expect(getCopilotCLISessionStateDir()).toBe(
|
||||
join('/tmp/copilot-home', 'session-state'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use the legacy XDG_STATE_HOME location', () => {
|
||||
setEnv('COPILOT_HOME', undefined);
|
||||
setEnv('XDG_STATE_HOME', '/tmp/xdg-state');
|
||||
|
||||
expect(getCopilotHome()).toBe(join(homedir(), '.copilot'));
|
||||
expect(getCopilotCliStateDir()).toBe(
|
||||
join(homedir(), '.copilot', 'ide'),
|
||||
);
|
||||
expect(getCopilotCLISessionStateDir()).toBe(
|
||||
join(homedir(), '.copilot', 'session-state'),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the user home directory', () => {
|
||||
setEnv('COPILOT_HOME', undefined);
|
||||
setEnv('XDG_STATE_HOME', undefined);
|
||||
|
||||
expect(getCopilotHome()).toBe(join(homedir(), '.copilot'));
|
||||
expect(getCopilotCliStateDir()).toBe(
|
||||
join(homedir(), '.copilot', 'ide'),
|
||||
);
|
||||
expect(getCopilotCLISessionStateDir()).toBe(
|
||||
join(homedir(), '.copilot', 'session-state'),
|
||||
);
|
||||
});
|
||||
});
|
||||
+11
-7
@@ -178,7 +178,7 @@ describe('CopilotCLISessionService', () => {
|
||||
let configurationService: IConfigurationService;
|
||||
let createSessionService: (options?: ICreateSessionServiceOptions) => CopilotCLISessionService;
|
||||
let tempStateHome: string | undefined;
|
||||
const originalXdgStateHome = process.env.XDG_STATE_HOME;
|
||||
const originalCopilotHome = process.env.COPILOT_HOME;
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
const sdk = {
|
||||
@@ -251,7 +251,11 @@ describe('CopilotCLISessionService', () => {
|
||||
void rm(tempStateHome, { recursive: true, force: true });
|
||||
tempStateHome = undefined;
|
||||
}
|
||||
process.env.XDG_STATE_HOME = originalXdgStateHome;
|
||||
if (originalCopilotHome === undefined) {
|
||||
delete process.env.COPILOT_HOME;
|
||||
} else {
|
||||
process.env.COPILOT_HOME = originalCopilotHome;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
disposables.clear();
|
||||
@@ -718,7 +722,7 @@ describe('CopilotCLISessionService', () => {
|
||||
describe('CopilotCLISessionService.tryGetPartialSesionHistory', () => {
|
||||
it('reconstructs history from persisted files', async () => {
|
||||
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
|
||||
process.env.XDG_STATE_HOME = tempStateHome;
|
||||
process.env.COPILOT_HOME = join(tempStateHome, '.copilot');
|
||||
const sessionId = 'partial-session';
|
||||
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
|
||||
const fileSystem = new MockFileSystemService();
|
||||
@@ -757,7 +761,7 @@ describe('CopilotCLISessionService', () => {
|
||||
|
||||
it('returns cached result on second call without re-reading the file', async () => {
|
||||
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
|
||||
process.env.XDG_STATE_HOME = tempStateHome;
|
||||
process.env.COPILOT_HOME = join(tempStateHome, '.copilot');
|
||||
const sessionId = 'cache-test-session';
|
||||
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
|
||||
const fileSystem = new MockFileSystemService();
|
||||
@@ -796,7 +800,7 @@ describe('CopilotCLISessionService', () => {
|
||||
|
||||
it('returns undefined when the events file does not exist', async () => {
|
||||
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
|
||||
process.env.XDG_STATE_HOME = tempStateHome;
|
||||
process.env.COPILOT_HOME = join(tempStateHome, '.copilot');
|
||||
|
||||
const result = await service.tryGetPartialSessionHistory('nonexistent-session-id');
|
||||
expect(result).toBeUndefined();
|
||||
@@ -863,7 +867,7 @@ describe('CopilotCLISessionService', () => {
|
||||
|
||||
it('falls back to partial session data when getSession fails with an unknown event type', async () => {
|
||||
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
|
||||
process.env.XDG_STATE_HOME = tempStateHome;
|
||||
process.env.COPILOT_HOME = join(tempStateHome, '.copilot');
|
||||
const sessionId = 'invalid-session';
|
||||
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
|
||||
const fileSystem = new MockFileSystemService();
|
||||
@@ -909,7 +913,7 @@ describe('CopilotCLISessionService', () => {
|
||||
|
||||
it('does not emit session when summary is truncated and no user turns exist', async () => {
|
||||
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
|
||||
process.env.XDG_STATE_HOME = tempStateHome;
|
||||
process.env.COPILOT_HOME = join(tempStateHome, '.copilot');
|
||||
const sessionId = 'no-user-turns-session';
|
||||
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
|
||||
const fileSystem = new MockFileSystemService();
|
||||
|
||||
+8
-8
@@ -101,8 +101,8 @@ describe('createLockFile', () => {
|
||||
let createdLockFile: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.XDG_STATE_HOME;
|
||||
process.env.XDG_STATE_HOME = testDir;
|
||||
originalEnv = process.env.COPILOT_HOME;
|
||||
process.env.COPILOT_HOME = path.join(testDir, '.copilot');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -111,9 +111,9 @@ describe('createLockFile', () => {
|
||||
createdLockFile = null;
|
||||
}
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.XDG_STATE_HOME = originalEnv;
|
||||
process.env.COPILOT_HOME = originalEnv;
|
||||
} else {
|
||||
delete process.env.XDG_STATE_HOME;
|
||||
delete process.env.COPILOT_HOME;
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true }).catch(() => { });
|
||||
});
|
||||
@@ -177,16 +177,16 @@ describe('cleanupStaleLockFiles', () => {
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalEnv = process.env.XDG_STATE_HOME;
|
||||
process.env.XDG_STATE_HOME = testDir;
|
||||
originalEnv = process.env.COPILOT_HOME;
|
||||
process.env.COPILOT_HOME = path.join(testDir, '.copilot');
|
||||
await fs.mkdir(copilotDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.XDG_STATE_HOME = originalEnv;
|
||||
process.env.COPILOT_HOME = originalEnv;
|
||||
} else {
|
||||
delete process.env.XDG_STATE_HOME;
|
||||
delete process.env.COPILOT_HOME;
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true }).catch(() => { });
|
||||
});
|
||||
|
||||
+2
-1
@@ -525,8 +525,9 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
|
||||
pullRequestNumber = SessionIdForPr.parsePullRequestNumber(resource);
|
||||
}
|
||||
|
||||
|
||||
// Reachable when `chatSessionPullRequest` is unknown, which keeps the action visible.
|
||||
if (!pullRequestNumber) {
|
||||
this.logService.warn('No pull request number could be resolved for the requested cloud session action.');
|
||||
return;
|
||||
}
|
||||
const repoIds = await getRepoId(this._gitService);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
|
||||
import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager';
|
||||
import { INTEGRATION_ID } from '../../../platform/endpoint/common/licenseAgreement';
|
||||
import { IFetcherService } from '../../../platform/networking/common/fetcherService';
|
||||
import { IFetcherService, type Response } from '../../../platform/networking/common/fetcherService';
|
||||
import { FetchBlockedError, type HttpFetchFn, type HttpResponse } from '../../../shared-fetch-utils/common/fetchTypes';
|
||||
import { rateLimitBackoffMiddleware } from '../../../shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware';
|
||||
import type { CreateSessionFailureReason, CreateSessionResult, CloudSession, SessionEvent, SubmitSessionEventsResult } from '../common/cloudSessionTypes';
|
||||
|
||||
/** Timeout for individual cloud API requests (ms). */
|
||||
@@ -15,6 +17,31 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
||||
/** Cloud sessions endpoint path. */
|
||||
const SESSIONS_PATH = '/agents/sessions';
|
||||
|
||||
/** Initial backoff applied when the server reports a rate limit without a hint. */
|
||||
const RATE_LIMIT_INITIAL_BACKOFF_MS = 60_000;
|
||||
|
||||
/** Upper bound on any rate limit backoff, including one the server asks for. */
|
||||
const RATE_LIMIT_MAX_BACKOFF_MS = 600_000;
|
||||
|
||||
/** What a cloud request produced, so each caller can map it onto its own result shape. */
|
||||
type CloudFetchOutcome =
|
||||
| { readonly kind: 'response'; readonly response: Response }
|
||||
| { readonly kind: 'rateLimited' }
|
||||
| { readonly kind: 'error' };
|
||||
|
||||
/** Carries the underlying response through the middleware, which only reads status and headers. */
|
||||
type AdaptedResponse = HttpResponse & { readonly original: Response };
|
||||
|
||||
/** Options for a single cloud API call. */
|
||||
type CloudRequestInit = {
|
||||
readonly method: string;
|
||||
readonly json?: unknown;
|
||||
/** Passed to the fetcher for request telemetry. */
|
||||
readonly callSite: string;
|
||||
/** Reported to {@link CloudSessionApiClient.onRateLimited}. */
|
||||
readonly operation: string;
|
||||
};
|
||||
|
||||
// ── Cloud agent application IDs ─────────────────────────────────────────────────
|
||||
|
||||
/** Agent application IDs used by the cloud sessions API (`agent_id` field). */
|
||||
@@ -37,43 +64,84 @@ export const CloudAgentId = {
|
||||
*/
|
||||
export class CloudSessionApiClient {
|
||||
|
||||
/** Timestamp (epoch ms) until which all requests should be skipped due to 429. */
|
||||
/** Timestamp (epoch ms) until which all requests should be skipped due to a rate limit. */
|
||||
private _rateLimitedUntil = 0;
|
||||
|
||||
/** Number of times we've been rate-limited. */
|
||||
private _rateLimitCount = 0;
|
||||
|
||||
/** Callback fired when a 429 is received. */
|
||||
/** Callback fired when the server reports a new rate limit. */
|
||||
onRateLimited: ((callSite: string, retryAfterSec: number) => void) | undefined;
|
||||
|
||||
/**
|
||||
* Shared rate limit handling. Only this middleware is applied: `403` here means policy
|
||||
* blocked rather than an auth failure, and `5xx` backoff is owned by the exporter's circuit
|
||||
* breaker, so neither the auth nor the server error middleware belongs in this stack.
|
||||
*/
|
||||
private readonly _rateLimitedFetch: HttpFetchFn;
|
||||
|
||||
constructor(
|
||||
private readonly _tokenManager: ICopilotTokenManager,
|
||||
private readonly _authService: IAuthenticationService,
|
||||
private readonly _fetcherService: IFetcherService,
|
||||
) { }
|
||||
// Injectable so tests can exercise the backoff without waiting on the wall clock.
|
||||
private readonly _now: () => number = Date.now,
|
||||
) {
|
||||
this._rateLimitedFetch = rateLimitBackoffMiddleware({
|
||||
initialDelayMs: RATE_LIMIT_INITIAL_BACKOFF_MS,
|
||||
maxDelayMs: RATE_LIMIT_MAX_BACKOFF_MS,
|
||||
now: this._now,
|
||||
})(async (request) => {
|
||||
const { method, json, callSite } = request.state as CloudRequestInit;
|
||||
const original = await this._fetcherService.fetch(request.url, {
|
||||
callSite,
|
||||
// FetchOptions.method is typed narrowly (GET/POST/PUT) for CAPI
|
||||
// compatibility; the underlying fetcher accepts DELETE at runtime.
|
||||
method: method as 'POST',
|
||||
headers: request.headers,
|
||||
json,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
status: original.status,
|
||||
headers: original.headers,
|
||||
body: null,
|
||||
text: () => original.text(),
|
||||
json: () => original.json(),
|
||||
original,
|
||||
} satisfies AdaptedResponse;
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns true if we're currently rate-limited and should skip requests. */
|
||||
isRateLimited(): boolean {
|
||||
return Date.now() < this._rateLimitedUntil;
|
||||
return this._now() < this._rateLimitedUntil;
|
||||
}
|
||||
|
||||
/** Record a 429 response and back off for the indicated duration. */
|
||||
private _handleRateLimit(res: { headers?: { get?(name: string): string | null } }, callSite: string): void {
|
||||
let retryAfterSec = 60; // Default: 60 seconds
|
||||
/**
|
||||
* Performs a cloud API request, short-circuiting while rate limited.
|
||||
*
|
||||
* The middleware decides how long to wait; this only mirrors that window so
|
||||
* {@link isRateLimited} can be polled synchronously by the exporter.
|
||||
*/
|
||||
private async _fetch(path: string, init: CloudRequestInit): Promise<CloudFetchOutcome> {
|
||||
// Checked before building the request so a blocked call costs no token lookup, and so the
|
||||
// telemetry callback only fires for newly reported limits.
|
||||
if (this.isRateLimited()) {
|
||||
return { kind: 'rateLimited' };
|
||||
}
|
||||
const { url, headers } = await this._buildRequest(path);
|
||||
if (!url) {
|
||||
return { kind: 'error' };
|
||||
}
|
||||
try {
|
||||
const header = res.headers?.get?.('Retry-After');
|
||||
if (header) {
|
||||
const parsed = parseInt(header, 10);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed <= 600) {
|
||||
retryAfterSec = parsed;
|
||||
}
|
||||
const response = await this._rateLimitedFetch({ url, headers, state: init });
|
||||
return { kind: 'response', response: (response as AdaptedResponse).original };
|
||||
} catch (err) {
|
||||
if (err instanceof FetchBlockedError) {
|
||||
this._rateLimitedUntil = Math.max(this._rateLimitedUntil, this._now() + err.retryAfterMs);
|
||||
this.onRateLimited?.(init.operation, Math.round(err.retryAfterMs / 1000));
|
||||
return { kind: 'rateLimited' };
|
||||
}
|
||||
} catch {
|
||||
// Use default
|
||||
return { kind: 'error' };
|
||||
}
|
||||
this._rateLimitedUntil = Date.now() + retryAfterSec * 1000;
|
||||
this._rateLimitCount++;
|
||||
this.onRateLimited?.(callSite, retryAfterSec);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,43 +155,31 @@ export class CloudSessionApiClient {
|
||||
sessionId: string,
|
||||
indexingLevel: 'user' | 'repo_and_user' = 'user',
|
||||
): Promise<CreateSessionResult> {
|
||||
if (this.isRateLimited()) {
|
||||
return { ok: false, reason: 'rate_limited' };
|
||||
}
|
||||
try {
|
||||
const { url, headers } = await this._buildRequest(SESSIONS_PATH);
|
||||
if (!url) {
|
||||
return { ok: false, reason: 'error' };
|
||||
}
|
||||
|
||||
const body = {
|
||||
const outcome = await this._fetch(SESSIONS_PATH, {
|
||||
method: 'POST',
|
||||
callSite: 'chronicle.cloudCreateSession',
|
||||
operation: 'createSession',
|
||||
json: {
|
||||
owner_id: ownerId,
|
||||
repo_id: repoId,
|
||||
agent_task_id: sessionId,
|
||||
indexing_level: indexingLevel,
|
||||
};
|
||||
},
|
||||
});
|
||||
if (outcome.kind !== 'response') {
|
||||
return { ok: false, reason: outcome.kind === 'rateLimited' ? 'rate_limited' : 'error' };
|
||||
}
|
||||
|
||||
const res = await this._fetcherService.fetch(url, {
|
||||
callSite: 'chronicle.cloudCreateSession',
|
||||
method: 'POST',
|
||||
headers,
|
||||
json: body,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
this._handleRateLimit(res, 'createSession');
|
||||
return { ok: false, reason: 'rate_limited' };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const reason: CreateSessionFailureReason = res.status === 403 ? 'policy_blocked' : 'error';
|
||||
return { ok: false, reason };
|
||||
}
|
||||
const res = outcome.response;
|
||||
if (!res.ok) {
|
||||
const reason: CreateSessionFailureReason = res.status === 403 ? 'policy_blocked' : 'error';
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await res.json() as { id: string; task_id?: string; agent_task_id?: string };
|
||||
return { ok: true, response };
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return { ok: false, reason: 'error' };
|
||||
}
|
||||
}
|
||||
@@ -137,69 +193,40 @@ export class CloudSessionApiClient {
|
||||
sessionId: string,
|
||||
events: SessionEvent[],
|
||||
): Promise<SubmitSessionEventsResult> {
|
||||
if (this.isRateLimited()) {
|
||||
return { ok: false, reason: 'rate_limited' };
|
||||
const outcome = await this._fetch(`${SESSIONS_PATH}/${sessionId}/events`, {
|
||||
method: 'POST',
|
||||
callSite: 'chronicle.cloudSubmitEvents',
|
||||
operation: 'submitEvents',
|
||||
json: { events },
|
||||
});
|
||||
if (outcome.kind !== 'response') {
|
||||
return { ok: false, reason: outcome.kind === 'rateLimited' ? 'rate_limited' : 'error' };
|
||||
}
|
||||
try {
|
||||
const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}/events`);
|
||||
if (!url) {
|
||||
return { ok: false, reason: 'error' };
|
||||
}
|
||||
|
||||
const res = await this._fetcherService.fetch(url, {
|
||||
callSite: 'chronicle.cloudSubmitEvents',
|
||||
method: 'POST',
|
||||
headers,
|
||||
json: { events },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
this._handleRateLimit(res, 'submitEvents');
|
||||
return { ok: false, reason: 'rate_limited' };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const reason: 'policy_blocked' | 'error' = res.status === 403 ? 'policy_blocked' : 'error';
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'error' };
|
||||
const res = outcome.response;
|
||||
if (!res.ok) {
|
||||
const reason: 'policy_blocked' | 'error' = res.status === 403 ? 'policy_blocked' : 'error';
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by ID (used for reattach verification).
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<CloudSession | undefined> {
|
||||
if (this.isRateLimited()) {
|
||||
const outcome = await this._fetch(`${SESSIONS_PATH}/${sessionId}`, {
|
||||
method: 'GET',
|
||||
callSite: 'chronicle.cloudGetSession',
|
||||
operation: 'getSession',
|
||||
});
|
||||
if (outcome.kind !== 'response' || !outcome.response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}`);
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await this._fetcherService.fetch(url, {
|
||||
callSite: 'chronicle.cloudGetSession',
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
this._handleRateLimit(res, 'getSession');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (await res.json()) as CloudSession;
|
||||
return (await outcome.response.json()) as CloudSession;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -211,36 +238,21 @@ export class CloudSessionApiClient {
|
||||
*/
|
||||
async listSessions(): Promise<Array<{ id: string; task_id?: string; agent_task_id?: string; agent_id?: number; state: string; created_at: string }>> {
|
||||
const allSessions: Array<{ id: string; task_id?: string; agent_task_id?: string; agent_id?: number; state: string; created_at: string }> = [];
|
||||
if (this.isRateLimited()) {
|
||||
return allSessions;
|
||||
}
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}?page_size=${pageSize}&page_number=${page}`);
|
||||
if (!url) {
|
||||
return allSessions;
|
||||
}
|
||||
|
||||
const res = await this._fetcherService.fetch(url, {
|
||||
callSite: 'chronicle.cloudListSessions',
|
||||
const outcome = await this._fetch(`${SESSIONS_PATH}?page_size=${pageSize}&page_number=${page}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
callSite: 'chronicle.cloudListSessions',
|
||||
operation: 'listSessions',
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
this._handleRateLimit(res, 'listSessions');
|
||||
if (outcome.kind !== 'response' || !outcome.response.ok) {
|
||||
return allSessions;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return allSessions;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const data = await outcome.response.json();
|
||||
const sessions = Array.isArray(data) ? data : (data as Record<string, unknown>).sessions;
|
||||
const pageSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
@@ -272,38 +284,20 @@ export class CloudSessionApiClient {
|
||||
* treated as success), or 'error' on failure.
|
||||
*/
|
||||
async deleteSession(taskId: string): Promise<'deleted' | 'not_found' | 'error'> {
|
||||
if (this.isRateLimited()) {
|
||||
const outcome = await this._fetch(`/agents/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: 'DELETE',
|
||||
callSite: 'chronicle.cloudDeleteSession',
|
||||
operation: 'deleteSession',
|
||||
});
|
||||
if (outcome.kind !== 'response') {
|
||||
return 'error';
|
||||
}
|
||||
try {
|
||||
const { url, headers } = await this._buildRequest(`/agents/tasks/${encodeURIComponent(taskId)}`);
|
||||
if (!url) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const res = await this._fetcherService.fetch(url, {
|
||||
callSite: 'chronicle.cloudDeleteSession',
|
||||
// FetchOptions.method is typed narrowly (GET/POST/PUT) for CAPI
|
||||
// compatibility; the underlying fetcher accepts DELETE at runtime.
|
||||
method: 'DELETE' as 'POST',
|
||||
headers,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
this._handleRateLimit(res, 'deleteSession');
|
||||
return 'error';
|
||||
}
|
||||
if (res.status === 404) {
|
||||
return 'not_found';
|
||||
}
|
||||
if (res.ok) {
|
||||
return 'deleted';
|
||||
}
|
||||
return 'error';
|
||||
} catch (err) {
|
||||
return 'error';
|
||||
const res = outcome.response;
|
||||
if (res.status === 404) {
|
||||
return 'not_found';
|
||||
}
|
||||
return res.ok ? 'deleted' : 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,33 +305,18 @@ export class CloudSessionApiClient {
|
||||
* Single API call that queues all eligible sessions for reindexing.
|
||||
*/
|
||||
async backfillAnalytics(indexingLevel: 'user' | 'repo_and_user'): Promise<{ ok: true; sessionsQueued: number } | { ok: false }> {
|
||||
if (this.isRateLimited()) {
|
||||
const outcome = await this._fetch('/agents/analytics/backfill', {
|
||||
method: 'POST',
|
||||
callSite: 'chronicle.cloudBackfillAnalytics',
|
||||
operation: 'backfillAnalytics',
|
||||
json: { indexing_level: indexingLevel },
|
||||
});
|
||||
if (outcome.kind !== 'response' || !outcome.response.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, headers } = await this._buildRequest('/agents/analytics/backfill');
|
||||
if (!url) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const res = await this._fetcherService.fetch(url, {
|
||||
callSite: 'chronicle.cloudBackfillAnalytics',
|
||||
method: 'POST',
|
||||
headers,
|
||||
json: { indexing_level: indexingLevel },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
this._handleRateLimit(res, 'backfillAnalytics');
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const data = await res.json() as { sessions_queued?: number };
|
||||
const data = await outcome.response.json() as { sessions_queued?: number };
|
||||
return { ok: true, sessionsQueued: data.sessions_queued ?? 0 };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
|
||||
@@ -31,11 +31,12 @@ function createMockServices() {
|
||||
return { tokenManager, authService, fetcherService };
|
||||
}
|
||||
|
||||
function makeFetchResponse(status: number, body: unknown = {}): { ok: boolean; status: number; headers: { get: (n: string) => string | null }; json: () => Promise<unknown> } {
|
||||
function makeFetchResponse(status: number, body: unknown = {}, headers: Record<string, string> = {}): { ok: boolean; status: number; headers: { get: (n: string) => string | null }; json: () => Promise<unknown> } {
|
||||
const lowerCased = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
headers: { get: (name: string) => lowerCased.get(name.toLowerCase()) ?? null },
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
@@ -124,4 +125,69 @@ describe('CloudSessionApiClient', () => {
|
||||
expect(result).toEqual({ ok: false, reason: 'rate_limited' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('skips requests while rate limited and resumes once the window passes', async () => {
|
||||
const { tokenManager, authService, fetcherService } = createMockServices();
|
||||
let now = Date.UTC(2026, 0, 1);
|
||||
const fetch = fetcherService.fetch as any;
|
||||
fetch.mockResolvedValue(makeFetchResponse(429, {}, { 'retry-after': '120' }));
|
||||
|
||||
const client = new CloudSessionApiClient(tokenManager, authService, fetcherService, () => now);
|
||||
|
||||
const first = await client.submitSessionEvents('sess-1', []);
|
||||
const callsAfterLimit = fetch.mock.calls.length;
|
||||
|
||||
// Inside the window the call short-circuits without touching the network.
|
||||
now += 60_000;
|
||||
const during = await client.submitSessionEvents('sess-1', []);
|
||||
const callsDuringWindow = fetch.mock.calls.length;
|
||||
|
||||
// Past the window the client tries again and recovers.
|
||||
now += 61_000;
|
||||
fetch.mockResolvedValue(makeFetchResponse(200));
|
||||
const after = await client.submitSessionEvents('sess-1', []);
|
||||
|
||||
expect({ first, during, after, callsAfterLimit, callsDuringWindow, callsTotal: fetch.mock.calls.length, limitedNow: client.isRateLimited() }).toEqual({
|
||||
first: { ok: false, reason: 'rate_limited' },
|
||||
during: { ok: false, reason: 'rate_limited' },
|
||||
after: { ok: true },
|
||||
callsAfterLimit: 1,
|
||||
callsDuringWindow: 1,
|
||||
callsTotal: 2,
|
||||
limitedNow: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports each new limit once through onRateLimited', async () => {
|
||||
const { tokenManager, authService, fetcherService } = createMockServices();
|
||||
let now = Date.UTC(2026, 0, 1);
|
||||
(fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(429, {}, { 'retry-after': '90' }));
|
||||
|
||||
const client = new CloudSessionApiClient(tokenManager, authService, fetcherService, () => now);
|
||||
const reported: Array<{ callSite: string; retryAfterSec: number }> = [];
|
||||
client.onRateLimited = (callSite, retryAfterSec) => reported.push({ callSite, retryAfterSec });
|
||||
|
||||
await client.createSession(1, 2, 'local-1');
|
||||
// A follow-up blocked by the same window must not report again.
|
||||
now += 30_000;
|
||||
await client.createSession(1, 2, 'local-2');
|
||||
|
||||
expect(reported).toEqual([{ callSite: 'createSession', retryAfterSec: 90 }]);
|
||||
});
|
||||
|
||||
it('clamps an implausible retry-after to the maximum backoff', async () => {
|
||||
const { tokenManager, authService, fetcherService } = createMockServices();
|
||||
const now = Date.UTC(2026, 0, 1);
|
||||
(fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(429, {}, { 'retry-after': '86400' }));
|
||||
|
||||
const client = new CloudSessionApiClient(tokenManager, authService, fetcherService, () => now);
|
||||
const reported: number[] = [];
|
||||
client.onRateLimited = (_callSite, retryAfterSec) => reported.push(retryAfterSec);
|
||||
|
||||
await client.getSession('sess-1');
|
||||
|
||||
expect(reported).toEqual([600]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,7 @@ export class CodeBlockTrackingChatResponseStream implements ChatResponseStream {
|
||||
warning = this.forward(this._wrapped.warning.bind(this._wrapped));
|
||||
info = this.forward(this._wrapped.info.bind(this._wrapped));
|
||||
hookProgress = this.forward(this._wrapped.hookProgress.bind(this._wrapped));
|
||||
voiceProgress = this.forward(this._wrapped.voiceProgress.bind(this._wrapped));
|
||||
reference2 = this.forward(this._wrapped.reference2.bind(this._wrapped));
|
||||
codeCitation = this.forward(this._wrapped.codeCitation.bind(this._wrapped));
|
||||
anchor = this.forward(this._wrapped.anchor.bind(this._wrapped));
|
||||
|
||||
@@ -840,8 +840,15 @@ export class CopilotLanguageModelWrapper extends Disposable {
|
||||
let thinkingActive = false;
|
||||
const finishCallback: FinishedCallback = async (_text, index, delta): Promise<undefined> => {
|
||||
if (delta.thinking) {
|
||||
// Show thinking progress for unencrypted thinking deltas
|
||||
if (!isEncryptedThinkingDelta(delta.thinking)) {
|
||||
if (isEncryptedThinkingDelta(delta.thinking)) {
|
||||
if (options.includeEncryptedThinking) {
|
||||
progress.report(new vscode.LanguageModelThinkingPart(
|
||||
delta.thinking.text ?? '',
|
||||
delta.thinking.id,
|
||||
{ encrypted_content: delta.thinking.encrypted },
|
||||
));
|
||||
}
|
||||
} else {
|
||||
const text = delta.thinking.text ?? '';
|
||||
progress.report(new vscode.LanguageModelThinkingPart(text, delta.thinking.id, delta.thinking.metadata));
|
||||
thinkingActive = true;
|
||||
|
||||
+31
-5
@@ -20,6 +20,29 @@ export type Props = PromptElementProps<{
|
||||
messages: Array<vscode.LanguageModelChatMessage | vscode.LanguageModelChatMessage2>;
|
||||
}>;
|
||||
|
||||
interface IThinkingGroup {
|
||||
readonly id: string;
|
||||
readonly text: string[];
|
||||
readonly metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function groupThinkingParts(parts: readonly vscode.LanguageModelThinkingPart[]): IThinkingGroup[] {
|
||||
const groups = new Map<string, IThinkingGroup>();
|
||||
for (const part of parts) {
|
||||
if (!part.id) {
|
||||
continue;
|
||||
}
|
||||
const previous = groups.get(part.id);
|
||||
const text = Array.isArray(part.value) ? part.value : [part.value];
|
||||
groups.set(part.id, {
|
||||
id: part.id,
|
||||
text: [...previous?.text ?? [], ...text],
|
||||
metadata: { ...previous?.metadata, ...part.metadata },
|
||||
});
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
export class LanguageModelAccessPrompt extends PromptElement<Props> {
|
||||
async render() {
|
||||
|
||||
@@ -38,14 +61,17 @@ export class LanguageModelAccessPrompt extends PromptElement<Props> {
|
||||
const statefulMarkerPart = message.content.find(part => part instanceof vscode.LanguageModelDataPart && part.mimeType === CustomDataPartMimeTypes.StatefulMarker) as vscode.LanguageModelDataPart | undefined;
|
||||
const statefulMarker = statefulMarkerPart && decodeStatefulMarker(statefulMarkerPart.data);
|
||||
const filteredContent = message.content.filter(part => !(part instanceof vscode.LanguageModelDataPart));
|
||||
// There should only be one string part per message
|
||||
const content = filteredContent.find(part => part instanceof LanguageModelTextPart);
|
||||
const content = filteredContent.filter(part => part instanceof LanguageModelTextPart).map(part => part.value).join('');
|
||||
const toolCalls = filteredContent.filter(part => part instanceof vscode.LanguageModelToolCallPart);
|
||||
const thinking = filteredContent.find(part => part instanceof vscode.LanguageModelThinkingPart);
|
||||
const thinkingParts = filteredContent.filter(part => part instanceof vscode.LanguageModelThinkingPart);
|
||||
const thinkingGroups = groupThinkingParts(thinkingParts);
|
||||
|
||||
const statefulMarkerElement = statefulMarker && <StatefulMarkerContainer statefulMarker={statefulMarker} />;
|
||||
const thinkingElement = thinking && thinking.id && <ThinkingDataContainer thinking={{ id: thinking.id, text: thinking.value, metadata: thinking.metadata }} />;
|
||||
chatMessages.push(<AssistantMessage name={message.name} toolCalls={toolCalls.map(tc => ({ id: tc.callId, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))}>{statefulMarkerElement}{content?.value}{thinkingElement}</AssistantMessage>);
|
||||
const thinkingElements = thinkingGroups.map(group => {
|
||||
const encrypted = typeof group.metadata.encrypted_content === 'string' ? group.metadata.encrypted_content : undefined;
|
||||
return <ThinkingDataContainer thinking={{ id: group.id, text: group.text, metadata: group.metadata, encrypted }} />;
|
||||
});
|
||||
chatMessages.push(<AssistantMessage name={message.name} toolCalls={toolCalls.map(tc => ({ id: tc.callId, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))}>{statefulMarkerElement}{content}{thinkingElements}</AssistantMessage>);
|
||||
} else if (message.role === vscode.LanguageModelChatMessageRole.User) {
|
||||
for (const part of message.content) {
|
||||
if (part instanceof vscode.LanguageModelToolResultPart2 || part instanceof vscode.LanguageModelToolResultPart) {
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Raw } from '@vscode/prompt-tsx';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { IChatMLFetcher } from '../../../../platform/chat/common/chatMLFetcher';
|
||||
import { StaticChatMLFetcher } from '../../../../platform/chat/test/common/staticChatMLFetcher';
|
||||
import { MockEndpoint } from '../../../../platform/endpoint/test/node/mockEndpoint';
|
||||
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { LanguageModelChatMessageRole, LanguageModelTextPart, LanguageModelThinkingPart } from '../../../../vscodeTypes';
|
||||
import { createExtensionUnitTestingServices } from '../../../test/node/services';
|
||||
import { renderPromptElement } from '../../../prompts/node/base/promptRenderer';
|
||||
import { LanguageModelAccessPrompt } from '../languageModelAccessPrompt';
|
||||
|
||||
describe('LanguageModelAccessPrompt', () => {
|
||||
test('preserves all assistant text and groups thinking by id', async () => {
|
||||
const services = createExtensionUnitTestingServices();
|
||||
services.define(IChatMLFetcher, new StaticChatMLFetcher([]));
|
||||
const accessor = services.createTestingAccessor();
|
||||
const endpoint = accessor.get(IInstantiationService).createInstance(MockEndpoint, 'gpt-5');
|
||||
const message = {
|
||||
role: LanguageModelChatMessageRole.Assistant,
|
||||
content: [
|
||||
new LanguageModelTextPart('first'),
|
||||
new LanguageModelThinkingPart('a1', 'rs_a', { encrypted_content: 'opaque-a' }),
|
||||
new LanguageModelThinkingPart('b', 'rs_b', { encrypted_content: 'opaque-b' }),
|
||||
new LanguageModelThinkingPart('a2', 'rs_a'),
|
||||
new LanguageModelTextPart('second'),
|
||||
],
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
const { messages } = await renderPromptElement(
|
||||
accessor.get(IInstantiationService),
|
||||
endpoint,
|
||||
LanguageModelAccessPrompt,
|
||||
{ noSafety: true, messages: [message] },
|
||||
);
|
||||
const assistant = messages.find(candidate => candidate.role === Raw.ChatRole.Assistant);
|
||||
const text = assistant?.content
|
||||
.filter(part => part.type === Raw.ChatCompletionContentPartKind.Text)
|
||||
.map(part => part.text)
|
||||
.join('');
|
||||
const thinking = assistant?.content
|
||||
.filter(part => part.type === Raw.ChatCompletionContentPartKind.Opaque)
|
||||
.map(part => part.value);
|
||||
|
||||
expect({ text, thinking }).toEqual({
|
||||
text: 'firstsecond',
|
||||
thinking: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: {
|
||||
id: 'rs_a',
|
||||
text: ['a1', 'a2'],
|
||||
metadata: { encrypted_content: 'opaque-a' },
|
||||
encrypted: 'opaque-a',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: {
|
||||
id: 'rs_b',
|
||||
text: ['b'],
|
||||
metadata: { encrypted_content: 'opaque-b' },
|
||||
encrypted: 'opaque-b',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -701,7 +701,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);
|
||||
if (speculativeRequest) {
|
||||
const firstEdit = await requestToReuse.firstEdit.p;
|
||||
return firstEdit.map(val => ({ ...val, isFromSpeculativeRequest: true }));
|
||||
return firstEdit.map(val => ({ ...val, isFromSpeculativeRequest: true, baseCacheEntry: val.baseCacheEntry ?? val }));
|
||||
}
|
||||
return nextEditResult.nextEdit.isError() ? nextEditResult.nextEdit : requestToReuse.firstEdit.p;
|
||||
} else {
|
||||
|
||||
+36
-6
@@ -4,10 +4,10 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { ConfigKey } from '../../../../platform/configuration/common/configurationService';
|
||||
import { ConfigKey, ExperimentBasedConfig, ExperimentBasedConfigType } from '../../../../platform/configuration/common/configurationService';
|
||||
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
|
||||
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
|
||||
import { AggressivenessLevel, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, UserHappinessScoreConfiguration } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';
|
||||
import { AggressivenessLevel, AggressivenessSetting, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, UserHappinessScoreConfiguration } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';
|
||||
import { ILogService } from '../../../../platform/log/common/logService';
|
||||
import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
|
||||
@@ -47,9 +47,22 @@ class TestUserInteractionMonitor extends UserInteractionMonitor {
|
||||
* Mock configuration service that allows setting specific config values for testing.
|
||||
*/
|
||||
class MockConfigurationService extends InMemoryConfigurationService {
|
||||
private _useAdaptiveAggressiveness = false;
|
||||
|
||||
constructor() {
|
||||
super(new DefaultsOnlyConfigurationService());
|
||||
}
|
||||
|
||||
useAdaptiveAggressiveness(): void {
|
||||
this._useAdaptiveAggressiveness = true;
|
||||
}
|
||||
|
||||
override getExperimentBasedConfig<T extends ExperimentBasedConfigType>(key: ExperimentBasedConfig<T>, experimentationService: IExperimentationService): T {
|
||||
if (this._useAdaptiveAggressiveness && key === ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel) {
|
||||
return undefined as T;
|
||||
}
|
||||
return super.getExperimentBasedConfig(key, experimentationService);
|
||||
}
|
||||
}
|
||||
|
||||
interface TelemetryCall {
|
||||
@@ -197,13 +210,25 @@ describe('UserInteractionMonitor', () => {
|
||||
});
|
||||
|
||||
describe('aggressiveness level calculation', () => {
|
||||
test('returns neutral aggressiveness with no history', () => {
|
||||
// With no data, score is 0.5, which is between low and medium thresholds for the default config
|
||||
const level = monitor.getAggressivenessLevel().aggressivenessLevel;
|
||||
expect(level).toBe(AggressivenessLevel.Medium);
|
||||
test('defaults to medium aggressiveness without using adaptive scoring', () => {
|
||||
expect(monitor.getAggressivenessLevel()).toEqual({
|
||||
aggressivenessLevel: AggressivenessLevel.Medium,
|
||||
userHappinessScore: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('explicit user eagerness takes priority over configured aggressiveness', () => {
|
||||
configurationService.setConfig(ConfigKey.Advanced.InlineEditsAggressiveness, AggressivenessSetting.High);
|
||||
configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel, AggressivenessLevel.Low);
|
||||
|
||||
expect(monitor.getAggressivenessLevel()).toEqual({
|
||||
aggressivenessLevel: AggressivenessLevel.High,
|
||||
userHappinessScore: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns high aggressiveness after many acceptances', () => {
|
||||
configurationService.useAdaptiveAggressiveness();
|
||||
// Fill with 10 acceptances
|
||||
for (let i = 0; i < 10; i++) {
|
||||
monitor.handleAcceptance();
|
||||
@@ -214,6 +239,7 @@ describe('UserInteractionMonitor', () => {
|
||||
});
|
||||
|
||||
test('returns low aggressiveness after many rejections', () => {
|
||||
configurationService.useAdaptiveAggressiveness();
|
||||
// Fill with 10 rejections
|
||||
for (let i = 0; i < 10; i++) {
|
||||
monitor.handleRejection();
|
||||
@@ -239,6 +265,7 @@ describe('UserInteractionMonitor', () => {
|
||||
});
|
||||
|
||||
test('recent actions have more weight than older ones', () => {
|
||||
configurationService.useAdaptiveAggressiveness();
|
||||
// Start with acceptances, end with rejections
|
||||
for (let i = 0; i < 5; i++) {
|
||||
monitor.handleAcceptance();
|
||||
@@ -270,6 +297,7 @@ describe('UserInteractionMonitor', () => {
|
||||
|
||||
describe('ignored action limiting', () => {
|
||||
test('ignored actions are included in aggressiveness calculation', () => {
|
||||
configurationService.useAdaptiveAggressiveness();
|
||||
// With custom config that includes ignored actions
|
||||
const customConfig: UserHappinessScoreConfiguration = {
|
||||
...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,
|
||||
@@ -294,6 +322,7 @@ describe('UserInteractionMonitor', () => {
|
||||
});
|
||||
|
||||
test('total ignored limit is respected', () => {
|
||||
configurationService.useAdaptiveAggressiveness();
|
||||
const customConfig: UserHappinessScoreConfiguration = {
|
||||
...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,
|
||||
includeIgnored: true,
|
||||
@@ -325,6 +354,7 @@ describe('UserInteractionMonitor', () => {
|
||||
let mockTelemetryService: MockTelemetryService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService.useAdaptiveAggressiveness();
|
||||
mockTelemetryService = new MockTelemetryService();
|
||||
monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, mockTelemetryService);
|
||||
});
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('NextEditCache rebase — Fibonacci scenario', () => {
|
||||
|
||||
assert(cachedEdit !== undefined, 'setKthNextEdit should return the cached edit');
|
||||
assert(cachedEdit.userEditSince !== undefined, 'userEditSince should be set');
|
||||
cachedEdit.wasRenderedAsInlineSuggestion = true;
|
||||
|
||||
const rebaseResult = cache.tryRebaseCacheEntry(
|
||||
cachedEdit,
|
||||
@@ -180,6 +181,64 @@ describe('NextEditCache rebase — Fibonacci scenario', () => {
|
||||
assert(rebaseResult.edit !== undefined, 'should rebase successfully');
|
||||
assert(rebaseResult.edit.rebasedEdit !== undefined, 'should have a rebased edit for the class body');
|
||||
assert.strictEqual(rebaseResult.edit.modelTelemetry, testModelTelemetry, 'should preserve model attribution on the rebased edit');
|
||||
const baseCacheEntry = rebaseResult.edit.baseCacheEntry;
|
||||
assert(baseCacheEntry, 'should reference the stable cache entry');
|
||||
assert.strictEqual(baseCacheEntry, cachedEdit);
|
||||
assert.strictEqual(baseCacheEntry.wasRenderedAsInlineSuggestion, true, 'should preserve inline-rendered state on the stable cache entry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextEditCache ghost-text presentation state', () => {
|
||||
|
||||
const document = new StringText('const value = 1;\n');
|
||||
const docId = DocumentId.create(URI.file('/test/cache-presentation-state.ts').toString());
|
||||
|
||||
function makeSource(): NextEditFetchRequest {
|
||||
const logContext = new InlineEditRequestLogContext('test', 0, undefined);
|
||||
return new NextEditFetchRequest(generateUuid(), logContext, undefined, false);
|
||||
}
|
||||
|
||||
it('does not carry inline-rendered state to a replacement cache entry', () => {
|
||||
const workspace = new MutableObservableWorkspace();
|
||||
workspace.addDocument({ id: docId, initialValue: document.value });
|
||||
const cache = new NextEditCache(workspace, new LogServiceImpl([]), new DefaultsOnlyConfigurationService(), new NullExperimentationService());
|
||||
|
||||
const first = cache.setKthNextEdit(
|
||||
docId,
|
||||
document,
|
||||
undefined,
|
||||
StringReplacement.insert(document.value.length, 'first'),
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
makeSource(),
|
||||
{ isFromCursorJump: false, modelTelemetry: testModelTelemetry },
|
||||
);
|
||||
assert(first);
|
||||
first.wasRenderedAsInlineSuggestion = true;
|
||||
|
||||
const replacement = cache.setKthNextEdit(
|
||||
docId,
|
||||
document,
|
||||
undefined,
|
||||
StringReplacement.insert(document.value.length, 'replacement'),
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
makeSource(),
|
||||
{ isFromCursorJump: false, modelTelemetry: testModelTelemetry },
|
||||
);
|
||||
const result = cache.lookupNextEdit(docId, document, [OffsetRange.emptyAt(document.value.length)]);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
isReplacementEntry: result === replacement,
|
||||
newText: result?.edit?.newText,
|
||||
wasRenderedAsInlineSuggestion: result?.wasRenderedAsInlineSuggestion,
|
||||
}, {
|
||||
isReplacementEntry: true,
|
||||
newText: 'replacement',
|
||||
wasRenderedAsInlineSuggestion: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+40
@@ -627,6 +627,46 @@ describe('NextEditProvider speculative requests', () => {
|
||||
await statelessProvider.calls[1].completed.p;
|
||||
});
|
||||
|
||||
it('reused speculative request preserves inline-rendered state on the cached entry', async () => {
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
|
||||
|
||||
const statelessProvider = new TestStatelessNextEditProvider();
|
||||
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
|
||||
const specContinue = new DeferredPromise<void>();
|
||||
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(2, 'console.log(value + 1);'), continueSignal: specContinue });
|
||||
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
|
||||
|
||||
const doc = workspace.addDocument({
|
||||
id: DocumentId.create(URI.file('/test/spec-cache-entry-identity.ts').toString()),
|
||||
initialValue: 'const value = 1;\nconsole.log(value);',
|
||||
});
|
||||
doc.setSelection([new OffsetRange(0, 0)], undefined);
|
||||
|
||||
const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);
|
||||
assert(firstSuggestion.result?.edit);
|
||||
nextEditProvider.handleShown(firstSuggestion);
|
||||
await statelessProvider.waitForCall(2);
|
||||
nextEditProvider.handleAcceptance(doc.id, firstSuggestion);
|
||||
doc.applyEdit(firstSuggestion.result.edit.toEdit());
|
||||
|
||||
const speculativeSuggestion = await getNextEdit(nextEditProvider, doc.id);
|
||||
assert(speculativeSuggestion.result?.cacheEntry);
|
||||
speculativeSuggestion.result.cacheEntry.wasRenderedAsInlineSuggestion = true;
|
||||
|
||||
specContinue.complete();
|
||||
await statelessProvider.calls[1].completed.p;
|
||||
|
||||
const cachedSuggestion = await getNextEdit(nextEditProvider, doc.id);
|
||||
assert(cachedSuggestion.result?.cacheEntry);
|
||||
expect({
|
||||
isSameCacheEntry: cachedSuggestion.result.cacheEntry === speculativeSuggestion.result.cacheEntry,
|
||||
wasRenderedAsInlineSuggestion: cachedSuggestion.result.cacheEntry.wasRenderedAsInlineSuggestion,
|
||||
}).toEqual({
|
||||
isSameCacheEntry: true,
|
||||
wasRenderedAsInlineSuggestion: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips cache delay for edits from speculative requests even when enforceCacheDelay is true', async () => {
|
||||
const CACHE_DELAY_MS = 5_000;
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
|
||||
|
||||
@@ -35,12 +35,13 @@ import { DeferredPromise, timeout } from '../../../util/vs/base/common/async';
|
||||
import { CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
|
||||
import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors';
|
||||
import { Emitter } from '../../../util/vs/base/common/event';
|
||||
import { stringHash } from '../../../util/vs/base/common/hash';
|
||||
import { Disposable, IDisposable } from '../../../util/vs/base/common/lifecycle';
|
||||
import { Mutable } from '../../../util/vs/base/common/types';
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
import { generateUuid } from '../../../util/vs/base/common/uuid';
|
||||
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatResponsePullRequestPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelToolResult2, MarkdownString } from '../../../vscodeTypes';
|
||||
import { ChatResponsePullRequestPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelTextPart, LanguageModelToolResult2, MarkdownString } from '../../../vscodeTypes';
|
||||
import { InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes';
|
||||
import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection';
|
||||
import { Conversation, IResultMetadata, ResponseStreamParticipant, TurnStatus, TurnTokenUsageMetadata } from '../../prompt/common/conversation';
|
||||
@@ -52,7 +53,7 @@ import { PseudoStopStartResponseProcessor } from '../../prompt/node/pseudoStartS
|
||||
import { ResponseProcessorContext } from '../../prompt/node/responseProcessorContext';
|
||||
import { SummarizedConversationHistoryMetadata } from '../../prompts/node/agent/summarizedConversationHistory';
|
||||
import { ToolFailureEncountered, ToolResultMetadata } from '../../prompts/node/panel/toolCalling';
|
||||
import { ToolName } from '../../tools/common/toolNames';
|
||||
import { getToolName, ToolName } from '../../tools/common/toolNames';
|
||||
import { IToolsService, ToolCallCancelledError } from '../../tools/common/toolsService';
|
||||
import { ReadFileParams } from '../../tools/node/readFileTool';
|
||||
import { isHookAbortError, processHookResults } from './hookResultProcessor';
|
||||
@@ -86,6 +87,10 @@ export interface IToolCallingLoopOptions {
|
||||
* The current chat request
|
||||
*/
|
||||
request: ChatRequest;
|
||||
/**
|
||||
* Enables deterministic Voice Mode progress for the top-level Agent loop.
|
||||
*/
|
||||
enableVoiceProgress?: boolean;
|
||||
/**
|
||||
* A getter that returns true if VS Code has requested the extension to
|
||||
* gracefully yield. When set, it's likely that the editor will immediately
|
||||
@@ -145,6 +150,171 @@ interface SubagentStopHookResult {
|
||||
readonly reasons?: readonly string[];
|
||||
}
|
||||
|
||||
type VoiceProgressPhase = 'investigating' | 'planning' | 'editing' | 'validating' | 'recovering';
|
||||
|
||||
interface VoiceProgressToolInput {
|
||||
readonly stage: VoiceProgressPhase;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
type VoiceProgressToolInputResult = { readonly input: VoiceProgressToolInput } | { readonly error: string };
|
||||
|
||||
const voiceProgressPhases = new Set<VoiceProgressPhase>(['investigating', 'planning', 'editing', 'validating', 'recovering']);
|
||||
const voiceProgressSummaryMaxLength = 240;
|
||||
const unsafeVoiceProgressSummaryPattern = /[`*_#\[\]<>]|(?:https?:\/\/|file:\/\/)|(?:^|\s)(?:\.{0,2}[\\/]|[A-Za-z]:\\)|\b[\w.-]+\/[\w./-]+\b|\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b|\b(?:gh[pousr]_|AKIA)[A-Za-z0-9_-]+|\b[0-9a-f]{8}-[0-9a-f-]{27,}\b|\b[0-9a-f]{32,}\b/i;
|
||||
|
||||
const editingToolNames = new Set<string>([
|
||||
ToolName.ApplyPatch,
|
||||
ToolName.CreateDirectory,
|
||||
ToolName.CreateFile,
|
||||
ToolName.CreateNewJupyterNotebook,
|
||||
ToolName.EditFile,
|
||||
ToolName.EditNotebook,
|
||||
ToolName.MultiReplaceString,
|
||||
ToolName.ReplaceString,
|
||||
]);
|
||||
|
||||
const validationToolNames = new Set<string>([
|
||||
ToolName.CoreCreateAndRunTask,
|
||||
ToolName.CoreRunTask,
|
||||
ToolName.CoreRunTest,
|
||||
ToolName.GetErrors,
|
||||
ToolName.RunNotebookCell,
|
||||
]);
|
||||
|
||||
const investigatingToolNames = new Set<string>([
|
||||
ToolName.Codebase,
|
||||
ToolName.VSCodeAPI,
|
||||
ToolName.FindFiles,
|
||||
ToolName.FindTextInFiles,
|
||||
ToolName.ReadFile,
|
||||
ToolName.ViewImage,
|
||||
ToolName.ListDirectory,
|
||||
ToolName.GetScmChanges,
|
||||
ToolName.ReadProjectStructure,
|
||||
ToolName.SearchWorkspaceSymbols,
|
||||
ToolName.GetNotebookSummary,
|
||||
ToolName.ReadCellOutput,
|
||||
ToolName.FetchWebPage,
|
||||
ToolName.FindTestFiles,
|
||||
ToolName.GithubSemanticRepoSearch,
|
||||
ToolName.GithubTextSearch,
|
||||
ToolName.SearchSubagent,
|
||||
ToolName.ExploreSubagent,
|
||||
ToolName.CoreRunSubagent,
|
||||
ToolName.ToolSearch,
|
||||
ToolName.CoreReadPage,
|
||||
ToolName.CoreScreenshotPage,
|
||||
]);
|
||||
|
||||
const planningToolNames = new Set<string>([
|
||||
ToolName.CoreManageTodoList,
|
||||
ToolName.CoreReviewPlan,
|
||||
ToolName.CoreAskQuestions,
|
||||
]);
|
||||
|
||||
function isEditingTool(name: string): boolean {
|
||||
return editingToolNames.has(getToolName(name));
|
||||
}
|
||||
|
||||
function isInvestigatingTool(name: string): boolean {
|
||||
const toolName = getToolName(name);
|
||||
return investigatingToolNames.has(toolName) || /(?:^|_)(?:explore|find|grep|inspect|list|read|search)(?:_|$)/i.test(toolName);
|
||||
}
|
||||
|
||||
function isPlanningTool(name: string): boolean {
|
||||
const toolName = getToolName(name);
|
||||
return planningToolNames.has(toolName) || /(?:askQuestions|artifact|plan|todo)/i.test(toolName);
|
||||
}
|
||||
|
||||
function isValidationToolCall(call: IToolCall): boolean {
|
||||
const name = getToolName(call.name);
|
||||
if (validationToolNames.has(name)) {
|
||||
return true;
|
||||
}
|
||||
return name === ToolName.CoreRunInTerminal && /\b(?:build|check|compile|lint|test|typecheck)\b/i.test(call.arguments);
|
||||
}
|
||||
|
||||
function isVoiceProgressPhase(value: string): value is VoiceProgressPhase {
|
||||
return voiceProgressPhases.has(value as VoiceProgressPhase);
|
||||
}
|
||||
|
||||
function parseVoiceProgressToolInput(argumentsJson: string): VoiceProgressToolInputResult {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(argumentsJson);
|
||||
} catch {
|
||||
return { error: 'the input must be valid JSON' };
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return { error: 'the input must be an object' };
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
if (Object.keys(input).some(key => key !== 'stage' && key !== 'summary')) {
|
||||
return { error: 'only stage and summary are allowed' };
|
||||
}
|
||||
if (typeof input.stage !== 'string' || !isVoiceProgressPhase(input.stage)) {
|
||||
return { error: 'stage must be investigating, planning, editing, validating, or recovering' };
|
||||
}
|
||||
if (typeof input.summary !== 'string') {
|
||||
return { error: 'summary must be a string' };
|
||||
}
|
||||
const summary = input.summary.replace(/\s+/g, ' ').trim();
|
||||
if (!summary) {
|
||||
return { error: 'summary must not be empty' };
|
||||
}
|
||||
if (summary.length > voiceProgressSummaryMaxLength) {
|
||||
return { error: `summary must be at most ${voiceProgressSummaryMaxLength} characters` };
|
||||
}
|
||||
if (unsafeVoiceProgressSummaryPattern.test(summary)) {
|
||||
return { error: 'summary must use plain speech without markdown, paths, commands, identifiers, URLs, or secrets' };
|
||||
}
|
||||
return { input: { stage: input.stage, summary } };
|
||||
}
|
||||
|
||||
function getVoiceProgressMessage(phase: VoiceProgressPhase, requestId: string): string {
|
||||
let variants: readonly string[];
|
||||
switch (phase) {
|
||||
case 'investigating':
|
||||
variants = [
|
||||
l10n.t("I'm tracing the relevant code now."),
|
||||
l10n.t("I'm looking through the code to find the right path."),
|
||||
l10n.t("I'm investigating how this fits together."),
|
||||
];
|
||||
break;
|
||||
case 'planning':
|
||||
variants = [
|
||||
l10n.t("I've got the context. I'm working out the approach."),
|
||||
l10n.t("I'm mapping out the cleanest change now."),
|
||||
l10n.t("I've found the path. I'm planning the update."),
|
||||
];
|
||||
break;
|
||||
case 'editing':
|
||||
variants = [
|
||||
l10n.t("Found the spot. I'm making the change now."),
|
||||
l10n.t("There it is. I'm updating the code."),
|
||||
l10n.t("I've got the change point. Making the edit now."),
|
||||
];
|
||||
break;
|
||||
case 'validating':
|
||||
variants = [
|
||||
l10n.t("Nice, that's in. I'm checking it now."),
|
||||
l10n.t("The update's ready. I'm putting it through its checks."),
|
||||
l10n.t("Good progress. I'm verifying everything now."),
|
||||
];
|
||||
break;
|
||||
case 'recovering':
|
||||
variants = [
|
||||
l10n.t("That hit a snag. I'm switching approaches."),
|
||||
l10n.t("Small detour. I'm trying a better route."),
|
||||
l10n.t("Not quite. I've got another angle to try."),
|
||||
];
|
||||
break;
|
||||
}
|
||||
const index = (stringHash(`${requestId}:${phase}`, 0) >>> 0) % variants.length;
|
||||
return variants[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a hook context message from blocking reasons.
|
||||
* @param reasons The reasons hooks blocked the agent from stopping
|
||||
@@ -168,6 +338,7 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
private static NextToolCallId = Date.now();
|
||||
|
||||
private static readonly TASK_COMPLETE_TOOL_NAME = 'task_complete';
|
||||
private static readonly VOICE_PROGRESS_TOOL_NAME = 'report_voice_progress';
|
||||
|
||||
private toolCallResults: Record<string, LanguageModelToolResult2> = Object.create(null);
|
||||
private toolCallRounds: IToolCallRound[] = [];
|
||||
@@ -179,6 +350,7 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
private toolsAvailableEmitted = false;
|
||||
private lastHeaderRequestId: string | undefined;
|
||||
private lastModelCallId: string | undefined;
|
||||
private readonly reportedVoiceProgress = new Set<VoiceProgressPhase>();
|
||||
|
||||
/**
|
||||
* Running total of Copilot credits across every model call in the current
|
||||
@@ -654,6 +826,134 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
}
|
||||
}
|
||||
|
||||
private isVoiceProgressEnabled(): boolean {
|
||||
return Boolean(this.options.enableVoiceProgress && this.options.request.isVoiceModeInput && !this.options.request.subAgentInvocationId);
|
||||
}
|
||||
|
||||
protected reportVoiceProgress(outputStream: ChatResponseStream | undefined, phase: VoiceProgressPhase, summary?: string): boolean {
|
||||
if (!this.options.enableVoiceProgress || !this.options.request.isVoiceModeInput || this.options.request.subAgentInvocationId || this.reportedVoiceProgress.has(phase)) {
|
||||
return false;
|
||||
}
|
||||
this.reportedVoiceProgress.add(phase);
|
||||
outputStream?.voiceProgress(phase, summary ?? getVoiceProgressMessage(phase, this.options.request.id));
|
||||
this._logService.info(`[VoiceProgress] emitted request=${this.options.request.id} phase=${phase} source=${summary ? 'model' : 'fallback'} stream=${Boolean(outputStream)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected getVoiceProgressFallbackPhase(round: IToolCallRound): VoiceProgressPhase | undefined {
|
||||
const hasEditingTool = round.toolCalls.some(call => isEditingTool(call.name));
|
||||
const validationFailed = round.toolCalls.some(call => getToolName(call.name) === ToolName.CoreTestFailure);
|
||||
if (validationFailed || (hasEditingTool && this.reportedVoiceProgress.has('validating'))) {
|
||||
return 'recovering';
|
||||
}
|
||||
if (!this.reportedVoiceProgress.has('editing') && hasEditingTool) {
|
||||
return 'editing';
|
||||
}
|
||||
if (round.toolCalls.some(isValidationToolCall)) {
|
||||
return 'validating';
|
||||
}
|
||||
if (round.toolCalls.some(call => isPlanningTool(call.name))) {
|
||||
return 'planning';
|
||||
}
|
||||
if (round.toolCalls.some(call => isInvestigatingTool(call.name))) {
|
||||
return 'investigating';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected reportVoiceProgressForRound(outputStream: ChatResponseStream | undefined, round: IToolCallRound): void {
|
||||
const phase = this.getVoiceProgressFallbackPhase(round);
|
||||
if (!phase) {
|
||||
return;
|
||||
}
|
||||
const emitted = this.reportVoiceProgress(outputStream, phase);
|
||||
this._logService.info(`[VoiceProgress] fallback request=${this.options.request.id} phase=${phase} emitted=${emitted} tools=${round.toolCalls.map(call => getToolName(call.name)).join(',')}`);
|
||||
}
|
||||
|
||||
protected ensureVoiceProgressTool(availableTools: LanguageModelToolInformation[]): LanguageModelToolInformation[] {
|
||||
if (!this.isVoiceProgressEnabled() || availableTools.some(tool => tool.name === ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME)) {
|
||||
return availableTools;
|
||||
}
|
||||
this._logService.info(`[VoiceProgress] injected tool request=${this.options.request.id} availableTools=${availableTools.length + 1}`);
|
||||
return [...availableTools, {
|
||||
name: ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME,
|
||||
description: 'Report one concise factual spoken progress update to the user at a meaningful stage change. Call this in parallel with actual work when possible. Do not use it for acknowledgements, questions, confirmations, or the final response.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stage: {
|
||||
type: 'string',
|
||||
enum: ['investigating', 'planning', 'editing', 'validating', 'recovering'],
|
||||
description: 'The current work stage.',
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: voiceProgressSummaryMaxLength,
|
||||
description: 'A concise user-facing factual update in plain speech, without markdown, paths, commands, identifiers, secrets, reasoning, or raw source and tool output.',
|
||||
},
|
||||
},
|
||||
required: ['stage', 'summary'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
tags: [],
|
||||
source: undefined,
|
||||
}];
|
||||
}
|
||||
|
||||
protected processVoiceProgressToolCalls(outputStream: ChatResponseStream | undefined, toolCalls: readonly IToolCall[]): void {
|
||||
if (!this.isVoiceProgressEnabled()) {
|
||||
return;
|
||||
}
|
||||
for (const toolCall of toolCalls) {
|
||||
if (toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseVoiceProgressToolInput(toolCall.arguments);
|
||||
let resultMessage: string;
|
||||
if ('error' in parsed) {
|
||||
resultMessage = `Voice progress was not reported because ${parsed.error}.`;
|
||||
} else if (this.reportVoiceProgress(outputStream, parsed.input.stage, parsed.input.summary)) {
|
||||
resultMessage = 'Voice progress reported.';
|
||||
} else {
|
||||
resultMessage = `Voice progress for ${parsed.input.stage} was already reported.`;
|
||||
}
|
||||
this.toolCallResults[toolCall.id] = new LanguageModelToolResult2([new LanguageModelTextPart(resultMessage)]);
|
||||
this._logService.info(`[VoiceProgress] processed model tool request=${this.options.request.id} call=${toolCall.id} valid=${!('error' in parsed)}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected hasProductiveToolCalls(round: IToolCallRound): boolean {
|
||||
return round.toolCalls.some(toolCall =>
|
||||
toolCall.name !== ToolCallingLoop.TASK_COMPLETE_TOOL_NAME
|
||||
&& toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME
|
||||
);
|
||||
}
|
||||
|
||||
protected getPersistableToolCallingState(): { toolCallRounds: IToolCallRound[]; toolCallResults: Record<string, LanguageModelToolResult2> } {
|
||||
const toolCallRounds: IToolCallRound[] = [];
|
||||
const toolCallResults: Record<string, LanguageModelToolResult2> = {};
|
||||
for (const round of this.toolCallRounds) {
|
||||
const persistableRound = this.withoutVoiceProgressToolCalls(round);
|
||||
if (!persistableRound.toolCalls.length && !persistableRound.response && !persistableRound.thinking && !persistableRound.statefulMarker && !persistableRound.compaction && !persistableRound.hookContext) {
|
||||
continue;
|
||||
}
|
||||
toolCallRounds.push(persistableRound);
|
||||
for (const toolCall of persistableRound.toolCalls) {
|
||||
const result = this.toolCallResults[toolCall.id];
|
||||
if (result) {
|
||||
toolCallResults[toolCall.id] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { toolCallRounds, toolCallResults };
|
||||
}
|
||||
|
||||
private withoutVoiceProgressToolCalls(round: IToolCallRound): IToolCallRound {
|
||||
const toolCalls = round.toolCalls.filter(toolCall => toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME);
|
||||
return toolCalls.length === round.toolCalls.length ? round : { ...round, toolCalls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the `task_complete` tool is present in the available tools when running in
|
||||
* autopilot mode. If it's missing (e.g. filtered out by the tool picker), it's resolved
|
||||
@@ -1133,6 +1433,8 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
this.agentSpan = agentSpan;
|
||||
this.chatSessionIdForTools = chatSessionId;
|
||||
this.toolsAvailableEmitted = false;
|
||||
this.reportedVoiceProgress.clear();
|
||||
this._logService.info(`[VoiceProgress] loop request=${this.options.request.id} configured=${Boolean(this.options.enableVoiceProgress)} voice=${Boolean(this.options.request.isVoiceModeInput)} subagent=${Boolean(this.options.request.subAgentInvocationId)} stream=${Boolean(outputStream)}`);
|
||||
|
||||
while (true) {
|
||||
if (lastResult && i++ >= this.options.toolCallLimit) {
|
||||
@@ -1162,6 +1464,7 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
agentSpan?.addEvent('turn_start', { turnId, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });
|
||||
this.resolveAutopilotProgress();
|
||||
const result = await this.runOne(outputStream, i, token);
|
||||
this.reportVoiceProgressForRound(outputStream, result.round);
|
||||
if (lastRequestMessagesStartingIndexForRun === undefined) {
|
||||
lastRequestMessagesStartingIndexForRun = result.lastRequestMessages.length - 1;
|
||||
}
|
||||
@@ -1176,7 +1479,7 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
|
||||
// If the model produced productive (non-task_complete) tool calls after being nudged,
|
||||
// reset the stop hook flag and iteration count so it can be nudged again.
|
||||
if (this.autopilotStopHookActive && result.round.toolCalls.length && !result.round.toolCalls.some(tc => tc.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)) {
|
||||
if (this.autopilotStopHookActive && this.hasProductiveToolCalls(result.round)) {
|
||||
this.autopilotStopHookActive = false;
|
||||
this.autopilotIterationCount = 0;
|
||||
}
|
||||
@@ -1191,6 +1494,7 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
if (result.response.type !== ChatFetchResponseType.Success && this.shouldAutoRetry(result.response)) {
|
||||
this.autopilotRetryCount++;
|
||||
this._logService.info(`[ToolCallingLoop] Auto-retrying on error (attempt ${this.autopilotRetryCount}/${ToolCallingLoop.MAX_AUTOPILOT_RETRIES}): ${result.response.type}`);
|
||||
this.reportVoiceProgress(outputStream, 'recovering');
|
||||
if (this.options.request.permissionLevel === 'autopilot') {
|
||||
this.showAutopilotProgress(outputStream, l10n.t('Autopilot: recovering from a request error\u2026'), l10n.t('Autopilot recovered from a request error'));
|
||||
} else {
|
||||
@@ -1288,7 +1592,12 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...lastResult, toolCallRounds: this.toolCallRounds, toolCallResults: this.toolCallResults };
|
||||
const persistableState = this.getPersistableToolCallingState();
|
||||
return {
|
||||
...lastResult,
|
||||
round: this.withoutVoiceProgressToolCalls(lastResult.round),
|
||||
...persistableState,
|
||||
};
|
||||
}
|
||||
|
||||
private async emitReadFileTrajectories() {
|
||||
@@ -1468,9 +1777,12 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
}
|
||||
|
||||
// Ensure task_complete is available in autopilot mode so the model can signal completion
|
||||
availableTools = this.ensureAutopilotTools(availableTools);
|
||||
availableTools = this.ensureVoiceProgressTool(this.ensureAutopilotTools(availableTools));
|
||||
|
||||
const isToolInputFailure = effectiveBuildPromptResult.metadata.get(ToolFailureEncountered);
|
||||
if (isToolInputFailure) {
|
||||
this.reportVoiceProgress(outputStream, 'recovering');
|
||||
}
|
||||
const conversationSummary = effectiveBuildPromptResult.metadata.get(SummarizedConversationHistoryMetadata);
|
||||
if (conversationSummary) {
|
||||
this.turn.setMetadata(conversationSummary);
|
||||
@@ -1525,7 +1837,10 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
chatResult = await that.options.responseProcessor.processResponse(this.context, inputStream, responseStream, token);
|
||||
} else {
|
||||
const subagentInvocationId = getSubAgentInvocationId(context);
|
||||
const responseProcessor = that._instantiationService.createInstance(PseudoStopStartResponseProcessor, [], undefined, { subagentInvocationId });
|
||||
const responseProcessor = that._instantiationService.createInstance(PseudoStopStartResponseProcessor, [], undefined, {
|
||||
subagentInvocationId,
|
||||
hiddenToolNames: that.isVoiceProgressEnabled() ? new Set([ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME]) : undefined,
|
||||
});
|
||||
await responseProcessor.processResponse(this.context, inputStream, responseStream, token);
|
||||
}
|
||||
return chatResult;
|
||||
@@ -1626,6 +1941,7 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
const fetchResult = await this.fetch(fetchOptions, token).finally(() => {
|
||||
this.stopHookUserInitiated = false;
|
||||
});
|
||||
this.processVoiceProgressToolCalls(outputStream, toolCalls);
|
||||
markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.DidFetch);
|
||||
|
||||
// Store the server-echoed headerRequestId from the fetch response for subagent telemetry linking.
|
||||
@@ -1711,12 +2027,14 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
thinkingItem?.updateWithFetchResult(fetchResult);
|
||||
|
||||
// Log the assistant message to the transcript
|
||||
const transcriptToolRequests: ToolRequest[] = toolCalls.map(tc => ({
|
||||
const transcriptToolRequests: ToolRequest[] = toolCalls
|
||||
.filter(toolCall => toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME)
|
||||
.map(tc => ({
|
||||
toolCallId: tc.id,
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
type: 'function' as const,
|
||||
}));
|
||||
}));
|
||||
this._sessionTranscriptService.logAssistantMessage(
|
||||
this.options.conversation.sessionId,
|
||||
fetchResult.value,
|
||||
|
||||
+249
-5
@@ -4,19 +4,22 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ChatRequest, LanguageModelToolInformation } from 'vscode';
|
||||
import type { ChatRequest, ChatResponseStream, LanguageModelToolInformation } from 'vscode';
|
||||
import { IChatHookService } from '../../../../platform/chat/common/chatHookService';
|
||||
import { ChatFetchResponseType, ChatResponse } from '../../../../platform/chat/common/commonTypes';
|
||||
import { SpyChatResponseStream } from '../../../../util/common/test/mockChatResponseStream';
|
||||
import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
|
||||
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
|
||||
import { generateUuid } from '../../../../util/vs/base/common/uuid';
|
||||
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { Conversation, Turn } from '../../../prompt/common/conversation';
|
||||
import { IBuildPromptContext, IToolCallRound } from '../../../prompt/common/intents';
|
||||
import { IBuildPromptContext, IToolCall, IToolCallRound } from '../../../prompt/common/intents';
|
||||
import { IBuildPromptResult, nullRenderPromptResult } from '../../../prompt/node/intents';
|
||||
import { createExtensionUnitTestingServices } from '../../../test/node/services';
|
||||
import { ToolName } from '../../../tools/common/toolNames';
|
||||
import { IToolsService } from '../../../tools/common/toolsService';
|
||||
import { TestToolsService } from '../../../tools/node/test/testToolsService';
|
||||
import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';
|
||||
import { IToolCallingLoopOptions, IToolCallSingleResult, ToolCallingLoop } from '../../node/toolCallingLoop';
|
||||
import { MockChatHookService } from './mockChatHookService';
|
||||
|
||||
@@ -69,6 +72,34 @@ class AutopilotTestToolCallingLoop extends ToolCallingLoop<IToolCallingLoopOptio
|
||||
public testEnsureAutopilotTools(tools: LanguageModelToolInformation[]): LanguageModelToolInformation[] {
|
||||
return this.ensureAutopilotTools(tools);
|
||||
}
|
||||
|
||||
public testReportVoiceProgress(stream: ChatResponseStream, phase: 'investigating' | 'planning' | 'editing' | 'validating' | 'recovering'): void {
|
||||
this.reportVoiceProgress(stream, phase);
|
||||
}
|
||||
|
||||
public testReportVoiceProgressForRound(stream: ChatResponseStream, round: IToolCallRound): void {
|
||||
this.reportVoiceProgressForRound(stream, round);
|
||||
}
|
||||
|
||||
public testEnsureVoiceProgressTool(tools: LanguageModelToolInformation[]): LanguageModelToolInformation[] {
|
||||
return this.ensureVoiceProgressTool(tools);
|
||||
}
|
||||
|
||||
public testProcessVoiceProgressToolCalls(stream: ChatResponseStream, toolCalls: readonly IToolCall[]): void {
|
||||
this.processVoiceProgressToolCalls(stream, toolCalls);
|
||||
}
|
||||
|
||||
public testGetToolCallResult(toolCallId: string): LanguageModelToolResult2 | undefined {
|
||||
return this.createPromptContext([], undefined).toolCallResults?.[toolCallId];
|
||||
}
|
||||
|
||||
public testGetPersistableToolCallingState(): { toolCallRounds: IToolCallRound[]; toolCallResults: Record<string, LanguageModelToolResult2> } {
|
||||
return this.getPersistableToolCallingState();
|
||||
}
|
||||
|
||||
public testHasProductiveToolCalls(round: IToolCallRound): boolean {
|
||||
return this.hasProductiveToolCalls(round);
|
||||
}
|
||||
}
|
||||
|
||||
function createMockChatRequest(overrides: Partial<ChatRequest> = {}): ChatRequest {
|
||||
@@ -102,7 +133,7 @@ function createTestConversation(turnCount: number = 1): Conversation {
|
||||
return new Conversation(generateUuid(), turns);
|
||||
}
|
||||
|
||||
function createMockRound(toolCallNames: string[] = [], response: string = ''): IToolCallRound {
|
||||
function createMockRound(toolCallNames: string[] = [], response: string = '', toolArguments = '{}'): IToolCallRound {
|
||||
return {
|
||||
id: generateUuid(),
|
||||
response,
|
||||
@@ -110,7 +141,7 @@ function createMockRound(toolCallNames: string[] = [], response: string = ''): I
|
||||
toolCalls: toolCallNames.map(name => ({
|
||||
id: generateUuid(),
|
||||
name,
|
||||
arguments: '{}',
|
||||
arguments: toolArguments,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -150,7 +181,7 @@ describe('ToolCallingLoop autopilot', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createLoop(permissionLevel?: string, requestOverrides: Partial<ChatRequest> = {}): AutopilotTestToolCallingLoop {
|
||||
function createLoop(permissionLevel?: string, requestOverrides: Partial<ChatRequest> = {}, enableVoiceProgress = true): AutopilotTestToolCallingLoop {
|
||||
const conversation = createTestConversation(1);
|
||||
const request = createMockChatRequest({
|
||||
permissionLevel,
|
||||
@@ -162,12 +193,225 @@ describe('ToolCallingLoop autopilot', () => {
|
||||
conversation,
|
||||
toolCallLimit: 10,
|
||||
request,
|
||||
enableVoiceProgress,
|
||||
}
|
||||
);
|
||||
disposables.add(loop);
|
||||
return loop;
|
||||
}
|
||||
|
||||
describe('voice progress', () => {
|
||||
it('classifies read and planning tools as semantic progress', () => {
|
||||
const loop = createLoop(undefined, { id: 'voice-request', isVoiceModeInput: true });
|
||||
const stream = new SpyChatResponseStream();
|
||||
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.ReadFile]));
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.CoreManageTodoList]));
|
||||
|
||||
expect(stream.items).toEqual([
|
||||
expect.objectContaining({ id: 'investigating' }),
|
||||
expect.objectContaining({ id: 'planning' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects phrase variants deterministically per request', () => {
|
||||
const firstLoop = createLoop(undefined, { id: 'stable-request', isVoiceModeInput: true });
|
||||
const secondLoop = createLoop(undefined, { id: 'stable-request', isVoiceModeInput: true });
|
||||
const firstStream = new SpyChatResponseStream();
|
||||
const secondStream = new SpyChatResponseStream();
|
||||
|
||||
firstLoop.testReportVoiceProgress(firstStream, 'editing');
|
||||
secondLoop.testReportVoiceProgress(secondStream, 'editing');
|
||||
|
||||
expect(firstStream.items).toEqual(secondStream.items);
|
||||
});
|
||||
|
||||
it('does not emit for typed or subagent requests', () => {
|
||||
const typedLoop = createLoop();
|
||||
const subagentLoop = createLoop(undefined, { isVoiceModeInput: true, subAgentInvocationId: 'subagent' });
|
||||
const disabledLoop = createLoop(undefined, { isVoiceModeInput: true }, false);
|
||||
const stream = new SpyChatResponseStream();
|
||||
|
||||
typedLoop.testReportVoiceProgress(stream, 'editing');
|
||||
subagentLoop.testReportVoiceProgress(stream, 'editing');
|
||||
disabledLoop.testReportVoiceProgress(stream, 'editing');
|
||||
|
||||
expect(stream.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits each significant phase once in order', () => {
|
||||
const loop = createLoop(undefined, { isVoiceModeInput: true });
|
||||
const stream = new SpyChatResponseStream();
|
||||
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.ReadFile]));
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.CoreManageTodoList]));
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.EditFile]));
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.CoreRunInTerminal], '', '{"command":"npm test"}'));
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.EditFile]));
|
||||
|
||||
expect(stream.items).toEqual([
|
||||
expect.objectContaining({ id: 'investigating' }),
|
||||
expect.objectContaining({ id: 'planning' }),
|
||||
expect.objectContaining({ id: 'editing' }),
|
||||
expect.objectContaining({ id: 'validating' }),
|
||||
expect.objectContaining({ id: 'recovering' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers the progress tool only to a top-level voice Agent loop', () => {
|
||||
const voiceLoop = createLoop(undefined, { isVoiceModeInput: true });
|
||||
const typedLoop = createLoop();
|
||||
const subagentLoop = createLoop(undefined, { isVoiceModeInput: true, subAgentInvocationId: 'subagent' });
|
||||
const nonAgentLoop = createLoop(undefined, { isVoiceModeInput: true }, false);
|
||||
|
||||
expect({
|
||||
voiceTools: voiceLoop.testEnsureVoiceProgressTool([]).map(tool => ({
|
||||
name: tool.name,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
typedTools: typedLoop.testEnsureVoiceProgressTool([]),
|
||||
subagentTools: subagentLoop.testEnsureVoiceProgressTool([]),
|
||||
nonAgentTools: nonAgentLoop.testEnsureVoiceProgressTool([]),
|
||||
}).toEqual({
|
||||
voiceTools: [{
|
||||
name: 'report_voice_progress',
|
||||
inputSchema: expect.objectContaining({
|
||||
required: ['stage', 'summary'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
}],
|
||||
typedTools: [],
|
||||
subagentTools: [],
|
||||
nonAgentTools: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('turns a valid model progress call into a hidden progress part and local result', () => {
|
||||
const loop = createLoop(undefined, { isVoiceModeInput: true });
|
||||
const stream = new SpyChatResponseStream();
|
||||
|
||||
loop.testProcessVoiceProgressToolCalls(stream, [{
|
||||
id: 'progress-call',
|
||||
name: 'report_voice_progress',
|
||||
arguments: JSON.stringify({
|
||||
stage: 'investigating',
|
||||
summary: 'I am tracing the request flow now.',
|
||||
}),
|
||||
}]);
|
||||
|
||||
const result = loop.testGetToolCallResult('progress-call');
|
||||
expect({
|
||||
streamItems: stream.items,
|
||||
resultText: result?.content[0] instanceof LanguageModelTextPart ? result.content[0].value : undefined,
|
||||
}).toEqual({
|
||||
streamItems: [expect.objectContaining({
|
||||
id: 'investigating',
|
||||
value: 'I am tracing the request flow now.',
|
||||
})],
|
||||
resultText: 'Voice progress reported.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsafe or out-of-bounds model progress summaries', () => {
|
||||
const loop = createLoop(undefined, { isVoiceModeInput: true });
|
||||
const stream = new SpyChatResponseStream();
|
||||
const calls = [
|
||||
{ id: 'bad-stage', stage: 'done', summary: 'Finished.' },
|
||||
{ id: 'too-long', stage: 'editing', summary: 'x'.repeat(241) },
|
||||
{ id: 'markdown', stage: 'editing', summary: 'Editing **src/secret.ts** now.' },
|
||||
];
|
||||
|
||||
loop.testProcessVoiceProgressToolCalls(stream, calls.map(call => ({
|
||||
id: call.id,
|
||||
name: 'report_voice_progress',
|
||||
arguments: JSON.stringify({ stage: call.stage, summary: call.summary }),
|
||||
})));
|
||||
|
||||
expect({
|
||||
streamItems: stream.items,
|
||||
results: calls.map(call => {
|
||||
const result = loop.testGetToolCallResult(call.id);
|
||||
return result?.content[0] instanceof LanguageModelTextPart ? result.content[0].value : undefined;
|
||||
}),
|
||||
}).toEqual({
|
||||
streamItems: [],
|
||||
results: [
|
||||
'Voice progress was not reported because stage must be investigating, planning, editing, validating, or recovering.',
|
||||
'Voice progress was not reported because summary must be at most 240 characters.',
|
||||
'Voice progress was not reported because summary must use plain speech without markdown, paths, commands, identifiers, URLs, or secrets.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('model progress suppresses the same-stage deterministic fallback', () => {
|
||||
const loop = createLoop(undefined, { isVoiceModeInput: true });
|
||||
const stream = new SpyChatResponseStream();
|
||||
|
||||
loop.testProcessVoiceProgressToolCalls(stream, [{
|
||||
id: 'editing-progress',
|
||||
name: 'report_voice_progress',
|
||||
arguments: '{"stage":"editing","summary":"I found the change point and I am updating it."}',
|
||||
}]);
|
||||
loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.EditFile]));
|
||||
|
||||
expect(stream.items).toEqual([expect.objectContaining({
|
||||
id: 'editing',
|
||||
value: 'I found the change point and I am updating it.',
|
||||
})]);
|
||||
});
|
||||
|
||||
it('removes the internal progress call and summary from persisted tool history', () => {
|
||||
const loop = createLoop(undefined, { isVoiceModeInput: true });
|
||||
const stream = new SpyChatResponseStream();
|
||||
const progressCall: IToolCall = {
|
||||
id: 'progress-call',
|
||||
name: 'report_voice_progress',
|
||||
arguments: '{"stage":"editing","summary":"I found the change point."}',
|
||||
};
|
||||
loop.testProcessVoiceProgressToolCalls(stream, [progressCall]);
|
||||
loop.addToolCallRound({
|
||||
id: 'mixed-round',
|
||||
response: '',
|
||||
toolInputRetry: 0,
|
||||
toolCalls: [
|
||||
progressCall,
|
||||
{ id: 'edit-call', name: ToolName.EditFile, arguments: '{}' },
|
||||
],
|
||||
});
|
||||
|
||||
const state = loop.testGetPersistableToolCallingState();
|
||||
expect({
|
||||
state,
|
||||
leaksProgress: JSON.stringify(state).includes('report_voice_progress') || JSON.stringify(state).includes('I found the change point.'),
|
||||
}).toEqual({
|
||||
state: {
|
||||
toolCallRounds: [{
|
||||
id: 'mixed-round',
|
||||
response: '',
|
||||
toolInputRetry: 0,
|
||||
toolCalls: [{ id: 'edit-call', name: ToolName.EditFile, arguments: '{}' }],
|
||||
}],
|
||||
toolCallResults: {},
|
||||
},
|
||||
leaksProgress: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat voice progress as productive autopilot work', () => {
|
||||
const loop = createLoop('autopilot', { isVoiceModeInput: true });
|
||||
|
||||
expect({
|
||||
progressOnly: loop.testHasProductiveToolCalls(createMockRound(['report_voice_progress'])),
|
||||
taskCompleteOnly: loop.testHasProductiveToolCalls(createMockRound(['task_complete'])),
|
||||
progressAndEdit: loop.testHasProductiveToolCalls(createMockRound(['report_voice_progress', ToolName.EditFile])),
|
||||
}).toEqual({
|
||||
progressOnly: false,
|
||||
taskCompleteOnly: false,
|
||||
progressAndEdit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAutopilotContinue', () => {
|
||||
it('should return a nudge message when task_complete was not called', async () => {
|
||||
const loop = createLoop('autopilot');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import type { ChatQuestion, ChatResponseClearToPreviousToolInvocationReason, ChatResponseFileTree, ChatResponsePart, ChatResponseStream, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, Location, NotebookEdit, TextEdit, ThinkingDelta, Uri } from 'vscode';
|
||||
import type { ChatQuestion, ChatResponseClearToPreviousToolInvocationReason, ChatResponseFileTree, ChatResponsePart, ChatResponseStream, ChatResponseVoiceProgressStage, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, Location, NotebookEdit, TextEdit, ThinkingDelta, Uri } from 'vscode';
|
||||
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
|
||||
import { FinalizableChatResponseStream } from '../../../util/common/chatResponseStreamImpl';
|
||||
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
|
||||
@@ -89,6 +89,10 @@ export class ResponseStreamWithLinkification implements FinalizableChatResponseS
|
||||
return this;
|
||||
}
|
||||
|
||||
voiceProgress(id: ChatResponseVoiceProgressStage, value: string): ChatResponseStream {
|
||||
this.enqueue(() => this._progress.voiceProgress(id, value), false);
|
||||
return this;
|
||||
}
|
||||
|
||||
reference(value: Uri | Location): ChatResponseStream {
|
||||
this.enqueue(() => this._progress.reference(value), false);
|
||||
|
||||
@@ -40,6 +40,7 @@ import { assertType, Mutable } from '../../../util/vs/base/common/types';
|
||||
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatResponseMarkdownPart, ChatResponseProgressPart, ChatResponseTextEditPart, LanguageModelToolResult2 } from '../../../vscodeTypes';
|
||||
import { CodeBlocksMetadata, CodeBlockTrackingChatResponseStream } from '../../codeBlocks/node/codeBlockProcessor';
|
||||
import { Intent } from '../../common/constants';
|
||||
import { CopilotInteractiveEditorResponse, InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes';
|
||||
import { formatHookErrorMessage, HookAbortError, isHookAbortError, processHookResults } from '../../intents/node/hookResultProcessor';
|
||||
import { EmptyPromptError, IToolCallingBuiltPromptEvent, IToolCallingLoopOptions, IToolCallingResponseEvent, IToolCallLoopResult, ToolCallingLoop, ToolCallingLoopFetchOptions, ToolCallLimitBehavior } from '../../intents/node/toolCallingLoop';
|
||||
@@ -332,6 +333,7 @@ export class DefaultIntentRequestHandler {
|
||||
onHitToolCallLimit: this.handlerOptions.confirmOnMaxToolIterations !== false
|
||||
? ToolCallLimitBehavior.Confirm : ToolCallLimitBehavior.Stop,
|
||||
request: this.request,
|
||||
enableVoiceProgress: this.intent.id === Intent.Agent,
|
||||
documentContext: this.documentContext,
|
||||
streamParticipants: this.makeResponseStreamParticipants(intentInvocation),
|
||||
temperature: this.handlerOptions.temperature ?? this.options.temperature,
|
||||
|
||||
@@ -39,7 +39,7 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
|
||||
constructor(
|
||||
private readonly stopStartMappings: readonly StartStopMapping[],
|
||||
private readonly processNonReportedDelta: ((deltas: IResponseDelta[]) => string[]) | undefined,
|
||||
private readonly options?: { subagentInvocationId?: string }
|
||||
private readonly options?: { subagentInvocationId?: string; hiddenToolNames?: ReadonlySet<string> }
|
||||
) { }
|
||||
|
||||
async processResponse(_context: IResponseProcessorContext, inputStream: AsyncIterable<IResponsePart>, outputStream: ChatResponseStream, token: CancellationToken): Promise<void> {
|
||||
@@ -98,6 +98,9 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
|
||||
|
||||
if (delta.beginToolCalls?.length) {
|
||||
for (const beginCall of delta.beginToolCalls) {
|
||||
if (this.options?.hiddenToolNames?.has(beginCall.name)) {
|
||||
continue;
|
||||
}
|
||||
progress.beginToolInvocation(beginCall.id ?? '', getContributedToolName(beginCall.name), { subagentInvocationId: this.options?.subagentInvocationId });
|
||||
}
|
||||
}
|
||||
@@ -105,7 +108,7 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
|
||||
if (delta.copilotToolCallStreamUpdates?.length) {
|
||||
const now = Date.now();
|
||||
for (const update of delta.copilotToolCallStreamUpdates) {
|
||||
if (!update.name) {
|
||||
if (!update.name || this.options?.hiddenToolNames?.has(update.name)) {
|
||||
continue;
|
||||
}
|
||||
const toolId = update.id ?? '';
|
||||
|
||||
+57
-6
@@ -6,7 +6,7 @@
|
||||
|
||||
import { Raw, RenderPromptResult } from '@vscode/prompt-tsx';
|
||||
import { afterEach, beforeEach, expect, suite, test, vi } from 'vitest';
|
||||
import type { ChatLanguageModelToolReference, ChatPromptReference, ChatRequest, ExtendedChatResponsePart, LanguageModelChat } from 'vscode';
|
||||
import type { ChatLanguageModelToolReference, ChatPromptReference, ChatRequest, ExtendedChatResponsePart, LanguageModelChat, LanguageModelToolInformation } from 'vscode';
|
||||
import { IChatMLFetcher } from '../../../../platform/chat/common/chatMLFetcher';
|
||||
import { toTextPart } from '../../../../platform/chat/common/globalStringUtils';
|
||||
import { StaticChatMLFetcher } from '../../../../platform/chat/test/common/staticChatMLFetcher';
|
||||
@@ -25,10 +25,12 @@ import { isObject, isUndefinedOrNull } from '../../../../util/vs/base/common/typ
|
||||
import { generateUuid } from '../../../../util/vs/base/common/uuid';
|
||||
import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';
|
||||
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatLocation, ChatResponseConfirmationPart, ChatResponseMarkdownPart, LanguageModelTextPart, LanguageModelToolResult, Uri } from '../../../../vscodeTypes';
|
||||
import { ChatLocation, ChatResponseConfirmationPart, ChatResponseMarkdownPart, ChatResponseVoiceProgressPart, LanguageModelTextPart, LanguageModelToolResult, Uri } from '../../../../vscodeTypes';
|
||||
import { Intent } from '../../../common/constants';
|
||||
import { ToolCallingLoop } from '../../../intents/node/toolCallingLoop';
|
||||
import { ToolResultMetadata } from '../../../prompts/node/panel/toolCalling';
|
||||
import { createExtensionUnitTestingServices } from '../../../test/node/services';
|
||||
import { ToolName } from '../../../tools/common/toolNames';
|
||||
import { Conversation, Turn } from '../../common/conversation';
|
||||
import { IBuildPromptContext } from '../../common/intents';
|
||||
import { ToolCallRound } from '../../common/toolCallRound';
|
||||
@@ -46,6 +48,7 @@ suite('defaultIntentRequestHandler', () => {
|
||||
let endpoint: IChatEndpoint;
|
||||
let turnIdCounter = 0;
|
||||
let builtPrompts: IBuildPromptContext[] = [];
|
||||
let availableTools: LanguageModelToolInformation[] = [];
|
||||
const sessionId = 'some-session-id';
|
||||
|
||||
const getTurnId = () => `turn-id-${turnIdCounter}`;
|
||||
@@ -62,6 +65,7 @@ suite('defaultIntentRequestHandler', () => {
|
||||
accessor = services.createTestingAccessor();
|
||||
endpoint = accessor.get(IInstantiationService).createInstance(MockEndpoint, undefined);
|
||||
builtPrompts = [];
|
||||
availableTools = [];
|
||||
response = [];
|
||||
promptResult = nullRenderPromptResult();
|
||||
turnIdCounter = 0;
|
||||
@@ -89,7 +93,7 @@ suite('defaultIntentRequestHandler', () => {
|
||||
}
|
||||
|
||||
class TestIntent implements IIntent {
|
||||
id = 'test';
|
||||
constructor(readonly id: string = 'test') { }
|
||||
description = 'test intent';
|
||||
locations = [ChatLocation.Panel];
|
||||
invoke(): Promise<IIntentInvocation> {
|
||||
@@ -118,6 +122,10 @@ suite('defaultIntentRequestHandler', () => {
|
||||
|
||||
return promptResult;
|
||||
}
|
||||
|
||||
async getAvailableTools(): Promise<LanguageModelToolInformation[]> {
|
||||
return availableTools;
|
||||
}
|
||||
}
|
||||
|
||||
class TestChatRequest implements ChatRequest {
|
||||
@@ -127,6 +135,7 @@ suite('defaultIntentRequestHandler', () => {
|
||||
attempt = 1;
|
||||
enableCommandDetection = false;
|
||||
isParticipantDetected = false;
|
||||
isVoiceModeInput?: boolean;
|
||||
location = ChatLocation.Panel;
|
||||
location2 = undefined;
|
||||
prompt = 'hello world!';
|
||||
@@ -146,8 +155,9 @@ suite('defaultIntentRequestHandler', () => {
|
||||
|
||||
const makeHandler = ({
|
||||
request = new TestChatRequest(),
|
||||
turns = []
|
||||
}: { request?: ChatRequest; turns?: Turn[] } = {}) => {
|
||||
turns = [],
|
||||
intent = new TestIntent(),
|
||||
}: { request?: ChatRequest; turns?: Turn[]; intent?: IIntent } = {}) => {
|
||||
turns.push(new Turn(
|
||||
getTurnId(),
|
||||
{ type: 'user', message: request.prompt },
|
||||
@@ -157,7 +167,7 @@ suite('defaultIntentRequestHandler', () => {
|
||||
const instaService = accessor.get(IInstantiationService);
|
||||
return instaService.createInstance(
|
||||
DefaultIntentRequestHandler,
|
||||
new TestIntent(),
|
||||
intent,
|
||||
new Conversation(sessionId, turns),
|
||||
request,
|
||||
responseStream,
|
||||
@@ -305,6 +315,47 @@ suite('defaultIntentRequestHandler', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('voice editAgent emits investigating fallback through the actual handler', async () => {
|
||||
const request = new TestChatRequest();
|
||||
request.isVoiceModeInput = true;
|
||||
availableTools = [{
|
||||
name: ToolName.ReadFile,
|
||||
description: 'Read a file.',
|
||||
inputSchema: { type: 'object' },
|
||||
tags: [],
|
||||
source: undefined,
|
||||
}];
|
||||
const requestSpy = vi.spyOn(endpoint, 'makeChatRequest2');
|
||||
const handler = makeHandler({ request, intent: new TestIntent(Intent.Agent) });
|
||||
chatResponse[0] = [{
|
||||
text: '',
|
||||
copilotToolCalls: [{
|
||||
arguments: '{}',
|
||||
name: ToolName.ReadFile,
|
||||
id: 'read_call',
|
||||
}],
|
||||
}];
|
||||
chatResponse[1] = 'done';
|
||||
const toolResult = new LanguageModelToolResult([new LanguageModelTextPart('read result')]);
|
||||
promptResult = {
|
||||
...nullRenderPromptResult(),
|
||||
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
|
||||
metadata: promptResultMetadata([new ToolResultMetadata('read_call__vscode-0', toolResult)]),
|
||||
};
|
||||
|
||||
await handler.getResult();
|
||||
|
||||
expect({
|
||||
availableTools: requestSpy.mock.calls.at(0)?.[0]?.requestOptions?.tools?.map(tool => tool.function.name),
|
||||
voiceProgress: response
|
||||
.filter(part => part instanceof ChatResponseVoiceProgressPart)
|
||||
.map(part => ({ id: part.id, value: part.value })),
|
||||
}).toEqual({
|
||||
availableTools: [ToolName.ReadFile, 'report_voice_progress'],
|
||||
voiceProgress: [expect.objectContaining({ id: 'investigating' })],
|
||||
});
|
||||
});
|
||||
|
||||
function fillWithToolCalls(insertN = 20) {
|
||||
promptResult = [];
|
||||
for (let i = 0; i < insertN; i++) {
|
||||
|
||||
@@ -128,6 +128,7 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> {
|
||||
</SystemMessage>}
|
||||
</>;
|
||||
const isAutopilot = this.props.promptContext.request?.permissionLevel === 'autopilot';
|
||||
const isVoiceModeInput = this.props.promptContext.request?.isVoiceModeInput && !this.props.promptContext.request.subAgentInvocationId;
|
||||
const sessionResource = this.props.promptContext.request?.sessionResource;
|
||||
const sessionId = sessionResource ? sessionResourceToId(sessionResource) : undefined;
|
||||
const debugTargetSessionIds = extractDebugTargetSessionIds([...this.props.promptContext.chatVariables].map(v => v.reference));
|
||||
@@ -140,6 +141,11 @@ export class AgentPrompt extends PromptElement<AgentPromptProps> {
|
||||
When you have fully completed the task, call the task_complete tool to signal that you are done.<br />
|
||||
IMPORTANT: Before calling task_complete, you MUST provide a brief text summary of what was accomplished in your message. The task is not complete until both the summary and the task_complete call are present.
|
||||
</SystemMessage>}
|
||||
{isVoiceModeInput && <SystemMessage priority={80}>
|
||||
Voice Mode is active, and you are GitHub Copilot speaking directly to the user. Keep the final response concise and easy to understand aloud. Do not expose internal reasoning.<br />
|
||||
You MUST call the report_voice_progress tool in the same response as your first real work tool calls, using investigating before reading or searching. Call it again at later meaningful stage changes, not for every operation, and in parallel with actual work when possible. Use planning when deciding the approach, editing while making changes, validating while running tests, builds, lint, or checks, and recovering after a concrete failure or change of approach.<br />
|
||||
Each summary must be a concise factual update in plain speech, at most 240 characters, with no markdown, paths, commands, identifiers, secrets, reasoning, or raw source and tool output. Do not repeat the acknowledgement or final response. Questions, confirmations, questionnaires, and the final response use their existing structured flows instead.
|
||||
</SystemMessage>}
|
||||
{templateVariablesContext.length > 0 && <SystemMessage>{templateVariablesContext}</SystemMessage>}
|
||||
<UserMessage>
|
||||
{await this.getOrCreateGlobalAgentContext(this.props.endpoint)}
|
||||
|
||||
@@ -167,6 +167,35 @@ testFamilies.forEach(family => {
|
||||
}, undefined)).toMatchFileSnapshot(getSnapshotFile('simple_case'));
|
||||
});
|
||||
|
||||
if (family === 'default') {
|
||||
test('voice progress guidance appears only for top-level voice requests', async () => {
|
||||
const promptContext = {
|
||||
chatVariables: new ChatVariablesCollection(),
|
||||
history: [],
|
||||
query: 'hello',
|
||||
};
|
||||
const topLevelVoicePrompt = await agentPromptToString(accessor, {
|
||||
...promptContext,
|
||||
request: { isVoiceModeInput: true } as IBuildPromptContext['request'],
|
||||
});
|
||||
const subagentVoicePrompt = await agentPromptToString(accessor, {
|
||||
...promptContext,
|
||||
request: { isVoiceModeInput: true, subAgentInvocationId: 'subagent' } as IBuildPromptContext['request'],
|
||||
});
|
||||
const typedPrompt = await agentPromptToString(accessor, promptContext);
|
||||
|
||||
expect({
|
||||
topLevelVoice: topLevelVoicePrompt.includes('You MUST call the report_voice_progress tool in the same response as your first real work tool calls'),
|
||||
subagentVoice: subagentVoicePrompt.includes('You MUST call the report_voice_progress tool in the same response as your first real work tool calls'),
|
||||
typed: typedPrompt.includes('You MUST call the report_voice_progress tool in the same response as your first real work tool calls'),
|
||||
}).toEqual({
|
||||
topLevelVoice: true,
|
||||
subagentVoice: false,
|
||||
typed: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('all tools', async () => {
|
||||
const toolsService = accessor.get(IToolsService);
|
||||
await expect(await agentPromptToString(accessor, {
|
||||
|
||||
+40
@@ -206,6 +206,46 @@ suite('Tool stream throttling', () => {
|
||||
assert.strictEqual(updateCalls[0].toolCallId, 'tool1');
|
||||
});
|
||||
|
||||
test('hidden local tools do not create or update tool cards', async () => {
|
||||
const responseSource = new AsyncIterableSource<IResponsePart>();
|
||||
const beginCalls: { toolCallId: string; toolName: string }[] = [];
|
||||
const hiddenStream = new ChatResponseStreamImpl(
|
||||
() => { },
|
||||
() => { },
|
||||
undefined,
|
||||
(toolCallId, toolName) => beginCalls.push({ toolCallId, toolName }),
|
||||
(toolCallId, streamData) => updateCalls.push({ toolCallId, streamData }),
|
||||
);
|
||||
const processor = new PseudoStopStartResponseProcessor([], undefined, {
|
||||
hiddenToolNames: new Set(['report_voice_progress']),
|
||||
});
|
||||
|
||||
responseSource.emitOne({
|
||||
delta: {
|
||||
text: '',
|
||||
beginToolCalls: [
|
||||
{ id: 'hidden', name: 'report_voice_progress' },
|
||||
{ id: 'visible', name: 'visible_tool' },
|
||||
],
|
||||
copilotToolCallStreamUpdates: [
|
||||
{ id: 'hidden', name: 'report_voice_progress', arguments: '{"stage":"editing"}' },
|
||||
{ id: 'visible', name: 'visible_tool', arguments: '{"value":1}' },
|
||||
],
|
||||
},
|
||||
});
|
||||
responseSource.resolve();
|
||||
|
||||
await processor.doProcessResponse(responseSource.asyncIterable, hiddenStream, CancellationToken.None);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
beginCalls,
|
||||
updateCalls: updateCalls.map(call => call.toolCallId),
|
||||
}, {
|
||||
beginCalls: [{ toolCallId: 'visible', toolName: 'visible_tool' }],
|
||||
updateCalls: ['visible'],
|
||||
});
|
||||
});
|
||||
|
||||
test('rapid updates within throttle window are throttled', async () => {
|
||||
const responseSource = new AsyncIterableSource<IResponsePart>();
|
||||
const processor = new PseudoStopStartResponseProcessor([], undefined);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/do
|
||||
import { Edits } from '../../../../platform/inlineEdits/common/dataTypes/edit';
|
||||
import { ImportChanges } from '../../../../platform/inlineEdits/common/dataTypes/importFilteringOptions';
|
||||
import { LanguageId } from '../../../../platform/inlineEdits/common/dataTypes/languageId';
|
||||
import { DEFAULT_OPTIONS, EarlyDivergenceCancellationMode, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PatchModelPrediction, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';
|
||||
import { AggressivenessLevel, DEFAULT_OPTIONS, EarlyDivergenceCancellationMode, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PatchModelPrediction, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';
|
||||
import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext';
|
||||
import { IInlineEditsModelService } from '../../../../platform/inlineEdits/common/inlineEditsModelService';
|
||||
import { NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StreamedEdit, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider';
|
||||
@@ -1047,6 +1047,40 @@ describe('XtabProvider integration', () => {
|
||||
expect(getMessageText(systemMessage!)).toBe(xtab275SystemPrompt);
|
||||
});
|
||||
|
||||
it('applies configured aggressiveness only to aggressiveness strategies', async () => {
|
||||
const lines = ['const x = 1;', 'const y = 2;'];
|
||||
const captureUserPrompt = async (promptingStrategy: PromptingStrategy, aggressivenessLevel: AggressivenessLevel) => {
|
||||
mockModelService.setSelectedConfig({ promptingStrategy });
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel, aggressivenessLevel);
|
||||
streamingFetcher.setStreamingLines(lines);
|
||||
|
||||
const gen = createProvider().provideNextEdit(createRequestWithEdit(lines, { insertionOffset: 3, insertedText: 'a' }), createMockLogger(), createLogContext(), CancellationToken.None);
|
||||
await AsyncIterUtils.drainUntilReturn(gen);
|
||||
|
||||
const messages = streamingFetcher.capturedOptions.at(-1)?.messages;
|
||||
const userMessage = messages?.find(message => message.role === Raw.ChatRole.User);
|
||||
expect(userMessage).toBeDefined();
|
||||
return getMessageText(userMessage!);
|
||||
};
|
||||
|
||||
const nonAggressiveLow = await captureUserPrompt(PromptingStrategy.Xtab275, AggressivenessLevel.Low);
|
||||
const nonAggressiveHigh = await captureUserPrompt(PromptingStrategy.Xtab275, AggressivenessLevel.High);
|
||||
const aggressiveLow = await captureUserPrompt(PromptingStrategy.XtabAggressiveness, AggressivenessLevel.Low);
|
||||
const aggressiveHigh = await captureUserPrompt(PromptingStrategy.XtabAggressiveness, AggressivenessLevel.High);
|
||||
|
||||
expect({
|
||||
nonAggressivePromptsMatch: nonAggressiveLow === nonAggressiveHigh,
|
||||
nonAggressivePromptHasLevel: nonAggressiveLow.includes('<|aggressive|>'),
|
||||
aggressiveLowHasLevel: aggressiveLow.includes('<|aggressive|>low<|/aggressive|>'),
|
||||
aggressiveHighHasLevel: aggressiveHigh.includes('<|aggressive|>high<|/aggressive|>'),
|
||||
}).toEqual({
|
||||
nonAggressivePromptsMatch: true,
|
||||
nonAggressivePromptHasLevel: false,
|
||||
aggressiveLowHasLevel: true,
|
||||
aggressiveHighHasLevel: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('retries with default model after NotFound response', async () => {
|
||||
const provider = createProvider();
|
||||
|
||||
@@ -1977,6 +2011,30 @@ describe('XtabProvider integration', () => {
|
||||
// ========================================================================
|
||||
|
||||
describe('debounce behavior', () => {
|
||||
it('does not change timing for a non-aggressiveness strategy when user eagerness is default', async () => {
|
||||
mockModelService.setSelectedConfig({ promptingStrategy: PromptingStrategy.Xtab275 });
|
||||
const setBaseDebounceTime = vi.spyOn(DelaySession.prototype, 'setBaseDebounceTime');
|
||||
const setExpectedTotalTime = vi.spyOn(DelaySession.prototype, 'setExpectedTotalTime');
|
||||
|
||||
try {
|
||||
const lines = ['const x = 1;', 'const y = 2;'];
|
||||
streamingFetcher.setStreamingLines(lines);
|
||||
const gen = createProvider().provideNextEdit(createRequestWithEdit(lines, { insertionOffset: 3, insertedText: 'a' }), createMockLogger(), createLogContext(), CancellationToken.None);
|
||||
await AsyncIterUtils.drainUntilReturn(gen);
|
||||
|
||||
expect({
|
||||
setBaseDebounceTimeCalls: setBaseDebounceTime.mock.calls.length,
|
||||
setExpectedTotalTimeCalls: setExpectedTotalTime.mock.calls.length,
|
||||
}).toEqual({
|
||||
setBaseDebounceTimeCalls: 0,
|
||||
setExpectedTotalTimeCalls: 0,
|
||||
});
|
||||
} finally {
|
||||
setBaseDebounceTime.mockRestore();
|
||||
setExpectedTotalTime.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('debounce is skipped in simulation tests', async () => {
|
||||
// Override the simulation test context to indicate we're in sim tests
|
||||
const testingServiceCollection = createExtensionUnitTestingServices(disposables);
|
||||
|
||||
@@ -907,7 +907,7 @@ export namespace ConfigKey {
|
||||
export const InlineEditsXtabOnlyMergeConflictLines = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.xtabProvider.onlyMergeConflictLines', ConfigType.ExperimentBased, false);
|
||||
export const InlineEditsXtabDuplicateAdditionsMode = defineTeamInternalSetting<DuplicateAdditionsMode>('chat.advanced.inlineEdits.xtabProvider.diffPatch.duplicateAdditionsMode', ConfigType.ExperimentBased, DuplicateAdditionsMode.Off, DuplicateAdditionsMode.VALIDATOR);
|
||||
export const InlineEditsXtabSplitPatchOnDiff = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.xtabProvider.diffPatch.splitOnDiff', ConfigType.ExperimentBased, false, vBoolean());
|
||||
export const InlineEditsXtabAggressivenessLevel = defineTeamInternalSetting<xtabPromptOptions.AggressivenessLevel | undefined>('chat.advanced.inlineEdits.xtabProvider.aggressivenessLevel', ConfigType.ExperimentBased, undefined);
|
||||
export const InlineEditsXtabAggressivenessLevel = defineTeamInternalSetting<xtabPromptOptions.AggressivenessLevel | undefined>('chat.advanced.inlineEdits.xtabProvider.aggressivenessLevel', ConfigType.ExperimentBased, xtabPromptOptions.AggressivenessLevel.Medium);
|
||||
export const InlineEditsAggressivenessLowMinResponseTimeMs = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.aggressiveness.lowMinResponseTimeMs', ConfigType.ExperimentBased, 1500);
|
||||
export const InlineEditsAggressivenessMediumMinResponseTimeMs = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.aggressiveness.mediumMinResponseTimeMs', ConfigType.ExperimentBased, 700);
|
||||
export const InlineEditsAggressivenessHighDebounceMs = defineTeamInternalSetting<number>('chat.advanced.inlineEdits.aggressiveness.highDebounceMs', ConfigType.ExperimentBased, 0);
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RequestType } from '@vscode/copilot-api';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { Minimatch } from 'minimatch';
|
||||
import { createSha256Hash } from '../../../util/common/crypto';
|
||||
import { coalesce } from '../../../util/vs/base/common/arrays';
|
||||
import { Limiter, raceCancellationError } from '../../../util/vs/base/common/async';
|
||||
import { DeferredPromise, Limiter, raceCancellationError, timeout } from '../../../util/vs/base/common/async';
|
||||
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
|
||||
import { IDisposable } from '../../../util/vs/base/common/lifecycle';
|
||||
import { ResourceMap } from '../../../util/vs/base/common/map';
|
||||
@@ -18,9 +18,13 @@ import { IFileSystemService } from '../../filesystem/common/fileSystemService';
|
||||
import { readFileFromTextBufferOrFS } from '../../filesystem/node/fileSystemServiceImpl';
|
||||
import { IGitService, RepoContext, normalizeFetchUrl } from '../../git/common/gitService';
|
||||
import { ILogService } from '../../log/common/logService';
|
||||
import { Response } from '../../networking/common/fetcherService';
|
||||
import { IRequestLogger } from '../../requestLogger/common/requestLogger';
|
||||
import { IWorkspaceService } from '../../workspace/common/workspaceService';
|
||||
import { composeFetchMiddleware } from '../../../shared-fetch-utils/common/advancedFetcher';
|
||||
import { FetchBlockedError, type HttpFetchFn, type HttpResponse } from '../../../shared-fetch-utils/common/fetchTypes';
|
||||
import { authBlockedMiddleware } from '../../../shared-fetch-utils/common/middleware/authBlockedMiddleware';
|
||||
import { rateLimitBackoffMiddleware } from '../../../shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware';
|
||||
import { serverErrorBackoffMiddleware } from '../../../shared-fetch-utils/common/middleware/serverErrorBackoffMiddleware';
|
||||
|
||||
type ContentExclusionRule = {
|
||||
paths: string[];
|
||||
@@ -36,20 +40,71 @@ type ContentExclusionResponse = {
|
||||
|
||||
type RepoMetadata = { repoRootPath: string; fetchUrls: string[] };
|
||||
|
||||
const NON_GIT_FILE_KEY = 'non-git-file';
|
||||
/** Rules for a single repo, along with when they were fetched so they can expire individually. */
|
||||
type CachedRules = {
|
||||
patterns: string[];
|
||||
ifAnyMatch: RegExp[];
|
||||
ifNoneMatch: RegExp[];
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches content exclusion policies from GH remotes
|
||||
* A memoised {@link RemoteContentExclusion.isIgnored} result, tagged with the rule generation it
|
||||
* was computed against so it can be discarded when the rules behind it change.
|
||||
*/
|
||||
type CachedVerdict = { verdict: boolean; generation: number };
|
||||
|
||||
/** A repo awaiting rules, shared by every caller that asks for it while the fetch is outstanding. */
|
||||
type PendingFetch = { readonly deferred: DeferredPromise<void>; dispatched: boolean };
|
||||
|
||||
const NON_GIT_FILE_KEY = 'non-git-file';
|
||||
|
||||
const MINIMATCH_OPTIONS = {
|
||||
nocase: true,
|
||||
matchBase: true,
|
||||
nonegate: true,
|
||||
dot: true
|
||||
};
|
||||
|
||||
/** Max repos the content exclusion endpoint accepts in a single request. */
|
||||
const REPOS_PER_REQUEST = 10;
|
||||
/** How many batches may be in flight at once. */
|
||||
const MAX_CONCURRENT_BATCHES = 5;
|
||||
/** Window used to collect repos before dispatching, so bursts collapse into full batches. */
|
||||
const BATCH_WINDOW_MS = 50;
|
||||
/** How long fetched rules stay valid before they are refreshed on next use. */
|
||||
const RULE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Fetches content exclusion policies from GH remotes.
|
||||
*
|
||||
* The rules endpoint lives on api.github.com, so it shares the caller's regular REST rate limit
|
||||
* budget. Requests are therefore coalesced per repo, batched, and backed off on failure.
|
||||
*/
|
||||
export class RemoteContentExclusion implements IDisposable {
|
||||
// The cache which maps remote fetch url to the minimatch patterns, order of patterns matters here
|
||||
private _contentExclusionCache: Map<string, { patterns: string[]; ifAnyMatch: RegExp[]; ifNoneMatch: RegExp[] }> = new Map();
|
||||
private _contentExclusionFetchPromise: Promise<void> | null = null;
|
||||
// Rules keyed by remote fetch url. Only ever holds successfully fetched rules, so a failed
|
||||
// request can never be mistaken for "this repo has no exclusions".
|
||||
private readonly _contentExclusionCache: Map<string, CachedRules> = new Map();
|
||||
// Repos waiting to be fetched, along with the promise every caller for that repo shares. Entries
|
||||
// stay registered until their request settles, so a lookup arriving mid-flight joins it.
|
||||
private readonly _pendingRepos: Map<string, PendingFetch> = new Map();
|
||||
private readonly _batchLimiter: Limiter<void>;
|
||||
private _scheduledDrain: Promise<void> | undefined;
|
||||
private _disposed = false;
|
||||
// Flattened, precompiled view of every glob rule so isIgnored does not recompile per call.
|
||||
private _compiledGlobs: Minimatch[] = [];
|
||||
private _regexRuleCount = 0;
|
||||
// Bumped whenever the rules change, which retires every verdict memoised against them.
|
||||
private _rulesGeneration = 0;
|
||||
// When the soonest expiring rule set goes stale. Memoised verdicts are only trusted before this.
|
||||
private _earliestRuleExpiry = 0;
|
||||
// This caches the ignore results as they can be expensive to compute and a single render can request results 100s of times
|
||||
private _ignoreGlobResultCache: ResourceMap<boolean> = new ResourceMap();
|
||||
private _ignoreGlobResultCache: ResourceMap<CachedVerdict> = new ResourceMap();
|
||||
// Map of the hash of file contents to the result of the regex check
|
||||
private _ignoreRegexResultCache: Map<string, boolean> = new Map();
|
||||
private _lastRuleFetch = 0;
|
||||
private _ignoreRegexResultCache: Map<string, CachedVerdict> = new Map();
|
||||
// Requests go through the shared middleware stack so rate limit and server error backoff are
|
||||
// handled the same way as every other cached CAPI-client value.
|
||||
private readonly _fetchExclusionRules: HttpFetchFn;
|
||||
private _disposables: IDisposable[] = [];
|
||||
private readonly _fileReadLimiter: Limiter<string | Uint8Array>;
|
||||
// Cache of repository root paths to their metadata to avoid calling getRepositoryFetchUrls for every file
|
||||
@@ -63,11 +118,10 @@ export class RemoteContentExclusion implements IDisposable {
|
||||
private readonly _capiClientService: ICAPIClientService,
|
||||
private readonly _fileSystemService: IFileSystemService,
|
||||
private readonly _workspaceService: IWorkspaceService,
|
||||
private readonly _requestLogger: IRequestLogger
|
||||
private readonly _requestLogger: IRequestLogger,
|
||||
// Injectable so tests can exercise rule expiry and backoff without waiting on the wall clock.
|
||||
private readonly _now: () => number = Date.now
|
||||
) {
|
||||
// This is a specialized entry to store the global rules that apply to files outside of any git repository
|
||||
// The other option was to maintain a separate cache for non git files but that would be redundant
|
||||
this._contentExclusionCache.set(NON_GIT_FILE_KEY, { patterns: [], ifAnyMatch: [], ifNoneMatch: [] });
|
||||
this._disposables.push(this._gitService.onDidCloseRepository((r) => {
|
||||
const repoInfo = this.getRepositoryInfo(r);
|
||||
if (!repoInfo) {
|
||||
@@ -78,22 +132,33 @@ export class RemoteContentExclusion implements IDisposable {
|
||||
for (const url of repoInfo.fetchUrls) {
|
||||
this._contentExclusionCache.delete(url);
|
||||
}
|
||||
this.rebuildCompiledRules();
|
||||
// Dropping a repo's rules can flip verdicts that were memoised while they applied.
|
||||
this.invalidateVerdicts();
|
||||
}));
|
||||
|
||||
this._fileReadLimiter = new Limiter<string | Uint8Array>(10);
|
||||
this._disposables.push(this._fileReadLimiter);
|
||||
this._batchLimiter = new Limiter<void>(MAX_CONCURRENT_BATCHES);
|
||||
this._disposables.push(this._batchLimiter);
|
||||
|
||||
this._fetchExclusionRules = composeFetchMiddleware(
|
||||
// Order matters: the rate limit check sits inside the auth check so that a quota
|
||||
// exhausted 403 is recognised by its headers and waits for the reset, instead of being
|
||||
// misread as an auth failure and blocking the token for an hour.
|
||||
authBlockedMiddleware(),
|
||||
rateLimitBackoffMiddleware({ now: this._now }),
|
||||
serverErrorBackoffMiddleware(),
|
||||
)(request => this._capiClientService.makeRequest<HttpResponse>(
|
||||
{ headers: request.headers },
|
||||
{ type: RequestType.ContentExclusion, repos: (request.state?.repos ?? []) as string[] }
|
||||
));
|
||||
}
|
||||
|
||||
public async isIgnored(file: URI, token: CancellationToken = CancellationToken.None): Promise<boolean> {
|
||||
// 1. If glob is not ignored, but there is no regex we can return false as the URI will not change
|
||||
// 2. If glob is not ignored, but there are regex we need to read file content which will happen lower in the regex code.
|
||||
// 3. If glob is ignored, it will return true despite regex since the most restrictive exclusion takes the cake
|
||||
if ((this._ignoreGlobResultCache.has(file) && !this.isRegexContextExclusionsEnabled) || this._ignoreGlobResultCache.get(file)) {
|
||||
return this._ignoreGlobResultCache.get(file) ?? false;
|
||||
}
|
||||
// Any pending requests that may be in flight should be awaited before returning a result
|
||||
if (this._contentExclusionFetchPromise) {
|
||||
await raceCancellationError(this._contentExclusionFetchPromise, token);
|
||||
const memoised = this.memoisedVerdict(file);
|
||||
if (memoised !== undefined) {
|
||||
return memoised;
|
||||
}
|
||||
|
||||
// Try to find the repository from the cache first to avoid expensive git extension calls
|
||||
@@ -118,29 +183,18 @@ export class RemoteContentExclusion implements IDisposable {
|
||||
|
||||
const fileName = file.path.toLowerCase().replace(repoMetadata.repoRootPath.toLowerCase(), '');
|
||||
|
||||
// We're missing entries for this repository in the cache, so we fetch it.
|
||||
// Or it has been more than 30 minutes so the current rules are stale
|
||||
if (this.shouldFetchContentExclusionRules(repoMetadata) || (Date.now() - this._lastRuleFetch > 30 * 60 * 1000)) {
|
||||
this._logService.trace(`Fetching content exclusions, due to ${this.shouldFetchContentExclusionRules(repoMetadata) ? 'repository change' : 'stale cache'}.`);
|
||||
this._lastRuleFetch = Date.now();
|
||||
await raceCancellationError(this.makeContentExclusionRequest(), token);
|
||||
}
|
||||
// Only waits on the repos this file actually belongs to, so an unrelated in-flight batch
|
||||
// cannot block this lookup.
|
||||
const rulesLoaded = await raceCancellationError(this.ensureRulesLoaded(repoMetadata.fetchUrls), token);
|
||||
// Captured up front so that a refresh landing while this verdict is being computed retires
|
||||
// it, rather than it being stored as if it reflected the newer rules.
|
||||
const generation = this._rulesGeneration;
|
||||
|
||||
const minimatchConfig = {
|
||||
nocase: true,
|
||||
matchBase: true,
|
||||
nonegate: true,
|
||||
dot: true
|
||||
};
|
||||
|
||||
for (const { patterns } of this._contentExclusionCache.values()) {
|
||||
for (const rule of patterns) {
|
||||
const matchesPattern = minimatch(fileName, rule, minimatchConfig) || minimatch(file.path, rule, minimatchConfig);
|
||||
if (matchesPattern) {
|
||||
this._logService.debug(`File ${file.path} is ignored by content exclusion rule ${rule}`);
|
||||
this._ignoreGlobResultCache.set(file, true);
|
||||
return true;
|
||||
}
|
||||
for (const glob of this._compiledGlobs) {
|
||||
if (glob.match(fileName) || glob.match(file.path)) {
|
||||
this._logService.debug(`File ${file.path} is ignored by content exclusion rule ${glob.pattern}`);
|
||||
this._ignoreGlobResultCache.set(file, { verdict: true, generation });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let fileContents: string = '';
|
||||
@@ -157,8 +211,9 @@ export class RemoteContentExclusion implements IDisposable {
|
||||
fileContents = typeof fileContentOrBuffer === 'string' ? fileContentOrBuffer : new TextDecoder().decode(fileContentOrBuffer);
|
||||
fileContentHash = await createSha256Hash(fileContents);
|
||||
// Cache hit for these file contents, no need to run the regex patterns
|
||||
if (this._ignoreRegexResultCache.has(fileContentHash)) {
|
||||
return this._ignoreRegexResultCache.get(fileContentHash) ?? false;
|
||||
const cachedRegexVerdict = this._ignoreRegexResultCache.get(fileContentHash);
|
||||
if (cachedRegexVerdict && cachedRegexVerdict.generation === generation) {
|
||||
return cachedRegexVerdict.verdict;
|
||||
}
|
||||
} catch {
|
||||
// We failed to read the file, so it should just be ignored as we have no idea what the contents are or if it exists
|
||||
@@ -168,151 +223,301 @@ export class RemoteContentExclusion implements IDisposable {
|
||||
}
|
||||
if (ifAnyMatch.length > 0 && fileContents && ifAnyMatch.some(pattern => pattern.test(fileContents))) {
|
||||
this._logService.debug(`File ${file.path} is ignored by content exclusion rule ifAnyMatch`);
|
||||
this._ignoreRegexResultCache.set(fileContentHash, true);
|
||||
this._ignoreRegexResultCache.set(fileContentHash, { verdict: true, generation });
|
||||
return true;
|
||||
}
|
||||
if (ifNoneMatch.length > 0 && fileContents && !ifNoneMatch.some(pattern => pattern.test(fileContents))) {
|
||||
this._logService.debug(`File ${file.path} is ignored by content exclusion rule ifNoneMatch`);
|
||||
this._ignoreRegexResultCache.set(fileContentHash, true);
|
||||
this._ignoreRegexResultCache.set(fileContentHash, { verdict: true, generation });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this._ignoreGlobResultCache.set(file, false);
|
||||
this._ignoreRegexResultCache.set(fileContentHash, false);
|
||||
// Only memoise a negative verdict once every relevant rule set has actually loaded. Caching it
|
||||
// after a failed fetch would leave the file permanently allowed.
|
||||
if (rulesLoaded) {
|
||||
this._ignoreGlobResultCache.set(file, { verdict: false, generation });
|
||||
// Only meaningful when regex rules forced us to read (and hash) the file.
|
||||
if (fileContentHash) {
|
||||
this._ignoreRegexResultCache.set(fileContentHash, { verdict: false, generation });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a memoised verdict when it can still be trusted.
|
||||
*
|
||||
* A verdict is only reusable while the rules behind it are both unchanged and unexpired,
|
||||
* otherwise the file has to be re-evaluated so that policy changes are picked up. Skipping the
|
||||
* expiry check here would pin a file to its first answer forever, since a cached verdict
|
||||
* short-circuits the refresh that would notice new rules.
|
||||
*/
|
||||
private memoisedVerdict(file: URI): boolean | undefined {
|
||||
const cached = this._ignoreGlobResultCache.get(file);
|
||||
if (!cached || cached.generation !== this._rulesGeneration || this._now() >= this._earliestRuleExpiry) {
|
||||
return undefined;
|
||||
}
|
||||
// An exclusion is the most restrictive answer, so a positive verdict stands on its own. A
|
||||
// negative one is only final when no regex rule could still exclude the file on content.
|
||||
return cached.verdict || !this.isRegexContextExclusionsEnabled ? cached.verdict : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not there are regex context exclusions.
|
||||
*/
|
||||
public get isRegexContextExclusionsEnabled(): boolean {
|
||||
return [...this._contentExclusionCache.values()].some(({ ifAnyMatch, ifNoneMatch }: { ifAnyMatch: RegExp[]; ifNoneMatch: RegExp[] }) => ifAnyMatch.length > 0 || ifNoneMatch.length > 0);
|
||||
return this._regexRuleCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the content exclusion rules for the given repositories. Primarily used to load a bunch of repos at once prior to a search for example.
|
||||
* @param repoUris The list of repository URIs to load the content exclusion rules for
|
||||
*/
|
||||
public async loadRepos(repoUris: URI[]) {
|
||||
const repos = await Promise.all(repoUris.map(uri => this._gitService.getRepositoryFetchUrls(uri)));
|
||||
const repoInfos = repos.map(repo => {
|
||||
const fetchUrls: string[] = [];
|
||||
for (const repo of repos) {
|
||||
const repoInfo = this.getRepositoryInfo(repo);
|
||||
// Populate the repo root cache for future lookups
|
||||
if (repoInfo) {
|
||||
this._repoRootCache.set(repoInfo.repoRootPath, repoInfo);
|
||||
fetchUrls.push(...repoInfo.fetchUrls);
|
||||
}
|
||||
return this.shouldFetchContentExclusionRules(repoInfo);
|
||||
});
|
||||
if (repoInfos.some(info => info)) {
|
||||
this._lastRuleFetch = Date.now();
|
||||
await this.makeContentExclusionRequest();
|
||||
}
|
||||
await this.ensureRulesLoaded(fetchUrls);
|
||||
}
|
||||
|
||||
public async asMinimatchPatterns() {
|
||||
await this._contentExclusionFetchPromise;
|
||||
const patterns: string[] = Array.from(this._contentExclusionCache.values()).flatMap(({ patterns }) => patterns);
|
||||
return patterns;
|
||||
// Anything already queued must land first so callers see a complete pattern set.
|
||||
await Promise.all([...this._pendingRepos.values()].map(pending => pending.deferred.p));
|
||||
return Array.from(this._contentExclusionCache.values()).flatMap(({ patterns }) => patterns);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this._disposed = true;
|
||||
// Released before the limiter is disposed: it drops queued work without ever running it, so
|
||||
// anything still registered here would otherwise leave its callers waiting forever.
|
||||
this.settlePending([...this._pendingRepos]);
|
||||
this._pendingRepos.clear();
|
||||
this._disposables.forEach(d => d.dispose());
|
||||
this._disposables = [];
|
||||
this._contentExclusionCache.clear();
|
||||
this._compiledGlobs = [];
|
||||
this._regexRuleCount = 0;
|
||||
this._earliestRuleExpiry = 0;
|
||||
}
|
||||
|
||||
private shouldFetchContentExclusionRules(repoInfo: RepoMetadata | undefined): boolean {
|
||||
if (!repoInfo) {
|
||||
return false;
|
||||
/**
|
||||
* Ensures rules for the given repos are loaded, fetching only what is missing or expired.
|
||||
*
|
||||
* Callers asking for the same repo share a single request, and each caller only waits on the
|
||||
* repos it asked for, so a large background load cannot stall an individual file check.
|
||||
*
|
||||
* @returns whether every required rule set is now available. `false` means at least one fetch
|
||||
* failed, and the caller must not memoise a verdict derived from the incomplete rules.
|
||||
*/
|
||||
private async ensureRulesLoaded(fetchUrls: readonly string[]): Promise<boolean> {
|
||||
// Global/org rules are keyed under the non-git pseudo repo and can apply to any file.
|
||||
const required = new Set<string>(fetchUrls);
|
||||
required.add(NON_GIT_FILE_KEY);
|
||||
|
||||
const now = this._now();
|
||||
const waits: Promise<void>[] = [];
|
||||
for (const url of required) {
|
||||
const cached = this._contentExclusionCache.get(url);
|
||||
if (cached && now - cached.fetchedAt < RULE_TTL_MS) {
|
||||
continue;
|
||||
}
|
||||
waits.push(this.enqueueRepo(url));
|
||||
}
|
||||
let shouldFetch = false;
|
||||
for (const remoteRepoUrl of repoInfo?.fetchUrls ?? []) {
|
||||
if (!this._contentExclusionCache.has(remoteRepoUrl)) {
|
||||
shouldFetch = true;
|
||||
this._contentExclusionCache.set(remoteRepoUrl, { patterns: [], ifAnyMatch: [], ifNoneMatch: [] });
|
||||
|
||||
if (waits.length > 0) {
|
||||
await Promise.all(waits);
|
||||
}
|
||||
|
||||
for (const url of required) {
|
||||
if (!this._contentExclusionCache.has(url)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return shouldFetch;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Registers a repo for the next batch, joining an existing fetch when one is outstanding. */
|
||||
private enqueueRepo(url: string): Promise<void> {
|
||||
const existing = this._pendingRepos.get(url);
|
||||
if (existing) {
|
||||
// Queued or already in flight; share that result rather than issuing a duplicate request.
|
||||
return existing.deferred.p;
|
||||
}
|
||||
const pending: PendingFetch = { deferred: new DeferredPromise<void>(), dispatched: false };
|
||||
if (this._disposed) {
|
||||
// Nothing will ever run, so release the caller instead of leaving it waiting.
|
||||
pending.deferred.complete(undefined);
|
||||
return pending.deferred.p;
|
||||
}
|
||||
this._pendingRepos.set(url, pending);
|
||||
this.scheduleDrain();
|
||||
return pending.deferred.p;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around the actual request
|
||||
* TODO @lramos15 add cancellation to cancel the old request in flight
|
||||
* @returns The promise which resolves when the request is complete
|
||||
* Schedules a drain shortly after the first enqueue. The window is deliberately not reset by
|
||||
* later enqueues so that a steady stream of repos cannot starve the fetch indefinitely.
|
||||
*/
|
||||
private async makeContentExclusionRequest(): Promise<void> {
|
||||
if (this._contentExclusionFetchPromise) {
|
||||
await this._contentExclusionFetchPromise;
|
||||
private scheduleDrain(): void {
|
||||
if (this._scheduledDrain) {
|
||||
return;
|
||||
}
|
||||
this._scheduledDrain = (async () => {
|
||||
await timeout(BATCH_WINDOW_MS);
|
||||
this._scheduledDrain = undefined;
|
||||
this.drainPendingRepos();
|
||||
})();
|
||||
}
|
||||
|
||||
/** Dispatches everything queued but not yet sent as batched, concurrency limited requests. */
|
||||
private drainPendingRepos(): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
const batchable = [...this._pendingRepos].filter(([, pending]) => !pending.dispatched);
|
||||
if (batchable.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Entries deliberately stay in the map until their request settles, so a lookup arriving
|
||||
// while the request is slow joins it instead of queueing the same repo again.
|
||||
batchable.forEach(([, pending]) => { pending.dispatched = true; });
|
||||
|
||||
for (let i = 0; i < batchable.length; i += REPOS_PER_REQUEST) {
|
||||
const batch = batchable.slice(i, i + REPOS_PER_REQUEST);
|
||||
this._batchLimiter.queue(() => this.fetchRulesForBatch(batch));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one batch of repos. Rules are only cached on success, so a transient failure is retried
|
||||
* later rather than being remembered as "this repo has no exclusions".
|
||||
*/
|
||||
private async fetchRulesForBatch(batch: [string, PendingFetch][]): Promise<void> {
|
||||
const repos = batch.map(([repo]) => repo);
|
||||
const startTime = this._now();
|
||||
try {
|
||||
this._contentExclusionFetchPromise = this._contentExclusionRequest();
|
||||
await this._contentExclusionFetchPromise;
|
||||
this._contentExclusionFetchPromise = null;
|
||||
} catch {
|
||||
this._contentExclusionFetchPromise = null;
|
||||
}
|
||||
}
|
||||
const ghToken = (await this._authService.getGitHubSession('any', { silent: true }))?.accessToken;
|
||||
const response = await this._fetchExclusionRules({
|
||||
url: `capi:${RequestType.ContentExclusion}`,
|
||||
headers: { 'Authorization': `token ${ghToken}` },
|
||||
method: 'GET',
|
||||
state: { repos }
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* The actual function that fetches the content exclusion rules from the GH API.
|
||||
* Not recommended to call directly and instead use {@link makeContentExclusionRequest} as that ensures only one call is pending at any time
|
||||
*/
|
||||
private async _contentExclusionRequest(): Promise<void> {
|
||||
// Clear the result cache as new rules will come and therefore it is no longer valid
|
||||
this._ignoreGlobResultCache.clear();
|
||||
const startTime = Date.now();
|
||||
const capiClientService = this._capiClientService;
|
||||
const ghToken = (await this._authService.getGitHubSession('any', { silent: true }))?.accessToken;
|
||||
const remoteFetchUrls = Array.from(this._contentExclusionCache.keys());
|
||||
const updateRulesForRepos = async (reposToFetch: string[]) => {
|
||||
|
||||
const response = await capiClientService.makeRequest<Response>({
|
||||
headers: {
|
||||
'Authorization': `token ${ghToken}`
|
||||
},
|
||||
}, { type: RequestType.ContentExclusion, repos: reposToFetch });
|
||||
|
||||
if (!response.ok) {
|
||||
this._logService.error(`Failed to fetch content exclusion rules: ${response?.statusText}`);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
this._logService.error(`Failed to fetch content exclusion rules for ${repos.length} repo(s): ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const data: ContentExclusionResponse[] = await response.json();
|
||||
for (let j = 0; j < data.length; j++) {
|
||||
const patterns = data[j].rules.map(rule => rule.paths).flat();
|
||||
const ifAnyMatch = coalesce(data[j].rules.map(rule => rule.ifAnyMatch).flat()).map(pattern => stringToRegex(pattern));
|
||||
const ifNoneMatch = coalesce(data[j].rules.map(rule => rule.ifNoneMatch).flat()).map(pattern => stringToRegex(pattern));
|
||||
const repo = reposToFetch[j];
|
||||
const rulesForRepo = { patterns, ifAnyMatch, ifNoneMatch };
|
||||
this._contentExclusionCache.set(repo, rulesForRepo);
|
||||
this._logService.trace(`Fetched content exclusion rules for ${repo}: ${JSON.stringify(rulesForRepo)}`);
|
||||
|
||||
this.applyRules(repos, await response.json() as ContentExclusionResponse[], startTime);
|
||||
} catch (err) {
|
||||
if (err instanceof FetchBlockedError) {
|
||||
// A middleware is deliberately holding requests back. The repos stay uncached and are
|
||||
// picked up again once the block lifts.
|
||||
this._logService.warn(`Deferred content exclusion fetch for ${repos.length} repo(s): ${err.message}`);
|
||||
} else {
|
||||
this._logService.error(`Failed to fetch content exclusion rules: ${err}`);
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
// Waiters always resume. On failure the repo stays uncached so it is fetched again later.
|
||||
this.settlePending(batch);
|
||||
}
|
||||
}
|
||||
|
||||
// This is needed to fetch the global rules that could apply to non git files
|
||||
if (remoteFetchUrls.length === 0) {
|
||||
await updateRulesForRepos([]);
|
||||
/** Releases a batch's waiters, deregistering entries that still belong to this attempt. */
|
||||
private settlePending(batch: readonly [string, PendingFetch][]): void {
|
||||
for (const [url, pending] of batch) {
|
||||
if (this._pendingRepos.get(url) === pending) {
|
||||
this._pendingRepos.delete(url);
|
||||
}
|
||||
pending.deferred.complete(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private applyRules(repos: string[], data: ContentExclusionResponse[], startTime: number): void {
|
||||
const fetchedAt = this._now();
|
||||
const loggedRules: { patterns: string[]; ifAnyMatch: string[]; ifNoneMatch: string[] }[] = [];
|
||||
let rulesChanged = false;
|
||||
|
||||
for (let i = 0; i < repos.length; i++) {
|
||||
// A missing entry means the server reported no rules for that repo. That is still a
|
||||
// definitive answer, so it is cached to avoid refetching the repo forever.
|
||||
const rules = data[i]?.rules ?? [];
|
||||
const patterns = rules.flatMap(rule => rule.paths);
|
||||
const ifAnyMatch = this.toRegexes(rules.flatMap(rule => rule.ifAnyMatch));
|
||||
const ifNoneMatch = this.toRegexes(rules.flatMap(rule => rule.ifNoneMatch));
|
||||
const previous = this._contentExclusionCache.get(repos[i]);
|
||||
// Compared against what was there before, because rules being *removed* changes verdicts
|
||||
// just as much as rules being added.
|
||||
rulesChanged ||= !previous || !isSameRuleSet(previous, { patterns, ifAnyMatch, ifNoneMatch });
|
||||
this._contentExclusionCache.set(repos[i], { patterns, ifAnyMatch, ifNoneMatch, fetchedAt });
|
||||
loggedRules.push({
|
||||
patterns,
|
||||
ifAnyMatch: ifAnyMatch.map(r => r.toString()),
|
||||
ifNoneMatch: ifNoneMatch.map(r => r.toString())
|
||||
});
|
||||
}
|
||||
|
||||
// Process in batches of 10 as that's the max content exclusion rules we can fetch at a time
|
||||
for (let i = 0; i < remoteFetchUrls.length; i += 10) {
|
||||
const batch = remoteFetchUrls.slice(i, i + 10);
|
||||
await updateRulesForRepos(batch);
|
||||
}
|
||||
this._lastRuleFetch = Date.now();
|
||||
this._logService.info(`Fetched content exclusion rules in ${Date.now() - startTime}ms`);
|
||||
this.rebuildCompiledRules();
|
||||
|
||||
// Log the fetched rules to the request logger for debugging visibility
|
||||
const repos = Array.from(this._contentExclusionCache.keys());
|
||||
const rules = repos.map(repo => {
|
||||
const entry = this._contentExclusionCache.get(repo)!;
|
||||
return {
|
||||
patterns: entry.patterns,
|
||||
ifAnyMatch: entry.ifAnyMatch.map(r => r.toString()),
|
||||
ifNoneMatch: entry.ifNoneMatch.map(r => r.toString())
|
||||
};
|
||||
});
|
||||
this._requestLogger.logContentExclusionRules(repos, rules, Date.now() - startTime);
|
||||
if (rulesChanged) {
|
||||
this.invalidateVerdicts();
|
||||
}
|
||||
|
||||
const duration = this._now() - startTime;
|
||||
this._logService.info(`Fetched content exclusion rules for ${repos.length} repo(s) in ${duration}ms`);
|
||||
this._requestLogger.logContentExclusionRules(repos, loggedRules, duration);
|
||||
}
|
||||
|
||||
/** Retires every memoised verdict, since the rules they were computed against no longer hold. */
|
||||
private invalidateVerdicts(): void {
|
||||
this._rulesGeneration++;
|
||||
this._ignoreGlobResultCache.clear();
|
||||
this._ignoreRegexResultCache.clear();
|
||||
}
|
||||
|
||||
/** Rebuilds the flattened matcher list that {@link isIgnored} walks. */
|
||||
private rebuildCompiledRules(): void {
|
||||
const globs: Minimatch[] = [];
|
||||
let regexRuleCount = 0;
|
||||
let earliestExpiry = Number.POSITIVE_INFINITY;
|
||||
for (const { patterns, ifAnyMatch, ifNoneMatch, fetchedAt } of this._contentExclusionCache.values()) {
|
||||
for (const pattern of patterns) {
|
||||
try {
|
||||
globs.push(new Minimatch(pattern, MINIMATCH_OPTIONS));
|
||||
} catch (err) {
|
||||
this._logService.warn(`Skipping malformed content exclusion pattern '${pattern}': ${err}`);
|
||||
}
|
||||
}
|
||||
regexRuleCount += ifAnyMatch.length + ifNoneMatch.length;
|
||||
earliestExpiry = Math.min(earliestExpiry, fetchedAt + RULE_TTL_MS);
|
||||
}
|
||||
this._compiledGlobs = globs;
|
||||
this._regexRuleCount = regexRuleCount;
|
||||
// Zero while nothing is cached, which keeps memoised verdicts from being trusted before any
|
||||
// rules have been loaded.
|
||||
this._earliestRuleExpiry = this._contentExclusionCache.size > 0 ? earliestExpiry : 0;
|
||||
}
|
||||
|
||||
/** Compiles regex rules, skipping any the server sent that cannot be parsed. */
|
||||
private toRegexes(patterns: (string | undefined)[]): RegExp[] {
|
||||
const compiled: RegExp[] = [];
|
||||
for (const pattern of coalesce(patterns)) {
|
||||
try {
|
||||
compiled.push(stringToRegex(pattern));
|
||||
} catch (err) {
|
||||
this._logService.warn(`Skipping malformed content exclusion regex '${pattern}': ${err}`);
|
||||
}
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
|
||||
@@ -357,10 +562,20 @@ export class RemoteContentExclusion implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compares two rule sets by content, so an unchanged refresh does not retire memoised verdicts. */
|
||||
function isSameRuleSet(a: Omit<CachedRules, 'fetchedAt'>, b: Omit<CachedRules, 'fetchedAt'>): boolean {
|
||||
return equalStrings(a.patterns, b.patterns)
|
||||
&& equalStrings(a.ifAnyMatch.map(String), b.ifAnyMatch.map(String))
|
||||
&& equalStrings(a.ifNoneMatch.map(String), b.ifNoneMatch.map(String));
|
||||
}
|
||||
|
||||
function equalStrings(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a given string /pattern/flags to a RegExp object
|
||||
*/
|
||||
function stringToRegex(str: string): RegExp {
|
||||
*/function stringToRegex(str: string): RegExp {
|
||||
// Handle Regex format of `pattern` vs /pattern/
|
||||
if (!str.startsWith('/') && !str.endsWith('/')) {
|
||||
return new RegExp(str);
|
||||
|
||||
@@ -4,11 +4,45 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { FetchOptions, RequestMetadata } from '@vscode/copilot-api';
|
||||
import { Response } from '../../../networking/common/fetcherService';
|
||||
import { HeadersImpl, Response } from '../../../networking/common/fetcherService';
|
||||
|
||||
/** Shape of the content exclusion payload the endpoint returns for a single repo. */
|
||||
export type MockExclusionRules = {
|
||||
paths?: string[];
|
||||
ifAnyMatch?: string[];
|
||||
ifNoneMatch?: string[];
|
||||
};
|
||||
|
||||
/** Builds a successful content exclusion response for the requested repos. */
|
||||
export function rulesResponse(rulesByRepo: ReadonlyMap<string, MockExclusionRules>, repos: string[]): Partial<Response> {
|
||||
const payload = repos.map(repo => {
|
||||
const rules = rulesByRepo.get(repo);
|
||||
return {
|
||||
last_updated_at: 0,
|
||||
rules: rules ? [{ paths: rules.paths ?? [], ifAnyMatch: rules.ifAnyMatch, ifNoneMatch: rules.ifNoneMatch, source: { name: repo, type: 'Repository' } }] : []
|
||||
};
|
||||
});
|
||||
return { ok: true, status: 200, statusText: 'OK', json: () => Promise.resolve(payload) };
|
||||
}
|
||||
|
||||
/** Builds a failing response, optionally carrying GitHub's rate limit headers. */
|
||||
export function failureResponse(status: number, headers: Record<string, string> = {}): Partial<Response> {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
statusText: status === 403 ? 'Forbidden' : 'Error',
|
||||
headers: new HeadersImpl(headers)
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds a rate limited response of the shape api.github.com returns. */
|
||||
export function rateLimitedResponse(retryAfterSeconds: number): Partial<Response> {
|
||||
return failureResponse(429, { 'retry-after': String(retryAfterSeconds) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock implementation of ICAPIClientService for testing.
|
||||
* Returns an empty successful response by default.
|
||||
* Records every request so tests can assert on batching and coalescing behaviour.
|
||||
* Note: Does not fully implement ICAPIClientService - only the methods needed for tests.
|
||||
*/
|
||||
export class MockCAPIClientService {
|
||||
@@ -16,25 +50,70 @@ export class MockCAPIClientService {
|
||||
|
||||
abExpContext: string | undefined = undefined;
|
||||
|
||||
private _mockResponse: Response = {
|
||||
/** Each entry is the list of repos sent in one request, in dispatch order. */
|
||||
readonly requestedBatches: string[][] = [];
|
||||
|
||||
private _responder: (repos: string[]) => Partial<Response> = () => ({});
|
||||
private _gate: Promise<void> | undefined;
|
||||
private _openGate: (() => void) | undefined;
|
||||
|
||||
private readonly _defaultResponse: Response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Map(),
|
||||
headers: new HeadersImpl({}),
|
||||
text: () => Promise.resolve('[]'),
|
||||
json: () => Promise.resolve([]),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
body: null,
|
||||
} as unknown as Response;
|
||||
|
||||
/**
|
||||
* Sets the mock response to return from makeRequest.
|
||||
*/
|
||||
setMockResponse(response: Partial<Response>): void {
|
||||
this._mockResponse = { ...this._mockResponse, ...response } as Response;
|
||||
get requestCount(): number {
|
||||
return this.requestedBatches.length;
|
||||
}
|
||||
|
||||
makeRequest<T>(_request: FetchOptions, _requestMetadata: RequestMetadata): Promise<T> {
|
||||
return Promise.resolve(this._mockResponse as unknown as T);
|
||||
/** Every repo requested across all batches, including any duplicates. */
|
||||
get requestedRepos(): string[] {
|
||||
return this.requestedBatches.flat();
|
||||
}
|
||||
|
||||
/** How many times the given repo was asked for. */
|
||||
timesRequested(repo: string): number {
|
||||
return this.requestedRepos.filter(candidate => candidate === repo).length;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.requestedBatches.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a responder invoked with the repos of each request, so per-repo rules and
|
||||
* per-attempt failures can be simulated.
|
||||
*/
|
||||
setResponder(responder: (repos: string[]) => Partial<Response>): void {
|
||||
this._responder = responder;
|
||||
}
|
||||
|
||||
/** Holds every subsequent request open until {@link releaseRequests}, to model a slow endpoint. */
|
||||
blockRequests(): void {
|
||||
this._gate = new Promise<void>(resolve => { this._openGate = resolve; });
|
||||
}
|
||||
|
||||
releaseRequests(): void {
|
||||
this._openGate?.();
|
||||
this._gate = undefined;
|
||||
this._openGate = undefined;
|
||||
}
|
||||
|
||||
makeRequest<T>(_request: FetchOptions, requestMetadata: RequestMetadata): Promise<T> {
|
||||
const repos = 'repos' in requestMetadata ? requestMetadata.repos : [];
|
||||
// Recorded before awaiting the gate so tests can observe requests while they are in flight.
|
||||
this.requestedBatches.push([...repos]);
|
||||
const gate = this._gate;
|
||||
if (!gate) {
|
||||
return Promise.resolve({ ...this._defaultResponse, ...this._responder(repos) } as unknown as T);
|
||||
}
|
||||
return gate.then(() => ({ ...this._defaultResponse, ...this._responder(repos) }) as unknown as T);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,13 @@ import { NullRequestLogger } from '../../../requestLogger/node/nullRequestLogger
|
||||
import { TestLogService } from '../../../testing/common/testLogService';
|
||||
import { RemoteContentExclusion } from '../remoteContentExclusion';
|
||||
import { MockAuthenticationService } from './mockAuthenticationService';
|
||||
import { MockCAPIClientService } from './mockCAPIClientService';
|
||||
import { MockCAPIClientService, failureResponse, rateLimitedResponse, rulesResponse, type MockExclusionRules } from './mockCAPIClientService';
|
||||
import { MockGitService } from './mockGitService';
|
||||
import { MockWorkspaceService } from './mockWorkspaceService';
|
||||
|
||||
/** Key the implementation uses for global rules that apply outside any git repository. */
|
||||
const NON_GIT_FILE_KEY = 'non-git-file';
|
||||
|
||||
suite('RemoteContentExclusion', () => {
|
||||
let remoteContentExclusion: RemoteContentExclusion;
|
||||
let mockGitService: MockGitService;
|
||||
@@ -26,8 +29,42 @@ suite('RemoteContentExclusion', () => {
|
||||
let mockCAPIClientService: MockCAPIClientService;
|
||||
let mockFileSystemService: MockFileSystemService;
|
||||
let mockWorkspaceService: MockWorkspaceService;
|
||||
let now: number;
|
||||
|
||||
function remoteFor(repoRoot: string): string {
|
||||
return `https://github.com/org/${repoRoot.split('/').pop()}.git`;
|
||||
}
|
||||
|
||||
/** Routes each file to the repo whose root is the longest matching prefix of its path. */
|
||||
function routeToRepos(repoRoots: string[]): void {
|
||||
const byLongestRoot = [...repoRoots].sort((a, b) => b.length - a.length);
|
||||
mockGitService.getRepositoryFetchUrls = vi.fn().mockImplementation((uri: URI) => {
|
||||
mockGitService.getRepositoryFetchUrlsCallCount++;
|
||||
const root = byLongestRoot.find(candidate => uri.path === candidate || uri.path.startsWith(candidate + '/'));
|
||||
return Promise.resolve(root ? { rootUri: URI.file(root), remoteFetchUrls: [remoteFor(root)] } : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function respondWithRules(rules: Record<string, MockExclusionRules>): void {
|
||||
const byRepo = new Map(Object.entries(rules).map(([repoRoot, value]) => [remoteFor(repoRoot), value]));
|
||||
mockCAPIClientService.setResponder(repos => rulesResponse(byRepo, repos));
|
||||
}
|
||||
|
||||
/** Waits until the mock has recorded at least `count` requests, or gives up. */
|
||||
async function waitForRequests(count: number): Promise<void> {
|
||||
const deadline = Date.now() + 2000;
|
||||
while (mockCAPIClientService.requestCount < count && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
/** Gives any scheduled batching window time to elapse and dispatch. */
|
||||
function settleBatchWindow(): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
now = Date.UTC(2026, 0, 1);
|
||||
mockGitService = new MockGitService();
|
||||
mockLogService = new TestLogService();
|
||||
mockAuthService = new MockAuthenticationService();
|
||||
@@ -45,7 +82,8 @@ suite('RemoteContentExclusion', () => {
|
||||
mockCAPIClientService as unknown as ICAPIClientService,
|
||||
mockFileSystemService,
|
||||
mockWorkspaceService,
|
||||
new NullRequestLogger()
|
||||
new NullRequestLogger(),
|
||||
() => now
|
||||
);
|
||||
});
|
||||
|
||||
@@ -232,4 +270,227 @@ suite('RemoteContentExclusion', () => {
|
||||
expect(mockGitService.getRepositoryFetchUrlsCallCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request coalescing', () => {
|
||||
test('batches concurrent lookups instead of refreshing every repo per caller', async () => {
|
||||
const repoRoots = Array.from({ length: 25 }, (_, i) => `/workspace/repo${i}`);
|
||||
routeToRepos(repoRoots);
|
||||
|
||||
await Promise.all(repoRoots.map(root => remoteContentExclusion.isIgnored(URI.file(`${root}/src/file.ts`), CancellationToken.None)));
|
||||
|
||||
// 25 repos plus the non-git pseudo repo, sent 10 per request, each asked for exactly once.
|
||||
expect({
|
||||
requests: mockCAPIClientService.requestCount,
|
||||
reposSent: mockCAPIClientService.requestedRepos.length,
|
||||
uniqueReposSent: new Set(mockCAPIClientService.requestedRepos).size
|
||||
}).toEqual({ requests: 3, reposSent: 26, uniqueReposSent: 26 });
|
||||
});
|
||||
|
||||
test('only fetches repos that are missing from the cache', async () => {
|
||||
routeToRepos(['/workspace/repo-a', '/workspace/repo-b']);
|
||||
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None);
|
||||
mockCAPIClientService.reset();
|
||||
|
||||
// Same repo again: everything needed is already cached.
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None);
|
||||
const afterCachedRepo = mockCAPIClientService.requestedRepos;
|
||||
|
||||
// New repo: only the new remote is requested, not the whole cache.
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-b/one.ts'), CancellationToken.None);
|
||||
|
||||
expect({ afterCachedRepo, afterNewRepo: mockCAPIClientService.requestedRepos }).toEqual({
|
||||
afterCachedRepo: [],
|
||||
afterNewRepo: [remoteFor('/workspace/repo-b')]
|
||||
});
|
||||
});
|
||||
|
||||
test('caches an empty ruleset so repos without rules are not refetched', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
|
||||
// The default responder reports no rules for the requested repos.
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None);
|
||||
mockCAPIClientService.reset();
|
||||
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None);
|
||||
|
||||
expect(mockCAPIClientService.requestCount).toBe(0);
|
||||
});
|
||||
|
||||
test('refreshes rules once they expire', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None);
|
||||
mockCAPIClientService.reset();
|
||||
|
||||
now += 31 * 60 * 1000;
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None);
|
||||
|
||||
expect([...mockCAPIClientService.requestedRepos].sort()).toEqual([NON_GIT_FILE_KEY, remoteFor('/workspace/repo-a')].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure handling', () => {
|
||||
test('retries a repo whose fetch failed rather than treating it as unrestricted', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
|
||||
let attempts = 0;
|
||||
mockCAPIClientService.setResponder(repos => {
|
||||
attempts++;
|
||||
return attempts === 1
|
||||
? rateLimitedResponse(60)
|
||||
: rulesResponse(new Map([[remoteFor('/workspace/repo-a'), { paths: ['**/secret.ts'] }]]), repos);
|
||||
});
|
||||
|
||||
const secret = URI.file('/workspace/repo-a/secret.ts');
|
||||
const whileFailing = await remoteContentExclusion.isIgnored(secret, CancellationToken.None);
|
||||
|
||||
// Past the backoff window the rules load and the same file is now correctly excluded.
|
||||
now += 5 * 60 * 1000;
|
||||
const afterRecovery = await remoteContentExclusion.isIgnored(secret, CancellationToken.None);
|
||||
|
||||
expect({ whileFailing, afterRecovery }).toEqual({ whileFailing: false, afterRecovery: true });
|
||||
});
|
||||
|
||||
test('stops issuing requests while the backoff is in effect', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
mockCAPIClientService.setResponder(() => rateLimitedResponse(600));
|
||||
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None);
|
||||
const afterFirst = mockCAPIClientService.requestCount;
|
||||
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None);
|
||||
|
||||
expect({ afterFirst, afterSecond: mockCAPIClientService.requestCount }).toEqual({ afterFirst: 1, afterSecond: 1 });
|
||||
});
|
||||
|
||||
test('waits for the reported reset window when rate limited', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
const resetEpochSeconds = Math.floor((now + 10 * 60 * 1000) / 1000);
|
||||
mockCAPIClientService.setResponder(() => failureResponse(403, {
|
||||
'x-ratelimit-remaining': '0',
|
||||
'x-ratelimit-reset': String(resetEpochSeconds)
|
||||
}));
|
||||
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None);
|
||||
|
||||
// Still inside the window the server reported, so no further calls are made.
|
||||
now += 5 * 60 * 1000;
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None);
|
||||
const duringWindow = mockCAPIClientService.requestCount;
|
||||
|
||||
// Retrying after the reset also proves the quota 403 was classified as a rate limit
|
||||
// rather than an auth failure, which would have blocked the token for an hour.
|
||||
now += 6 * 60 * 1000;
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/three.ts'), CancellationToken.None);
|
||||
|
||||
expect({ duringWindow, afterWindow: mockCAPIClientService.requestCount }).toEqual({ duringWindow: 1, afterWindow: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rule matching', () => {
|
||||
test('excludes files matching a fetched glob rule', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
respondWithRules({ '/workspace/repo-a': { paths: ['**/*.env'] } });
|
||||
|
||||
expect({
|
||||
env: await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/config.env'), CancellationToken.None),
|
||||
source: await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/index.ts'), CancellationToken.None)
|
||||
}).toEqual({ env: true, source: false });
|
||||
});
|
||||
|
||||
test('ignores malformed patterns rather than failing every lookup', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
respondWithRules({ '/workspace/repo-a': { paths: ['**/*.env'], ifAnyMatch: ['/(unclosed/'] } });
|
||||
|
||||
expect(await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/config.env'), CancellationToken.None)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('picking up rule changes', () => {
|
||||
test('re-evaluates a file once its rules expire', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
respondWithRules({});
|
||||
|
||||
const file = URI.file('/workspace/repo-a/secret.ts');
|
||||
const beforeRuleAdded = await remoteContentExclusion.isIgnored(file, CancellationToken.None);
|
||||
|
||||
// The server starts excluding the file after the first verdict was memoised.
|
||||
respondWithRules({ '/workspace/repo-a': { paths: ['**/secret.ts'] } });
|
||||
now += 31 * 60 * 1000;
|
||||
const afterRuleAdded = await remoteContentExclusion.isIgnored(file, CancellationToken.None);
|
||||
|
||||
expect({ beforeRuleAdded, afterRuleAdded }).toEqual({ beforeRuleAdded: false, afterRuleAdded: true });
|
||||
});
|
||||
|
||||
test('stops excluding a file once the server removes the last rule', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
respondWithRules({ '/workspace/repo-a': { paths: ['**/secret.ts'] } });
|
||||
|
||||
const file = URI.file('/workspace/repo-a/secret.ts');
|
||||
const whileExcluded = await remoteContentExclusion.isIgnored(file, CancellationToken.None);
|
||||
|
||||
// Replacing the rules with an empty set must retire the memoised exclusion.
|
||||
respondWithRules({});
|
||||
now += 31 * 60 * 1000;
|
||||
const afterRuleRemoved = await remoteContentExclusion.isIgnored(file, CancellationToken.None);
|
||||
|
||||
expect({ whileExcluded, afterRuleRemoved }).toEqual({ whileExcluded: true, afterRuleRemoved: false });
|
||||
});
|
||||
|
||||
test('keeps memoised verdicts when a refresh returns identical rules', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
respondWithRules({ '/workspace/repo-a': { paths: ['**/secret.ts'] } });
|
||||
|
||||
await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/secret.ts'), CancellationToken.None);
|
||||
const other = URI.file('/workspace/repo-a/index.ts');
|
||||
await remoteContentExclusion.isIgnored(other, CancellationToken.None);
|
||||
|
||||
// An unchanged refresh should not force previously computed verdicts to be recomputed.
|
||||
now += 31 * 60 * 1000;
|
||||
await remoteContentExclusion.isIgnored(other, CancellationToken.None);
|
||||
mockGitService.getRepositoryFetchUrlsCallCount = 0;
|
||||
const afterUnchangedRefresh = await remoteContentExclusion.isIgnored(other, CancellationToken.None);
|
||||
|
||||
expect({ afterUnchangedRefresh, gitLookups: mockGitService.getRepositoryFetchUrlsCallCount }).toEqual({ afterUnchangedRefresh: false, gitLookups: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-flight requests', () => {
|
||||
test('joins a slow in-flight request instead of issuing a duplicate', async () => {
|
||||
routeToRepos(['/workspace/repo-a']);
|
||||
mockCAPIClientService.blockRequests();
|
||||
|
||||
const first = remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None);
|
||||
await waitForRequests(1);
|
||||
const whileDispatched = mockCAPIClientService.requestCount;
|
||||
|
||||
// A second lookup for the same repo arrives while the request is still open.
|
||||
const second = remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None);
|
||||
await settleBatchWindow();
|
||||
const afterSecondLookup = mockCAPIClientService.requestCount;
|
||||
|
||||
mockCAPIClientService.releaseRequests();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect({ whileDispatched, afterSecondLookup }).toEqual({ whileDispatched: 1, afterSecondLookup: 1 });
|
||||
});
|
||||
|
||||
test('releases callers waiting on queued batches when disposed', async () => {
|
||||
// More batches than the limiter runs concurrently, so some are still queued on dispose.
|
||||
const repoRoots = Array.from({ length: 80 }, (_, i) => `/workspace/repo${i}`);
|
||||
routeToRepos(repoRoots);
|
||||
mockCAPIClientService.blockRequests();
|
||||
|
||||
const lookups = Promise.all(repoRoots.map(root => remoteContentExclusion.isIgnored(URI.file(`${root}/file.ts`), CancellationToken.None)));
|
||||
await waitForRequests(1);
|
||||
|
||||
remoteContentExclusion.dispose();
|
||||
|
||||
// The limiter drops queued batches without running them, so their waiters must be
|
||||
// settled by dispose or these lookups would never resolve.
|
||||
await expect(lookups).resolves.toHaveLength(80);
|
||||
mockCAPIClientService.releaseRequests();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { FetchBlockedError, type FetchMiddleware, type HttpHeaders } from '../fetchTypes';
|
||||
|
||||
export class RateLimitBackoffError extends FetchBlockedError {
|
||||
constructor(retryAfterMs: number) {
|
||||
super(`Rate limited, backing off for ${Math.round(retryAfterMs / 1000)}s`, retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RateLimitBackoffOptions {
|
||||
/** Delay applied to the first rate limit when the server sends no hint. */
|
||||
readonly initialDelayMs?: number;
|
||||
readonly maxDelayMs?: number;
|
||||
readonly multiplier?: number;
|
||||
/** Injectable clock, primarily so tests do not have to wait on the wall clock. */
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks subsequent requests once the server reports a rate limit, so a client that shares a
|
||||
* quota with other callers cannot dig itself deeper.
|
||||
*
|
||||
* The wait is taken from the server whenever it says so, via `Retry-After` or GitHub's
|
||||
* `x-ratelimit-remaining`/`x-ratelimit-reset` pair, and otherwise falls back to an
|
||||
* exponentially increasing delay. Either way the wait is capped at {@link maxDelayMs}. The
|
||||
* backoff resets on the first response that is not rate limited.
|
||||
*
|
||||
* This complements {@link serverErrorBackoffMiddleware}, which covers `5xx` responses.
|
||||
*/
|
||||
export function rateLimitBackoffMiddleware(options?: RateLimitBackoffOptions): FetchMiddleware {
|
||||
const {
|
||||
initialDelayMs = 60_000,
|
||||
maxDelayMs = 15 * 60_000,
|
||||
multiplier = 2,
|
||||
now = Date.now,
|
||||
} = options ?? {};
|
||||
let consecutiveRateLimits = 0;
|
||||
let blockedUntil = 0;
|
||||
|
||||
return (next) => async (request) => {
|
||||
if (now() < blockedUntil) {
|
||||
throw new RateLimitBackoffError(blockedUntil - now());
|
||||
}
|
||||
|
||||
const response = await next(request);
|
||||
|
||||
if (!isRateLimited(response.status, response.headers)) {
|
||||
// A response that was already in flight when a concurrent request hit a rate limit must
|
||||
// not clear that newer block, otherwise later calls reach the server during the window
|
||||
// the server asked us to wait out.
|
||||
if (now() >= blockedUntil) {
|
||||
consecutiveRateLimits = 0;
|
||||
blockedUntil = 0;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
consecutiveRateLimits++;
|
||||
const hinted = retryAfterFromHeaders(response.headers, now);
|
||||
const backoff = hinted ?? initialDelayMs * Math.pow(multiplier, consecutiveRateLimits - 1);
|
||||
// `maxDelayMs` caps the server's hint too, so a bogus or hostile `Retry-After` cannot stall
|
||||
// the client indefinitely. Retrying a little early simply re-arms the backoff.
|
||||
const delay = Math.min(backoff, maxDelayMs);
|
||||
blockedUntil = now() + delay;
|
||||
throw new RateLimitBackoffError(delay);
|
||||
};
|
||||
}
|
||||
|
||||
function isRateLimited(status: number, headers: HttpHeaders): boolean {
|
||||
if (status === 429) {
|
||||
return true;
|
||||
}
|
||||
// GitHub reports an exhausted primary rate limit as a 403 carrying the quota headers, which
|
||||
// has to be told apart from a plain authorization failure.
|
||||
return status === 403 && readHeader(headers, 'x-ratelimit-remaining') === '0';
|
||||
}
|
||||
|
||||
function retryAfterFromHeaders(headers: HttpHeaders, now: () => number): number | undefined {
|
||||
const retryAfter = Number(readHeader(headers, 'retry-after'));
|
||||
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
||||
return retryAfter * 1000;
|
||||
}
|
||||
const reset = Number(readHeader(headers, 'x-ratelimit-reset'));
|
||||
if (Number.isFinite(reset) && reset > 0) {
|
||||
return Math.max(0, reset * 1000 - now());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** HTTP header names are case insensitive, but not every headers implementation normalises them. */
|
||||
function readHeader(headers: HttpHeaders, lowerCaseName: string): string | undefined {
|
||||
const canonical = lowerCaseName.replace(/(^|-)([a-z])/g, (_, separator: string, char: string) => separator + char.toUpperCase());
|
||||
return headers.get(lowerCaseName) ?? headers.get(canonical) ?? undefined;
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { HttpHeaders, HttpRequest, HttpResponse } from '../fetchTypes';
|
||||
import { RateLimitBackoffError, rateLimitBackoffMiddleware } from '../middleware/rateLimitBackoffMiddleware';
|
||||
|
||||
function makeHeaders(entries: Record<string, string> = {}): HttpHeaders {
|
||||
const map = new Map(Object.entries(entries).map(([key, value]) => [key.toLowerCase(), value]));
|
||||
return { get: (name: string) => map.get(name.toLowerCase()) ?? null };
|
||||
}
|
||||
|
||||
function makeResponse(status: number, headers: Record<string, string> = {}): HttpResponse {
|
||||
return {
|
||||
status,
|
||||
headers: makeHeaders(headers),
|
||||
body: null,
|
||||
async text() { return ''; },
|
||||
async json() { return {}; },
|
||||
};
|
||||
}
|
||||
|
||||
const request: HttpRequest = { url: 'https://api.github.com/example', headers: {} };
|
||||
|
||||
describe('rateLimitBackoffMiddleware', () => {
|
||||
let now: number;
|
||||
let calls: number;
|
||||
|
||||
beforeEach(() => {
|
||||
now = Date.UTC(2026, 0, 1);
|
||||
calls = 0;
|
||||
});
|
||||
|
||||
/** Wires the middleware around a stub that always returns the given response. */
|
||||
function withResponse(response: HttpResponse) {
|
||||
return rateLimitBackoffMiddleware({ now: () => now })(async () => {
|
||||
calls++;
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
async function expectBlocked(fetchFn: (request: HttpRequest) => Promise<HttpResponse>): Promise<number> {
|
||||
try {
|
||||
await fetchFn(request);
|
||||
throw new Error('expected the request to be blocked');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(RateLimitBackoffError);
|
||||
return (err as RateLimitBackoffError).retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
it('passes successful responses straight through', async () => {
|
||||
const fetchFn = withResponse(makeResponse(200));
|
||||
|
||||
expect((await fetchFn(request)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('leaves a plain 403 alone so auth failures are not mistaken for rate limits', async () => {
|
||||
const fetchFn = withResponse(makeResponse(403));
|
||||
|
||||
expect((await fetchFn(request)).status).toBe(403);
|
||||
});
|
||||
|
||||
it('blocks further requests after a 429 and honours retry-after', async () => {
|
||||
const fetchFn = withResponse(makeResponse(429, { 'retry-after': '120' }));
|
||||
|
||||
const firstDelay = await expectBlocked(fetchFn);
|
||||
// The second attempt is refused locally, without reaching the server.
|
||||
const callsAfterBlock = calls;
|
||||
await expectBlocked(fetchFn);
|
||||
|
||||
expect({ firstDelay, callsAfterBlock, callsNow: calls }).toEqual({ firstDelay: 120_000, callsAfterBlock: 1, callsNow: 1 });
|
||||
});
|
||||
|
||||
it('treats an exhausted quota 403 as a rate limit and waits for the reset', async () => {
|
||||
const resetEpochSeconds = Math.floor((now + 10 * 60_000) / 1000);
|
||||
const fetchFn = withResponse(makeResponse(403, {
|
||||
'x-ratelimit-remaining': '0',
|
||||
'x-ratelimit-reset': String(resetEpochSeconds)
|
||||
}));
|
||||
|
||||
const delay = await expectBlocked(fetchFn);
|
||||
|
||||
// Still blocked partway through the window, allowed through once it passes.
|
||||
now += 5 * 60_000;
|
||||
await expectBlocked(fetchFn);
|
||||
const callsDuringWindow = calls;
|
||||
|
||||
now += 6 * 60_000;
|
||||
await expectBlocked(fetchFn);
|
||||
|
||||
expect({ delay, callsDuringWindow, callsAfterWindow: calls }).toEqual({ delay: 10 * 60_000, callsDuringWindow: 1, callsAfterWindow: 2 });
|
||||
});
|
||||
|
||||
it('backs off exponentially when the server sends no hint', async () => {
|
||||
const fetchFn = withResponse(makeResponse(429));
|
||||
|
||||
const delays: number[] = [];
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
delays.push(await expectBlocked(fetchFn));
|
||||
now += delays[delays.length - 1];
|
||||
}
|
||||
|
||||
expect(delays).toEqual([60_000, 120_000, 240_000]);
|
||||
});
|
||||
|
||||
it('caps the server hint so a bogus retry-after cannot stall the client', async () => {
|
||||
const fetchFn = rateLimitBackoffMiddleware({ maxDelayMs: 600_000, now: () => now })(async () => {
|
||||
calls++;
|
||||
return makeResponse(429, { 'retry-after': '86400' });
|
||||
});
|
||||
|
||||
expect(await expectBlocked(fetchFn)).toBe(600_000);
|
||||
});
|
||||
|
||||
it('keeps a newer block when an older successful response lands afterwards', async () => {
|
||||
let releaseSlowResponse = () => { };
|
||||
const slowResponse = new Promise<void>(resolve => { releaseSlowResponse = resolve; });
|
||||
let isFirstCall = true;
|
||||
const fetchFn = rateLimitBackoffMiddleware({ now: () => now })(async () => {
|
||||
calls++;
|
||||
if (isFirstCall) {
|
||||
isFirstCall = false;
|
||||
await slowResponse;
|
||||
return makeResponse(200);
|
||||
}
|
||||
return makeResponse(429, { 'retry-after': '120' });
|
||||
});
|
||||
|
||||
// A slow success is still in flight when a second request is rate limited.
|
||||
const inFlight = fetchFn(request);
|
||||
await expectBlocked(fetchFn);
|
||||
releaseSlowResponse();
|
||||
await inFlight;
|
||||
|
||||
// The block established by the newer 429 must survive the older success.
|
||||
const delayAfterSuccess = await expectBlocked(fetchFn);
|
||||
|
||||
expect({ delayAfterSuccess, calls }).toEqual({ delayAfterSuccess: 120_000, calls: 2 });
|
||||
});
|
||||
|
||||
it('resets the backoff once a request succeeds', async () => {
|
||||
let status = 429;
|
||||
const fetchFn = rateLimitBackoffMiddleware({ now: () => now })(async () => {
|
||||
calls++;
|
||||
return makeResponse(status);
|
||||
});
|
||||
|
||||
await expectBlocked(fetchFn);
|
||||
now += 60_000;
|
||||
|
||||
status = 200;
|
||||
await fetchFn(request);
|
||||
|
||||
// Back to the initial delay rather than continuing to double.
|
||||
status = 429;
|
||||
expect(await expectBlocked(fetchFn)).toBe(60_000);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ChatResponseReferencePartStatusKind } from '@vscode/prompt-tsx';
|
||||
import type { ChatQuestion, ChatResponseFileTree, ChatResponseStream, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, ExtendedChatResponsePart, Location, NotebookEdit, Progress, ThinkingDelta, Uri } from 'vscode';
|
||||
import { ChatHookType, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, MarkdownString, TextEdit } from '../../vscodeTypes';
|
||||
import type { ChatQuestion, ChatResponseFileTree, ChatResponseStream, ChatResponseVoiceProgressStage, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, ExtendedChatResponsePart, Location, NotebookEdit, Progress, ThinkingDelta, Uri } from 'vscode';
|
||||
import { ChatHookType, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseVoiceProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, MarkdownString, TextEdit } from '../../vscodeTypes';
|
||||
import type { ThemeIcon } from '../vs/base/common/themables';
|
||||
|
||||
|
||||
@@ -135,6 +135,10 @@ export class ChatResponseStreamImpl implements FinalizableChatResponseStream {
|
||||
this._push(new ChatResponseHookPart(hookType, stopReason, systemMessage));
|
||||
}
|
||||
|
||||
voiceProgress(id: ChatResponseVoiceProgressStage, value: string): void {
|
||||
this._push(new ChatResponseVoiceProgressPart(id, value));
|
||||
}
|
||||
|
||||
button(command: Command): void {
|
||||
this._push(new ChatResponseCommandButtonPart(command));
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ export class ChatResponseHookPart {
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatResponseVoiceProgressPart {
|
||||
constructor(
|
||||
readonly id: vscode.ChatResponseVoiceProgressStage,
|
||||
readonly value: string,
|
||||
) { }
|
||||
}
|
||||
|
||||
export class ChatResponseExternalEditPart {
|
||||
applied: Thenable<string>;
|
||||
didGetApplied!: (value: string) => void;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SnippetString } from '../../../vs/workbench/api/common/extHostTypes/sni
|
||||
import { SnippetTextEdit } from '../../../vs/workbench/api/common/extHostTypes/snippetTextEdit';
|
||||
import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/extHostTypes/symbolInformation';
|
||||
import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit';
|
||||
import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseAutoModeResolutionPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes';
|
||||
import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseAutoModeResolutionPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseVoiceProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes';
|
||||
import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing';
|
||||
import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums';
|
||||
import { t } from './l10n';
|
||||
@@ -60,6 +60,7 @@ const shim: typeof vscodeTypes = {
|
||||
ChatResponseWarningPart,
|
||||
ChatResponseInfoPart,
|
||||
ChatResponseHookPart,
|
||||
ChatResponseVoiceProgressPart,
|
||||
ChatResponseReferencePart,
|
||||
ChatResponseReferencePart2,
|
||||
ChatResponseCodeCitationPart,
|
||||
|
||||
@@ -27,6 +27,7 @@ export import ChatResponseClearToPreviousToolInvocationReason = vscode.ChatRespo
|
||||
export import ChatResponseMarkdownPart = vscode.ChatResponseMarkdownPart;
|
||||
export import ChatResponseThinkingProgressPart = vscode.ChatResponseThinkingProgressPart;
|
||||
export import ChatResponseHookPart = vscode.ChatResponseHookPart;
|
||||
export import ChatResponseVoiceProgressPart = vscode.ChatResponseVoiceProgressPart;
|
||||
export import ChatHookType = vscode.ChatHookType;
|
||||
export import ChatResponseFileTreePart = vscode.ChatResponseFileTreePart;
|
||||
export import ChatResponseAnchorPart = vscode.ChatResponseAnchorPart;
|
||||
|
||||
@@ -65,8 +65,12 @@ class Editor extends Disposable {
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
// `replaceSourceText` (not `sourceText.set`) applies authoritative host
|
||||
// text: it maps the selection through the change and clears stale
|
||||
// pending-paragraph state, so the caret stays valid after an undo shrinks
|
||||
// the document. The guard stops this echoing back as a user edit.
|
||||
this.isUpdatingFromExtension = true;
|
||||
this.model.sourceText.set(new StringValue(message.content), undefined);
|
||||
this.model.replaceSourceText(new StringValue(message.content));
|
||||
this.isUpdatingFromExtension = false;
|
||||
break;
|
||||
}
|
||||
@@ -157,7 +161,16 @@ class Editor extends Disposable {
|
||||
},
|
||||
}));
|
||||
|
||||
this._register(new EditorController(model, view));
|
||||
// Wire history chords (undo/redo) to the extension so they run against the
|
||||
// backing TextDocument's own undo stack. `record` is deliberately omitted:
|
||||
// the TextDocument owns the history, and a second local stack would drift
|
||||
// from the Edit menu, dirty state and hot exit.
|
||||
this._register(new EditorController(model, view, {
|
||||
historyStrategy: {
|
||||
undo: () => this.#vscode.postMessage({ type: 'history', command: 'undo' }),
|
||||
redo: () => this.#vscode.postMessage({ type: 'history', command: 'redo' }),
|
||||
},
|
||||
}));
|
||||
host.appendChild(view.element);
|
||||
|
||||
// Render comments as the VS Code V2 markdown cards. The card colours come
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vscode/extension-telemetry": "^0.9.8",
|
||||
"@vscode/markdown-editor": "^0.0.2-37",
|
||||
"@vscode/markdown-editor": "^0.0.2-40",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.8.0",
|
||||
"katex": "^0.16.33",
|
||||
@@ -632,9 +632,9 @@
|
||||
"integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA=="
|
||||
},
|
||||
"node_modules/@vscode/markdown-editor": {
|
||||
"version": "0.0.2-37",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-37.tgz",
|
||||
"integrity": "sha512-Glln7RyQ7dIl2v3OwiAGAcD+v3SdWjHRKdPr7XniVgVNOclnZeVsF29B67h4uDYSc2qEaqmAaGKXMG+0d+BQNA==",
|
||||
"version": "0.0.2-40",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-40.tgz",
|
||||
"integrity": "sha512-NPUmKKHDvauUM61SQpruezqVSi5Ly/+zmGZG89MFk0LZDYTklhGEiPhxQHvH6e3m+qaVbY268OkbajGEYRq7OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vscode/codicons": "^0.0.45",
|
||||
|
||||
@@ -909,7 +909,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/extension-telemetry": "^0.9.8",
|
||||
"@vscode/markdown-editor": "^0.0.2-37",
|
||||
"@vscode/markdown-editor": "^0.0.2-40",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.8.0",
|
||||
"katex": "^0.16.33",
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { MarkdownPreviewChangeIndicator, MarkdownPreviewInnerChange, Markdo
|
||||
interface LineChanges {
|
||||
readonly added: readonly number[];
|
||||
readonly deleted: readonly number[];
|
||||
readonly changedLineRanges: readonly ChangedLineRange[];
|
||||
readonly originalToModified: readonly number[];
|
||||
readonly modifiedToOriginal: readonly number[];
|
||||
readonly originalInnerChanges: readonly MarkdownPreviewInnerChange[];
|
||||
@@ -21,7 +22,7 @@ interface LineMappings {
|
||||
readonly modifiedToOriginal: number[];
|
||||
}
|
||||
|
||||
type ChangedLineRange = Pick<vscode.TextDiffChange, 'originalRange' | 'modifiedRange'>;
|
||||
export type ChangedLineRange = Pick<vscode.TextDiffChange, 'originalRange' | 'modifiedRange'>;
|
||||
|
||||
export class MarkdownPreviewLineDiffProvider {
|
||||
|
||||
@@ -55,6 +56,10 @@ export class MarkdownPreviewLineDiffProvider {
|
||||
return added.length || innerChanges.length || changeIndicators.length ? { added, innerChanges, changeIndicators } : undefined;
|
||||
}
|
||||
|
||||
public async getChangedLineRanges(): Promise<readonly ChangedLineRange[]> {
|
||||
return (await this.#getLineChanges()).changedLineRanges;
|
||||
}
|
||||
|
||||
public async translateOriginalLineToModified(line: number): Promise<number> {
|
||||
return translateLine(line, (await this.#getLineChanges()).originalToModified, this.#modifiedDocument.lineCount);
|
||||
}
|
||||
@@ -143,7 +148,7 @@ async function computeLineChanges(originalDocument: vscode.TextDocument, modifie
|
||||
const splitChangedLineRanges = splitChangedLineRangesByMarkdownBlocks(changedLineRanges, originalDocument, modifiedDocument);
|
||||
const changeIndicators = createChangeIndicators(splitChangedLineRanges, originalDocument, modifiedDocument, originalInnerChanges, modifiedInnerChanges);
|
||||
|
||||
return { added, deleted, originalInnerChanges, modifiedInnerChanges, changeIndicators, ...mappings };
|
||||
return { added, deleted, changedLineRanges, originalInnerChanges, modifiedInnerChanges, changeIndicators, ...mappings };
|
||||
}
|
||||
|
||||
function createChangeIndicators(ranges: readonly ChangedLineRange[], originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument, originalInnerChanges: readonly MarkdownPreviewInnerChange[], modifiedInnerChanges: readonly MarkdownPreviewInnerChange[]): MarkdownPreviewChangeIndicator[] {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as vscode from 'vscode';
|
||||
import { Disposable } from '../util/dispose';
|
||||
import { MdLinkOpener } from '../util/openDocumentLink';
|
||||
import { getMarkdownLocalResourceRoots } from '../util/resources';
|
||||
import { ChangedLineRange, MarkdownPreviewLineDiffProvider } from './lineDiff';
|
||||
|
||||
/**
|
||||
* Experimental hybrid (WYSIWYG) Markdown editor backed by the
|
||||
@@ -43,6 +44,18 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT
|
||||
webviewPanel: vscode.WebviewPanel,
|
||||
token: vscode.CancellationToken,
|
||||
): Promise<void> {
|
||||
await this.#resolveEditor(document, webviewPanel, token);
|
||||
}
|
||||
|
||||
public async resolveCustomTextEditorInlineDiff(
|
||||
documents: vscode.CustomEditorDiffDocuments<vscode.TextDocument>,
|
||||
webviewPanel: vscode.WebviewPanel,
|
||||
token: vscode.CancellationToken,
|
||||
): Promise<void> {
|
||||
await this.#resolveEditor(documents.modified, webviewPanel, token, documents.original);
|
||||
}
|
||||
|
||||
async #resolveEditor(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, token: vscode.CancellationToken, originalDocument?: vscode.TextDocument): Promise<void> {
|
||||
if (!vscode.workspace.isTrusted) {
|
||||
const cancel = { title: vscode.l10n.t("Cancel"), isCloseAffordance: true };
|
||||
const openAnyway = { title: vscode.l10n.t("Open Anyway") };
|
||||
@@ -61,9 +74,12 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT
|
||||
}
|
||||
}
|
||||
|
||||
if (token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
const webview = webviewPanel.webview;
|
||||
this.#configureWebview(document.uri, webview);
|
||||
this.#wireSingle(document, webviewPanel);
|
||||
this.#wireSingle(document, webviewPanel, originalDocument);
|
||||
}
|
||||
|
||||
#configureWebview(documentUri: vscode.Uri, webview: vscode.Webview): void {
|
||||
@@ -76,7 +92,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT
|
||||
webview.html = this.#getHtml(documentUri, webview);
|
||||
}
|
||||
|
||||
#wireSingle(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel): void {
|
||||
#wireSingle(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, originalDocument?: vscode.TextDocument): void {
|
||||
const webview = webviewPanel.webview;
|
||||
let isUpdatingFromWebview = false;
|
||||
let editQueue = Promise.resolve();
|
||||
@@ -93,6 +109,20 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT
|
||||
await this.#globalState.update(MarkdownEditorProvider.#readonlyStateKey, !!message.readonly);
|
||||
break;
|
||||
}
|
||||
case 'history': {
|
||||
// The TextDocument owns undo/redo, so route the chord to the built-in
|
||||
// command; the active custom editor input scopes it to this resource's
|
||||
// history, shared with the Edit menu and Command Palette. Drain any
|
||||
// in-flight edit first and only act while this panel is active, so the
|
||||
// chord cannot race a pending edit or land on a different document.
|
||||
if (message.command === 'undo' || message.command === 'redo') {
|
||||
await editQueue;
|
||||
if (webviewPanel.active) {
|
||||
await vscode.commands.executeCommand(message.command);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'openLink': {
|
||||
await this.#linkOpener.openDocumentLink(message.href as string, document.uri);
|
||||
break;
|
||||
@@ -129,7 +159,9 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT
|
||||
});
|
||||
|
||||
const highlight = this.#wireHighlight(webview);
|
||||
const quickDiff = this.#wireQuickDiff(document, webview);
|
||||
const quickDiff = originalDocument
|
||||
? this.#wireDocumentDiff(originalDocument, document, webview)
|
||||
: this.#wireQuickDiff(document, webview);
|
||||
const comments = this.#wireComments(document, webview);
|
||||
const onDidGrantWorkspaceTrust = vscode.workspace.onDidGrantWorkspaceTrust(() => {
|
||||
this.#configureWebview(document.uri, webview);
|
||||
@@ -184,6 +216,32 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT
|
||||
return vscode.Disposable.from(diffProvider, onChange, onMessage, onDocumentChange);
|
||||
}
|
||||
|
||||
#wireDocumentDiff(originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument, webview: vscode.Webview): vscode.Disposable {
|
||||
const lineDiffProvider = new MarkdownPreviewLineDiffProvider(originalDocument, modifiedDocument);
|
||||
const postMarkers = async () => {
|
||||
const originalVersion = originalDocument.version;
|
||||
const modifiedVersion = modifiedDocument.version;
|
||||
const changes = await lineDiffProvider.getChangedLineRanges();
|
||||
if (originalVersion !== originalDocument.version || modifiedVersion !== modifiedDocument.version) {
|
||||
return;
|
||||
}
|
||||
webview.postMessage({ type: 'gutterMarkers', markers: lineRangesToGutterMarkers(modifiedDocument, changes) });
|
||||
};
|
||||
|
||||
const onMessage = webview.onDidReceiveMessage(message => {
|
||||
if (message.type === 'ready') {
|
||||
void postMarkers();
|
||||
}
|
||||
});
|
||||
const onDocumentChange = vscode.workspace.onDidChangeTextDocument(event => {
|
||||
if (event.document.uri.toString() === originalDocument.uri.toString() || event.document.uri.toString() === modifiedDocument.uri.toString()) {
|
||||
void postMarkers();
|
||||
}
|
||||
});
|
||||
|
||||
return vscode.Disposable.from(onMessage, onDocumentChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the workbench's agent/session comments (the same store the code
|
||||
* editor renders its comments from) to the webview: existing comments are
|
||||
@@ -337,3 +395,20 @@ function toGutterMarkers(document: vscode.TextDocument, changes: readonly vscode
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
export function lineRangesToGutterMarkers(document: vscode.TextDocument, changes: readonly ChangedLineRange[]): GutterMarkerMessage[] {
|
||||
return changes.map(change => {
|
||||
if (change.modifiedRange.isEmpty) {
|
||||
const offset = document.offsetAt(change.modifiedRange.start);
|
||||
return { start: offset, endExclusive: offset, type: 'deleted' };
|
||||
}
|
||||
|
||||
const start = document.offsetAt(change.modifiedRange.start);
|
||||
const endExclusive = document.offsetAt(document.lineAt(change.modifiedRange.end.line - 1).range.end);
|
||||
return {
|
||||
start,
|
||||
endExclusive,
|
||||
type: change.originalRange.isEmpty ? 'added' : 'modified',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import 'mocha';
|
||||
import * as vscode from 'vscode';
|
||||
import { lineRangesToGutterMarkers } from '../preview/markdownEditorProvider';
|
||||
|
||||
suite('Markdown editor diff', () => {
|
||||
test('maps modified-side line changes to quick diff gutter markers', async () => {
|
||||
const document = await vscode.workspace.openTextDocument({ language: 'markdown', content: 'one\ntwo changed\nthree added\nfour\n' });
|
||||
const changes = [
|
||||
{ originalRange: new vscode.Range(1, 0, 2, 0), modifiedRange: new vscode.Range(1, 0, 2, 0) },
|
||||
{ originalRange: new vscode.Range(2, 0, 2, 0), modifiedRange: new vscode.Range(2, 0, 3, 0) },
|
||||
{ originalRange: new vscode.Range(3, 0, 4, 0), modifiedRange: new vscode.Range(3, 0, 3, 0) },
|
||||
];
|
||||
|
||||
assert.deepStrictEqual(lineRangesToGutterMarkers(document, changes), [
|
||||
{ start: 4, endExclusive: 15, type: 'modified' },
|
||||
{ start: 16, endExclusive: 27, type: 'added' },
|
||||
{ start: 28, endExclusive: 28, type: 'deleted' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Generated
+45
-45
@@ -11,8 +11,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.82.0",
|
||||
"@github/copilot": "^1.0.73",
|
||||
"@github/copilot-sdk": "^1.0.8",
|
||||
"@github/copilot": "^1.0.77",
|
||||
"@github/copilot-sdk": "^1.0.9-preview.1",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/dev-tunnels-connections": "^1.3.41",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@microsoft/mxc-sdk": "0.6.1",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@vscode/codicons": "^0.0.46-27",
|
||||
"@vscode/codicons": "^0.0.46-28",
|
||||
"@vscode/copilot-api": "^0.4.2",
|
||||
"@vscode/deviceid": "^0.1.1",
|
||||
"@vscode/diff": "0.0.2-7",
|
||||
@@ -1113,9 +1113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.73.tgz",
|
||||
"integrity": "sha512-8I2Ejg2CX/PQA3c2H8W1zuqhniCeR1q1/bD8CrV53/ZLw8GF7DAV0xQpwa8ELYvFgjXb6AADojafCKwdbVef+A==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.77.tgz",
|
||||
"integrity": "sha512-nkTtDPKvsClAByPPqnD/57vK7YIBK1dgiv7aVc9uO3rxKCyqiqYaBqwi8pMzesvGP3yl+//+iMzaBXNWEcZVWQ==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
@@ -1124,20 +1124,20 @@
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.73",
|
||||
"@github/copilot-darwin-x64": "1.0.73",
|
||||
"@github/copilot-linux-arm64": "1.0.73",
|
||||
"@github/copilot-linux-x64": "1.0.73",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.73",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.73",
|
||||
"@github/copilot-win32-arm64": "1.0.73",
|
||||
"@github/copilot-win32-x64": "1.0.73"
|
||||
"@github/copilot-darwin-arm64": "1.0.77",
|
||||
"@github/copilot-darwin-x64": "1.0.77",
|
||||
"@github/copilot-linux-arm64": "1.0.77",
|
||||
"@github/copilot-linux-x64": "1.0.77",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.77",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.77",
|
||||
"@github/copilot-win32-arm64": "1.0.77",
|
||||
"@github/copilot-win32-x64": "1.0.77"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-5jv7t2sw35/zI0cPze38hG6239NT5/q/Emjx6gLibYkolDqMDJjpm17Ps7tc8oafUEOiMQMb+ar7+qi6rSiGJA==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-sCWSH5+Flm/OxFe7dzsBfyj7ADBkzkR54Sz5NGw7dtcVVEOnVUkZLjEtNmZ1t5QRD4Sf1+g/DiwgJEbsR9xR1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1151,9 +1151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-l794k6Ahb11AG2FQT/P4TEWxWblzM1h8aQQCzG8jBWp8dfwjhyYjJ+d+0CWQzM3Fc1ddNUZRjKXCUsfvFjiZhQ==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-ReNlB+g+OBiqHwmY5leJBIyvHZQcjyWL/OY8aVimHyESn2ToPKP3eUNTzSUJvvbPM6+0LXwEpijLedkRd2Cn1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1167,9 +1167,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-Zu0W5nupJjNeem0brqU/pG+VY0IWr6EWr/FsC90g5SEDiaM4VhVNVWcz8t0E3DQCSYetV6IBaNMtjs/3uIIiDQ==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-A8j/WBPFvV5WfLbgnIIQLUVuFRAR7kLyc5WgId6XLCu1ARbkRM7353zz9mEXXwjc6LqotHVg80ooANJjNtmSPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1186,9 +1186,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-k33XIr6/PVp+K+5F/zv3No4PPaNImvHz73mcbIw63oxh5iiacXjgr0WqbBIS5s/rkhOWjNPIkbof/TTPZ7mQjA==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-2eefKkdUnQ1Y8oxyRyexHBXVpuSmrfEM8XJauquVjPc0JqF5nab9axwpFPzrRSF1GB+25F9tUK2sDQRyp08wag==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1205,9 +1205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-HJWzhfD3oaiIgfRAHkNWzp17fELtshqM9HVN5n+lFEmSO2EETCEh0P1lhJc4m+FYfXSJnL0raAqVuyaNMuPoPw==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-YtltOZQp8plytSKSGTWWKbOx3QD8iZH04sLtKTrYs6nu5UalIgFPoMkwamy1gh7h5EBkeVxD2s3epVrlvP4X4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1224,9 +1224,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-/BpOXSb16wHEu8I1SaKiLszQ4Kvu4+Z4uCn7W0bv4xI4fPZwTEG0u3zgaI2W9Ao3+aBl0XRpPmpWzE9ziYEq+w==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-owINwPgHU/ZZBwFhVPgkgGjLkF6e4QbdofADvKMdKJWV2+7oWjXUIlPA4/PwraD2Gkuu583l7m0XLL27TN8oUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1243,12 +1243,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8.tgz",
|
||||
"integrity": "sha512-dbahVsyt2aX8qqtOOtmYNe40MnvzSvOSHYFFgoFK7gHZSTNz9QgOht8b1sCCJlcXaFAn/w+5qNc7CwWoCjpQ0g==",
|
||||
"version": "1.0.9-preview.1",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.9-preview.1.tgz",
|
||||
"integrity": "sha512-/hUYdxpa4HL57uKmRCLmcg31wWjZPKGBbBfIhVqJdWXkp4/E6lbtHoiIbT5SLCPpf4c8Ka5zw356z+k4Nn/diA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.73",
|
||||
"@github/copilot": "^1.0.76-5",
|
||||
"koffi": "^3.1.0",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
@@ -1267,9 +1267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-DbPeXiYzQjpOy9oboaBvuCzjRwfcL987c3bG09cK1crdCDrKfkTJ7NXpcp1KWRPIRFO1FQm1qToNE89J+L3uvg==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-l5oQaMLCRup0nmmpbqOAYEAJ5YWgNlaoO0psNaKDzvTbdzEJRZqib2t7+p3bgoDpK7SB/m8m1uxFC4XT3hlprg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1283,9 +1283,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-8D3E1l5i+N5Eq8HIOQpx+Zbcb3MXdFxszksM2gqq175Z1S7Zna67oY4GoR3psxlbIpSyHKiLEBWYiaps6ayHWw==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-8Mo9y3/8CVU2w35WqwSiRMTGH1kKHR3URPSJYF4J4OG8L7NOEy2fafXR9Tuq3H21Srg3OzFkl/A+Taunqz9KcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4171,9 +4171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/codicons": {
|
||||
"version": "0.0.46-27",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-27.tgz",
|
||||
"integrity": "sha512-R6lEiJzbDrcrIT+pjM0aauVFjGXVHj4K9ClMzI4aOQWZH/fSswOJljypunppD81Mjwia1RzxA5LiBAlBJUI5PA==",
|
||||
"version": "0.0.46-28",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-28.tgz",
|
||||
"integrity": "sha512-Rj3yNS72a7N0FN/JeT/muXRCBzNNvnbQ99B++bCDyaOZkbHAfP/3DS7YoiAxa4z+ZiG5ZowJ5b9ncB40YGe1ig==",
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/@vscode/component-explorer": {
|
||||
|
||||
+3
-3
@@ -97,8 +97,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.82.0",
|
||||
"@github/copilot": "^1.0.73",
|
||||
"@github/copilot-sdk": "^1.0.8",
|
||||
"@github/copilot": "^1.0.77",
|
||||
"@github/copilot-sdk": "^1.0.9-preview.1",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/dev-tunnels-connections": "^1.3.41",
|
||||
@@ -109,7 +109,7 @@
|
||||
"@microsoft/mxc-sdk": "0.6.1",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@vscode/codicons": "^0.0.46-27",
|
||||
"@vscode/codicons": "^0.0.46-28",
|
||||
"@vscode/copilot-api": "^0.4.2",
|
||||
"@vscode/deviceid": "^0.1.1",
|
||||
"@vscode/diff": "0.0.2-7",
|
||||
|
||||
Generated
+41
-41
@@ -8,8 +8,8 @@
|
||||
"name": "vscode-reh",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.73",
|
||||
"@github/copilot-sdk": "^1.0.8",
|
||||
"@github/copilot": "^1.0.77",
|
||||
"@github/copilot-sdk": "^1.0.9-preview.1",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/mxc-sdk": "0.6.1",
|
||||
@@ -61,9 +61,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.73.tgz",
|
||||
"integrity": "sha512-8I2Ejg2CX/PQA3c2H8W1zuqhniCeR1q1/bD8CrV53/ZLw8GF7DAV0xQpwa8ELYvFgjXb6AADojafCKwdbVef+A==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.77.tgz",
|
||||
"integrity": "sha512-nkTtDPKvsClAByPPqnD/57vK7YIBK1dgiv7aVc9uO3rxKCyqiqYaBqwi8pMzesvGP3yl+//+iMzaBXNWEcZVWQ==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
@@ -72,20 +72,20 @@
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.73",
|
||||
"@github/copilot-darwin-x64": "1.0.73",
|
||||
"@github/copilot-linux-arm64": "1.0.73",
|
||||
"@github/copilot-linux-x64": "1.0.73",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.73",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.73",
|
||||
"@github/copilot-win32-arm64": "1.0.73",
|
||||
"@github/copilot-win32-x64": "1.0.73"
|
||||
"@github/copilot-darwin-arm64": "1.0.77",
|
||||
"@github/copilot-darwin-x64": "1.0.77",
|
||||
"@github/copilot-linux-arm64": "1.0.77",
|
||||
"@github/copilot-linux-x64": "1.0.77",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.77",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.77",
|
||||
"@github/copilot-win32-arm64": "1.0.77",
|
||||
"@github/copilot-win32-x64": "1.0.77"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-5jv7t2sw35/zI0cPze38hG6239NT5/q/Emjx6gLibYkolDqMDJjpm17Ps7tc8oafUEOiMQMb+ar7+qi6rSiGJA==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-sCWSH5+Flm/OxFe7dzsBfyj7ADBkzkR54Sz5NGw7dtcVVEOnVUkZLjEtNmZ1t5QRD4Sf1+g/DiwgJEbsR9xR1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -99,9 +99,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-l794k6Ahb11AG2FQT/P4TEWxWblzM1h8aQQCzG8jBWp8dfwjhyYjJ+d+0CWQzM3Fc1ddNUZRjKXCUsfvFjiZhQ==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-ReNlB+g+OBiqHwmY5leJBIyvHZQcjyWL/OY8aVimHyESn2ToPKP3eUNTzSUJvvbPM6+0LXwEpijLedkRd2Cn1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -115,9 +115,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-Zu0W5nupJjNeem0brqU/pG+VY0IWr6EWr/FsC90g5SEDiaM4VhVNVWcz8t0E3DQCSYetV6IBaNMtjs/3uIIiDQ==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-A8j/WBPFvV5WfLbgnIIQLUVuFRAR7kLyc5WgId6XLCu1ARbkRM7353zz9mEXXwjc6LqotHVg80ooANJjNtmSPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -134,9 +134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-k33XIr6/PVp+K+5F/zv3No4PPaNImvHz73mcbIw63oxh5iiacXjgr0WqbBIS5s/rkhOWjNPIkbof/TTPZ7mQjA==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-2eefKkdUnQ1Y8oxyRyexHBXVpuSmrfEM8XJauquVjPc0JqF5nab9axwpFPzrRSF1GB+25F9tUK2sDQRyp08wag==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -153,9 +153,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-HJWzhfD3oaiIgfRAHkNWzp17fELtshqM9HVN5n+lFEmSO2EETCEh0P1lhJc4m+FYfXSJnL0raAqVuyaNMuPoPw==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-YtltOZQp8plytSKSGTWWKbOx3QD8iZH04sLtKTrYs6nu5UalIgFPoMkwamy1gh7h5EBkeVxD2s3epVrlvP4X4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -172,9 +172,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-/BpOXSb16wHEu8I1SaKiLszQ4Kvu4+Z4uCn7W0bv4xI4fPZwTEG0u3zgaI2W9Ao3+aBl0XRpPmpWzE9ziYEq+w==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-owINwPgHU/ZZBwFhVPgkgGjLkF6e4QbdofADvKMdKJWV2+7oWjXUIlPA4/PwraD2Gkuu583l7m0XLL27TN8oUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -191,12 +191,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8.tgz",
|
||||
"integrity": "sha512-dbahVsyt2aX8qqtOOtmYNe40MnvzSvOSHYFFgoFK7gHZSTNz9QgOht8b1sCCJlcXaFAn/w+5qNc7CwWoCjpQ0g==",
|
||||
"version": "1.0.9-preview.1",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.9-preview.1.tgz",
|
||||
"integrity": "sha512-/hUYdxpa4HL57uKmRCLmcg31wWjZPKGBbBfIhVqJdWXkp4/E6lbtHoiIbT5SLCPpf4c8Ka5zw356z+k4Nn/diA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.73",
|
||||
"@github/copilot": "^1.0.76-5",
|
||||
"koffi": "^3.1.0",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
@@ -215,9 +215,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.73.tgz",
|
||||
"integrity": "sha512-DbPeXiYzQjpOy9oboaBvuCzjRwfcL987c3bG09cK1crdCDrKfkTJ7NXpcp1KWRPIRFO1FQm1qToNE89J+L3uvg==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.77.tgz",
|
||||
"integrity": "sha512-l5oQaMLCRup0nmmpbqOAYEAJ5YWgNlaoO0psNaKDzvTbdzEJRZqib2t7+p3bgoDpK7SB/m8m1uxFC4XT3hlprg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -231,9 +231,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.73",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.73.tgz",
|
||||
"integrity": "sha512-8D3E1l5i+N5Eq8HIOQpx+Zbcb3MXdFxszksM2gqq175Z1S7Zna67oY4GoR3psxlbIpSyHKiLEBWYiaps6ayHWw==",
|
||||
"version": "1.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.77.tgz",
|
||||
"integrity": "sha512-8Mo9y3/8CVU2w35WqwSiRMTGH1kKHR3URPSJYF4J4OG8L7NOEy2fafXR9Tuq3H21Srg3OzFkl/A+Taunqz9KcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.73",
|
||||
"@github/copilot-sdk": "^1.0.8",
|
||||
"@github/copilot": "^1.0.77",
|
||||
"@github/copilot-sdk": "^1.0.9-preview.1",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/mxc-sdk": "0.6.1",
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@vscode/codicons": "^0.0.46-27",
|
||||
"@vscode/codicons": "^0.0.46-28",
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/tree-sitter-wasm": "^0.3.1",
|
||||
"@vscode/vscode-languagedetection": "1.0.23",
|
||||
@@ -73,9 +73,9 @@
|
||||
"integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ=="
|
||||
},
|
||||
"node_modules/@vscode/codicons": {
|
||||
"version": "0.0.46-27",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-27.tgz",
|
||||
"integrity": "sha512-R6lEiJzbDrcrIT+pjM0aauVFjGXVHj4K9ClMzI4aOQWZH/fSswOJljypunppD81Mjwia1RzxA5LiBAlBJUI5PA==",
|
||||
"version": "0.0.46-28",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-28.tgz",
|
||||
"integrity": "sha512-Rj3yNS72a7N0FN/JeT/muXRCBzNNvnbQ99B++bCDyaOZkbHAfP/3DS7YoiAxa4z+ZiG5ZowJ5b9ncB40YGe1ig==",
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/@vscode/iconv-lite-umd": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"dependencies": {
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@vscode/codicons": "^0.0.46-27",
|
||||
"@vscode/codicons": "^0.0.46-28",
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/tree-sitter-wasm": "^0.3.1",
|
||||
"@vscode/vscode-languagedetection": "1.0.23",
|
||||
|
||||
@@ -150,9 +150,17 @@ export class StandardKeyboardEvent implements IKeyboardEvent {
|
||||
this.altKey = e.altKey;
|
||||
this.metaKey = e.metaKey;
|
||||
this.altGraphKey = e.getModifierState?.('AltGraph');
|
||||
this.keyCode = extractKeyCode(e);
|
||||
this.code = e.code;
|
||||
|
||||
// Browsers are inconsistent while an IME composition is in flight: most keystrokes arrive as
|
||||
// `keyCode: 229` (which maps to `KEY_IN_COMPOSITION`), but some platform/IME combinations
|
||||
// report the real key code for keys the IME owns - notably the Enter that commits a
|
||||
// composition, but also Space, Escape and the arrows used to pick candidates. Normalize to
|
||||
// `KEY_IN_COMPOSITION` so that "the IME owns this keystroke" has a single representation
|
||||
// that `equals()`, direct `keyCode` readers and keybinding resolution all understand,
|
||||
// instead of acting on a key the user never directed at the application.
|
||||
this.keyCode = e.isComposing ? KeyCode.KEY_IN_COMPOSITION : extractKeyCode(e);
|
||||
|
||||
// console.info(e.type + ": keyCode: " + e.keyCode + ", which: " + e.which + ", charCode: " + e.charCode + ", detail: " + e.detail + " ====> " + this.keyCode + ' -- ' + KeyCode[this.keyCode]);
|
||||
|
||||
this.ctrlKey = this.ctrlKey || this.keyCode === KeyCode.Ctrl;
|
||||
|
||||
@@ -117,6 +117,7 @@ export class InputBox extends Widget {
|
||||
private maxHeight: number = Number.POSITIVE_INFINITY;
|
||||
private scrollableElement: ScrollableElement | undefined;
|
||||
private readonly hover: MutableDisposable<IDisposable> = this._register(new MutableDisposable());
|
||||
private readonly messageResizeObserver: MutableDisposable<IDisposable> = this._register(new MutableDisposable());
|
||||
|
||||
private _onDidChange = this._register(new Emitter<string>());
|
||||
public get onDidChange(): Event<string> { return this._onDidChange.event; }
|
||||
@@ -527,10 +528,13 @@ export class InputBox extends Widget {
|
||||
},
|
||||
onHide: () => {
|
||||
this.state = 'closed';
|
||||
this.messageResizeObserver.clear();
|
||||
},
|
||||
layout: layout
|
||||
});
|
||||
|
||||
this.observeElementResize();
|
||||
|
||||
// ARIA Support
|
||||
let alertText: string;
|
||||
if (this.message.type === MessageType.ERROR) {
|
||||
@@ -555,9 +559,27 @@ export class InputBox extends Widget {
|
||||
this.contextViewProvider.hideContextView();
|
||||
}
|
||||
|
||||
this.messageResizeObserver.clear();
|
||||
this.state = 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the validation message sized and anchored to the input while the
|
||||
* message is showing and the input itself is resized, e.g. because the
|
||||
* containing view was resized.
|
||||
*/
|
||||
private observeElementResize(): void {
|
||||
const observer = new dom.DisposableResizeObserver('InputBox.validationMessage', () => {
|
||||
// Ignore notifications for a hidden or detached input, laying out
|
||||
// against a degenerate anchor would move the message to the corner.
|
||||
if (this.element.isConnected && dom.getTotalWidth(this.element) > 0) {
|
||||
this.layoutMessage();
|
||||
}
|
||||
}, dom.getWindow(this.element));
|
||||
observer.observe(this.element);
|
||||
this.messageResizeObserver.value = observer;
|
||||
}
|
||||
|
||||
private layoutMessage(): void {
|
||||
if (this.state === 'open' && this.contextViewProvider) {
|
||||
this.contextViewProvider.layout();
|
||||
|
||||
@@ -759,4 +759,5 @@ export const codiconsLibrary = {
|
||||
cloudUploadCompact: register('cloud-upload-compact', 0xece9),
|
||||
micCompact: register('mic-compact', 0xecea),
|
||||
arrowUpCompact: register('arrow-up-compact', 0xeceb),
|
||||
xai: register('xai', 0xecec),
|
||||
} as const;
|
||||
|
||||
@@ -82,12 +82,13 @@ function extractEntry(stream: Readable, fileName: string, mode: number, targetPa
|
||||
|
||||
let istream: WriteStream;
|
||||
|
||||
token.onCancellationRequested(() => {
|
||||
const listener = token.onCancellationRequested(() => {
|
||||
istream?.destroy();
|
||||
});
|
||||
|
||||
return Promise.resolve(promises.mkdir(targetDirName, { recursive: true })).then(() => new Promise<void>((c, e) => {
|
||||
if (token.isCancellationRequested) {
|
||||
c();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ function extractEntry(stream: Readable, fileName: string, mode: number, targetPa
|
||||
} catch (error) {
|
||||
e(error);
|
||||
}
|
||||
}));
|
||||
})).finally(() => listener.dispose());
|
||||
}
|
||||
|
||||
function extractZip(zipfile: ZipFile, targetPath: string, options: IOptions, token: CancellationToken): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { StandardKeyboardEvent } from '../../browser/keyboardEvent.js';
|
||||
import { KeyCode, KeyMod } from '../../common/keyCodes.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js';
|
||||
|
||||
function keydown(init: KeyboardEventInit & { keyCode: number }): StandardKeyboardEvent {
|
||||
// `keyCode` is legacy but is what `StandardKeyboardEvent` reads, so it has to be set explicitly.
|
||||
const event = new KeyboardEvent('keydown', init);
|
||||
Object.defineProperty(event, 'keyCode', { get: () => init.keyCode });
|
||||
return new StandardKeyboardEvent(event);
|
||||
}
|
||||
|
||||
suite('StandardKeyboardEvent', () => {
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('reports the pressed key when no composition is in progress', () => {
|
||||
const event = keydown({ keyCode: 13, isComposing: false });
|
||||
assert.deepStrictEqual(
|
||||
[event.keyCode === KeyCode.Enter, event.equals(KeyCode.Enter)],
|
||||
[true, true]
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes the key code to KEY_IN_COMPOSITION while composing', () => {
|
||||
// Some platform/IME combinations report the real key code (rather than 229) for the Enter
|
||||
// that commits a composition. Normalizing means both `equals()` callers and the many
|
||||
// handlers that compare `keyCode` directly stop seeing a key the user never aimed at them.
|
||||
const event = keydown({ keyCode: 13, isComposing: true });
|
||||
assert.deepStrictEqual(
|
||||
[event.keyCode === KeyCode.KEY_IN_COMPOSITION, event.equals(KeyCode.Enter)],
|
||||
[true, false]
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes modified keybindings while composing too', () => {
|
||||
const event = keydown({ keyCode: 13, ctrlKey: true, isComposing: true });
|
||||
assert.strictEqual(event.equals(KeyMod.CtrlCmd | KeyCode.Enter), false);
|
||||
});
|
||||
|
||||
test('keeps matching KEY_IN_COMPOSITION while composing', () => {
|
||||
// Composition-aware callers ask about KEY_IN_COMPOSITION explicitly; the editor relies on
|
||||
// this to detect IME input, so it has to keep working for both key code shapes.
|
||||
for (const keyCode of [229, 13]) {
|
||||
const event = keydown({ keyCode, isComposing: true });
|
||||
assert.deepStrictEqual(
|
||||
[keyCode, event.equals(KeyCode.KEY_IN_COMPOSITION)],
|
||||
[keyCode, true]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolves to a chord that matches no keybinding while composing', () => {
|
||||
// Keybinding resolution goes through `toKeyCodeChord()` rather than `equals()`, so it needs
|
||||
// the same normalization to avoid running commands mid-composition.
|
||||
const event = keydown({ keyCode: 13, isComposing: true });
|
||||
assert.strictEqual(event.toKeyCodeChord().keyCode, KeyCode.KEY_IN_COMPOSITION);
|
||||
});
|
||||
});
|
||||
@@ -1074,7 +1074,7 @@ registerEditorCommand(new FindCommand({
|
||||
handler: x => x.closeFindWidget(),
|
||||
kbOpts: {
|
||||
weight: KeybindingWeight.EditorContrib + 5,
|
||||
kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')),
|
||||
kbExpr: EditorContextKeys.focus,
|
||||
primary: KeyCode.Escape,
|
||||
secondary: [KeyMod.Shift | KeyCode.Escape]
|
||||
}
|
||||
@@ -1167,7 +1167,7 @@ registerEditorCommand(new FindCommand({
|
||||
handler: x => x.replace(),
|
||||
kbOpts: {
|
||||
weight: KeybindingWeight.EditorContrib + 5,
|
||||
kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, CONTEXT_REPLACE_INPUT_FOCUSED, EditorContextKeys.isComposing.negate()),
|
||||
kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, CONTEXT_REPLACE_INPUT_FOCUSED),
|
||||
primary: KeyCode.Enter
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -423,7 +423,7 @@ registerEditorCommand(new RenameCommand({
|
||||
handler: x => x.acceptRenameInput(false),
|
||||
kbOpts: {
|
||||
weight: KeybindingWeight.EditorContrib + 99,
|
||||
kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')),
|
||||
kbExpr: EditorContextKeys.focus,
|
||||
primary: KeyCode.Enter
|
||||
}
|
||||
}));
|
||||
@@ -434,7 +434,7 @@ registerEditorCommand(new RenameCommand({
|
||||
handler: x => x.acceptRenameInput(true),
|
||||
kbOpts: {
|
||||
weight: KeybindingWeight.EditorContrib + 99,
|
||||
kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')),
|
||||
kbExpr: EditorContextKeys.focus,
|
||||
primary: KeyMod.CtrlCmd + KeyCode.Enter
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -50,6 +50,7 @@ export const enum AccessibleViewProviderId {
|
||||
SessionsChanges = 'sessionsChanges',
|
||||
Survey = 'survey',
|
||||
Automations = 'automations',
|
||||
BrowserElementCommenting = 'browserElementCommenting',
|
||||
}
|
||||
|
||||
export const enum AccessibleViewType {
|
||||
|
||||
@@ -668,6 +668,7 @@ export class ActionListWidget<T> extends Disposable {
|
||||
|
||||
private readonly _collapsedSections = new Set<string>();
|
||||
private _filterText = '';
|
||||
private _imeSessionInProgress = false;
|
||||
private _suppressHover = false;
|
||||
private _hasLaidOut = false;
|
||||
private readonly _filterInput: HTMLInputElement | undefined;
|
||||
@@ -845,10 +846,34 @@ export class ActionListWidget<T> extends Disposable {
|
||||
filterActionBar.push(filterActions, { icon: true, label: false });
|
||||
}
|
||||
|
||||
this._register(dom.addDisposableListener(this._filterInput, 'input', () => {
|
||||
this._filterText = this._filterInput!.value;
|
||||
// While an IME composition is running the input holds intermediate text (e.g. pinyin)
|
||||
// which must not drive the filter: re-filtering splices the list, re-highlights a row and
|
||||
// re-layouts the popup, all of which disrupt the composition and the IME candidate window.
|
||||
// Filter once the composition commits instead.
|
||||
const onFilterValueChanged = () => {
|
||||
const value = this._filterInput!.value;
|
||||
// `compositionend` and the `input` event that follows it both land here (and browsers
|
||||
// disagree on their order), so only filter when the text actually changed.
|
||||
if (this._imeSessionInProgress || value === this._filterText) {
|
||||
return;
|
||||
}
|
||||
this._filterText = value;
|
||||
this._applyOrUpdateFilter();
|
||||
};
|
||||
|
||||
this._register(dom.addDisposableListener(this._filterInput, 'compositionstart', () => {
|
||||
this._imeSessionInProgress = true;
|
||||
// A dynamic filter request issued for the previous value can still be in flight.
|
||||
// Letting it resolve now would splice and re-layout the list underneath the IME
|
||||
// candidate window - the very disruption this guard exists to prevent. The
|
||||
// committed value starts a fresh request from `compositionend`.
|
||||
this._filterCts.value?.cancel();
|
||||
}));
|
||||
this._register(dom.addDisposableListener(this._filterInput, 'compositionend', () => {
|
||||
this._imeSessionInProgress = false;
|
||||
onFilterValueChanged();
|
||||
}));
|
||||
this._register(dom.addDisposableListener(this._filterInput, 'input', onFilterValueChanged));
|
||||
}
|
||||
|
||||
if (this._options?.secondaryHeading) {
|
||||
@@ -924,7 +949,7 @@ export class ActionListWidget<T> extends Disposable {
|
||||
|
||||
// ArrowRight opens submenu for the focused item and moves focus into it
|
||||
this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
if (e.key === 'ArrowRight' && !e.isComposing) {
|
||||
const focused = this._list.getFocus();
|
||||
if (focused.length > 0) {
|
||||
const element = this._list.element(focused[0]);
|
||||
@@ -945,7 +970,7 @@ export class ActionListWidget<T> extends Disposable {
|
||||
if (this._filterInput) {
|
||||
this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => {
|
||||
if (this._filterInput && !dom.isActiveElement(this._filterInput)
|
||||
&& e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
&& !e.isComposing && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
this._filterInput.focus();
|
||||
this._filterInput.value = e.key;
|
||||
this._filterText = e.key;
|
||||
|
||||
@@ -203,6 +203,43 @@ suite('ActionListWidget', () => {
|
||||
assert.ok(widget.domNode.textContent?.includes('ma-fresh-result'));
|
||||
});
|
||||
|
||||
test('does not filter while an IME composition is in progress', () => {
|
||||
const filters: string[] = [];
|
||||
const widget = createActionListWidget(disposables, {
|
||||
onFilter: async filter => {
|
||||
filters.push(filter);
|
||||
return [action(`result-${filter}`)];
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(widget.filterInput);
|
||||
widget.filterInput.dispatchEvent(new Event('compositionstart'));
|
||||
typeFilter(widget, 'd');
|
||||
typeFilter(widget, 'deepseek');
|
||||
widget.filterInput.value = 'DeepSeek';
|
||||
widget.filterInput.dispatchEvent(new Event('compositionend'));
|
||||
// Chromium fires a trailing `input` for the committed text, which must not re-filter.
|
||||
typeFilter(widget, 'DeepSeek');
|
||||
|
||||
assert.deepStrictEqual(filters, ['DeepSeek']);
|
||||
});
|
||||
|
||||
test('cancels an in-flight dynamic filter when a composition starts', async () => {
|
||||
const pending = new DeferredPromise<readonly IActionListItem<ITestActionItem>[]>();
|
||||
const widget = createActionListWidget(disposables, {
|
||||
onFilter: () => pending.p,
|
||||
});
|
||||
|
||||
typeFilter(widget, 'd');
|
||||
assert.ok(widget.filterInput);
|
||||
widget.filterInput.dispatchEvent(new Event('compositionstart'));
|
||||
|
||||
// Resolving now must not splice/re-layout the list underneath the IME candidate window.
|
||||
pending.complete([action('stale-result')]);
|
||||
await timeout(0);
|
||||
assert.ok(!widget.domNode.textContent?.includes('stale-result'));
|
||||
});
|
||||
|
||||
test('batches row width writes before reading layout', () => {
|
||||
const widget = createActionListWidget(disposables, {
|
||||
items: [
|
||||
|
||||
@@ -23,6 +23,7 @@ import { localize } from '../../../nls.js';
|
||||
import { IAccessibilityService } from '../../accessibility/common/accessibility.js';
|
||||
import { ICommandAction, isICommandActionToggleInfo } from '../../action/common/action.js';
|
||||
import { IConfigurationService } from '../../configuration/common/configuration.js';
|
||||
import { ICommandService } from '../../commands/common/commands.js';
|
||||
import { IContextKeyService } from '../../contextkey/common/contextkey.js';
|
||||
import { IContextMenuService, IContextViewService } from '../../contextview/browser/contextView.js';
|
||||
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
|
||||
@@ -425,6 +426,7 @@ export class SubmenuEntryActionViewItem extends DropdownMenuActionViewItem {
|
||||
export interface IDropdownWithDefaultActionViewItemOptions extends IDropdownMenuActionViewItemOptions {
|
||||
renderKeybindingWithDefaultActionLabel?: boolean;
|
||||
togglePrimaryAction?: boolean;
|
||||
primaryActionIds?: readonly string[];
|
||||
}
|
||||
|
||||
export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
|
||||
@@ -448,7 +450,8 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
|
||||
@IContextMenuService protected _contextMenuService: IContextMenuService,
|
||||
@IMenuService protected _menuService: IMenuService,
|
||||
@IInstantiationService protected _instaService: IInstantiationService,
|
||||
@IStorageService protected _storageService: IStorageService
|
||||
@IStorageService protected _storageService: IStorageService,
|
||||
@ICommandService protected _commandService: ICommandService,
|
||||
) {
|
||||
super(null, submenuAction);
|
||||
this._options = options;
|
||||
@@ -458,10 +461,10 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
|
||||
let defaultAction: IAction | undefined;
|
||||
const defaultActionId = options?.togglePrimaryAction ? _storageService.get(this._storageKey, StorageScope.WORKSPACE) : undefined;
|
||||
if (defaultActionId) {
|
||||
defaultAction = submenuAction.actions.find(a => defaultActionId === a.id);
|
||||
defaultAction = submenuAction.actions.find(a => defaultActionId === a.id && this._canBePrimaryAction(a));
|
||||
}
|
||||
if (!defaultAction) {
|
||||
defaultAction = submenuAction.actions[0];
|
||||
defaultAction = submenuAction.actions.find(action => this._canBePrimaryAction(action)) ?? submenuAction.actions[0];
|
||||
}
|
||||
|
||||
this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, <MenuItemAction>defaultAction, { keybinding: this._getDefaultActionKeybindingLabel(defaultAction), hoverDelegate: options?.hoverDelegate }));
|
||||
@@ -481,16 +484,31 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
|
||||
}
|
||||
|
||||
private registerTogglePrimaryActionListener(): void {
|
||||
this._primaryActionListener.value = this._dropdown.actionRunner.onDidRun((e: IRunEvent) => {
|
||||
if (e.action instanceof MenuItemAction) {
|
||||
this.update(e.action);
|
||||
}
|
||||
});
|
||||
this._primaryActionListener.value = this._options?.primaryActionIds?.length
|
||||
? this._commandService.onDidExecuteCommand(event => {
|
||||
const action = (<SubmenuItemAction>this._action).actions.find(action => action.id === event.commandId);
|
||||
if (action instanceof MenuItemAction && this._canBePrimaryAction(action)) {
|
||||
this.update(action);
|
||||
}
|
||||
})
|
||||
: this._dropdown.actionRunner.onDidRun((e: IRunEvent) => {
|
||||
if (e.action instanceof MenuItemAction) {
|
||||
this.update(e.action);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private update(lastAction: MenuItemAction): void {
|
||||
if (!this._canBePrimaryAction(lastAction)) {
|
||||
return;
|
||||
}
|
||||
if (this._options?.togglePrimaryAction) {
|
||||
this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);
|
||||
if (this._storageService.get(this._storageKey, StorageScope.WORKSPACE) !== lastAction.id) {
|
||||
this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);
|
||||
}
|
||||
}
|
||||
if (this._defaultAction.action.id === lastAction.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._defaultActionDisposables.clear();
|
||||
@@ -506,6 +524,10 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
|
||||
}
|
||||
}
|
||||
|
||||
private _canBePrimaryAction(action: IAction): boolean {
|
||||
return !this._options?.primaryActionIds?.length || this._options.primaryActionIds.includes(action.id);
|
||||
}
|
||||
|
||||
private _getDefaultActionKeybindingLabel(defaultAction: IAction) {
|
||||
let defaultActionKeybinding: string | undefined;
|
||||
if (this._options?.renderKeybindingWithDefaultActionLabel) {
|
||||
@@ -527,15 +549,10 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
|
||||
super.actionRunner = actionRunner;
|
||||
|
||||
this._defaultAction.actionRunner = actionRunner;
|
||||
// When togglePrimaryAction is enabled, keep the dropdown's private
|
||||
// action runner so that the onDidRun listener only fires for actions
|
||||
// originating from the dropdown, not from unrelated toolbar buttons.
|
||||
if (!this._options?.togglePrimaryAction) {
|
||||
// Without an allowlist, retain the private runner so only dropdown executions become primary.
|
||||
if (!this._options?.togglePrimaryAction || this._options.primaryActionIds?.length) {
|
||||
this._dropdown.actionRunner = actionRunner;
|
||||
}
|
||||
if (this._primaryActionListener.value) {
|
||||
this.registerTogglePrimaryActionListener();
|
||||
}
|
||||
}
|
||||
|
||||
override get actionRunner(): IActionRunner {
|
||||
@@ -635,6 +652,7 @@ export function createActionViewItem(instaService: IInstantiationService, action
|
||||
return instaService.createInstance(DropdownWithDefaultActionViewItem, action, {
|
||||
...options,
|
||||
togglePrimaryAction: typeof action.item.isSplitButton !== 'boolean' ? action.item.isSplitButton.togglePrimaryAction : false,
|
||||
primaryActionIds: typeof action.item.isSplitButton !== 'boolean' ? action.item.isSplitButton.primaryActionIds : undefined,
|
||||
});
|
||||
} else {
|
||||
return instaService.createInstance(SubmenuEntryActionViewItem, action, options);
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface ISubmenuItem {
|
||||
* on the action that was last run.
|
||||
*/
|
||||
togglePrimaryAction: true;
|
||||
/**
|
||||
* Restricts which submenu commands can become the primary action.
|
||||
* Running an eligible command outside the submenu also updates the primary action.
|
||||
*/
|
||||
primaryActionIds?: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { generateUuid } from '../../../base/common/uuid.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js';
|
||||
import { IConfigurationService } from '../../configuration/common/configuration.js';
|
||||
import { AgentSession, AgentHostCodexAgentEnabledSettingId, AgentHostCopilotMultiRootEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, AgentHostSystemProxyEnabledSettingId, IAgentConnection, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js';
|
||||
import { AgentSession, AgentHostCodexAgentEnabledSettingId, AgentHostCodexMultiRootEnabledSettingId, AgentHostCopilotMultiRootEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, AgentHostSystemProxyEnabledSettingId, IAgentConnection, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js';
|
||||
import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js';
|
||||
import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js';
|
||||
import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js';
|
||||
@@ -38,7 +38,7 @@ import { encodeBase64 } from '../../../base/common/buffer.js';
|
||||
import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js';
|
||||
import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js';
|
||||
import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js';
|
||||
import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js';
|
||||
import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js';
|
||||
import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js';
|
||||
import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js';
|
||||
import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js';
|
||||
@@ -400,6 +400,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
}
|
||||
this._updateClaudeMultiRootEnabled();
|
||||
}
|
||||
if (e.affectsConfiguration(AgentHostCodexMultiRootEnabledSettingId)) {
|
||||
if (this._state.kind !== AgentHostClientState.Connected) {
|
||||
return;
|
||||
}
|
||||
this._updateCodexMultiRootEnabled();
|
||||
}
|
||||
if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_SETTING_ID) || e.affectsConfiguration(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID)) {
|
||||
if (this._state.kind !== AgentHostClientState.Connected) {
|
||||
return;
|
||||
@@ -714,6 +720,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
this._updateSystemProxyEnabled();
|
||||
this._updateCopilotMultiRootEnabled();
|
||||
this._updateClaudeMultiRootEnabled();
|
||||
this._updateCodexMultiRootEnabled();
|
||||
this._updateTerminalAutoApproveRules();
|
||||
this._updateCodexEnabled();
|
||||
this._updateDisableRepoInfoTelemetry();
|
||||
@@ -1581,6 +1588,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
}, this._clientId, 0);
|
||||
}
|
||||
|
||||
private _updateCodexMultiRootEnabled(): void {
|
||||
const enabled = this._configurationService.getValue<boolean>(AgentHostCodexMultiRootEnabledSettingId) === true;
|
||||
this.dispatchAction(ROOT_STATE_URI, {
|
||||
type: ActionType.RootConfigChanged,
|
||||
config: { [AgentHostCodexMultiRootEnabledConfigKey]: enabled },
|
||||
}, this._clientId, 0);
|
||||
}
|
||||
|
||||
private _updateCodexEnabled(): void {
|
||||
// Always forwards the current value; the host only acts on enable, so a
|
||||
// forwarded `false` only takes effect on the next agent host restart
|
||||
|
||||
@@ -15,64 +15,109 @@ import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
* These shapes are deliberately wire-friendly (plain JSON, no `VSBuffer`,
|
||||
* `URI`, or `workbench/contrib/chat` types) so they survive both the local
|
||||
* utility-process IPC channel and the remote JSON-RPC transport without a
|
||||
* translation step. The node side converts OpenAI Chat Completions wire
|
||||
* payloads to/from these; the renderer side converts these to/from the VS Code
|
||||
* translation step. The node side converts OpenAI Responses wire payloads
|
||||
* to/from these; the renderer side converts these to/from the VS Code
|
||||
* LM API (`ILanguageModelsService`).
|
||||
*/
|
||||
|
||||
/** A single tool/function call requested by the assistant. */
|
||||
export interface IByokLmToolCall {
|
||||
/** Stable id correlating the call with its later `tool` result message. */
|
||||
readonly id: string;
|
||||
/** Tool/function name. */
|
||||
export interface IByokLmTextPart {
|
||||
readonly type: 'text';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface IByokLmMessageItem {
|
||||
readonly type: 'message';
|
||||
readonly role: 'system' | 'developer' | 'user' | 'assistant';
|
||||
readonly content: IByokLmTextPart[];
|
||||
}
|
||||
|
||||
export interface IByokLmReasoningItem {
|
||||
readonly type: 'reasoning';
|
||||
readonly id?: string;
|
||||
readonly summary: string[];
|
||||
readonly encryptedContent?: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IByokLmFunctionCallItem {
|
||||
readonly type: 'function_call';
|
||||
readonly callId: string;
|
||||
readonly name: string;
|
||||
/** JSON-encoded tool input, which may be an object or a freeform string. */
|
||||
readonly argumentsJson: string;
|
||||
}
|
||||
|
||||
/** A tool/function the model may call. */
|
||||
export interface IByokLmTool {
|
||||
export interface IByokLmFunctionCallOutputItem {
|
||||
readonly type: 'function_call_output';
|
||||
readonly callId: string;
|
||||
readonly output: string;
|
||||
}
|
||||
|
||||
export interface IByokLmCustomToolCallItem {
|
||||
readonly type: 'custom_tool_call';
|
||||
readonly callId: string;
|
||||
readonly name: string;
|
||||
readonly input: string;
|
||||
}
|
||||
|
||||
export interface IByokLmCustomToolCallOutputItem {
|
||||
readonly type: 'custom_tool_call_output';
|
||||
readonly callId: string;
|
||||
readonly output: string;
|
||||
}
|
||||
|
||||
export type IByokLmInputItem =
|
||||
IByokLmMessageItem |
|
||||
IByokLmReasoningItem |
|
||||
IByokLmFunctionCallItem |
|
||||
IByokLmFunctionCallOutputItem |
|
||||
IByokLmCustomToolCallItem |
|
||||
IByokLmCustomToolCallOutputItem;
|
||||
|
||||
export interface IByokLmFunctionTool {
|
||||
readonly type: 'function';
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
/** JSON schema for the tool parameters. */
|
||||
readonly parametersSchema?: object;
|
||||
}
|
||||
|
||||
/** One chat message in a BYOK request. */
|
||||
export interface IByokLmChatMessage {
|
||||
readonly role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
/** Flattened text content. Empty string when the message carries only tool calls/results. */
|
||||
readonly content: string;
|
||||
/** Present on `assistant` messages that requested tool calls. */
|
||||
readonly toolCalls?: IByokLmToolCall[];
|
||||
/** Present on `tool` messages: the {@link IByokLmToolCall.id} this result answers. */
|
||||
readonly toolCallId?: string;
|
||||
export interface IByokLmCustomTool {
|
||||
readonly type: 'custom';
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
/** A chat request forwarded from the proxy to the renderer LM API. */
|
||||
export type IByokLmTool = IByokLmFunctionTool | IByokLmCustomTool;
|
||||
|
||||
export interface IByokLmChatRequest {
|
||||
/** Provider/vendor name (the LM API vendor that registered the model). */
|
||||
readonly vendor: string;
|
||||
/** Provider-local model id (the wire id the runtime sent on the OpenAI request). */
|
||||
readonly modelId: string;
|
||||
readonly messages: IByokLmChatMessage[];
|
||||
readonly instructions?: string;
|
||||
readonly input: IByokLmInputItem[];
|
||||
readonly tools?: IByokLmTool[];
|
||||
/** Opaque per-request model options forwarded to the LM provider. */
|
||||
readonly previousResponseId?: string;
|
||||
readonly reasoningEffort?: string;
|
||||
readonly modelOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The (buffered) completion produced by the renderer LM API. */
|
||||
export interface IByokLmOutputMessageItem {
|
||||
readonly type: 'message';
|
||||
readonly content: IByokLmTextPart[];
|
||||
}
|
||||
|
||||
export type IByokLmOutputItem =
|
||||
IByokLmOutputMessageItem |
|
||||
IByokLmReasoningItem |
|
||||
IByokLmFunctionCallItem |
|
||||
IByokLmCustomToolCallItem;
|
||||
|
||||
export interface IByokLmChatResult {
|
||||
/** Concatenated assistant text. */
|
||||
readonly content: string;
|
||||
/** Tool calls the assistant requested, if any. */
|
||||
readonly toolCalls?: IByokLmToolCall[];
|
||||
/** Best-effort token usage, when the provider reports it. */
|
||||
readonly output: IByokLmOutputItem[];
|
||||
readonly responseId?: string;
|
||||
readonly usage?: {
|
||||
readonly promptTokens?: number;
|
||||
readonly completionTokens?: number;
|
||||
readonly inputTokens?: number;
|
||||
readonly outputTokens?: number;
|
||||
readonly reasoningTokens?: number;
|
||||
};
|
||||
/** Set when the LM call failed; `content` is then empty. */
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
@@ -98,6 +143,10 @@ export interface IByokLmModelInfo {
|
||||
readonly maxContextWindowTokens?: number;
|
||||
/** Whether the model accepts image inputs, when known. */
|
||||
readonly supportsVision?: boolean;
|
||||
/** Reasoning effort values advertised by the renderer model, when known. */
|
||||
readonly supportedReasoningEfforts?: readonly string[];
|
||||
/** Default reasoning effort advertised by the renderer model, when known. */
|
||||
readonly defaultReasoningEffort?: string;
|
||||
}
|
||||
|
||||
export const IAgentHostByokLmHandler = createDecorator<IAgentHostByokLmHandler>('agentHostByokLmHandler');
|
||||
@@ -119,7 +168,7 @@ export interface IAgentHostByokLmHandler {
|
||||
readonly onDidChangeModels?: Event<void>;
|
||||
|
||||
/**
|
||||
* Run a BYOK chat completion against the extension-registered model that
|
||||
* Run a BYOK Responses request against the extension-registered model that
|
||||
* matches `request.vendor` + `request.modelId`. Rejects (or resolves with
|
||||
* {@link IByokLmChatResult.error}) when no such model is available.
|
||||
*/
|
||||
|
||||
@@ -8,12 +8,6 @@ import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
|
||||
export const IAgentHostCheckpointService = createDecorator<IAgentHostCheckpointService>('agentHostCheckpointService');
|
||||
|
||||
/**
|
||||
* `session_metadata` key under which the per-session baseline (turn/0)
|
||||
* checkpoint ref is stored.
|
||||
*/
|
||||
export const META_CHECKPOINT_BASE_REF = 'checkpoint.baseRef';
|
||||
|
||||
/**
|
||||
* Returns the canonical name for a per-turn checkpoint ref.
|
||||
* Distinct from the chat extension's `refs/sessions/...` so the two can
|
||||
@@ -42,24 +36,30 @@ export interface IAgentHostCheckpointService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Captures the session's baseline (turn/0) checkpoint. Idempotent: if
|
||||
* a baseline already exists for the session, returns the existing ref.
|
||||
* Returns `undefined` when the working directory is not a git work tree
|
||||
* (folder-isolation against a non-git folder) or when checkpoint
|
||||
* capture fails.
|
||||
* Captures the session's baseline (turn/0) checkpoint in each of
|
||||
* `workingDirectories`. Idempotent per repository: a directory that
|
||||
* already has a baseline ref is skipped, as is one that is not a git
|
||||
* work tree (folder-isolation against a non-git folder). Best-effort —
|
||||
* a failure for one repository does not stop the others.
|
||||
*
|
||||
* Called once per session, immediately after the session's working
|
||||
* directory has been resolved and any worktree metadata has been
|
||||
* directories have been resolved and any worktree metadata has been
|
||||
* persisted (e.g. `CopilotAgent._materializeProvisional`).
|
||||
*
|
||||
* The caller must pass the directories it just resolved rather than
|
||||
* letting this service look them up: at that point the resolved set
|
||||
* (which for an isolated session is the *worktree*, not the folder the
|
||||
* user picked) has not necessarily reached the state manager yet, so a
|
||||
* lookup can silently capture the baseline against the wrong repository.
|
||||
*/
|
||||
captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise<string | undefined>;
|
||||
captureBaselineCheckpoint(sessionUri: URI, workingDirectories: readonly URI[] | undefined): Promise<void>;
|
||||
|
||||
/**
|
||||
* Captures an end-of-turn checkpoint, chained to the previous turn's
|
||||
* checkpoint (or the baseline for turn 1). Persists the ref against
|
||||
* the turn via `ISessionDatabase.setTurnCheckpointRef`. Returns
|
||||
* `undefined` when the session is not git-backed, the baseline is
|
||||
* missing, or capture fails.
|
||||
* Captures an end-of-turn checkpoint in each of `workingDirectories`,
|
||||
* chained to the previous turn's checkpoint (or the baseline for turn 1).
|
||||
* Persists the ref against the turn via `ISessionDatabase.setTurnCheckpointRef`
|
||||
* once at least one repository captured successfully. A directory that is
|
||||
* not git-backed, or has no baseline, is skipped.
|
||||
*
|
||||
* If the captured tree OID matches the parent's tree OID (no-op turn)
|
||||
* the parent ref is recorded against the turn rather than creating a
|
||||
@@ -68,15 +68,19 @@ export interface IAgentHostCheckpointService {
|
||||
* Called from `AgentSideEffects` when a `ChatTurnComplete` action
|
||||
* fires, BEFORE the changeset service's `onTurnComplete` hook so the
|
||||
* per-turn changeset compute can pick up the new refs.
|
||||
*
|
||||
* As with {@link captureBaselineCheckpoint}, the caller supplies the directories
|
||||
* so that every checkpoint operation is explicit about the repositories
|
||||
* it acts on rather than depending on live session state.
|
||||
*/
|
||||
captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise<string | undefined>;
|
||||
captureTurnCheckpoint(sessionUri: URI, turnId: string, workingDirectories: readonly URI[] | undefined): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns the `{ parent, current }` checkpoint refs for a turn, or
|
||||
* `undefined` when either is missing. Used by the changeset service
|
||||
* to decide whether to take the git-diff fast path for per-turn diffs.
|
||||
*/
|
||||
getTurnCheckpointPair(sessionUri: URI, turnId: string): Promise<{ parent: string; current: string } | undefined>;
|
||||
getTurnCheckpointPair(sessionUri: URI, turnId: string, workingDirectory?: URI): Promise<{ parent: string; current: string } | undefined>;
|
||||
|
||||
/**
|
||||
* Returns the session's baseline checkpoint ref, or `undefined` when
|
||||
@@ -84,7 +88,7 @@ export interface IAgentHostCheckpointService {
|
||||
* failed). Used by the changeset service to resolve compare-turns
|
||||
* URIs whose `originalTurnId` is the `BASELINE_TURN_ID` sentinel.
|
||||
*/
|
||||
getBaselineCheckpointRef(sessionUri: URI): Promise<string | undefined>;
|
||||
getBaselineCheckpoint(sessionUri: URI, workingDirectory?: URI): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Deletes every checkpoint ref this service created for the session
|
||||
@@ -93,22 +97,25 @@ export interface IAgentHostCheckpointService {
|
||||
*
|
||||
* Called from a subscriber to `ISessionDataService.onWillDeleteSessionData`
|
||||
* before the session's data directory is removed.
|
||||
*
|
||||
* `workingDirectories` identifies the repositories holding the refs.
|
||||
* There is deliberately no fallback to the session's live state: by
|
||||
* the time this runs the session has typically already been removed
|
||||
* from the state manager, so omitting them is a silent no-op that
|
||||
* leaks the refs.
|
||||
*/
|
||||
disposeSessionData(sessionUri: URI): Promise<void>;
|
||||
deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op implementation of {@link IAgentHostCheckpointService} used as a
|
||||
* fallback in test fixtures that don't exercise checkpoint capture, and
|
||||
* as the default value for the optional `_checkpointService` parameter
|
||||
* on `AgentService` so existing test callsites keep compiling without
|
||||
* forced fixture updates.
|
||||
* fallback in test fixtures that don't exercise checkpoint capture.
|
||||
*/
|
||||
export const NULL_CHECKPOINT_SERVICE: IAgentHostCheckpointService = {
|
||||
_serviceBrand: undefined,
|
||||
captureBaseline: async () => undefined,
|
||||
captureTurnCheckpoint: async () => undefined,
|
||||
captureBaselineCheckpoint: async () => { },
|
||||
captureTurnCheckpoint: async () => { },
|
||||
getTurnCheckpointPair: async () => undefined,
|
||||
getBaselineCheckpointRef: async () => undefined,
|
||||
disposeSessionData: async () => { },
|
||||
getBaselineCheckpoint: async () => undefined,
|
||||
deleteCheckpoints: async () => { },
|
||||
};
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Sequencer } from '../../../base/common/async.js';
|
||||
import { VSBuffer } from '../../../base/common/buffer.js';
|
||||
import { LRUCache } from '../../../base/common/map.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
import { ISessionFileDiff, ISessionGitState } from './state/sessionState.js';
|
||||
@@ -91,6 +93,54 @@ export interface IPullOptions {
|
||||
|
||||
export const IAgentHostGitService = createDecorator<IAgentHostGitService>('agentHostGitService');
|
||||
|
||||
/**
|
||||
* Resolves linked checkouts to their primary worktree and caches successful mappings for every worktree reported by Git.
|
||||
* Resolution is serialized so concurrent requests across linked checkouts share one probe, while empty results remain retryable.
|
||||
*/
|
||||
class PrimaryWorktreeRootResolver {
|
||||
private readonly _roots = new LRUCache<string, URI>(100);
|
||||
private readonly _sequencer = new Sequencer();
|
||||
|
||||
constructor(private readonly _gitService: IAgentHostGitService) { }
|
||||
|
||||
async resolve(checkoutRoot: URI): Promise<URI | undefined> {
|
||||
const key = checkoutRoot.toString();
|
||||
const cached = this._roots.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return this._sequencer.queue(async () => {
|
||||
const cached = this._roots.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const roots = await this._gitService.getWorktreeRoots(checkoutRoot);
|
||||
const primaryRoot = roots[0];
|
||||
if (!primaryRoot) {
|
||||
return undefined;
|
||||
}
|
||||
this._roots.set(key, primaryRoot);
|
||||
for (const root of roots) {
|
||||
this._roots.set(root.toString(), primaryRoot);
|
||||
}
|
||||
return primaryRoot;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolver lifetime follows the injected Git service; each resolver owns a bounded path cache. */
|
||||
const primaryWorktreeRootResolvers = new WeakMap<IAgentHostGitService, PrimaryWorktreeRootResolver>();
|
||||
|
||||
/** Resolves the primary worktree root when Git reports a worktree listing. */
|
||||
export function tryResolvePrimaryWorktreeRoot(gitService: IAgentHostGitService, checkoutRoot: URI): Promise<URI | undefined> {
|
||||
let resolver = primaryWorktreeRootResolvers.get(gitService);
|
||||
if (!resolver) {
|
||||
resolver = new PrimaryWorktreeRootResolver(gitService);
|
||||
primaryWorktreeRootResolvers.set(gitService, resolver);
|
||||
}
|
||||
return resolver.resolve(checkoutRoot);
|
||||
}
|
||||
|
||||
export interface IRefQuery {
|
||||
readonly count?: number;
|
||||
readonly pattern?: string | string[];
|
||||
@@ -156,6 +206,7 @@ export interface IAgentHostGitService {
|
||||
getBranches(workingDirectory: URI, query?: IRefQuery): Promise<Branch[]>;
|
||||
getBranch(workingDirectory: URI, name: string): Promise<Branch | undefined>;
|
||||
getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined>;
|
||||
/** Returns worktree roots in Git's porcelain order, with the primary worktree first. */
|
||||
getWorktreeRoots(workingDirectory: URI): Promise<URI[]>;
|
||||
/**
|
||||
* Creates a worktree for a new branch. `onProgress` receives every checkout
|
||||
|
||||
@@ -45,4 +45,13 @@ export interface IAgentHostGitStateService {
|
||||
* @param sessionKey The key of the session for which to check the GitHub pull request.
|
||||
*/
|
||||
attachSessionGitHubPullRequest(sessionKey: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Detect GitHub issues referenced in a user message and add them to the
|
||||
* session's GitHub state. Already-known issues are kept, so the session
|
||||
* accumulates every issue referenced over its lifetime.
|
||||
* @param sessionKey The key of the session the message was sent to.
|
||||
* @param text The user message to scan for issue references.
|
||||
*/
|
||||
attachSessionGitHubIssues(sessionKey: string, text: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -451,6 +451,8 @@ export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove';
|
||||
*/
|
||||
export const AgentHostAutoReplyEnabledConfigKey = 'autoReplyEnabled';
|
||||
|
||||
export const AgentHostAutoReplyAnswer = 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.';
|
||||
|
||||
/**
|
||||
* The VS Code setting ID for auto-reply. Defined here so renderer-side
|
||||
* agent-host clients can forward it without importing from `workbench/contrib/chat`.
|
||||
@@ -482,6 +484,9 @@ export const AgentHostCopilotMultiRootEnabledConfigKey = 'copilotMultiRootEnable
|
||||
*/
|
||||
export const AgentHostClaudeMultiRootEnabledConfigKey = 'claudeMultiRootEnabled';
|
||||
|
||||
/** Root config key forwarded from the renderer that gates Codex multiple-working-directory support. */
|
||||
export const AgentHostCodexMultiRootEnabledConfigKey = 'codexMultiRootEnabled';
|
||||
|
||||
/**
|
||||
* Root config key forwarded from the renderer when VS Code's
|
||||
* `chat.tools.terminal.autoApprove` setting changes. Holds the effective
|
||||
@@ -756,6 +761,12 @@ export const platformRootSchema = createSchema({
|
||||
description: localize('agentHost.config.claudeMultiRootEnabled.description', "Whether the Claude provider advertises support for multiple working directories, letting a session span every folder of a multi-root workspace."),
|
||||
default: false,
|
||||
}),
|
||||
[AgentHostCodexMultiRootEnabledConfigKey]: schemaProperty<boolean>({
|
||||
type: 'boolean',
|
||||
title: localize('agentHost.config.codexMultiRootEnabled.title', "Codex Multiple Working Directories"),
|
||||
description: localize('agentHost.config.codexMultiRootEnabled.description', "Whether the Codex provider advertises support for multiple working directories, letting a session span every folder of a multi-root workspace."),
|
||||
default: false,
|
||||
}),
|
||||
[AgentHostTerminalAutoApproveRulesConfigKey]: schemaProperty<AgentHostTerminalAutoApproveRules>({
|
||||
type: 'object',
|
||||
title: localize('agentHost.config.terminalAutoApproveRules.title', "Terminal Auto Approve Rules"),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AgentHostClaudeMultiRootEnabledSettingId,
|
||||
AgentHostCodexAgentBinaryArgsSettingId,
|
||||
AgentHostCodexAgentEnabledSettingId,
|
||||
AgentHostCodexMultiRootEnabledSettingId,
|
||||
AgentHostCodexAgentSdkRootSettingId,
|
||||
AgentHostCodexAgentCodexHomeSettingId,
|
||||
AgentHostCopilotMultiRootEnabledSettingId,
|
||||
@@ -112,6 +113,12 @@ configurationRegistry.registerConfiguration({
|
||||
// `product.quality !== 'stable'`) to enable it for a build channel.
|
||||
included: false,
|
||||
},
|
||||
[AgentHostCodexMultiRootEnabledSettingId]: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('chat.agentHost.codexAgent.multiRootEnabled', "When enabled, Codex agent-host sessions advertise support for multiple working directories, so a session created in a multi-root workspace can span every workspace folder. Experimental; newly created sessions pick up a change without restarting the agent host."),
|
||||
default: false,
|
||||
included: false,
|
||||
},
|
||||
[AgentHostClaudeAgentEnabledSettingId]: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('chat.agentHost.claudeAgent.enabled', "When enabled, the agent host registers the Claude provider (subject to the Claude SDK being reachable). Independent of `#chat.agents.claude.preferAgentHost#` and `#chat.editor.claude.preferAgentHost#`, which choose which integration surfaces Claude. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."),
|
||||
|
||||
@@ -76,6 +76,13 @@ export const AgentHostCopilotMultiRootEnabledSettingId = 'chat.agentHost.copilot
|
||||
*/
|
||||
export const AgentHostClaudeMultiRootEnabledSettingId = 'chat.agentHost.claudeAgent.multiRootEnabled';
|
||||
|
||||
/**
|
||||
* Configuration key gating multiple-working-directory support for the Codex
|
||||
* agent-host provider. Hidden from the Settings UI and off by default while the
|
||||
* feature is dogfooded.
|
||||
*/
|
||||
export const AgentHostCodexMultiRootEnabledSettingId = 'chat.agentHost.codexAgent.multiRootEnabled';
|
||||
|
||||
// The Copilot-CLI-specific setting IDs (`customTerminalTool`, `opus48Prompt`,
|
||||
// `reasoningEffortOverride`, `modelCapabilityOverrides`) live with their
|
||||
// root-config keys in `copilotCliConfig.ts`.
|
||||
|
||||
@@ -244,6 +244,37 @@ export class CloudSandboxAuthenticationRequiredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Mission Control request that came back with a non-success status. Carries the {@link statusCode}
|
||||
* so callers can tell a failure that may clear on its own from one that never will.
|
||||
*/
|
||||
export class CloudSandboxRequestError extends Error {
|
||||
constructor(readonly statusCode: number | undefined, message: string) {
|
||||
super(message);
|
||||
this.name = 'CloudSandboxRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether re-issuing a request that failed with {@link error} could plausibly succeed later.
|
||||
*
|
||||
* Transport failures carry no status and are assumed transient, as are 5xx, 408 and 429. Every other
|
||||
* 4xx describes a request Mission Control will reject identically however often it is repeated — a
|
||||
* deleted environment, a revoked token — so repeating it only adds load. Callers that retry on a
|
||||
* timer MUST consult this, or a single dead session becomes an unbounded stream of failed requests.
|
||||
*
|
||||
* Note that {@link CloudSandboxAuthenticationRequiredError} counts as retryable: it is raised before
|
||||
* any request goes out, and covers the GitHub auth provider not having registered yet as well as a
|
||||
* genuinely signed-out user. Callers still need their own ceiling on how long they keep trying.
|
||||
*/
|
||||
export function isRetryableCloudSandboxError(error: unknown): boolean {
|
||||
if (!(error instanceof CloudSandboxRequestError) || error.statusCode === undefined) {
|
||||
return true;
|
||||
}
|
||||
const status = error.statusCode;
|
||||
return status === 408 || status === 429 || status < 400 || status >= 500;
|
||||
}
|
||||
|
||||
export const ICloudSandboxAgentHostService = createDecorator<ICloudSandboxAgentHostService>('cloudSandboxAgentHostService');
|
||||
|
||||
/** Options for establishing a live AHP relay to a cloud sandbox environment. */
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/** A GitHub issue referenced from a user message. */
|
||||
export interface IGitHubIssueReference {
|
||||
readonly owner: string;
|
||||
readonly repo: string;
|
||||
readonly number: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches `https://github.com/{owner}/{repo}/issues/{number}`, optionally with a
|
||||
* `www.` host, a trailing slash, a query string or a fragment (e.g. the
|
||||
* `#issuecomment-123` anchor GitHub appends when copying a comment link).
|
||||
*/
|
||||
const ISSUE_URL_PATTERN = /\bhttps?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)\b/gi;
|
||||
|
||||
/**
|
||||
* Matches the cross-repository shorthand `{owner}/{repo}#{number}`. The leading
|
||||
* boundary check rejects references that are part of a longer path (e.g. the
|
||||
* `microsoft/vscode#1` inside a URL, which the URL pattern already covers).
|
||||
*/
|
||||
const ISSUE_SHORTHAND_PATTERN = /(?<![\w./-])([\w.-]+)\/([\w.-]+)#(\d+)\b/g;
|
||||
|
||||
/** Upper bound on the number of issues tracked per session. */
|
||||
export const MAX_SESSION_ISSUE_REFERENCES = 10;
|
||||
|
||||
/**
|
||||
* Extracts the GitHub issues referenced in `text`, in order of first
|
||||
* appearance and without duplicates.
|
||||
*
|
||||
* Only unambiguous references are detected — full issue URLs and the
|
||||
* `owner/repo#number` shorthand. Bare `#number` references are deliberately
|
||||
* ignored because they cannot be resolved without guessing a repository and
|
||||
* are a common source of false positives (headings, code, IDs).
|
||||
*/
|
||||
export function parseGitHubIssueReferences(text: string): IGitHubIssueReference[] {
|
||||
const references: IGitHubIssueReference[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (owner: string, repo: string, rawNumber: string): void => {
|
||||
const number = Number(rawNumber);
|
||||
if (!Number.isSafeInteger(number) || number <= 0) {
|
||||
return;
|
||||
}
|
||||
const url = toGitHubIssueUrl({ owner, repo, number });
|
||||
if (seen.has(url)) {
|
||||
return;
|
||||
}
|
||||
seen.add(url);
|
||||
references.push({ owner, repo, number });
|
||||
};
|
||||
|
||||
for (const match of text.matchAll(ISSUE_URL_PATTERN)) {
|
||||
add(match[1], match[2], match[3]);
|
||||
}
|
||||
for (const match of text.matchAll(ISSUE_SHORTHAND_PATTERN)) {
|
||||
add(match[1], match[2], match[3]);
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/** Builds the canonical `github.com` URL for an issue reference. */
|
||||
export function toGitHubIssueUrl(reference: IGitHubIssueReference): string {
|
||||
return `https://github.com/${reference.owner}/${reference.repo}/issues/${reference.number}`;
|
||||
}
|
||||
|
||||
/** Parses a canonical GitHub issue URL back into its parts, or `undefined`. */
|
||||
export function parseGitHubIssueUrl(url: string): IGitHubIssueReference | undefined {
|
||||
return parseGitHubIssueReferences(url)[0];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { parse } from '../../../base/common/json.js';
|
||||
|
||||
const MAX_PARTIAL_TOOL_INPUT_PARSE_LENGTH = 4 * 1024;
|
||||
let lastDisplayInput: string | undefined;
|
||||
let lastDisplayValue: Record<string, unknown> | undefined;
|
||||
|
||||
export function parsePartialToolInput(raw: string, maxLength?: number): Record<string, unknown> | undefined {
|
||||
const parsed: unknown = parse(maxLength === undefined ? raw : raw.slice(0, maxLength));
|
||||
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length > 0
|
||||
? { ...parsed as Record<string, unknown> }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function parsePartialToolInputForDisplay(raw: string): Record<string, unknown> | undefined {
|
||||
const input = raw.slice(0, MAX_PARTIAL_TOOL_INPUT_PARSE_LENGTH);
|
||||
if (input !== lastDisplayInput) {
|
||||
lastDisplayInput = input;
|
||||
lastDisplayValue = parsePartialToolInput(input);
|
||||
}
|
||||
return lastDisplayValue ? { ...lastDisplayValue } : undefined;
|
||||
}
|
||||
@@ -391,8 +391,14 @@ export interface ISessionDataService {
|
||||
|
||||
/**
|
||||
* Recursively deletes the data directory for a session, if it exists.
|
||||
*
|
||||
* `workingDirectories` is forwarded verbatim to
|
||||
* {@link IWillDeleteSessionDataEvent.workingDirectories}. Callers that
|
||||
* tear down live session state as part of disposal must resolve it
|
||||
* *before* doing so, otherwise subscribers cannot locate the
|
||||
* repositories they need to clean up.
|
||||
*/
|
||||
deleteSessionData(session: URI): Promise<void>;
|
||||
deleteSessionData(session: URI, workingDirectories?: readonly string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fires immediately before a session's data directory (and the
|
||||
@@ -405,6 +411,10 @@ export interface ISessionDataService {
|
||||
* list of checkpoint refs from the (still-readable) database and
|
||||
* delete them before the directory is removed.
|
||||
*
|
||||
* The repositories to clean up are identified by
|
||||
* {@link IWillDeleteSessionDataEvent.workingDirectories}, which the
|
||||
* caller resolves before tearing down live session state.
|
||||
*
|
||||
* Subscribers must own their own error handling — exceptions
|
||||
* propagated out of `waitUntil` promises are logged and ignored;
|
||||
* deletion proceeds regardless.
|
||||
@@ -431,6 +441,21 @@ export interface ISessionDataService {
|
||||
*/
|
||||
export interface IWillDeleteSessionDataEvent {
|
||||
readonly session: URI;
|
||||
/**
|
||||
* The session's working directories (index 0 = primary), as resolved
|
||||
* by the caller of {@link ISessionDataService.deleteSessionData}
|
||||
* *before* any live session state was torn down.
|
||||
*
|
||||
* Subscribers that need to touch the session's repositories (deleting
|
||||
* checkpoint or reviewed refs) must use this rather than querying
|
||||
* session state themselves: by the time this event fires the session
|
||||
* has typically already been removed from the state manager, so a
|
||||
* live lookup returns `undefined` and the cleanup silently no-ops.
|
||||
*
|
||||
* `undefined` when the session had no working directories, or when
|
||||
* the caller did not supply them.
|
||||
*/
|
||||
readonly workingDirectories: readonly string[] | undefined;
|
||||
/**
|
||||
* Register an asynchronous task that must settle before the session's
|
||||
* data directory is removed.
|
||||
|
||||
@@ -1 +1 @@
|
||||
c72272f8
|
||||
8e0a9bbf
|
||||
|
||||
@@ -184,6 +184,16 @@ export interface ChatToolCallDeltaAction extends ToolCallActionBase {
|
||||
*/
|
||||
export interface ChatToolCallReadyAction extends ToolCallActionBase {
|
||||
type: ActionType.ChatToolCallReady;
|
||||
/**
|
||||
* Final contributor metadata. MUST NOT change execution ownership established
|
||||
* at `chat/toolCallStart`; a client contributor must keep the same `clientId`.
|
||||
*/
|
||||
contributor?: ToolCallContributor;
|
||||
/**
|
||||
* Final human-readable description of what the tool invocation intends to do.
|
||||
* When present, replaces the provisional intention from `chat/toolCallStart`.
|
||||
*/
|
||||
intention?: string;
|
||||
/** Message describing what the tool will do or what confirmation is needed */
|
||||
invocationMessage: StringOrMarkdown;
|
||||
/** Raw tool input */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
|
||||
|
||||
import { ActionType } from '../common/actions.js';
|
||||
import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ToolCallContributorKind, ResponsePartKind, PendingMessageKind, type ChatState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type InputRequestResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js';
|
||||
import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ToolCallContributorKind, ResponsePartKind, PendingMessageKind, type ChatState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type InputRequestResponsePart, type Turn, type PendingMessage, type ConfirmationOption, type ToolCallContributor } from './state.js';
|
||||
import { SessionStatus } from '../channels-session/state.js';
|
||||
import type { ChatAction } from '../action-origin.generated.js';
|
||||
import { softAssertNever } from '../common/reducer-helpers.js';
|
||||
@@ -33,6 +33,28 @@ function tcBaseWithMeta(tc: ToolCallState, meta: Record<string, unknown> | undef
|
||||
};
|
||||
}
|
||||
|
||||
function refineToolCallContributor(
|
||||
current: ToolCallContributor | undefined,
|
||||
next: ToolCallContributor | undefined,
|
||||
log?: (msg: string) => void,
|
||||
): ToolCallContributor | undefined {
|
||||
if (!next) {
|
||||
return current;
|
||||
}
|
||||
if (current?.kind === ToolCallContributorKind.Client) {
|
||||
if (next.kind === ToolCallContributorKind.Client && next.clientId === current.clientId) {
|
||||
return next;
|
||||
}
|
||||
log?.(`Ignoring contributor change for client tool call from '${current.clientId}'`);
|
||||
return current;
|
||||
}
|
||||
if (next.kind === ToolCallContributorKind.Client) {
|
||||
log?.(`Ignoring late client contributor '${next.clientId}' because client execution ownership must be established at tool call start`);
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Resolves a selected option from the confirmation options array by ID. */
|
||||
function resolveSelectedOption(options: ConfirmationOption[] | undefined, id: string | undefined): ConfirmationOption | undefined {
|
||||
if (!id || !options) {
|
||||
@@ -439,7 +461,11 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st
|
||||
) {
|
||||
return tc;
|
||||
}
|
||||
const base = tcBaseWithMeta(tc, action._meta);
|
||||
const base = {
|
||||
...tcBaseWithMeta(tc, action._meta),
|
||||
contributor: refineToolCallContributor(tc.contributor, action.contributor, log),
|
||||
intention: action.intention ?? tc.intention,
|
||||
};
|
||||
if (action.confirmed) {
|
||||
return {
|
||||
status: ToolCallStatus.Running,
|
||||
|
||||
@@ -793,21 +793,23 @@ export interface MessageAnnotationsAttachment extends MessageAttachmentBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* An attachment that references a chat transcript through a completed turn.
|
||||
*
|
||||
* The referenced chat MAY belong to any session, not only the message's own.
|
||||
* The model representation is a pointer — an `agent-host-session://` link, a
|
||||
* short transcript excerpt, and a hint to call the `get_session_context` server
|
||||
* tool — and both that link and that tool already resolve across sessions, so a
|
||||
* cross-session reference carries the same weight as a same-session one.
|
||||
* The host resolves the transcript from its first retained turn through
|
||||
* `endTurn`, inclusive, when accepting the message. Later turns do not change
|
||||
* the context represented by an already-sent attachment. When `endTurn` is
|
||||
* omitted (e.g. a drag-and-drop client that cannot know turn ids), the host
|
||||
* pins it to the referenced chat's latest completed turn as it accepts the
|
||||
* message; when `endTurn` is provided it MUST reference a completed, retained
|
||||
* An attachment that references a chat transcript through a fixed completed
|
||||
* turn.
|
||||
*
|
||||
* The referenced chat MAY belong to a different session than the message's
|
||||
* chat. The attachment's model representation identifies the chat in a way
|
||||
* that hosts can resolve regardless of the session that owns it.
|
||||
*
|
||||
* When `endTurn` is omitted, the host MUST resolve and pin the referenced
|
||||
* chat's latest completed turn when accepting the message. This lets clients
|
||||
* attach a chat without knowing its turn identifiers. When provided, `endTurn`
|
||||
* MUST reference a completed, retained turn. The host resolves the transcript
|
||||
* from its first retained turn through the pinned turn, inclusive. Later turns
|
||||
* do not change the context represented by an already-sent attachment.
|
||||
*
|
||||
* When the referenced chat has no completed retained turns, the resolved
|
||||
* transcript is empty and hosts MUST NOT reject the attachment on that basis.
|
||||
*
|
||||
* Hosts MUST NOT recursively expand chat attachments found inside the
|
||||
* referenced transcript. Clients SHOULD keep rendering `label` if the
|
||||
* referenced chat is later pruned, and treat opening `resource` as best-effort.
|
||||
@@ -821,8 +823,7 @@ export interface MessageChatAttachment extends MessageAttachmentBase {
|
||||
resource: URI;
|
||||
/**
|
||||
* Last completed turn included in the referenced transcript. When omitted,
|
||||
* the host resolves the referenced chat's latest completed turn as it
|
||||
* accepts the message.
|
||||
* the host pins the latest completed turn when accepting the message.
|
||||
*/
|
||||
endTurn?: string;
|
||||
}
|
||||
|
||||
@@ -48,13 +48,6 @@ export interface JsonRpcErrorResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/** A JSON-RPC parse error cannot identify the request that failed to parse. */
|
||||
export interface JsonRpcParseErrorResponse {
|
||||
readonly jsonrpc: '2.0';
|
||||
readonly id: null;
|
||||
readonly error: JsonRpcErrorResponse['error'];
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed JSON-RPC error response whose error object is a fully typed
|
||||
* {@link AhpError}. Useful when the caller knows the response is an AHP
|
||||
@@ -67,7 +60,7 @@ export interface AhpErrorResponse {
|
||||
}
|
||||
|
||||
/** A JSON-RPC response (success or error). */
|
||||
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse | JsonRpcParseErrorResponse;
|
||||
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
|
||||
|
||||
/** A JSON-RPC notification: has `method` but no `id`. */
|
||||
export interface JsonRpcNotification {
|
||||
|
||||
@@ -16,12 +16,18 @@
|
||||
export type {
|
||||
JsonRpcErrorResponse,
|
||||
JsonRpcNotification,
|
||||
JsonRpcParseErrorResponse,
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
JsonRpcSuccessResponse,
|
||||
} from './protocol/messages.js';
|
||||
|
||||
/** A JSON-RPC parse error cannot identify the request that failed to parse. */
|
||||
export interface JsonRpcParseErrorResponse {
|
||||
readonly jsonrpc: '2.0';
|
||||
readonly id: null;
|
||||
readonly error: JsonRpcErrorResponse['error'];
|
||||
}
|
||||
|
||||
// Typed message unions
|
||||
export type {
|
||||
AhpClientNotification,
|
||||
|
||||
@@ -111,7 +111,15 @@ export interface UsageInfoMeta {
|
||||
autoModeResolved?: IAutoModeResolvedInfo;
|
||||
/** Copilot-specific usage breakdown, including nano-AIU totals. */
|
||||
copilotUsage?: {
|
||||
/** This turn's nano-AIU cost. */
|
||||
totalNanoAiu?: number;
|
||||
/**
|
||||
* The whole session's accumulated nano-AIU cost, as reported by the
|
||||
* backend rather than summed from the turns. Clients SHOULD prefer this
|
||||
* over adding up per-turn totals: it is authoritative, and it also
|
||||
* covers work billed outside any turn (e.g. an out-of-turn compaction).
|
||||
*/
|
||||
sessionTotalNanoAiu?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
@@ -210,6 +218,7 @@ export function readUsageInfoMeta(usage: UsageInfo | undefined): UsageInfoMeta {
|
||||
const rawUsage = copilotUsage as Record<string, unknown>;
|
||||
const usage: Mutable<NonNullable<UsageInfoMeta['copilotUsage']>> = {};
|
||||
if (typeof rawUsage['totalNanoAiu'] === 'number') { usage.totalNanoAiu = rawUsage['totalNanoAiu']; }
|
||||
if (typeof rawUsage['sessionTotalNanoAiu'] === 'number') { usage.sessionTotalNanoAiu = rawUsage['sessionTotalNanoAiu']; }
|
||||
result.copilotUsage = usage;
|
||||
}
|
||||
const quotaSnapshots = meta['quotaSnapshots'];
|
||||
@@ -248,6 +257,10 @@ export function hasReportedUsage(usage: UsageInfo | undefined): boolean {
|
||||
const meta = readUsageInfoMeta(usage);
|
||||
// Negative totals are treated as absent, matching how credits are read for display.
|
||||
return (typeof meta.copilotUsage?.totalNanoAiu === 'number' && meta.copilotUsage.totalNanoAiu >= 0)
|
||||
// A report can carry only the session total — a compaction billed while no turn
|
||||
// was active advances it without any per-event billing payload — and that is
|
||||
// still consumption worth showing.
|
||||
|| (typeof meta.copilotUsage?.sessionTotalNanoAiu === 'number' && meta.copilotUsage.sessionTotalNanoAiu >= 0)
|
||||
|| (typeof meta.cost === 'number' && meta.cost >= 0);
|
||||
}
|
||||
|
||||
@@ -1153,6 +1166,11 @@ export interface ISessionGitHubState {
|
||||
readonly repo?: string;
|
||||
/** The URL of the GitHub pull request. */
|
||||
readonly pullRequestUrl?: string;
|
||||
/**
|
||||
* URLs of the GitHub issues referenced by the session's user messages, in
|
||||
* order of first appearance.
|
||||
*/
|
||||
readonly issueUrls?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1231,11 +1249,13 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
pullRequestUrl?: string;
|
||||
issueUrls?: readonly string[];
|
||||
} = {};
|
||||
|
||||
if (typeof raw['owner'] === 'string') { result.owner = raw['owner']; }
|
||||
if (typeof raw['repo'] === 'string') { result.repo = raw['repo']; }
|
||||
if (typeof raw['pullRequestUrl'] === 'string') { result.pullRequestUrl = raw['pullRequestUrl']; }
|
||||
if (Array.isArray(raw['issueUrls'])) { result.issueUrls = raw['issueUrls'].filter((url): url is string => typeof url === 'string'); }
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { escapeMarkdownLinkLabel } from '../../../base/common/htmlContent.js';
|
||||
import { basename } from '../../../base/common/resources.js';
|
||||
import { splitLines } from '../../../base/common/strings.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import type { StringOrMarkdown } from './state/protocol/state.js';
|
||||
|
||||
export type ToolPathResolver = (path: string) => string;
|
||||
|
||||
const identityPathResolver: ToolPathResolver = path => path;
|
||||
|
||||
/**
|
||||
* Minimum interval between streamed tool-call display updates
|
||||
*/
|
||||
export const STREAMING_TOOL_DISPLAY_INTERVAL_MS = 100;
|
||||
|
||||
/** Flattens a display message so equal updates can be suppressed. */
|
||||
export function streamingToolDisplayText(message: StringOrMarkdown): string {
|
||||
return typeof message === 'string' ? message : message.markdown;
|
||||
}
|
||||
|
||||
export function formatGenericToolInput(input: Record<string, unknown> | undefined, rawFallback?: string): string | undefined {
|
||||
if (!input) {
|
||||
return rawFallback;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return rawFallback;
|
||||
}
|
||||
}
|
||||
|
||||
function markdown(value: string): StringOrMarkdown {
|
||||
return { markdown: value };
|
||||
}
|
||||
|
||||
function formatPath(path: unknown, resolvePath: ToolPathResolver): string | undefined {
|
||||
if (typeof path !== 'string' || !path) {
|
||||
return undefined;
|
||||
}
|
||||
const uri = URI.file(resolvePath(path));
|
||||
return `[${escapeMarkdownLinkLabel(basename(uri))}](${uri})`;
|
||||
}
|
||||
|
||||
export function streamingToolTextLineCount(value: unknown): number | undefined {
|
||||
return typeof value === 'string' ? splitLines(value).length : undefined;
|
||||
}
|
||||
|
||||
export function getStreamingEditMessage(
|
||||
path: unknown,
|
||||
lineCount: number | undefined,
|
||||
resolvePath: ToolPathResolver = identityPathResolver,
|
||||
): StringOrMarkdown {
|
||||
const file = formatPath(path, resolvePath);
|
||||
if (lineCount !== undefined) {
|
||||
if (file) {
|
||||
return lineCount === 1
|
||||
? markdown(localize('toolStream.editOneLineInFile', "Editing 1 line in {0}", file))
|
||||
: markdown(localize('toolStream.editLinesInFile', "Editing {0} lines in {1}", lineCount, file));
|
||||
}
|
||||
return lineCount === 1
|
||||
? localize('toolStream.editOneLine', "Editing 1 line")
|
||||
: localize('toolStream.editLines', "Editing {0} lines", lineCount);
|
||||
}
|
||||
return file
|
||||
? markdown(localize('toolStream.editFile', "Editing {0}", file))
|
||||
: localize('toolStream.edit', "Editing file");
|
||||
}
|
||||
|
||||
export function getStreamingReplaceMessage(
|
||||
path: unknown,
|
||||
oldLineCount: number | undefined,
|
||||
newLineCount: number | undefined,
|
||||
resolvePath: ToolPathResolver = identityPathResolver,
|
||||
): StringOrMarkdown {
|
||||
const file = formatPath(path, resolvePath);
|
||||
if (oldLineCount !== undefined && newLineCount !== undefined) {
|
||||
if (file) {
|
||||
if (oldLineCount === 1 && newLineCount === 1) {
|
||||
return markdown(localize('toolStream.replaceOneLineWithOneLineInFile', "Replacing 1 line with 1 line in {0}", file));
|
||||
}
|
||||
if (oldLineCount === 1) {
|
||||
return markdown(localize('toolStream.replaceOneLineWithLinesInFile', "Replacing 1 line with {0} lines in {1}", newLineCount, file));
|
||||
}
|
||||
if (newLineCount === 1) {
|
||||
return markdown(localize('toolStream.replaceLinesWithOneLineInFile', "Replacing {0} lines with 1 line in {1}", oldLineCount, file));
|
||||
}
|
||||
return markdown(localize('toolStream.replaceLinesWithLinesInFile', "Replacing {0} lines with {1} lines in {2}", oldLineCount, newLineCount, file));
|
||||
}
|
||||
if (oldLineCount === 1 && newLineCount === 1) {
|
||||
return localize('toolStream.replaceOneLineWithOneLine', "Replacing 1 line with 1 line");
|
||||
}
|
||||
if (oldLineCount === 1) {
|
||||
return localize('toolStream.replaceOneLineWithLines', "Replacing 1 line with {0} lines", newLineCount);
|
||||
}
|
||||
if (newLineCount === 1) {
|
||||
return localize('toolStream.replaceLinesWithOneLine', "Replacing {0} lines with 1 line", oldLineCount);
|
||||
}
|
||||
return localize('toolStream.replaceLinesWithLines', "Replacing {0} lines with {1} lines", oldLineCount, newLineCount);
|
||||
}
|
||||
if (oldLineCount !== undefined) {
|
||||
if (file) {
|
||||
return oldLineCount === 1
|
||||
? markdown(localize('toolStream.replaceOneLineInFile', "Replacing 1 line in {0}", file))
|
||||
: markdown(localize('toolStream.replaceLinesInFile', "Replacing {0} lines in {1}", oldLineCount, file));
|
||||
}
|
||||
return oldLineCount === 1
|
||||
? localize('toolStream.replaceOneLine', "Replacing 1 line")
|
||||
: localize('toolStream.replaceLines', "Replacing {0} lines", oldLineCount);
|
||||
}
|
||||
return getStreamingEditMessage(path, undefined, resolvePath);
|
||||
}
|
||||
|
||||
export function getStreamingCreateMessage(
|
||||
path: unknown,
|
||||
lineCount: number | undefined,
|
||||
resolvePath: ToolPathResolver = identityPathResolver,
|
||||
): StringOrMarkdown {
|
||||
const file = formatPath(path, resolvePath);
|
||||
if (lineCount !== undefined) {
|
||||
if (file) {
|
||||
return lineCount === 1
|
||||
? markdown(localize('toolStream.createOneLineInFile', "Creating {0} (1 line)", file))
|
||||
: markdown(localize('toolStream.createLinesInFile', "Creating {0} ({1} lines)", file, lineCount));
|
||||
}
|
||||
return lineCount === 1
|
||||
? localize('toolStream.createOneLine', "Creating file (1 line)")
|
||||
: localize('toolStream.createLines', "Creating file ({0} lines)", lineCount);
|
||||
}
|
||||
return file
|
||||
? markdown(localize('toolStream.createFile', "Creating {0}", file))
|
||||
: localize('toolStream.create', "Creating file");
|
||||
}
|
||||
|
||||
export function getStreamingInsertMessage(
|
||||
path: unknown,
|
||||
lineCount: number | undefined,
|
||||
resolvePath: ToolPathResolver = identityPathResolver,
|
||||
): StringOrMarkdown {
|
||||
const file = formatPath(path, resolvePath);
|
||||
if (lineCount !== undefined) {
|
||||
if (file) {
|
||||
return lineCount === 1
|
||||
? markdown(localize('toolStream.insertOneLineInFile', "Inserting 1 line in {0}", file))
|
||||
: markdown(localize('toolStream.insertLinesInFile', "Inserting {0} lines in {1}", lineCount, file));
|
||||
}
|
||||
return lineCount === 1
|
||||
? localize('toolStream.insertOneLine', "Inserting 1 line")
|
||||
: localize('toolStream.insertLines', "Inserting {0} lines", lineCount);
|
||||
}
|
||||
return file
|
||||
? markdown(localize('toolStream.insertInFile', "Inserting text in {0}", file))
|
||||
: localize('toolStream.insert', "Inserting text");
|
||||
}
|
||||
|
||||
export function getStreamingPatchMessage(
|
||||
paths: readonly string[],
|
||||
lineCount: number | undefined,
|
||||
resolvePath: ToolPathResolver = identityPathResolver,
|
||||
): StringOrMarkdown {
|
||||
const fileList = paths.map(path => formatPath(path, resolvePath)).filter(path => path !== undefined).join(', ') || undefined;
|
||||
if (lineCount !== undefined) {
|
||||
if (fileList) {
|
||||
return lineCount === 1
|
||||
? markdown(localize('toolStream.patchOneLineInFiles', "Generating patch (1 line) in {0}", fileList))
|
||||
: markdown(localize('toolStream.patchLinesInFiles', "Generating patch ({0} lines) in {1}", lineCount, fileList));
|
||||
}
|
||||
return lineCount === 1
|
||||
? localize('toolStream.patchOneLine', "Generating patch (1 line)")
|
||||
: localize('toolStream.patchLines', "Generating patch ({0} lines)", lineCount);
|
||||
}
|
||||
return fileList
|
||||
? markdown(localize('toolStream.patchFiles', "Generating patch in {0}", fileList))
|
||||
: localize('toolStream.patch', "Generating patch");
|
||||
}
|
||||
@@ -37,7 +37,6 @@ import { IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName
|
||||
import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
|
||||
import { NodeWorkerDiffComputeService } from './diffComputeService.js';
|
||||
import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js';
|
||||
import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js';
|
||||
import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js';
|
||||
import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js';
|
||||
import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
|
||||
@@ -475,7 +474,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
this._publishChangesetDiffs(session, compareUri, []);
|
||||
return compareUri;
|
||||
}
|
||||
const workingDir = await this._resolveWorkingDirectory(ref.object);
|
||||
const workingDir = await this._resolveWorkingDirectory(session);
|
||||
if (!workingDir) {
|
||||
this._stateManager.dispatchServerAction(compareUri, {
|
||||
type: ActionType.ChangesetStatusChanged,
|
||||
@@ -588,7 +587,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
private async _computeTurnDiffsPreferCheckpoint(session: ProtocolURI, db: ISessionDatabase, turnId: string): Promise<readonly ISessionFileDiff[]> {
|
||||
const pair = await this._checkpointService.getTurnCheckpointPair(URI.parse(session), turnId);
|
||||
if (pair && pair.parent !== pair.current) {
|
||||
const workingDir = await this._resolveWorkingDirectory(db);
|
||||
const workingDir = await this._resolveWorkingDirectory(session);
|
||||
if (workingDir) {
|
||||
const fromRefDiffs = await this._gitService.computeFileDiffsBetweenRefs(workingDir, {
|
||||
sessionUri: session,
|
||||
@@ -609,13 +608,14 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
return computeTurnDiffs(session, db, this._diffComputeService, turnId);
|
||||
}
|
||||
|
||||
private async _resolveWorkingDirectory(db: ISessionDatabase): Promise<URI | undefined> {
|
||||
// Checkpoint baseline writes `checkpoint.workingDir` alongside
|
||||
// `checkpoint.baseRef`. We use that as the canonical working
|
||||
// directory for checkpoint diff computation; reading it here keeps
|
||||
// the changeset service out of agent-specific metadata keys.
|
||||
const raw = await db.getMetadata(META_CHECKPOINT_WORKING_DIR);
|
||||
return raw ? URI.parse(raw) : undefined;
|
||||
private async _resolveWorkingDirectory(session: ProtocolURI): Promise<URI | undefined> {
|
||||
// For the time being we default to the first working directory in the list, if any.
|
||||
// In the future we may want to support multiple working directories per session,
|
||||
// but for now we only support one.
|
||||
const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session);
|
||||
return workingDirectories && workingDirectories.length > 0
|
||||
? URI.parse(workingDirectories[0])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// ---- Lifecycle hooks invoked by AgentSideEffects -----------------------
|
||||
@@ -1027,7 +1027,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
|
||||
const sessionUri = URI.parse(session);
|
||||
const [baseline, pair] = await Promise.all([
|
||||
this._checkpointService.getBaselineCheckpointRef(sessionUri),
|
||||
this._checkpointService.getBaselineCheckpoint(sessionUri),
|
||||
this._checkpointService.getTurnCheckpointPair(sessionUri, latestTurnId),
|
||||
]);
|
||||
if (!baseline || !pair) {
|
||||
|
||||
@@ -7,19 +7,11 @@ import { SequencerByKey } from '../../../base/common/async.js';
|
||||
import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { IAgentHostCheckpointService, META_CHECKPOINT_BASE_REF, buildCheckpointRefName } from '../common/agentHostCheckpointService.js';
|
||||
import { IAgentHostCheckpointService, buildCheckpointRefName } from '../common/agentHostCheckpointService.js';
|
||||
import { AgentSession } from '../common/agentService.js';
|
||||
import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
|
||||
import { IAgentHostGitService } from '../common/agentHostGitService.js';
|
||||
|
||||
/**
|
||||
* `session_metadata` key under which the working directory used for
|
||||
* checkpoint capture is persisted (set when the baseline is created).
|
||||
* Stored as `URI.toString()`. Read by `captureTurnCheckpoint` /
|
||||
* `disposeSessionData` so they can resolve the repo without per-call
|
||||
* working-directory plumbing.
|
||||
*/
|
||||
export const META_CHECKPOINT_WORKING_DIR = 'checkpoint.workingDir';
|
||||
import { IAgentConfigurationService } from './agentConfigurationService.js';
|
||||
|
||||
export class AgentHostCheckpointService extends Disposable implements IAgentHostCheckpointService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
@@ -34,6 +26,7 @@ export class AgentHostCheckpointService extends Disposable implements IAgentHost
|
||||
|
||||
constructor(
|
||||
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
||||
@IAgentConfigurationService private readonly _agentConfigService: IAgentConfigurationService,
|
||||
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) {
|
||||
@@ -42,185 +35,222 @@ export class AgentHostCheckpointService extends Disposable implements IAgentHost
|
||||
// deleted, enumerate and delete every checkpoint ref we created
|
||||
// for that session BEFORE the database file disappears. The
|
||||
// `waitUntil` API blocks `deleteSessionData` until our promise
|
||||
// settles, so the deletion can't race the ref read.
|
||||
// settles, so the deletion can't race the ref read. The working
|
||||
// directories come from the event because the session has already
|
||||
// been removed from the state manager by this point.
|
||||
this._register(this._sessionDataService.onWillDeleteSessionData(e => {
|
||||
e.waitUntil(this.disposeSessionData(e.session));
|
||||
e.waitUntil(this.deleteCheckpoints(e.session, e.workingDirectories));
|
||||
}));
|
||||
}
|
||||
|
||||
captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise<string | undefined> {
|
||||
return this._sequencer.queue(sessionUri.toString(), () => this._captureBaseline(sessionUri, workingDirectory));
|
||||
captureBaselineCheckpoint(sessionUri: URI, workingDirectories: readonly URI[] | undefined): Promise<void> {
|
||||
return this._sequencer.queue(sessionUri.toString(), () => this._captureBaseline(sessionUri, workingDirectories));
|
||||
}
|
||||
|
||||
private async _captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise<string | undefined> {
|
||||
if (!workingDirectory) {
|
||||
return undefined;
|
||||
private async _captureBaseline(sessionUri: URI, workingDirectories: readonly URI[] | undefined): Promise<void> {
|
||||
if (!workingDirectories || workingDirectories.length === 0) {
|
||||
this._logService.trace(`[AgentHostCheckpoint] Skipping baseline capture for ${sessionUri.toString()} as no working directories are found`);
|
||||
return;
|
||||
}
|
||||
const ref = this._sessionDataService.openDatabase(sessionUri);
|
||||
try {
|
||||
const existing = await ref.object.getMetadata(META_CHECKPOINT_BASE_REF);
|
||||
if (existing) {
|
||||
return existing;
|
||||
|
||||
const sanitized = this._sanitizedSessionId(sessionUri);
|
||||
const baselineRefName = buildCheckpointRefName(sanitized, 0);
|
||||
|
||||
for (const workingDirectoryUri of workingDirectories) {
|
||||
try {
|
||||
// Check that the working directory has a git repository
|
||||
const repositoryRootUri = await this._gitService.getRepositoryRoot(workingDirectoryUri);
|
||||
if (!repositoryRootUri) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the baseline ref already exists
|
||||
const baselineCheckpointRef = await this.getBaselineCheckpoint(sessionUri, repositoryRootUri);
|
||||
if (baselineCheckpointRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create checkpoint commit
|
||||
const commit = await this._writeCheckpointCommit(repositoryRootUri, undefined, `Agent host session ${sanitized} - baseline checkpoint`);
|
||||
if (!commit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the baseline ref to point to the new commit
|
||||
await this._gitService.updateRef(repositoryRootUri, baselineRefName, commit);
|
||||
this._logService.trace(`[AgentHostCheckpoint] Captured baseline for ${sessionUri.toString()} at ${baselineRefName} in working directory ${workingDirectoryUri.toString()}`);
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Failed to capture baseline for ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()}`, err);
|
||||
}
|
||||
const sanitized = this._sanitizedSessionId(sessionUri);
|
||||
const refName = buildCheckpointRefName(sanitized, 0);
|
||||
const commit = await this._writeCheckpointCommit(workingDirectory, undefined, `Agent host session ${sanitized} - baseline checkpoint`);
|
||||
if (!commit) {
|
||||
return undefined;
|
||||
}
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
|
||||
if (!repoRoot) {
|
||||
return undefined;
|
||||
}
|
||||
await this._gitService.updateRef(repoRoot, refName, commit.commitOid);
|
||||
await ref.object.setMetadata(META_CHECKPOINT_BASE_REF, refName);
|
||||
await ref.object.setMetadata(META_CHECKPOINT_WORKING_DIR, workingDirectory.toString());
|
||||
this._logService.trace(`[AgentHostCheckpoint] Captured baseline for ${sessionUri.toString()} at ${refName}`);
|
||||
return refName;
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Failed to capture baseline for ${sessionUri.toString()}`, err);
|
||||
return undefined;
|
||||
} finally {
|
||||
ref.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise<string | undefined> {
|
||||
return this._sequencer.queue(sessionUri.toString(), () => this._captureTurnCheckpoint(sessionUri, turnId));
|
||||
captureTurnCheckpoint(sessionUri: URI, turnId: string, workingDirectories: readonly URI[] | undefined): Promise<void> {
|
||||
return this._sequencer.queue(sessionUri.toString(), () => this._captureTurnCheckpoint(sessionUri, turnId, workingDirectories));
|
||||
}
|
||||
|
||||
private async _captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise<string | undefined> {
|
||||
private async _captureTurnCheckpoint(sessionUri: URI, turnId: string, workingDirectories: readonly URI[] | undefined): Promise<void> {
|
||||
if (!workingDirectories || workingDirectories.length === 0) {
|
||||
this._logService.trace(`[AgentHostCheckpoint] Skipping turn checkpoint capture for ${sessionUri.toString()} as no working directories are found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = this._sessionDataService.openDatabase(sessionUri);
|
||||
|
||||
try {
|
||||
const [baseRef, workingDirRaw, existing, prevTurnRef] = await Promise.all([
|
||||
ref.object.getMetadata(META_CHECKPOINT_BASE_REF),
|
||||
ref.object.getMetadata(META_CHECKPOINT_WORKING_DIR),
|
||||
ref.object.getTurnCheckpointRef(turnId),
|
||||
ref.object.getPreviousCheckpointRef(turnId),
|
||||
]);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
if (!baseRef || !workingDirRaw) {
|
||||
// Baseline never captured — session is not git-backed or
|
||||
// baseline failed. Nothing to chain from.
|
||||
return undefined;
|
||||
}
|
||||
const workingDirectory = URI.parse(workingDirRaw);
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
|
||||
if (!repoRoot) {
|
||||
return undefined;
|
||||
}
|
||||
const parentRef = prevTurnRef ?? baseRef;
|
||||
const parentCommitOid = await this._gitService.revParse(repoRoot, parentRef);
|
||||
if (!parentCommitOid) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Parent ref ${parentRef} missing for session ${sessionUri.toString()}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
|
||||
if (!tree) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// No-op turn: if the tree is identical to the parent's tree,
|
||||
// don't create a redundant commit/ref — point the turn at the
|
||||
// parent ref so per-turn diffs against it are empty by
|
||||
// construction.
|
||||
const parentTree = await this._gitService.revParse(repoRoot, `${parentCommitOid}^{tree}`);
|
||||
if (parentTree && parentTree === tree) {
|
||||
await ref.object.setTurnCheckpointRef(turnId, parentRef);
|
||||
this._logService.trace(`[AgentHostCheckpoint] No-op turn ${turnId} for ${sessionUri.toString()}; reusing ${parentRef}`);
|
||||
return parentRef;
|
||||
}
|
||||
|
||||
const sanitized = this._sanitizedSessionId(sessionUri);
|
||||
const turnNumber = await this._nextTurnNumber(ref.object);
|
||||
const refName = buildCheckpointRefName(sanitized, turnNumber);
|
||||
const commitOid = await this._gitService.commitTree(repoRoot, tree, parentCommitOid, `Agent host session ${sanitized} - turn ${turnNumber}`);
|
||||
if (!commitOid) {
|
||||
return undefined;
|
||||
}
|
||||
await this._gitService.updateRef(repoRoot, refName, commitOid);
|
||||
await ref.object.setTurnCheckpointRef(turnId, refName);
|
||||
this._logService.trace(`[AgentHostCheckpoint] Captured turn ${turnNumber} for ${sessionUri.toString()} at ${refName}`);
|
||||
return refName;
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Failed to capture turn checkpoint for ${sessionUri.toString()}/${turnId}`, err);
|
||||
return undefined;
|
||||
} finally {
|
||||
ref.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async getTurnCheckpointPair(sessionUri: URI, turnId: string): Promise<{ parent: string; current: string } | undefined> {
|
||||
const ref = this._sessionDataService.openDatabase(sessionUri);
|
||||
try {
|
||||
const [current, prev, baseRef] = await Promise.all([
|
||||
const [checkpointRef, prevTurnCheckpointRef] = await Promise.all([
|
||||
ref.object.getTurnCheckpointRef(turnId),
|
||||
ref.object.getPreviousCheckpointRef(turnId),
|
||||
ref.object.getMetadata(META_CHECKPOINT_BASE_REF),
|
||||
]);
|
||||
if (!current) {
|
||||
return undefined;
|
||||
|
||||
if (checkpointRef) {
|
||||
// Already captured for this
|
||||
// turn, return the existing ref.
|
||||
return;
|
||||
}
|
||||
const parent = prev ?? baseRef;
|
||||
if (!parent) {
|
||||
return undefined;
|
||||
|
||||
let capturedCheckpointRef = false;
|
||||
for (const workingDirectoryUri of workingDirectories) {
|
||||
try {
|
||||
// Check that the working directory has a git repository
|
||||
const repositoryRootUri = await this._gitService.getRepositoryRoot(workingDirectoryUri);
|
||||
if (!repositoryRootUri) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the baseline ref exists for this repository. If it
|
||||
// doesn't exist, we cannot capture a turn checkpoint for this repository.
|
||||
const baselineCheckpointRef = await this.getBaselineCheckpoint(sessionUri, repositoryRootUri);
|
||||
if (!baselineCheckpointRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parentRef = prevTurnCheckpointRef ?? baselineCheckpointRef;
|
||||
const parentCommitOid = await this._gitService.revParse(repositoryRootUri, parentRef);
|
||||
if (!parentCommitOid) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Parent ref ${parentRef} missing for session ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tree = await this._gitService.captureWorkingTreeAsTree(repositoryRootUri);
|
||||
if (!tree) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commitOid = await this._gitService.commitTree(repositoryRootUri, tree, parentCommitOid, `Agent host session ${sanitized} - turn ${turnNumber}`);
|
||||
if (!commitOid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this._gitService.updateRef(repositoryRootUri, refName, commitOid);
|
||||
capturedCheckpointRef = true;
|
||||
|
||||
this._logService.trace(`[AgentHostCheckpoint] Captured turn ${turnNumber} for ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()} at ${refName}`);
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Failed to capture turn checkpoint for ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()}`, err);
|
||||
}
|
||||
}
|
||||
return { parent, current };
|
||||
|
||||
if (capturedCheckpointRef) {
|
||||
await ref.object.setTurnCheckpointRef(turnId, refName);
|
||||
}
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Failed to capture turn checkpoint for ${sessionUri.toString()}/${turnId}`, err);
|
||||
} finally {
|
||||
ref.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async getBaselineCheckpointRef(sessionUri: URI): Promise<string | undefined> {
|
||||
async getTurnCheckpointPair(
|
||||
sessionUri: URI,
|
||||
turnId: string,
|
||||
workingDirectory?: URI
|
||||
): Promise<{ parent: string; current: string } | undefined> {
|
||||
const ref = this._sessionDataService.openDatabase(sessionUri);
|
||||
try {
|
||||
return await ref.object.getMetadata(META_CHECKPOINT_BASE_REF);
|
||||
const [currentCheckpointRef, previousCheckpointRef, baselineCheckpointRef] = await Promise.all([
|
||||
ref.object.getTurnCheckpointRef(turnId),
|
||||
ref.object.getPreviousCheckpointRef(turnId),
|
||||
this.getBaselineCheckpoint(sessionUri, workingDirectory)
|
||||
]);
|
||||
if (!currentCheckpointRef || !baselineCheckpointRef) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentCheckpointRef,
|
||||
parent: previousCheckpointRef ?? baselineCheckpointRef
|
||||
};
|
||||
} finally {
|
||||
ref.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async disposeSessionData(sessionUri: URI): Promise<void> {
|
||||
await this._sequencer.queue(sessionUri.toString(), () => this._disposeSessionData(sessionUri));
|
||||
async getBaselineCheckpoint(sessionUri: URI, workingDirectory?: URI): Promise<string | undefined> {
|
||||
if (!workingDirectory) {
|
||||
const workingDirectories = this._agentConfigService.getEffectiveWorkingDirectories(sessionUri.toString());
|
||||
if (!workingDirectories || workingDirectories.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
workingDirectory = URI.parse(workingDirectories[0]);
|
||||
}
|
||||
|
||||
const sanitized = this._sanitizedSessionId(sessionUri);
|
||||
const baselineRefName = buildCheckpointRefName(sanitized, 0);
|
||||
|
||||
const baselineRef = await this._gitService.revParse(workingDirectory, baselineRefName);
|
||||
return baselineRef ? baselineRefName : undefined;
|
||||
}
|
||||
|
||||
private async _disposeSessionData(sessionUri: URI): Promise<void> {
|
||||
async deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise<void> {
|
||||
await this._sequencer.queue(sessionUri.toString(), () => this._deleteCheckpoints(sessionUri, workingDirectories));
|
||||
}
|
||||
|
||||
private async _deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise<void> {
|
||||
if (!workingDirectories || workingDirectories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refHandle = await this._sessionDataService.tryOpenDatabase(sessionUri);
|
||||
if (!refHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [workingDirRaw, baseRef, turnRefs] = await Promise.all([
|
||||
refHandle.object.getMetadata(META_CHECKPOINT_WORKING_DIR),
|
||||
refHandle.object.getMetadata(META_CHECKPOINT_BASE_REF),
|
||||
refHandle.object.getAllCheckpointRefs(),
|
||||
]);
|
||||
if (!workingDirRaw) {
|
||||
const turnRefs = await refHandle.object.getAllCheckpointRefs();
|
||||
if (turnRefs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const workingDirectory = URI.parse(workingDirRaw);
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
|
||||
if (!repoRoot) {
|
||||
return;
|
||||
|
||||
for (const workingDirectory of workingDirectories) {
|
||||
try {
|
||||
const workingDirectoryUri = URI.parse(workingDirectory);
|
||||
|
||||
const repositoryRootUri = await this._gitService.getRepositoryRoot(workingDirectoryUri);
|
||||
if (!repositoryRootUri) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baselineCheckpointRef = await this.getBaselineCheckpoint(sessionUri, repositoryRootUri);
|
||||
if (!baselineCheckpointRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dedup baseRef and turnRefs (a no-op turn may reuse its
|
||||
// parent's ref). Deleting the same ref twice is harmless but
|
||||
// noisy, and the batch API takes a list.
|
||||
const checkpointRefs = new Set<string>([baselineCheckpointRef, ...turnRefs]);
|
||||
await this._gitService.deleteRefs(repositoryRootUri, [...checkpointRefs]);
|
||||
this._logService.trace(`[AgentHostCheckpoint] Deleted ${checkpointRefs.size} checkpoint refs for ${sessionUri.toString()} in working directory ${workingDirectory}`);
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Failed to delete checkpoint refs for ${sessionUri.toString()} in working directory ${workingDirectory}`, err);
|
||||
}
|
||||
}
|
||||
// Dedup baseRef and turnRefs (a no-op turn may reuse its
|
||||
// parent's ref). Deleting the same ref twice is harmless but
|
||||
// noisy, and the batch API takes a list.
|
||||
const all = new Set<string>();
|
||||
if (baseRef) {
|
||||
all.add(baseRef);
|
||||
}
|
||||
for (const r of turnRefs) {
|
||||
all.add(r);
|
||||
}
|
||||
if (all.size === 0) {
|
||||
return;
|
||||
}
|
||||
await this._gitService.deleteRefs(repoRoot, [...all]);
|
||||
this._logService.trace(`[AgentHostCheckpoint] Deleted ${all.size} checkpoint refs for ${sessionUri.toString()}`);
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostCheckpoint] Failed to dispose checkpoint refs for ${sessionUri.toString()}`, err);
|
||||
} finally {
|
||||
@@ -229,23 +259,21 @@ export class AgentHostCheckpointService extends Disposable implements IAgentHost
|
||||
}
|
||||
|
||||
private async _writeCheckpointCommit(
|
||||
workingDirectory: URI,
|
||||
repositoryRootUri: URI,
|
||||
parentOid: string | undefined,
|
||||
message: string,
|
||||
): Promise<{ commitOid: string } | undefined> {
|
||||
const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
|
||||
): Promise<string | undefined> {
|
||||
const tree = await this._gitService.captureWorkingTreeAsTree(repositoryRootUri);
|
||||
if (!tree) {
|
||||
return undefined;
|
||||
}
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
|
||||
if (!repoRoot) {
|
||||
return undefined;
|
||||
}
|
||||
const commitOid = await this._gitService.commitTree(repoRoot, tree, parentOid, message);
|
||||
|
||||
const commitOid = await this._gitService.commitTree(repositoryRootUri, tree, parentOid, message);
|
||||
if (!commitOid) {
|
||||
return undefined;
|
||||
}
|
||||
return { commitOid };
|
||||
|
||||
return commitOid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Emitter } from '../../../base/common/event.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE } from '../common/agentHostGitStateService.js';
|
||||
import { ISessionGitHubState, readSessionGitHubState, readSessionGitState, SessionLifecycle, withSessionGitHubState, withSessionGitState, type ISessionGitState } from '../common/state/sessionState.js';
|
||||
import { MAX_SESSION_ISSUE_REFERENCES, parseGitHubIssueReferences, toGitHubIssueUrl } from '../common/githubIssueReferences.js';
|
||||
import { IAgentHostGitService } from '../common/agentHostGitService.js';
|
||||
import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
|
||||
import { ISessionDataService } from '../common/sessionDataService.js';
|
||||
@@ -93,6 +94,36 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a user message for GitHub issue references and merges them into the
|
||||
* session's GitHub state. References already recorded are preserved and keep
|
||||
* their position, so the list reflects the order in which the session first
|
||||
* mentioned each issue.
|
||||
*/
|
||||
async attachSessionGitHubIssues(sessionKey: string, text: string): Promise<void> {
|
||||
const references = parseGitHubIssueReferences(text);
|
||||
if (references.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrls = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta)?.issueUrls ?? [];
|
||||
const nextUrls = [...currentUrls];
|
||||
for (const reference of references) {
|
||||
const url = toGitHubIssueUrl(reference);
|
||||
if (!nextUrls.includes(url)) {
|
||||
nextUrls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextUrls.length === currentUrls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setSessionGitHubState(sessionKey, {
|
||||
issueUrls: nextUrls.slice(0, MAX_SESSION_ISSUE_REFERENCES)
|
||||
} satisfies ISessionGitHubState);
|
||||
}
|
||||
|
||||
async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise<void> {
|
||||
const sessionState = this._stateManager.getSessionState(sessionKey);
|
||||
if (sessionState?.lifecycle === SessionLifecycle.CreationFailed) {
|
||||
|
||||
@@ -86,7 +86,6 @@ import { IAgentPluginManager } from '../common/agentPluginManager.js';
|
||||
import { AgentPluginManager } from './agentPluginManager.js';
|
||||
import { AgentHostGitService } from './agentHostGitService.js';
|
||||
import { IAgentHostGitService } from '../common/agentHostGitService.js';
|
||||
import { AgentHostCheckpointService } from './agentHostCheckpointService.js';
|
||||
import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
|
||||
import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js';
|
||||
import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js';
|
||||
@@ -185,12 +184,6 @@ async function startAgentHost(): Promise<void> {
|
||||
diServices.set(ISandboxHelperService, new SandboxHelperService());
|
||||
const gitService = instantiationService.createInstance(AgentHostGitService);
|
||||
diServices.set(IAgentHostGitService, gitService);
|
||||
// Checkpoint service depends on session data + git services, so
|
||||
// construct it AFTER both are registered. Consumed by CopilotAgent
|
||||
// (baseline capture) and AgentService's inner DI (changeset
|
||||
// pipeline / end-of-turn capture).
|
||||
const checkpointService = disposables.add(instantiationService.createInstance(AgentHostCheckpointService));
|
||||
diServices.set(IAgentHostCheckpointService, checkpointService);
|
||||
// Register the agent SDK downloader BEFORE any service that injects it
|
||||
// (ClaudeAgentSdkService and CodexAgent below). The downloader resolves
|
||||
// dev-override env var → on-disk cache → product.agentSdks download.
|
||||
@@ -210,7 +203,7 @@ async function startAgentHost(): Promise<void> {
|
||||
diServices.set(IByokLmProxyService, byokLmProxyService);
|
||||
const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn));
|
||||
diServices.set(IAgentHostOTelService, agentHostOTelService);
|
||||
agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]);
|
||||
agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]);
|
||||
const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService);
|
||||
diServices.set(INetworkDiagnosticsService, networkDiagnosticsService);
|
||||
agentService.setNetworkDiagnosticsService(networkDiagnosticsService);
|
||||
@@ -231,6 +224,7 @@ async function startAgentHost(): Promise<void> {
|
||||
diServices.set(IEditArcReporterService, editArcReporterService);
|
||||
diServices.set(IAgentHostGitHubEndpointService, agentService.gitHubEndpointService);
|
||||
diServices.set(IAgentHostCompletions, agentService.completionsService);
|
||||
diServices.set(IAgentHostCheckpointService, agentService.checkpointService);
|
||||
|
||||
// CopilotApiService and the proxies that consume it are created AFTER the
|
||||
// GitHub endpoint service is re-exported (above) so CAPI endpoint discovery
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user