diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 5dc47fca7465e..04acaa073b1a8 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -17,7 +17,7 @@ on: jobs: linux-test: name: ${{ inputs.job_name }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 env: ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} NPM_ARCH: x64 @@ -36,6 +36,9 @@ jobs: - name: Setup system services run: | set -e + # Allow unprivileged user namespaces for Chromium's namespace sandbox + # Ubuntu 24.04 restricts this by default via AppArmor + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 # Start X server ./build/azure-pipelines/linux/apt-retry.sh sudo apt-get update ./build/azure-pipelines/linux/apt-retry.sh sudo apt-get install -y pkg-config \ @@ -133,6 +136,38 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Fontconfig diagnostics and cache reset + run: | + set -e + echo "--- Font package versions ---" + dpkg -l | grep -E 'libexpat|fontconfig|libfreetype|libpango' || true + echo "" + echo "--- Installed font packages ---" + apt list --installed 2>/dev/null | grep -E 'fonts-|fontconfig' || true + echo "" + echo "--- Verify fonts.conf integrity ---" + python3 -c " + import xml.etree.ElementTree as ET, glob, sys + for f in ['/etc/fonts/fonts.conf'] + sorted(glob.glob('/etc/fonts/conf.d/*.conf')): + try: + ET.parse(f) + except Exception as e: + print(f'WARNING: {f} is invalid: {e}', file=sys.stderr) + print('Font config XML validation complete') + " + echo "" + echo "--- Check for symlink loops in font dirs ---" + find /usr/share/fonts -maxdepth 3 -type l -exec test -d {} \; -print 2>/dev/null | head -10 || true + echo "" + echo "--- Clear and rebuild font cache ---" + sudo rm -rf /var/cache/fontconfig 2>/dev/null || true + rm -rf ~/.cache/fontconfig 2>/dev/null || true + fc-cache -f -v 2>&1 | tail -5 + echo "" + echo "--- fontconfig version ---" + fc-cache --version 2>&1 | head -1 + continue-on-error: true + - name: Transpile client and extensions run: npm run gulp transpile-client-esbuild transpile-extensions diff --git a/build/darwin/sign.ts b/build/darwin/sign.ts index e1adf88c8bd76..dc411d5e1fe8d 100644 --- a/build/darwin/sign.ts +++ b/build/darwin/sign.ts @@ -78,6 +78,9 @@ async function main(buildDir?: string): Promise { const appRoot = path.join(buildDir, `VSCode-darwin-${arch}`); const appName = product.nameLong + '.app'; const infoPlistPath = path.resolve(appRoot, appName, 'Contents', 'Info.plist'); + const embeddedInfoPlistPath = product.embedded + ? path.resolve(appRoot, appName, 'Contents', 'Applications', `${product.embedded.nameShort}.app`, 'Contents', 'Info.plist') + : undefined; const appOpts: SignOptions = { app: path.join(appRoot, appName), @@ -124,6 +127,51 @@ async function main(buildDir?: string): Promise { 'An application in Visual Studio Code wants to use Audio Capture.', `${infoPlistPath}` ]); + await spawn('plutil', [ + '-insert', + 'NSLocalNetworkUsageDescription', + '-string', + 'The app uses your local network for DNS resolution and to connect to locally running services.', + `${infoPlistPath}` + ]); + + if (embeddedInfoPlistPath && fs.existsSync(embeddedInfoPlistPath)) { + await spawn('plutil', [ + '-insert', + 'NSAppleEventsUsageDescription', + '-string', + `An application in ${product.embedded.nameShort} wants to use AppleScript.`, + `${embeddedInfoPlistPath}` + ]); + await spawn('plutil', [ + '-replace', + 'NSMicrophoneUsageDescription', + '-string', + `An application in ${product.embedded.nameShort} wants to use the Microphone.`, + `${embeddedInfoPlistPath}` + ]); + await spawn('plutil', [ + '-replace', + 'NSCameraUsageDescription', + '-string', + `An application in ${product.embedded.nameShort} wants to use the Camera.`, + `${embeddedInfoPlistPath}` + ]); + await spawn('plutil', [ + '-replace', + 'NSAudioCaptureUsageDescription', + '-string', + `An application in ${product.embedded.nameShort} wants to use Audio Capture.`, + `${embeddedInfoPlistPath}` + ]); + await spawn('plutil', [ + '-insert', + 'NSLocalNetworkUsageDescription', + '-string', + `The app uses your local network for DNS resolution and to connect to locally running services.`, + `${embeddedInfoPlistPath}` + ]); + } } await retrySignOnKeychainError(() => sign(appOpts)); diff --git a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md index ede08805ce8bb..633814b85fd5e 100644 --- a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md @@ -1,5 +1,6 @@ --- name: agent-customization +user-invocable: false # don't show as slash command, we have sepcialized create-agent, create-instructions, create-hook prompts for that description: '**WORKFLOW SKILL** — Create, update, review, fix, or debug VS Code agent customization files (.instructions.md, .prompt.md, .agent.md, SKILL.md, copilot-instructions.md, AGENTS.md). USE FOR: saving coding preferences; troubleshooting why instructions/skills/agents are ignored or not invoked; configuring applyTo patterns; defining tool restrictions; creating custom agent modes or specialized workflows; packaging domain knowledge; fixing YAML frontmatter syntax. DO NOT USE FOR: general coding questions (use default agent); runtime debugging or error diagnosis; MCP server configuration (use MCP docs directly); VS Code extension development. INVOKES: file system tools (read/write customization files), ask-questions tool (interview user for requirements), subagents for codebase exploration. FOR SINGLE OPERATIONS: For quick YAML frontmatter fixes or creating a single file from a known pattern, edit the file directly — no skill needed.' --- diff --git a/extensions/copilot/assets/prompts/create-agent.prompt.md b/extensions/copilot/assets/prompts/skills/create-agent/SKILL.md similarity index 97% rename from extensions/copilot/assets/prompts/create-agent.prompt.md rename to extensions/copilot/assets/prompts/skills/create-agent/SKILL.md index 7c58a13165306..ca2d24dc2167b 100644 --- a/extensions/copilot/assets/prompts/create-agent.prompt.md +++ b/extensions/copilot/assets/prompts/skills/create-agent/SKILL.md @@ -2,7 +2,7 @@ name: create-agent description: 'Create a custom agent (.agent.md) for a specific job.' argument-hint: What job should this agent do and how? -agent: agent +disable-model-invocation: true --- Related skill: `agent-customization`. Load and follow **agents.md** for template and principles. diff --git a/extensions/copilot/assets/prompts/create-hook.prompt.md b/extensions/copilot/assets/prompts/skills/create-hook/SKILL.md similarity index 97% rename from extensions/copilot/assets/prompts/create-hook.prompt.md rename to extensions/copilot/assets/prompts/skills/create-hook/SKILL.md index 68061ba487929..79aad3bf0c14c 100644 --- a/extensions/copilot/assets/prompts/create-hook.prompt.md +++ b/extensions/copilot/assets/prompts/skills/create-hook/SKILL.md @@ -2,7 +2,7 @@ name: create-hook description: 'Create a hook (.json) to enforce policy or automate agent lifecycle events.' argument-hint: What should be enforced or automated? -agent: agent +disable-model-invocation: true --- Related skill: `agent-customization`. Load and follow **hooks.md** for template and principles. diff --git a/extensions/copilot/assets/prompts/create-instructions.prompt.md b/extensions/copilot/assets/prompts/skills/create-instructions/SKILL.md similarity index 97% rename from extensions/copilot/assets/prompts/create-instructions.prompt.md rename to extensions/copilot/assets/prompts/skills/create-instructions/SKILL.md index 7a27b5f52bed9..a45e2526e1045 100644 --- a/extensions/copilot/assets/prompts/create-instructions.prompt.md +++ b/extensions/copilot/assets/prompts/skills/create-instructions/SKILL.md @@ -2,7 +2,7 @@ name: create-instructions description: 'Create an instructions file (.instructions.md) for a project rule or convention.' argument-hint: What rule or convention to enforce? -agent: agent +disable-model-invocation: true --- Related skill: `agent-customization`. Load and follow **instructions.md** for template and principles. diff --git a/extensions/copilot/assets/prompts/create-prompt.prompt.md b/extensions/copilot/assets/prompts/skills/create-prompt/SKILL.md similarity index 97% rename from extensions/copilot/assets/prompts/create-prompt.prompt.md rename to extensions/copilot/assets/prompts/skills/create-prompt/SKILL.md index 5945ee547cca2..fbf0fe0372480 100644 --- a/extensions/copilot/assets/prompts/create-prompt.prompt.md +++ b/extensions/copilot/assets/prompts/skills/create-prompt/SKILL.md @@ -2,7 +2,7 @@ name: create-prompt description: 'Create a reusable prompt file (.prompt.md) for a common task.' argument-hint: What task should this prompt help with? -agent: agent +disable-model-invocation: true --- Related skill: `agent-customization`. Load and follow **prompts.md** for template and principles. diff --git a/extensions/copilot/assets/prompts/create-skill.prompt.md b/extensions/copilot/assets/prompts/skills/create-skill/SKILL.md similarity index 97% rename from extensions/copilot/assets/prompts/create-skill.prompt.md rename to extensions/copilot/assets/prompts/skills/create-skill/SKILL.md index dcad53e1717de..a60e7439908f8 100644 --- a/extensions/copilot/assets/prompts/create-skill.prompt.md +++ b/extensions/copilot/assets/prompts/skills/create-skill/SKILL.md @@ -2,7 +2,7 @@ name: create-skill description: 'Create a reusable skill (SKILL.md) that packages a workflow.' argument-hint: What should this skill produce? -agent: agent +disable-model-invocation: true --- Related skill: `agent-customization`. Load and follow **skills.md** for template and principles. diff --git a/extensions/copilot/assets/prompts/init.prompt.md b/extensions/copilot/assets/prompts/skills/init/SKILL.md similarity index 98% rename from extensions/copilot/assets/prompts/init.prompt.md rename to extensions/copilot/assets/prompts/skills/init/SKILL.md index e1ee5be18a505..6c8dca9a8f357 100644 --- a/extensions/copilot/assets/prompts/init.prompt.md +++ b/extensions/copilot/assets/prompts/skills/init/SKILL.md @@ -2,7 +2,7 @@ name: init description: Generate or update workspace instructions file for AI coding agents argument-hint: Optionally specify a focus area or pattern to document for agents -agent: agent +disable-model-invocation: true --- Related skill: `agent-customization`. Load and follow **workspace-instructions.md** for template, principles, and anti-patterns. diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3a79d1e5c5c00..4e543202fb1cd 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6152,30 +6152,6 @@ { "path": "./assets/prompts/plan.prompt.md", "when": "chatSessionType == local" - }, - { - "path": "./assets/prompts/init.prompt.md", - "when": "chatSessionType == local" - }, - { - "path": "./assets/prompts/create-prompt.prompt.md", - "when": "chatSessionType == local" - }, - { - "path": "./assets/prompts/create-instructions.prompt.md", - "when": "chatSessionType == local" - }, - { - "path": "./assets/prompts/create-skill.prompt.md", - "when": "chatSessionType == local" - }, - { - "path": "./assets/prompts/create-agent.prompt.md", - "when": "chatSessionType == local" - }, - { - "path": "./assets/prompts/create-hook.prompt.md", - "when": "chatSessionType == local" } ], "chatSkills": [ @@ -6202,6 +6178,30 @@ { "path": "./assets/prompts/skills/agent-customization/SKILL.md", "when": "chatSessionType == local || chatSessionType == copilotcli" + }, + { + "path": "./assets/prompts/skills/init/SKILL.md", + "when": "chatSessionType == local" + }, + { + "path": "./assets/prompts/skills/create-prompt/SKILL.md", + "when": "chatSessionType == local" + }, + { + "path": "./assets/prompts/skills/create-instructions/SKILL.md", + "when": "chatSessionType == local" + }, + { + "path": "./assets/prompts/skills/create-skill/SKILL.md", + "when": "chatSessionType == local" + }, + { + "path": "./assets/prompts/skills/create-agent/SKILL.md", + "when": "chatSessionType == local" + }, + { + "path": "./assets/prompts/skills/create-hook/SKILL.md", + "when": "chatSessionType == local" } ], "terminal": { @@ -6413,7 +6413,7 @@ "node-gyp": "npm:node-gyp@10.3.1", "zod": "3.25.76" }, - "vscodeCommit": "20b24833bc088c84b778ee89be4c17f15660441a", + "vscodeCommit": "5be5cffdefe8d8961ea7a21397a936de68b04628", "__metadata": { "id": "7ec7d6e6-b89e-4cc5-a59b-d6c4d238246f", "publisherId": { diff --git a/extensions/copilot/src/extension/chatSessions/common/chatCustomAgentsService.ts b/extensions/copilot/src/extension/chatSessions/common/chatCustomAgentsService.ts index 7bc69191a5d18..3ef49e1c214b9 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatCustomAgentsService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatCustomAgentsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService'; +import type { ChatCustomAgent } from 'vscode'; import { createServiceIdentifier } from '../../../util/common/services'; import { Event } from '../../../util/vs/base/common/event'; import { IDisposable } from '../../../util/vs/base/common/lifecycle'; @@ -13,5 +13,5 @@ export const IChatCustomAgentsService = createServiceIdentifier; - getCustomAgents(): ParsedPromptFile[]; + getCustomAgents(): readonly ChatCustomAgent[]; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatCustomAgentsService.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatCustomAgentsService.ts index b777b4f54484f..8a6718234b00c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatCustomAgentsService.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatCustomAgentsService.ts @@ -4,11 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { ILogService } from '../../../platform/log/common/logService'; -import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService'; -import { coalesce } from '../../../util/vs/base/common/arrays'; import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation'; -import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Emitter, Event } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IChatCustomAgentsService } from '../common/chatCustomAgentsService'; @@ -19,12 +15,10 @@ export class ChatCustomAgentsService extends Disposable implements IChatCustomAg private readonly _onDidChangeCustomAgents = this._register(new Emitter()); readonly onDidChangeCustomAgents: Event = this._onDidChangeCustomAgents.event; - private customAgents: ParsedPromptFile[] = []; + private customAgents: readonly vscode.ChatCustomAgent[] = []; private refreshCts: CancellationTokenSource | undefined; constructor( - @IPromptsService private readonly promptsService: IPromptsService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -35,8 +29,8 @@ export class ChatCustomAgentsService extends Disposable implements IChatCustomAg this.triggerRefreshCustomAgents(); } - getCustomAgents(): ParsedPromptFile[] { - return [...this.customAgents]; + getCustomAgents(): readonly vscode.ChatCustomAgent[] { + return this.customAgents; } override dispose(): void { @@ -59,23 +53,21 @@ export class ChatCustomAgentsService extends Disposable implements IChatCustomAg } private async refreshCustomAgents(token: CancellationToken): Promise { - const parsedAgents = coalesce(await Promise.all(vscode.chat.customAgents.map(async resource => { - try { - return await this.promptsService.parseFile(resource.uri, token); - } catch (error) { - if (isCancellationError(error) || token.isCancellationRequested) { - return undefined; - } - this.logService.error(`[ChatCustomAgentsService] Failed to parse custom agent ${resource.uri.toString()}`, error); - return undefined; + try { + const customAgents = await vscode.chat.getCustomAgents(token); + + if (token.isCancellationRequested) { + return; } - }))); - if (token.isCancellationRequested) { - return; - } + this.customAgents = customAgents; + this._onDidChangeCustomAgents.fire(); + } catch (error) { + if (token.isCancellationRequested) { + return; + } - this.customAgents = parsedAgents; - this._onDidChangeCustomAgents.fire(); + console.error('Failed to refresh custom agents', error); + } } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index e0370f6a6f31d..810c6c71ab150 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -91,7 +91,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } } - const worktreePath = await this.gitService.createWorktree(activeRepository.rootUri, { branch, commitish: baseBranch }); + const worktreePath = await this.gitService.createWorktree(activeRepository.rootUri, { branch, commitish: baseBranch, noTrack: true }); if (worktreePath && activeRepository.headCommitHash && activeRepository.headBranchName) { const baseBranchName = baseBranch ?? activeRepository.headBranchName; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 55d66865022a6..0dbbf8806f8a4 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -27,7 +27,7 @@ import { StopWatch } from '../../../util/vs/base/common/stopwatch'; import { hasKey } from '../../../util/vs/base/common/types'; import { EXTENSION_ID } from '../../common/constants'; import { GitBranchNameGenerator } from '../../prompt/node/gitBranch'; -import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; +import { IChatSessionMetadataStore, RepositoryProperties } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager'; @@ -1360,7 +1360,14 @@ export function registerCLIChatCommands( return; } - copilotCliWorkspaceSession.trackSessionWorkspaceFolder(sessionId, workspaceFolder.fsPath, repository.headBranchName ? { repositoryPath: repository.rootUri.fsPath, branchName: repository.headBranchName } : undefined); + const repositoryProperties = repository.headBranchName + ? { + repositoryPath: repository.rootUri.fsPath, + branchName: repository.headBranchName + } satisfies RepositoryProperties + : undefined; + + await copilotCliWorkspaceSession.trackSessionWorkspaceFolder(sessionId, workspaceFolder.fsPath, repositoryProperties); copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId); await contentProvider.refreshSession({ reason: 'update', sessionId }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index f8db18505e4e7..a0e1442fb27df 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -2390,7 +2390,7 @@ export function registerCLIChatCommands( } satisfies RepositoryProperties : undefined; - copilotCliWorkspaceSession.trackSessionWorkspaceFolder(sessionId, workspaceFolder.fsPath, repositoryProperties); + await copilotCliWorkspaceSession.trackSessionWorkspaceFolder(sessionId, workspaceFolder.fsPath, repositoryProperties); copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId); copilotcliSessionItemProvider.notifySessionsChange(); diff --git a/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts b/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts index 4d6b879eb04b9..698b8f90dd820 100644 --- a/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts +++ b/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts @@ -10,7 +10,7 @@ import type OpenAI from 'openai'; import { IChatMLFetcher, Source } from '../../../platform/chat/common/chatMLFetcher'; import { ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; import { CustomModel, EndpointEditToolName, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; -import { OpenAIResponsesProcessor, responseApiInputToRawMessagesForLogging } from '../../../platform/endpoint/node/responsesApi'; +import { getResponsesApiCompactionThresholdFromBody, OpenAIResponsesProcessor, responseApiInputToRawMessagesForLogging } from '../../../platform/endpoint/node/responsesApi'; import { ILogService } from '../../../platform/log/common/logService'; import { FinishedCallback, OptionalChatRequestParams } from '../../../platform/networking/common/fetch'; import { Response } from '../../../platform/networking/common/fetcherService'; @@ -455,7 +455,7 @@ class StreamingPassThroughEndpoint implements IChatEndpoint { // We parse the stream just to return a correct ChatCompletion for logging the response and token usage details. const requestId = response.headers.get('X-Request-ID') ?? generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; - const processor = this.instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, requestId, ghRequestId); + const processor = this.instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, requestId, ghRequestId, getResponsesApiCompactionThresholdFromBody(this.requestBody)); const parser = new SSEParser((ev) => { try { logService.trace(`[StreamingPassThroughEndpoint] SSE: ${ev.data}`); diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index 40be899221915..733e831341918 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -79,6 +79,15 @@ function computeReducedWindow( function convertLineEditToEdit(nextLineEdit: LineEdit, document: StringText): StringEdit { const rootedLineEdit = new RootedLineEdit(document, nextLineEdit); const suggestedEdit = rootedLineEdit.toEdit(); + // LineReplacement.toSingleTextEdit always joins newLines with '\n'. + // If the document uses '\r\n' line endings, we need to match that in + // the replacement text so that applying the edit produces consistent + // line endings and the resulting content matches what VS Code reports. + if (document.value.includes('\r\n')) { + return new StringEdit(suggestedEdit.replacements.map( + r => new StringReplacement(r.replaceRange, r.newText.replace(/\n/g, '\r\n')) + )); + } return suggestedEdit; } diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts index ccfde10593daa..189b2506be2f9 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts @@ -58,10 +58,8 @@ describe('NextEditProvider Caching', () => { afterAll(() => { disposableStore.dispose(); }); - it('caches a response with multiple edits and reuses them correctly with rebasing', async () => { - const obsWorkspace = new MutableObservableWorkspace(); - const obsGit = new ObservableGit(gitExtensionService); - const statelessNextEditProvider: IStatelessNextEditProvider = { + function createStatelessNextEditProvider(): IStatelessNextEditProvider { + return { ID: 'TestNextEditProvider', provideNextEdit: async function* (request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) { const telemetryBuilder = new StatelessNextEditTelemetryBuilder(request.headerRequestId); @@ -92,6 +90,12 @@ describe('NextEditProvider Caching', () => { return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions))); } }; + } + + it('caches a response with multiple edits and reuses them correctly with rebasing', async () => { + const obsWorkspace = new MutableObservableWorkspace(); + const obsGit = new ObservableGit(gitExtensionService); + const statelessNextEditProvider = createStatelessNextEditProvider(); const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger); @@ -192,4 +196,84 @@ describe('NextEditProvider Caching', () => { const myPoint = new Point3D(0, 1, 2);" `); }); + + it('caches a response with multiple edits correctly when document uses CRLF line endings', async () => { + const obsWorkspace = new MutableObservableWorkspace(); + const obsGit = new ObservableGit(gitExtensionService); + const statelessNextEditProvider = createStatelessNextEditProvider(); + + const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger); + + // Use \r\n line endings to simulate a Windows document + const initialValue = [ + 'class Point {', + '\tconstructor(', + '\t\tprivate readonly x: number,', + '\t\tprivate readonly y: number,', + '\t) { }', + '\tgetDistance() {', + '\t\treturn Math.sqrt(this.x ** 2 + this.y ** 2);', + '\t}', + '}', + '', + 'const myPoint = new Point(0, 1);', + ].join('\r\n'); + + const doc = obsWorkspace.addDocument({ + id: DocumentId.create(URI.file('/test/test.ts').toString()), + initialValue, + }); + doc.setSelection([new OffsetRange(1, 1)], undefined); + + // Insert "3D" after "Point" at offset 11 (same offset, within first line before any line ending) + doc.applyEdit(StringEdit.insert(11, '3D')); + + const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false }; + const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context); + const cancellationToken = CancellationToken.None; + const tb1 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + + // First edit: should add z parameter + let result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb1.nesBuilder); + tb1.dispose(); + assert(result.result?.edit); + doc.applyEdit(result.result.edit.toEdit()); + + // Verify CRLF line endings are preserved + expect(doc.value.get().value).toContain('\r\n'); + expect(doc.value.get().value).not.toMatch(/[^\r]\n/); + + // Second edit: should update getDistance method — this uses a cached edit + const tb2 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb2.nesBuilder); + tb2.dispose(); + assert(result.result?.edit, 'second cached edit should be found'); + doc.applyEdit(result.result.edit.toEdit()); + + expect(doc.value.get().value).not.toMatch(/[^\r]\n/); + + // Third edit: should update the variable — also from cache + const tb3 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb3.nesBuilder); + tb3.dispose(); + assert(result.result?.edit, 'third cached edit should be found'); + doc.applyEdit(result.result.edit.toEdit()); + + // Final state should match expected content with CRLF throughout + const expectedLines = [ + 'class Point3D {', + '\tconstructor(', + '\t\tprivate readonly x: number,', + '\t\tprivate readonly y: number,', + '\t\tprivate readonly z: number,', + '\t) { }', + '\tgetDistance() {', + '\t\treturn Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2);', + '\t}', + '}', + '', + 'const myPoint = new Point3D(0, 1, 2);', + ].join('\r\n'); + expect(doc.value.get().value).toBe(expectedLines); + }); }); diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 65c0ac6630d0b..6f26da048c5e2 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -17,7 +17,7 @@ import { IInteractionService } from '../../../platform/chat/common/interactionSe import { ConfigKey, HARD_TOOL_LIMIT, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ICAPIClientService } from '../../../platform/endpoint/common/capiClient'; import { isAutoModel } from '../../../platform/endpoint/node/autoChatEndpoint'; -import { OpenAIResponsesProcessor, responseApiInputToRawMessagesForLogging, sendCompletionOutputTelemetry } from '../../../platform/endpoint/node/responsesApi'; +import { getResponsesApiCompactionThresholdFromBody, OpenAIResponsesProcessor, responseApiInputToRawMessagesForLogging, sendCompletionOutputTelemetry } from '../../../platform/endpoint/node/responsesApi'; import { collectSingleLineErrorMessage, ILogService } from '../../../platform/log/common/logService'; import { isAnthropicToolSearchEnabled } from '../../../platform/networking/common/anthropic'; import { FinishedCallback, getRequestId, IResponseDelta, OptionalChatRequestParams, RequestId } from '../../../platform/networking/common/fetch'; @@ -1103,7 +1103,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { const handle = connection.sendRequest(request, { userInitiated: !!userInitiatedRequest, turnId, requestId: ourRequestId }, cancellationToken); const extendedBaseTelemetryData = baseTelemetryData.extendedBy({ modelCallId }); - const processor = this._instantiationService.createInstance(OpenAIResponsesProcessor, extendedBaseTelemetryData, modelRequestId.headerRequestId, modelRequestId.gitHubRequestId); + const processor = this._instantiationService.createInstance(OpenAIResponsesProcessor, extendedBaseTelemetryData, this._telemetryService, modelRequestId.headerRequestId, modelRequestId.gitHubRequestId, getResponsesApiCompactionThresholdFromBody(request)); // Set up streaming first so event listeners are registered before we // await the first event — AsyncIterableObject runs its executor eagerly. diff --git a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx index 84334a9556cd1..3ddf60b0d7c13 100644 --- a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx @@ -885,37 +885,6 @@ suite('ReadFile', () => { testAccessor.dispose(); }); - test('should send skillStorage=internal for vscode-chat-internal scheme', async () => { - const skillContent = '# Internal Skill'; - const skillUri = URI.from({ scheme: 'vscode-chat-internal', path: '/skills/internal-skill/SKILL.md' }); - const testDoc = createTextDocumentData(skillUri, skillContent, 'markdown').document; - - const services = createExtensionUnitTestingServices(); - services.define(IWorkspaceService, new SyncDescriptor( - TestWorkspaceService, - [[URI.file('/workspace')], [testDoc]] - )); - - const mockCustomInstructions = new MockCustomInstructionsService(); - mockCustomInstructions.setSkillFiles([skillUri], SkillStorage.Internal); - services.define(ICustomInstructionsService, mockCustomInstructions); - - const telemetry = new CapturingTelemetryService(); - services.define(ITelemetryService, telemetry); - - const testAccessor = services.createTestingAccessor(); - const readFileTool = testAccessor.get(IInstantiationService).createInstance(ReadFileTool); - - const input: IReadFileParamsV2 = { filePath: skillUri.toString() }; - await readFileTool.invoke({ input, toolInvocationToken: null as never }, CancellationToken.None); - - const event = telemetry.events.find(e => e.eventName === 'skillContentRead'); - expect(event).toBeDefined(); - expect(event!.properties!.skillStorage).toBe(SkillStorage.Internal); - - testAccessor.dispose(); - }); - test('should not send skillContentRead for non-skill files', async () => { const telemetry = new CapturingTelemetryService(); const services = createExtensionUnitTestingServices(); diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index 4bc9398c21c6f..4012f33aef7a7 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -206,9 +206,6 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, } async function isExternalInstructionsFile(normalizedUri: URI, customInstructionsService: ICustomInstructionsService, buildPromptContext?: IBuildPromptContext): Promise { - if (normalizedUri.scheme === 'vscode-chat-internal') { - return true; - } if (customInstructionsService.getExtensionSkillInfo(normalizedUri)) { return true; } diff --git a/extensions/copilot/src/extension/tools/vscode-node/tools.ts b/extensions/copilot/src/extension/tools/vscode-node/tools.ts index 29b6b8eff7244..30f581d34793c 100644 --- a/extensions/copilot/src/extension/tools/vscode-node/tools.ts +++ b/extensions/copilot/src/extension/tools/vscode-node/tools.ts @@ -150,6 +150,52 @@ export class ToolsContribution extends Disposable { } })); + // Needed by the artifact feature to resolve memory file URIs to open them, since they use a custom URI scheme that vscode doesn't know about + this._register(vscode.commands.registerCommand('github.copilot.chat.tools.memory.resolveMemoryFileUri', (memoryPath: string, sessionResource?: string): string | undefined => { + if (!memoryPath || !memoryPath.startsWith('/memories/')) { + return undefined; + } + if (memoryPath.includes('..')) { + return undefined; + } + + const MEMORY_BASE_DIR = 'memory-tool/memories'; + const segments = memoryPath.split('/').filter(s => s.length > 0); + + let resolved: URI; + + if (memoryPath.startsWith('/memories/session/') || memoryPath === '/memories/session') { + const storageUri = this.extensionContext.storageUri; + if (!storageUri) { + return undefined; + } + const relativeSegments = segments.slice(2); + const baseUri = URI.from(storageUri); + if (sessionResource) { + const sessionId = extractSessionId(sessionResource); + resolved = URI.joinPath(baseUri, MEMORY_BASE_DIR, sessionId, ...relativeSegments); + } else { + resolved = URI.joinPath(baseUri, MEMORY_BASE_DIR, ...relativeSegments); + } + } else if (memoryPath.startsWith('/memories/repo/') || memoryPath === '/memories/repo') { + const storageUri = this.extensionContext.storageUri; + if (!storageUri) { + return undefined; + } + const relativeSegments = segments.slice(2); + resolved = URI.joinPath(URI.from(storageUri), MEMORY_BASE_DIR, 'repo', ...relativeSegments); + } else { + const globalStorageUri = this.extensionContext.globalStorageUri; + if (!globalStorageUri) { + return undefined; + } + const relativeSegments = segments.slice(1); + resolved = URI.joinPath(globalStorageUri, MEMORY_BASE_DIR, ...relativeSegments); + } + + return resolved.toString(); + })); + this._register(vscode.commands.registerCommand('github.copilot.chat.tools.memory.clearMemories', async () => { const confirm = await vscode.window.showWarningMessage( l10n.t('Are you sure you want to clear all memories? This cannot be undone.'), diff --git a/extensions/copilot/src/extension/vscode.proposed.chatPromptFiles.d.ts b/extensions/copilot/src/extension/vscode.proposed.chatPromptFiles.d.ts index 38c99529cebdb..6e9a425b8bca3 100644 --- a/extensions/copilot/src/extension/vscode.proposed.chatPromptFiles.d.ts +++ b/extensions/copilot/src/extension/vscode.proposed.chatPromptFiles.d.ts @@ -9,7 +9,12 @@ declare module 'vscode' { // #region Resource Classes /** - * Represents a chat-related resource, such as a custom agent, instructions, prompt file, or skill. + * Indicates where a chat resource was loaded from. + */ + export type ChatResourceSource = 'local' | 'user' | 'extension' | 'plugin'; + + /** + * Represents a chat-related resource, such as a custom agent, instructions, prompt file, skill, or slash command. */ export interface ChatResource { /** @@ -18,6 +23,189 @@ declare module 'vscode' { readonly uri: Uri; } + /** + * Represents a custom chat agent resource. + */ + export interface ChatCustomAgent { + /** + * Uri to the custom agent. This is typically a `.agent.md` file. + */ + readonly uri: Uri; + + /** + * Display name of the custom agent. + */ + readonly name: string; + + /** + * Optional description of the custom agent. + */ + readonly description?: string; + + /** + * Where the custom agent was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * Optional hint that describes what arguments the custom agent accepts. + */ + readonly argumentHint?: string; + + /** + * Whether this custom agent should be shown to users as invocable. + */ + readonly userInvocable: boolean; + + /** + * Whether this custom agent should be excluded from model invocation. + */ + readonly disableModelInvocation: boolean; + } + + /** + * Represents an instruction file resource. + */ + export interface ChatInstruction { + /** + * Uri to the instruction. + */ + readonly uri: Uri; + + /** + * Display name of the instruction. + */ + readonly name: string; + + /** + * Optional description of the instruction. + */ + readonly description?: string; + + /** + * Where the instruction was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * The optional apply pattern used to scope the instruction. + */ + readonly pattern?: string; + } + + /** + * Represents a skill resource. + */ + export interface ChatSkill { + /** + * Uri to the chat resource. This is typically a `.agent.md`, `.instructions.md`, `.prompt.md`, or `SKILL.md` file. + */ + readonly uri: Uri; + + /** + * Display name of the skill. + */ + readonly name: string; + + /** + * Optional description of the skill. + */ + readonly description?: string; + + /** + * Where the skill was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * Whether this skill should be shown to users as invocable. + */ + readonly userInvocable?: boolean; + } + + /** + * Represents a slash command resource. + */ + export interface ChatSlashCommand { + /** + * Uri to the chat resource. + */ + readonly uri: Uri; + + /** + * Display name of the chat resource. + */ + readonly name: string; + + /** + * Optional description of the chat resource. + */ + readonly description?: string; + + /** + * Where the chat resource was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * Optional hint that describes what arguments the slash command accepts. + */ + readonly argumentHint?: string; + + /** + * Whether this slash command should be shown to users as invocable. + */ + readonly userInvocable?: boolean; + } + + export interface ChatHook { + readonly uri: Uri; + } + + export interface ChatPlugin { + readonly uri: Uri; + } + // #endregion // #region Providers @@ -133,8 +321,16 @@ declare module 'vscode' { /** * The list of currently available custom agents. These are `.agent.md` files * from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getCustomAgents provideCustomAgents} instead, which queries the current list of custom agents on demand. This property may become out of sync with the actual available custom agents. + */ + export const customAgents: readonly ChatCustomAgent[]; + + /** + * Provide the list of currently available custom agents. These are `.agent.md` files + * from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. */ - export const customAgents: readonly ChatResource[]; + export function getCustomAgents(token: CancellationToken): Thenable; /** * An event that fires when the list of {@link instructions instructions} changes. @@ -144,8 +340,16 @@ declare module 'vscode' { /** * The list of currently available instructions. These are `.instructions.md` files * from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getInstructions getInstructions} instead, which queries the current list of instructions on demand. This property may become out of sync with the actual available instructions. */ - export const instructions: readonly ChatResource[]; + export const instructions: readonly ChatInstruction[]; + + /** + * Provide the list of currently available instructions. These are `.instructions.md` files + * from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getInstructions(token: CancellationToken): Thenable; /** * An event that fires when the list of {@link skills skills} changes. @@ -155,8 +359,35 @@ declare module 'vscode' { /** * The list of currently available skills. These are `SKILL.md` files * from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getSkills getSkills} instead, which queries the current list of skills on demand. This property may become out of sync with the actual available skills. + */ + export const skills: readonly ChatSkill[]; + + /** + * Provide the list of currently available skills. These are `SKILL.md` files + * from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getSkills(token: CancellationToken): Thenable; + + /** + * An event that fires when the list of {@link slashCommands slash commands} changes. + */ + export const onDidChangeSlashCommands: Event; + + /** + * The list of currently available slash commands. These are `.prompt.md` files and + * user-invocable `SKILL.md` files from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getSlashCommands getSlashCommands} instead, which queries the current list of slash commands on demand. This property may become out of sync with the actual available slash commands. */ - export const skills: readonly ChatResource[]; + export const slashCommands: readonly ChatSlashCommand[]; + + /** + * Provide the list of currently available slash commands. These are `.prompt.md` files and + * user-invocable `SKILL.md` files from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getSlashCommands(token: CancellationToken): Thenable; /** * An event that fires when the list of {@link hooks hooks} changes. @@ -167,9 +398,16 @@ declare module 'vscode' { * The list of currently available hook configuration files. * These are JSON files that define lifecycle hooks from all sources * (workspace, user, and extension-provided). + * @deprecated Use {@link getHooks getHooks} instead, which queries the current list of hook configuration files on demand. This property may become out of sync with the actual available hook configuration files. */ export const hooks: readonly ChatResource[]; + /** + * Provide the list of currently available hook configuration files. These are JSON files that define lifecycle hooks from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getHooks(token: CancellationToken): Thenable; + /** * An event that fires when the list of {@link plugins plugins} changes. */ @@ -177,9 +415,16 @@ declare module 'vscode' { /** * The list of currently installed agent plugins. + * @deprecated Use {@link getPlugins getPlugins} instead, which queries the current list of installed agent plugins on demand. This property may become out of sync with the actual installed agent plugins. */ export const plugins: readonly ChatResource[]; + /** + * Provide the list of currently installed agent plugins. + * @param token A cancellation token. + */ + export function getPlugins(token: CancellationToken): Thenable; + /** * Register a provider for custom agents. * @param provider The custom agent provider. diff --git a/extensions/copilot/src/extension/vscode.proposed.mcpServerDefinitions.d.ts b/extensions/copilot/src/extension/vscode.proposed.mcpServerDefinitions.d.ts index 04a50dd823cf7..855056f181e2c 100644 --- a/extensions/copilot/src/extension/vscode.proposed.mcpServerDefinitions.d.ts +++ b/extensions/copilot/src/extension/vscode.proposed.mcpServerDefinitions.d.ts @@ -77,9 +77,12 @@ declare module 'vscode' { * when the last gateway is disposed. The gateway dynamically tracks server * additions and removals via {@link McpGateway.onDidChangeServers}. * + * @param chatSessionResource Optional chat session resource URI to associate with this + * gateway. When provided, MCP tool calls made through this gateway will be associated + * with the chat session, enabling inline elicitation UI in the chat response. * @returns A promise that resolves to an {@link McpGateway} if successful, * or `undefined` if no Node process is available (e.g., in serverless web environments). */ - export function startMcpGateway(): Thenable; + export function startMcpGateway(chatSessionResource?: Uri): Thenable; } } diff --git a/extensions/copilot/src/extension/xtab/node/cursorLineDivergence.ts b/extensions/copilot/src/extension/xtab/node/cursorLineDivergence.ts new file mode 100644 index 0000000000000..773d93bc71c58 --- /dev/null +++ b/extensions/copilot/src/extension/xtab/node/cursorLineDivergence.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { StringEdit } from '../../../util/vs/editor/common/core/edits/stringEdit'; +import { Position } from '../../../util/vs/editor/common/core/position'; +import { PositionOffsetTransformer } from '../../../util/vs/editor/common/core/text/positionToOffset'; + +/** + * Resolves the current content of the cursor line after applying an intermediate + * user edit to the original document. + * + * The cursor line's 0-based index in the original document may no longer be + * valid after the user inserts or deletes lines above the cursor. This function + * maps the cursor line's character offset through the edit to find the correct + * line in the resulting document. + * + * @param originalDoc A transformer for the original document text (reused to + * avoid recomputing line offsets). + * @param cursorDocLineIdx 0-based line index in the original document. + * @returns The line content, or `undefined` if the cursor line index is out of + * bounds or the original position falls inside a replacement range + * (making the mapping ambiguous). + */ +export function getCurrentCursorLine( + originalDoc: PositionOffsetTransformer, + cursorDocLineIdx: number, + intermediateEdit: StringEdit, +): string | undefined { + const lineNumber = cursorDocLineIdx + 1; // 1-based + const lineCount = originalDoc.textLength.lineCount + 1; + + if (lineNumber < 1 || lineNumber > lineCount) { + return undefined; + } + + const cursorLineStartOffset = originalDoc.getOffset(new Position(lineNumber, 1)); + + // Walk through the edit's replacements (sorted, non-overlapping) and + // accumulate the character-offset delta for replacements entirely before + // the cursor line start. + let delta = 0; + for (const replacement of intermediateEdit.replacements) { + if (replacement.replaceRange.endExclusive <= cursorLineStartOffset) { + delta += replacement.newText.length - replacement.replaceRange.length; + } else if (replacement.replaceRange.start < cursorLineStartOffset) { + // The cursor line start falls inside a replacement — ambiguous. + return undefined; + } else { + break; + } + } + + const mappedOffset = cursorLineStartOffset + delta; + const currentDoc = intermediateEdit.apply(originalDoc.text); + const currentTransformer = new PositionOffsetTransformer(currentDoc); + + // Map the offset back to a position in the current document, then extract + // the full line content. + const currentPos = currentTransformer.getPosition(mappedOffset); + const lineStart = currentTransformer.getOffset(new Position(currentPos.lineNumber, 1)); + const lineLen = currentTransformer.getLineLength(currentPos.lineNumber); + return currentDoc.substring(lineStart, lineStart + lineLen); +} + +/** + * A minimal single-line edit: the text between `startOffset` and `endOffset` + * in the original was replaced with `inserted`. + */ +interface LineDiff { + readonly startOffset: number; + readonly endOffset: number; + readonly replaced: string; + readonly inserted: string; +} + +/** + * Computes the minimal edit between two versions of the same line by stripping + * the longest common prefix and suffix. + * + * Example: `diffLine("function fi", "function fib")` + * → `{ startOffset: 11, endOffset: 11, replaced: "", inserted: "b" }` + */ +function diffLine(before: string, after: string): LineDiff { + let prefixLen = 0; + while (prefixLen < before.length && prefixLen < after.length + && before[prefixLen] === after[prefixLen]) { + prefixLen++; + } + let suffixLen = 0; + while (suffixLen < before.length - prefixLen && suffixLen < after.length - prefixLen + && before[before.length - 1 - suffixLen] === after[after.length - 1 - suffixLen]) { + suffixLen++; + } + return { + startOffset: prefixLen, + endOffset: before.length - suffixLen, + replaced: before.substring(prefixLen, before.length - suffixLen), + inserted: after.substring(prefixLen, after.length - suffixLen), + }; +} + +/** + * Checks whether the model's cursor line output is compatible with what the user + * has typed since the request started. + * + * Algorithm: + * 1. Diff original → current to find what the user typed. + * 2. Diff original → model to find what the model changed. + * 3. If the user's edit range falls within the model's edit range, check whether + * the model's new text is a continuation of the user's typing. + * + * @example + * original: `function fi`, current: `function fib`, model: `function fibonacci(n): number` + * → user typed "b", model inserted "bonacci(n): number" + * → model text starts with "b" → compatible ✓ + * + * @example + * original: `function fi`, current: `function fix`, model: `function fibonacci(n): number` + * → user typed "x", model inserted "bonacci(n): number" + * → model text does not start with "x" → incompatible ✗ + */ +export function isModelCursorLineCompatible(originalCursorLine: string, currentCursorLine: string, modelCursorLine: string): boolean { + const userEdit = diffLine(originalCursorLine, currentCursorLine); + const modelEdit = diffLine(originalCursorLine, modelCursorLine); + + // No actual user change — trivially compatible. + if (userEdit.replaced.length === 0 && userEdit.inserted.length === 0) { + return true; + } + + // The user's edit range must fall within the model's edit range. + // If the user edited a region the model didn't touch, we can't determine + // compatibility from the cursor line alone. + const userEditWithinModelEdit = userEdit.startOffset >= modelEdit.startOffset + && userEdit.endOffset <= modelEdit.endOffset; + + if (!userEditWithinModelEdit) { + return false; + } + + return isUserEditCompatibleWithModelEdit(userEdit, modelEdit, currentCursorLine, modelCursorLine); +} + +const AUTO_CLOSE_PAIRS = new Set(['()', '[]', '{}', '<>', '""', `''`, '``']); + +/** + * Checks whether the user's edit is compatible with the model's edit. + * + * For pure insertions, compatibility is determined by whether the model is + * continuing the user's inserted text. + * + * For deletions and replacements, avoid treating an empty inserted string as + * universally compatible. In those cases, only accept when the resulting line + * already matches the model, or when the model is editing the exact same range + * and replacing the exact same original text with a compatible continuation. + */ +function isUserEditCompatibleWithModelEdit(userEdit: LineDiff, modelEdit: LineDiff, currentCursorLine: string, modelCursorLine: string): boolean { + if (userEdit.replaced.length > 0) { + if (currentCursorLine === modelCursorLine) { + return true; + } + + return userEdit.startOffset === modelEdit.startOffset + && userEdit.endOffset === modelEdit.endOffset + && userEdit.replaced === modelEdit.replaced + && userEdit.inserted.length > 0 + && isUserTypingCompatibleWithModelText(userEdit.inserted, modelEdit.inserted); + } + + return isUserTypingCompatibleWithModelText(userEdit.inserted, modelEdit.inserted); +} + +/** + * Checks whether the user's typed text is compatible with the model's new text. + * + * Rules: + * 1. The model's new text must **start with** the user's typed text — the model + * is continuing what the user began. + * 2. If the user's text is a known auto-close pair (e.g. `()`, `{}`), accept + * if both characters appear in order in the model's text (subsequence match). + */ +function isUserTypingCompatibleWithModelText(userTypedText: string, modelNewText: string): boolean { + if (modelNewText.startsWith(userTypedText)) { + return true; + } + + // Subsequence check for auto-close pairs: if the user typed e.g. "()" and the + // model's text is "(x, y)", the pair characters appear in order. + if (AUTO_CLOSE_PAIRS.has(userTypedText)) { + return isSubsequenceOf(userTypedText, modelNewText); + } + + return false; +} + +/** + * Returns true if every character of `subsequence` appears in `str` in order. + */ +function isSubsequenceOf(subsequence: string, str: string): boolean { + let si = 0; + for (let ti = 0; ti < subsequence.length; ti++) { + const idx = str.indexOf(subsequence[ti], si); + if (idx === -1) { + return false; + } + si = idx + 1; + } + return true; +} diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index e1e8a89dfe6d1..df6dfc987046e 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -39,7 +39,7 @@ import { ErrorUtils } from '../../../util/common/errors'; import { Result } from '../../../util/common/result'; import { assertNever } from '../../../util/vs/base/common/assert'; import { DeferredPromise, raceCancellation, raceTimeout, timeout } from '../../../util/vs/base/common/async'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation'; import { isAbsolute } from '../../../util/vs/base/common/path'; import { StopWatch } from '../../../util/vs/base/common/stopwatch'; import { URI } from '../../../util/vs/base/common/uri'; @@ -65,6 +65,7 @@ import { nes41Miniv3SystemPrompt, simplifiedPrompt, systemPromptTemplate, unifie import { PromptTags, ResponseTags } from '../common/tags'; import { TerminalMonitor } from '../common/terminalOutput'; import { CurrentDocument } from '../common/xtabCurrentDocument'; +import { getCurrentCursorLine, isModelCursorLineCompatible } from './cursorLineDivergence'; import { XtabCustomDiffPatchResponseHandler } from './xtabCustomDiffPatchResponseHandler'; import { XtabEndpoint } from './xtabEndpoint'; import { CursorJumpPrediction, XtabNextCursorPredictor } from './xtabNextCursorPredictor'; @@ -92,6 +93,40 @@ export interface ModelConfig extends xtabPromptOptions.PromptOptions { modelName: string | undefined; } +interface RequestTracingContext { + tracer: ILogger; + logContext: InlineEditRequestLogContext; + telemetry: StatelessNextEditTelemetryBuilder; +} + +interface EditWindowInfo { + editWindow: OffsetRange; + editWindowLines: string[]; + cursorOriginalLinesOffset: number; + editWindowLineRange: OffsetRange; +} + +interface EditStreamContext { + endpoint: IChatEndpoint; + modelServiceConfig: xtabPromptOptions.ModelConfiguration; + messages: Raw.ChatMessage[]; + clippedTaggedCurrentDoc: ClippedDocument; + editWindowInfo: EditWindowInfo; + promptPieces: PromptPieces; + prediction: Prediction | undefined; + originalEditWindow: OffsetRange | undefined; +} + +interface ResponseOpts { + responseFormat: xtabPromptOptions.ResponseFormat; + shouldRemoveCursorTagFromResponse: boolean; +} + +interface FetchMetadata { + aggressivenessLevel: xtabPromptOptions.AggressivenessLevel; + userHappinessScore: number | undefined; +} + export class XtabProvider implements IStatelessNextEditProvider { public static readonly ID = XTabProviderId; @@ -149,8 +184,9 @@ export class XtabProvider implements IStatelessNextEditProvider { } const delaySession = this.userInteractionMonitor.createDelaySession(request.providerRequestStartDateTime); + const tracing: RequestTracingContext = { tracer: logger, logContext, telemetry }; - const iterator = this.doGetNextEdit(request, delaySession, logger, logContext, cancellationToken, telemetry, RetryState.NotRetrying.INSTANCE); + const iterator = this.doGetNextEdit(request, delaySession, tracing, cancellationToken, RetryState.NotRetrying.INSTANCE); let res = await iterator.next(); // for-async-await loop doesn't work because we need to access the final return value @@ -178,20 +214,16 @@ export class XtabProvider implements IStatelessNextEditProvider { private doGetNextEdit( request: StatelessNextEditRequest, delaySession: DelaySession, - logger: ILogger, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, - telemetryBuilder: StatelessNextEditTelemetryBuilder, retryState: RetryState.t, ): EditStreaming { return this.doGetNextEditWithSelection( request, getOrDeduceSelectionFromLastEdit(request.getActiveDocument()), delaySession, - logger, - logContext, + tracing, cancellationToken, - telemetryBuilder, retryState, ); } @@ -200,10 +232,8 @@ export class XtabProvider implements IStatelessNextEditProvider { request: StatelessNextEditRequest, selection: Range | null, delaySession: DelaySession, - parentTracer: ILogger, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, - telemetryBuilder: StatelessNextEditTelemetryBuilder, retryState: RetryState.t, /** * For cursor jump scenarios, this is the edit window around the original cursor position @@ -213,7 +243,8 @@ export class XtabProvider implements IStatelessNextEditProvider { originalEditWindow?: OffsetRange, ): EditStreaming { - const tracer = parentTracer.createSubLogger(['XtabProvider', 'doGetNextEditWithSelection']); + const tracer = tracing.tracer.createSubLogger(['XtabProvider', 'doGetNextEditWithSelection']); + const { logContext, telemetry } = tracing; const activeDocument = request.getActiveDocument(); @@ -223,19 +254,19 @@ export class XtabProvider implements IStatelessNextEditProvider { const { promptOptions, modelServiceConfig } = this.determineModelConfiguration(activeDocument); - telemetryBuilder.setModelConfig(JSON.stringify(modelServiceConfig)); + telemetry.setModelConfig(JSON.stringify(modelServiceConfig)); - const endpoint = this.getEndpointWithLogging(promptOptions.modelName, logContext, telemetryBuilder); + const endpoint = this.getEndpointWithLogging(promptOptions.modelName, logContext, telemetry); const cursorPosition = new Position(selection.endLineNumber, selection.endColumn); const currentDocument = new CurrentDocument(activeDocument.documentAfterEdits, cursorPosition); - this._configureDebounceTimings(request, currentDocument, promptOptions, telemetryBuilder, delaySession, tracer); + this._configureDebounceTimings(request, currentDocument, promptOptions, telemetry, delaySession, tracer); const areaAroundEditWindowLinesRange = computeAreaAroundEditWindowLinesRange(currentDocument); - const editWindowLinesRange = this.computeEditWindowLinesRange(currentDocument, request, tracer, telemetryBuilder); + const editWindowLinesRange = this.computeEditWindowLinesRange(currentDocument, request, tracer, telemetry); const cursorOriginalLinesOffset = Math.max(0, currentDocument.cursorLineOffset - editWindowLinesRange.start); const editWindowLastLineLength = currentDocument.transformer.getLineLength(editWindowLinesRange.endExclusive); @@ -277,18 +308,18 @@ export class XtabProvider implements IStatelessNextEditProvider { const { clippedTaggedCurrentDoc, areaAroundCodeToEdit } = taggedCurrentFileContentResult.val; - telemetryBuilder.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); + telemetry.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); const { aggressivenessLevel, userHappinessScore } = this.userInteractionMonitor.getAggressivenessLevel(); // Log user's raw aggressiveness setting when explicitly changed from default const userAggressivenessSetting = this.configService.getExperimentBasedConfig(ConfigKey.Advanced.InlineEditsAggressiveness, this.expService); - telemetryBuilder.setUserAggressivenessSetting(userAggressivenessSetting); + telemetry.setUserAggressivenessSetting(userAggressivenessSetting); // Log aggressiveness level and user happiness score - telemetryBuilder.setXtabAggressivenessLevel(aggressivenessLevel); + telemetry.setXtabAggressivenessLevel(aggressivenessLevel); if (userHappinessScore !== undefined) { - telemetryBuilder.setXtabUserHappinessScore(userHappinessScore); + telemetry.setXtabUserHappinessScore(userHappinessScore); } const langCtx = await this.getAndProcessLanguageContext( @@ -297,8 +328,7 @@ export class XtabProvider implements IStatelessNextEditProvider { activeDocument, cursorPosition, promptOptions, - tracer, - logContext, + { tracer, logContext, telemetry }, cancellationToken, ); @@ -325,8 +355,8 @@ export class XtabProvider implements IStatelessNextEditProvider { const { prompt: userPrompt, nDiffsInPrompt, diffTokensInPrompt } = getUserPrompt(promptPieces); - telemetryBuilder.setNDiffsInPrompt(nDiffsInPrompt); - telemetryBuilder.setDiffTokensInPrompt(diffTokensInPrompt); + telemetry.setNDiffsInPrompt(nDiffsInPrompt); + telemetry.setDiffTokensInPrompt(diffTokensInPrompt); const responseFormat = xtabPromptOptions.ResponseFormat.fromPromptingStrategy(promptOptions.promptingStrategy); @@ -338,7 +368,7 @@ export class XtabProvider implements IStatelessNextEditProvider { }); logContext.setPrompt(messages); - telemetryBuilder.setPrompt(messages); + telemetry.setPrompt(messages); const HARD_CHAR_LIMIT = 30000 * 4; // 30K tokens, assuming 4 chars per token -- we use approximation here because counting tokens exactly is time-consuming const promptCharCount = charCount(messages); @@ -346,7 +376,7 @@ export class XtabProvider implements IStatelessNextEditProvider { return new NoNextEditReason.PromptTooLarge('final'); } - await this.debounce(delaySession, retryState, tracer, telemetryBuilder, cancellationToken); + await this.debounce(delaySession, retryState, tracer, telemetry, cancellationToken); if (cancellationToken.isCancellationRequested) { return new NoNextEditReason.GotCancelled('afterDebounce'); } @@ -354,46 +384,49 @@ export class XtabProvider implements IStatelessNextEditProvider { // Fire-and-forget: collect lint errors and terminal output for telemetry in background to avoid blocking the main path Promise.resolve().then(() => { const lintErrorsData = lintErrors.getData(); - telemetryBuilder.setLintErrors(lintErrorsData); + telemetry.setLintErrors(lintErrorsData); logContext.setDiagnosticsData(lintErrorsData); const terminalOutputData = this.terminalMonitor.getData(); - telemetryBuilder.setTerminalOutput(terminalOutputData); + telemetry.setTerminalOutput(terminalOutputData); logContext.setTerminalData(terminalOutputData); }); // Fire-and-forget: compute GhostText-style similar files context for telemetry - telemetryBuilder.setSimilarFilesContext( + telemetry.setSimilarFilesContext( this.similarFilesContextService.compute(activeDocument.id.uri, activeDocument.languageId, activeDocument.documentAfterEdits.value, currentDocument.cursorOffset) ); request.fetchIssued = true; - return yield* this.streamEditsWithFiltering( - request, + const editStreamCtx: EditStreamContext = { endpoint, modelServiceConfig, messages, clippedTaggedCurrentDoc, - editWindow, - editWindowLines, - cursorOriginalLinesOffset, - editWindowLinesRange, + editWindowInfo: { + editWindow, + editWindowLines, + cursorOriginalLinesOffset, + editWindowLineRange: editWindowLinesRange, + }, promptPieces, prediction, + originalEditWindow, + }; + + return yield* this.streamEditsWithFiltering( + request, + editStreamCtx, { shouldRemoveCursorTagFromResponse, responseFormat, - retryState, - aggressivenessLevel, - userHappinessScore, }, + { aggressivenessLevel, userHappinessScore }, + retryState, delaySession, - tracer, - telemetryBuilder, - logContext, + { tracer, logContext, telemetry }, cancellationToken, - originalEditWindow, ); } @@ -476,8 +509,7 @@ export class XtabProvider implements IStatelessNextEditProvider { activeDocument: StatelessNextEditDocument, cursorPosition: Position, promptOptions: ModelConfig, - tracer: ILogger, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, ): Promise { const recordingEnabled = this.configService.getConfig(ConfigKey.TeamInternal.InlineEditsLogContextRecorderEnabled); @@ -486,13 +518,13 @@ export class XtabProvider implements IStatelessNextEditProvider { return Promise.resolve(undefined); } - const langCtxPromise = this.getLanguageContext(request, delaySession, activeDocument, cursorPosition, tracer, logContext, cancellationToken); + const langCtxPromise = this.getLanguageContext(request, delaySession, activeDocument, cursorPosition, tracing, cancellationToken); // if recording, add diagnostics for the file to the recording and hook up the language context promise to write to the recording if (recordingEnabled) { langCtxPromise.then(langCtxs => { if (langCtxs) { - logContext.setLanguageContext(langCtxs); + tracing.logContext.setLanguageContext(langCtxs); } }); } @@ -508,8 +540,7 @@ export class XtabProvider implements IStatelessNextEditProvider { delaySession: DelaySession, activeDocument: StatelessNextEditDocument, cursorPosition: Position, - tracer: ILogger, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, ): Promise { try { @@ -580,59 +611,34 @@ export class XtabProvider implements IStatelessNextEditProvider { return { start, end, items: langCtxItems }; } catch (error: unknown) { - logContext.setError(ErrorUtils.fromUnknown(error)); - tracer.trace(`Failed to fetch language context: ${error}`); + tracing.logContext.setError(ErrorUtils.fromUnknown(error)); + tracing.tracer.trace(`Failed to fetch language context: ${error}`); return undefined; } } private async *streamEditsWithFiltering( request: StatelessNextEditRequest, - endpoint: IChatEndpoint, - modelServiceConfig: xtabPromptOptions.ModelConfiguration, - messages: Raw.ChatMessage[], - clippedTaggedCurrentDoc: ClippedDocument, - editWindow: OffsetRange, - editWindowLines: string[], - cursorOriginalLinesOffset: number, - editWindowLineRange: OffsetRange, - promptPieces: PromptPieces, - prediction: Prediction | undefined, - opts: { - responseFormat: xtabPromptOptions.ResponseFormat; - shouldRemoveCursorTagFromResponse: boolean; - retryState: RetryState.t; - aggressivenessLevel: xtabPromptOptions.AggressivenessLevel; - userHappinessScore: number | undefined; - }, + editStreamCtx: EditStreamContext, + responseOpts: ResponseOpts, + fetchMetadata: FetchMetadata, + retryState: RetryState.t, delaySession: DelaySession, - parentTracer: ILogger, - telemetryBuilder: StatelessNextEditTelemetryBuilder, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, - originalEditWindow: OffsetRange | undefined, ): EditStreaming { - const tracer = parentTracer.createSubLogger('streamEditsWithFiltering'); + const tracer = tracing.tracer.createSubLogger('streamEditsWithFiltering'); + const subTracing: RequestTracingContext = { ...tracing, tracer }; const iterator = this.streamEdits( request, - endpoint, - modelServiceConfig, - messages, - clippedTaggedCurrentDoc, - editWindow, - editWindowLines, - cursorOriginalLinesOffset, - editWindowLineRange, - promptPieces, - prediction, - opts, + editStreamCtx, + responseOpts, + fetchMetadata, + retryState, delaySession, - tracer, - telemetryBuilder, - logContext, + subTracing, cancellationToken, - originalEditWindow, ); let nEdits = 0; @@ -656,7 +662,7 @@ export class XtabProvider implements IStatelessNextEditProvider { if (nEdits === 0 && r.value instanceof NoNextEditReason.NoSuggestions // only retry if there was no error, cancellation, etc. ) { - return yield* this.doGetNextEditsWithCursorJump(request, modelServiceConfig, editWindow, promptPieces, delaySession, parentTracer, logContext, cancellationToken, telemetryBuilder, opts.retryState); + return yield* this.doGetNextEditsWithCursorJump(request, editStreamCtx, delaySession, tracing, cancellationToken, retryState); } return r.value; @@ -664,31 +670,49 @@ export class XtabProvider implements IStatelessNextEditProvider { private async *streamEdits( request: StatelessNextEditRequest, - endpoint: IChatEndpoint, - modelServiceConfig: xtabPromptOptions.ModelConfiguration, - messages: Raw.ChatMessage[], - clippedTaggedCurrentDoc: ClippedDocument, - editWindow: OffsetRange, - editWindowLines: string[], - cursorOriginalLinesOffset: number, - editWindowLineRange: OffsetRange, - promptPieces: PromptPieces, - prediction: Prediction | undefined, - opts: { - responseFormat: xtabPromptOptions.ResponseFormat; - shouldRemoveCursorTagFromResponse: boolean; - retryState: RetryState.t; - aggressivenessLevel: xtabPromptOptions.AggressivenessLevel; - userHappinessScore: number | undefined; - }, + editStreamCtx: EditStreamContext, + responseOpts: ResponseOpts, + fetchMetadata: FetchMetadata, + retryState: RetryState.t, delaySession: DelaySession, - parentTracer: ILogger, - telemetryBuilder: StatelessNextEditTelemetryBuilder, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, - originalEditWindow: OffsetRange | undefined, ): EditStreaming { - const tracer = parentTracer.createSubLogger('streamEdits'); + const tracer = tracing.tracer.createSubLogger('streamEdits'); + + // Create a local cancellation source linked to the caller's token. + // This lets us cancel the fetch immediately on cursor-line divergence + // without reaching into the request's CancellationTokenSource (which + // is owned by nextEditProvider.ts). + const fetchCts = new CancellationTokenSource(cancellationToken); + const fetchCancellationToken = fetchCts.token; + + try { + return yield* this._streamEditsImpl( + request, editStreamCtx, responseOpts, fetchMetadata, retryState, + delaySession, { ...tracing, tracer }, cancellationToken, + fetchCts, fetchCancellationToken, + ); + } finally { + fetchCts.dispose(); + } + } + + private async *_streamEditsImpl( + request: StatelessNextEditRequest, + editStreamCtx: EditStreamContext, + responseOpts: ResponseOpts, + fetchMetadata: FetchMetadata, + retryState: RetryState.t, + delaySession: DelaySession, + tracing: RequestTracingContext, + cancellationToken: CancellationToken, + fetchCts: CancellationTokenSource, + fetchCancellationToken: CancellationToken, + ): EditStreaming { + const { tracer, logContext, telemetry } = tracing; + const { endpoint, messages, clippedTaggedCurrentDoc, editWindowInfo, promptPieces, prediction, originalEditWindow } = editStreamCtx; + const { editWindow, editWindowLines, cursorOriginalLinesOffset, editWindowLineRange } = editWindowInfo; const targetDocument = request.getActiveDocument().id; @@ -708,7 +732,7 @@ export class XtabProvider implements IStatelessNextEditProvider { logContext.setHeaderRequestId(request.headerRequestId); - telemetryBuilder.setFetchStartedAt(); + telemetry.setFetchStartedAt(); logContext.setFetchStartTime(); // we must not await this promise because we want to stream edits as they come in @@ -743,14 +767,14 @@ export class XtabProvider implements IStatelessNextEditProvider { }, useFetcher, customMetadata: { - aggressivenessLevel: opts.aggressivenessLevel, - userHappinessScore: opts.userHappinessScore, + aggressivenessLevel: fetchMetadata.aggressivenessLevel, + userHappinessScore: fetchMetadata.userHappinessScore, }, }, - cancellationToken, + fetchCancellationToken, ); - telemetryBuilder.setResponse(fetchResultPromise.then((response) => ({ response, ttft }))); + telemetry.setResponse(fetchResultPromise.then((response) => ({ response, ttft }))); logContext.setFullResponse(fetchResultPromise.then((response) => response.type === ChatFetchResponseType.Success ? response.value : undefined)); const fetchRes = await Promise.race([firstTokenReceived.p, fetchResultPromise]); @@ -759,7 +783,7 @@ export class XtabProvider implements IStatelessNextEditProvider { !this.forceUseDefaultModel // if we haven't already forced using the default model; otherwise, this could cause an infinite loop ) { this.forceUseDefaultModel = true; - return yield* this.doGetNextEdit(request, delaySession, tracer, logContext, cancellationToken, telemetryBuilder, opts.retryState); // use the same retry state + return yield* this.doGetNextEdit(request, delaySession, tracing, cancellationToken, retryState); // use the same retry state } // diff-patch based model returns no choices if it has no edits to suggest if (fetchRes.type === ChatFetchResponseType.Unknown && fetchRes.reason === RESPONSE_CONTAINED_NO_CHOICES) { @@ -800,22 +824,22 @@ export class XtabProvider implements IStatelessNextEditProvider { const trace = `Line ${i++} emitted with latency ${fetchRequestStopWatch.elapsed()} ms`; tracer.trace(trace); - yield opts.shouldRemoveCursorTagFromResponse + yield responseOpts.shouldRemoveCursorTagFromResponse ? v.replaceAll(PromptTags.CURSOR, '') : v; } })(); - const isFromCursorJump = opts.retryState instanceof RetryState.Retrying && opts.retryState.reason === 'cursorJump'; + const isFromCursorJump = retryState instanceof RetryState.Retrying && retryState.reason === 'cursorJump'; let cleanedLinesStream: AsyncIterable; - if (opts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowOnly) { + if (responseOpts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowOnly) { cleanedLinesStream = linesStream; - } else if (opts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowWithEditIntent || - opts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowWithEditIntentShort) { + } else if (responseOpts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowWithEditIntent || + responseOpts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowWithEditIntentShort) { // Determine parse mode based on response format - const parseMode = opts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowWithEditIntentShort + const parseMode = responseOpts.responseFormat === xtabPromptOptions.ResponseFormat.EditWindowWithEditIntentShort ? EditIntentParseMode.ShortName : EditIntentParseMode.Tags; @@ -823,11 +847,11 @@ export class XtabProvider implements IStatelessNextEditProvider { const { editIntent, remainingLinesStream, parseError } = await parseEditIntentFromStream(linesStream, tracer, parseMode); // Log the edit intent for telemetry - telemetryBuilder.setEditIntent(editIntent); + telemetry.setEditIntent(editIntent); // Log parse errors for telemetry - this helps detect malformed model output during flights if (parseError) { - telemetryBuilder.setEditIntentParseError(parseError); + telemetry.setEditIntentParseError(parseError); } // Check if we should show this edit based on intent and aggressiveness @@ -837,7 +861,7 @@ export class XtabProvider implements IStatelessNextEditProvider { } cleanedLinesStream = remainingLinesStream; - } else if (opts.responseFormat === xtabPromptOptions.ResponseFormat.CustomDiffPatch) { + } else if (responseOpts.responseFormat === xtabPromptOptions.ResponseFormat.CustomDiffPatch) { const activeDoc = request.getActiveDocument(); const currentDocument = promptPieces.currentDocument; const lastLine = currentDocument.lines[clippedTaggedCurrentDoc.keptRange.endExclusive - 1]; @@ -852,7 +876,7 @@ export class XtabProvider implements IStatelessNextEditProvider { tracer, () => chatResponseFailure ? mapChatFetcherErrorToNoNextEditReason(chatResponseFailure) : undefined, ); - } else if (opts.responseFormat === xtabPromptOptions.ResponseFormat.UnifiedWithXml) { + } else if (responseOpts.responseFormat === xtabPromptOptions.ResponseFormat.UnifiedWithXml) { const linesIter = linesStream[Symbol.asyncIterator](); const firstLine = await linesIter.next(); @@ -867,7 +891,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const trimmedLines = firstLine.value.trim(); if (trimmedLines === ResponseTags.NO_CHANGE.start) { - return yield* this.doGetNextEditsWithCursorJump(request, modelServiceConfig, editWindow, promptPieces, delaySession, tracer, logContext, cancellationToken, telemetryBuilder, opts.retryState); + return yield* this.doGetNextEditsWithCursorJump(request, editStreamCtx, delaySession, tracing, cancellationToken, retryState); } if (trimmedLines === ResponseTags.INSERT.start) { @@ -922,10 +946,10 @@ export class XtabProvider implements IStatelessNextEditProvider { } else { return new NoNextEditReason.Unexpected(new Error(`unexpected tag ${trimmedLines}`)); } - } else if (opts.responseFormat === xtabPromptOptions.ResponseFormat.CodeBlock) { + } else if (responseOpts.responseFormat === xtabPromptOptions.ResponseFormat.CodeBlock) { cleanedLinesStream = linesWithBackticksRemoved(linesStream); } else { - assertNever(opts.responseFormat); + assertNever(responseOpts.responseFormat); } const diffOptions: ResponseProcessor.DiffParams = { @@ -936,10 +960,54 @@ export class XtabProvider implements IStatelessNextEditProvider { tracer.trace(`starting to diff stream against edit window lines with latency ${fetchRequestStopWatch.elapsed()} ms`); + // Wrap the line stream to detect early cursor-line divergence. + // If the user has typed at the cursor since the request started and the cursor line + // in the model's response doesn't match what the user currently has, the response + // is stale and we can cancel early instead of waiting for the full response. + // + // We check compatibility using `isModelCursorLineCompatible`: the user's + // cursor-line change must be contained within the model's cursor-line change range + // and match via the helper's `startsWith` / auto-close subsequence rules. + const earlyCursorLineDivergenceCancellation = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, this.expService); + let cursorLineDiverged = false; + const divergenceCheckedStream: AsyncIterable = earlyCursorLineDivergenceCancellation + ? (async function* () { + let lineIdx = 0; + for await (const line of cleanedLinesStream) { + if (lineIdx === cursorOriginalLinesOffset) { + const intermediateEdit = request.intermediateUserEdit; + if (intermediateEdit && !intermediateEdit.isEmpty()) { + const cursorDocLineIdx = editWindowLineRange.start + cursorOriginalLinesOffset; + const currentCursorLine = getCurrentCursorLine(request.documentBeforeEdits.getTransformer(), cursorDocLineIdx, intermediateEdit); + if (currentCursorLine !== undefined) { + const originalCursorLine = editWindowLines[cursorOriginalLinesOffset]; + if (currentCursorLine !== originalCursorLine // user changed the cursor line + && !isModelCursorLineCompatible(originalCursorLine, currentCursorLine, line) // model's cursor line isn't compatible with user's typing + ) { + cursorLineDiverged = true; + tracer.trace(`Cursor line DIVERGED: model="${line}" current="${currentCursorLine}"`); + // Cancel our local fetch token so the HTTP request is + // aborted immediately. We own this token, so this is safe. + fetchCts.cancel(); + return; + } + } + } + } + yield line; + lineIdx++; + } + })() + : cleanedLinesStream; + let i = 0; let hasBeenDelayed = false; try { - for await (const edit of ResponseProcessor.diff(editWindowLines, cleanedLinesStream, cursorOriginalLinesOffset, diffOptions)) { + for await (const edit of ResponseProcessor.diff(editWindowLines, divergenceCheckedStream, cursorOriginalLinesOffset, diffOptions)) { + + if (cursorLineDiverged) { + break; + } tracer.trace(`ResponseProcessor streamed edit #${i} with latency ${fetchRequestStopWatch.elapsed()} ms`); @@ -984,7 +1052,7 @@ export class XtabProvider implements IStatelessNextEditProvider { if (!hasBeenDelayed) { // delay only the first one hasBeenDelayed = true; - const artificialDelay = this.determineArtificialDelayMs(delaySession, tracer, telemetryBuilder); + const artificialDelay = this.determineArtificialDelayMs(delaySession, tracer, telemetry); if (artificialDelay) { await timeout(artificialDelay); tracer.trace(`Artificial delay of ${artificialDelay} ms completed`); @@ -999,6 +1067,10 @@ export class XtabProvider implements IStatelessNextEditProvider { } } + if (cursorLineDiverged) { + return new NoNextEditReason.GotCancelled('cursorLineDiverged'); + } + if (chatResponseFailure) { return mapChatFetcherErrorToNoNextEditReason(chatResponseFailure); } @@ -1014,20 +1086,18 @@ export class XtabProvider implements IStatelessNextEditProvider { private async *doGetNextEditsWithCursorJump( request: StatelessNextEditRequest, - modelConfig: xtabPromptOptions.ModelConfiguration, - editWindow: OffsetRange, - promptPieces: PromptPieces, + editStreamCtx: EditStreamContext, delaySession: DelaySession, - tracer: ILogger, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, - telemetryBuilder: StatelessNextEditTelemetryBuilder, retryState: RetryState.t, ): EditStreaming { + const { tracer, telemetry } = tracing; + const { editWindowInfo: { editWindow }, modelServiceConfig, promptPieces } = editStreamCtx; const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, editWindow); - const nextCursorLinePrediction = this.nextCursorPredictor.determineEnablement(modelConfig.supportsNextCursorLinePrediction); + const nextCursorLinePrediction = this.nextCursorPredictor.determineEnablement(modelServiceConfig.supportsNextCursorLinePrediction); if (nextCursorLinePrediction === undefined || retryState instanceof RetryState.Retrying) { return noSuggestions; @@ -1038,7 +1108,7 @@ export class XtabProvider implements IStatelessNextEditProvider { return new NoNextEditReason.GotCancelled('beforeNextCursorPredictionFetchUserTyped'); } - const nextCursorLineR = await this.nextCursorPredictor.predictNextCursorPosition(promptPieces, tracer, telemetryBuilder, cancellationToken); + const nextCursorLineR = await this.nextCursorPredictor.predictNextCursorPosition(promptPieces, tracer, telemetry, cancellationToken); if (cancellationToken.isCancellationRequested) { return new NoNextEditReason.GotCancelled('afterNextCursorPredictionFetch'); @@ -1051,33 +1121,33 @@ export class XtabProvider implements IStatelessNextEditProvider { if (nextCursorLineR.isError()) { tracer.trace(`Predicted next cursor line error: ${nextCursorLineR.err.message}`); - telemetryBuilder.setNextCursorLineError(nextCursorLineR.err.message); + telemetry.setNextCursorLineError(nextCursorLineR.err.message); return noSuggestions; } const prediction: CursorJumpPrediction = nextCursorLineR.val; if (prediction.kind === 'differentFile') { - return yield* this.handleCrossFilePrediction(prediction, nextCursorLinePrediction, request, editWindow, promptPieces, delaySession, tracer, logContext, cancellationToken, telemetryBuilder); + return yield* this.handleCrossFilePrediction(prediction, nextCursorLinePrediction, request, editStreamCtx, delaySession, tracing, cancellationToken); } const nextCursorLineZeroBased = prediction.lineNumber; const lineDistanceFromCursorLine = nextCursorLineZeroBased - promptPieces.currentDocument.cursorLineOffset; - telemetryBuilder.setNextCursorLineDistance(lineDistanceFromCursorLine); - telemetryBuilder.setNextCursorIsCrossFile(false); + telemetry.setNextCursorLineDistance(lineDistanceFromCursorLine); + telemetry.setNextCursorIsCrossFile(false); tracer.trace(`Predicted next cursor line: ${nextCursorLineZeroBased}`); if (nextCursorLineZeroBased >= promptPieces.currentDocument.lines.length) { // >= because the line index is zero-based tracer.trace(`Predicted next cursor line error: exceedsDocumentLines`); - telemetryBuilder.setNextCursorLineError('exceedsDocumentLines'); + telemetry.setNextCursorLineError('exceedsDocumentLines'); return noSuggestions; } if (promptPieces.editWindowLinesRange.contains(nextCursorLineZeroBased)) { tracer.trace(`Predicted next cursor line error: withinEditWindow`); - telemetryBuilder.setNextCursorLineError('withinEditWindow'); + telemetry.setNextCursorLineError('withinEditWindow'); return noSuggestions; } @@ -1097,10 +1167,8 @@ export class XtabProvider implements IStatelessNextEditProvider { request, new Range(nextCursorLineOneBased, nextCursorColumn, nextCursorLineOneBased, nextCursorColumn), delaySession, - tracer, - logContext, + tracing, cancellationToken, - telemetryBuilder, new RetryState.Retrying('cursorJump'), editWindow, // Pass the original edit window (before cursor jump) so the cache can serve the edit from both locations ); @@ -1116,18 +1184,18 @@ export class XtabProvider implements IStatelessNextEditProvider { prediction: Extract, nextCursorLinePrediction: NextCursorLinePrediction, request: StatelessNextEditRequest, - editWindow: OffsetRange, - promptPieces: PromptPieces, + editStreamCtx: EditStreamContext, delaySession: DelaySession, - tracer: ILogger, - logContext: InlineEditRequestLogContext, + tracing: RequestTracingContext, cancellationToken: CancellationToken, - telemetryBuilder: StatelessNextEditTelemetryBuilder, ): EditStreaming { + const { tracer, telemetry } = tracing; + const { editWindowInfo: { editWindow }, promptPieces } = editStreamCtx; + const workspaceRoot = promptPieces.activeDoc.workspaceRoot; if (!workspaceRoot && !isAbsolute(prediction.filePath)) { tracer.trace('Predicted cross-file cursor jump error: noWorkspaceRoot'); - telemetryBuilder.setNextCursorLineError('crossFile:noWorkspaceRoot'); + telemetry.setNextCursorLineError('crossFile:noWorkspaceRoot'); return new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, editWindow); } @@ -1138,7 +1206,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const nextCursorLineOneBased = prediction.lineNumber + 1; const nextCursorPosition = new Position(nextCursorLineOneBased, 1); - telemetryBuilder.setNextCursorIsCrossFile(true); + telemetry.setNextCursorIsCrossFile(true); tracer.trace(`Predicted cross-file cursor jump: ${prediction.filePath}:${prediction.lineNumber}`); switch (nextCursorLinePrediction) { @@ -1151,7 +1219,7 @@ export class XtabProvider implements IStatelessNextEditProvider { targetTextDoc = await this.workspaceService.openTextDocument(targetUri); } catch (err) { tracer.trace(`Failed to open target file for cross-file edit: ${ErrorUtils.fromUnknown(err).message}`); - telemetryBuilder.setNextCursorLineError('crossFile:failedToOpenFile'); + telemetry.setNextCursorLineError('crossFile:failedToOpenFile'); return new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, editWindow, nextCursorPosition, targetDocumentId); } @@ -1195,10 +1263,8 @@ export class XtabProvider implements IStatelessNextEditProvider { syntheticRequest, new Range(nextCursorLineOneBased, 1, nextCursorLineOneBased, 1), delaySession, - tracer, - logContext, + tracing, cancellationToken, - telemetryBuilder, new RetryState.Retrying('cursorJump'), editWindow, ); diff --git a/extensions/copilot/src/extension/xtab/test/node/cursorLineDivergence.spec.ts b/extensions/copilot/src/extension/xtab/test/node/cursorLineDivergence.spec.ts new file mode 100644 index 0000000000000..9b9930bfa6147 --- /dev/null +++ b/extensions/copilot/src/extension/xtab/test/node/cursorLineDivergence.spec.ts @@ -0,0 +1,587 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { StringEdit, StringReplacement } from '../../../../util/vs/editor/common/core/edits/stringEdit'; +import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange'; +import { PositionOffsetTransformer } from '../../../../util/vs/editor/common/core/text/positionToOffset'; +import { getCurrentCursorLine, isModelCursorLineCompatible } from '../../node/cursorLineDivergence'; + +// ============================================================================ +// isModelCursorLineCompatible — unit tests +// ============================================================================ + +describe('isModelCursorLineCompatible', () => { + + // ── Visual helper ────────────────────────────────────────────────────── + // + // Each test case is written as a table row: + // original cursor line → what user typed → what model produced + // expected: compatible / incompatible + // + // "compatible" means the model's output is consistent with the user's + // typing and the stream should continue. "incompatible" means the model + // diverged and the stream should be cancelled early. + // ──────────────────────────────────────────────────────────────────────── + + describe('user typed text that the model also predicted', () => { + + it('user typed one char that starts the model completion', () => { + // original: `function fi` + // user typed `b` → current: `function fib` + // model: `function fibonacci(n: number): number` + expect(isModelCursorLineCompatible( + 'function fi', + 'function fib', + 'function fibonacci(n: number): number', + )).toBe(true); + }); + + it('user typed several chars that the model also predicted', () => { + // original: `const x` + // user typed ` = 4` → current: `const x = 4` + // model: `const x = 42;` + expect(isModelCursorLineCompatible( + 'const x', + 'const x = 4', + 'const x = 42;', + )).toBe(true); + }); + + it('user typed the exact text the model produced', () => { + // original: `return` + // user typed ` 0;` → current: `return 0;` + // model: `return 0;` + expect(isModelCursorLineCompatible( + 'return', + 'return 0;', + 'return 0;', + )).toBe(true); + }); + }); + + describe('user typed text that diverges from the model', () => { + + it('user typed a different character', () => { + // original: `function fi` + // user typed `x` → current: `function fix` + // model: `function fibonacci(n: number): number` + expect(isModelCursorLineCompatible( + 'function fi', + 'function fix', + 'function fibonacci(n: number): number', + )).toBe(false); + }); + + it('user typed a completely different word', () => { + // original: `const ` + // user typed `bar` → current: `const bar` + // model: `const foo = 1;` + expect(isModelCursorLineCompatible( + 'const ', + 'const bar', + 'const foo = 1;', + )).toBe(false); + }); + + it('user typed text that appears later but not at the right position', () => { + // original: `ab` + // user typed `z` → current: `abz` + // model: `abcz` + // modelNewText = "cz", userTypedText = "z" + // → "cz" does not start with "z" → cancel + expect(isModelCursorLineCompatible( + 'ab', + 'abz', + 'abcz', + )).toBe(false); + }); + }); + + describe('user typed in the middle of the line', () => { + + it('user inserted text at cursor mid-line, model also inserts at same spot', () => { + // original: `foo()` (cursor between the parens) + // user typed `x` → current: `foo(x)` + // model: `foo(x, y)` + expect(isModelCursorLineCompatible( + 'foo()', + 'foo(x)', + 'foo(x, y)', + )).toBe(true); + }); + + it('user inserted text mid-line, model changed something else', () => { + // original: `foo()` + // user typed `x` → current: `foo(x)` + // model: `bar(a, b)` + expect(isModelCursorLineCompatible( + 'foo()', + 'foo(x)', + 'bar(a, b)', + )).toBe(false); + }); + }); + + describe('model did not change the cursor line', () => { + + it('user typed but model kept cursor line identical — incompatible', () => { + // original: `const x = 1;` + // user typed `2` → current: `const x = 12;` + // model: `const x = 1;` (no change) + // + // The model's edit range is empty (no diff), so the user's edit + // range cannot be "within" it → incompatible. + expect(isModelCursorLineCompatible( + 'const x = 1;', + 'const x = 12;', + 'const x = 1;', + )).toBe(false); + }); + }); + + describe('edge cases', () => { + + it('empty original line, user typed, model also added text', () => { + // original: `` + // user typed `f` → current: `f` + // model: `function foo() {` + expect(isModelCursorLineCompatible( + '', + 'f', + 'function foo() {', + )).toBe(true); + }); + + it('empty original line, user typed char not in model text', () => { + // original: `` + // user typed `z` → current: `z` + // model: `let x = 1;` + // → "z" is not found in "let x = 1;" → incompatible + expect(isModelCursorLineCompatible( + '', + 'z', + 'let x = 1;', + )).toBe(false); + }); + + it('user deleted text from the cursor line', () => { + // original: `foobar` + // user deleted `bar` → current: `foo` + // model: `foobaz` + expect(isModelCursorLineCompatible( + 'foobar', + 'foo', + 'foobaz', + )).toBe(false); + }); + + it('user replaced text at cursor, model has same replacement', () => { + // original: `hello world` + // user replaced `world` → current: `hello earth` + // model: `hello earth!` (same replacement + extra) + expect(isModelCursorLineCompatible( + 'hello world', + 'hello earth', + 'hello earth!', + )).toBe(true); + }); + + it('all three lines identical — trivially compatible', () => { + expect(isModelCursorLineCompatible( + 'no change', + 'no change', + 'no change', + )).toBe(true); + }); + }); + + // ── Adversarial scenarios ────────────────────────────────────────────── + // These document known limitations and intentional false-positive / + // false-negative behaviour so that regressions are caught if the + // implementation changes. + // ──────────────────────────────────────────────────────────────────────── + + describe('auto-close pairs', () => { + + it('user typed ( which auto-closed to () — model fills parens', () => { + // original: `foo` + // user typed `(`, editor auto-closed → current: `foo()` + // model: `foo(x, y)` + // → userTypedText="()" is an auto-close pair + // → subsequence check: "(" at 0, ")" at 5 in "(x, y)" → compatible + expect(isModelCursorLineCompatible( + 'foo', + 'foo()', + 'foo(x, y)', + )).toBe(true); + }); + + it('user typed { which auto-closed to {} — model fills braces', () => { + // original: `if (x) ` + // → current: `if (x) {}` + // model: `if (x) { return 1; }` + expect(isModelCursorLineCompatible( + 'if (x) ', + 'if (x) {}', + 'if (x) { return 1; }', + )).toBe(true); + }); + + it('user typed [ which auto-closed to []', () => { + expect(isModelCursorLineCompatible( + 'arr', + 'arr[]', + 'arr[0]', + )).toBe(true); + }); + + it('user typed " which auto-closed to "" — model fills string', () => { + expect(isModelCursorLineCompatible( + 'const s = ', + 'const s = ""', + 'const s = "hello"', + )).toBe(true); + }); + + it('auto-close pair but model has no closing char — incompatible', () => { + // user typed `(` auto-closed to `()`, model has `(x, y` with no `)` + expect(isModelCursorLineCompatible( + 'foo', + 'foo()', + 'foo(x, y', + )).toBe(false); + }); + }); + + describe('known limitations — non-overlapping edits on the same line', () => { + + it('user appended ; at end, model changed identifier at start (FALSE POSITIVE: cancels)', () => { + // original: `const x = foo()` + // user typed `;` at end → current: `const x = foo();` + // model: `const y = foo()` (changed x→y) + // → The edit ranges don't overlap: user at col 15, model at col 6-7. + // The range check rejects because user's edit position (15) is + // outside the model's edit range (6–7). + // + // This is a false positive: the model's rename of `x`→`y` is independent + // of the user's `;`, but we cancel because our range-containment check is + // overly strict. The full rebase system handles disjoint edits correctly. + expect(isModelCursorLineCompatible( + 'const x = foo()', + 'const x = foo();', + 'const y = foo()', + )).toBe(false); // false positive — ideally should be true + }); + }); + + describe('prefix match and coincidental matches', () => { + + it('user typed char that starts model text — compatible', () => { + // original: `let ` + // user typed `a` → current: `let a` + // model: `let apple = 1;` + // → modelNewText = "apple = 1;", starts with "a" → compatible + expect(isModelCursorLineCompatible( + 'let ', + 'let a', + 'let apple = 1;', + )).toBe(true); + }); + + it('user typed char that does NOT start model text — cancel', () => { + // original: `let ` + // user typed `a` → current: `let a` + // model: `let banana = 1;` + // → modelNewText = "banana = 1;", does not start with "a" + // → Even though "a" appears inside "banana", it's coincidental → cancel + expect(isModelCursorLineCompatible( + 'let ', + 'let a', + 'let banana = 1;', + )).toBe(false); + }); + + it('user typed text that does not start model text — cancel', () => { + // original: `x` + // user typed `y` → current: `xy` + // model: `x01234567890y` ("y" appears far in, not at start) + expect(isModelCursorLineCompatible( + 'x', + 'xy', + 'x01234567890y', + )).toBe(false); + }); + + it('user typed text at non-zero offset — cancel', () => { + // original: `prefix` + // user typed `ABCDEF` → current: `prefixABCDEF` + // model: `prefix_ABCDEF_suffix` + // → modelNewText = "_ABCDEF_suffix", does not start with "ABCDEF" → cancel + expect(isModelCursorLineCompatible( + 'prefix', + 'prefixABCDEF', + 'prefix_ABCDEF_suffix', + )).toBe(false); + }); + + it('user typed text at position 0 — compatible', () => { + // original: `prefix` + // user typed `ABCDEF` → current: `prefixABCDEF` + // model: `prefixABCDEF_and_more` + // → modelNewText = "ABCDEF_and_more", starts with "ABCDEF" → compatible + expect(isModelCursorLineCompatible( + 'prefix', + 'prefixABCDEF', + 'prefixABCDEF_and_more', + )).toBe(true); + }); + }); + + describe('user deleted text', () => { + + it('user deleted suffix, model replaced same suffix differently', () => { + // original: `foobar` + // user deleted `bar` → current: `foo` + // model: `foobaz` + // → User's edit starts at offset 3, model's edit starts at offset 5. + // user's prefixLen (3) < model's prefixLen (5) → range check fails → cancels. + // Correct: user deleted text, model wants different text in same area. + expect(isModelCursorLineCompatible( + 'foobar', + 'foo', + 'foobaz', + )).toBe(false); + }); + + it('user deleted text, model predicted the same deletion', () => { + // original: `console.log(x);` + // user deleted `console.log(` → current: `x);` + // model: `x);` (same result) + expect(isModelCursorLineCompatible( + 'console.log(x);', + 'x);', + 'x);', + )).toBe(true); + }); + }); + + describe('whitespace edits', () => { + + it('user added indentation, model suggests code at different indent — compatible (short text)', () => { + // original: `return 1;` + // user typed ` ` (2 chars) → current: ` return 1;` + // model: ` return value;` + // → " " found at position 0 in model new text → compatible + expect(isModelCursorLineCompatible( + 'return 1;', + ' return 1;', + ' return value;', + )).toBe(true); + }); + + it('user added indentation, model has same indentation and more changes', () => { + // original: `return 1;` + // user typed ` ` → current: ` return 1;` + // model: ` return 42;` + // → " " found at position 0 → compatible + expect(isModelCursorLineCompatible( + 'return 1;', + ' return 1;', + ' return 42;', + )).toBe(true); + }); + }); + + describe('repeated characters and prefix/suffix ambiguity', () => { + + it('repeated chars at divergence point', () => { + // original: `aaa` + // user typed `b` → current: `aaab` + // model: `aaac` + // → prefixLen=3, modelPrefixLen=3, userTypedText="b", modelNewText="c" + // → "c".startsWith("b") → false → cancel + expect(isModelCursorLineCompatible( + 'aaa', + 'aaab', + 'aaac', + )).toBe(false); + }); + + it('repeated chars — user typed same char as existing', () => { + // original: `aaa` + // user typed `a` → current: `aaaa` + // model: `aaaab` + // → prefixLen=3, suffixLen=0, userTypedText="a", originalReplacedText="" + // → modelPrefixLen=3, modelNewText="ab" + // → "ab".startsWith("a") → true → compatible + expect(isModelCursorLineCompatible( + 'aaa', + 'aaaa', + 'aaaab', + )).toBe(true); + }); + }); + + describe('model output edge cases', () => { + + it('model produces empty line, user typed text', () => { + // original: `foo` + // user typed `b` → current: `foob` + // model: `` (empty) + expect(isModelCursorLineCompatible( + 'foo', + 'foob', + '', + )).toBe(false); + }); + + it('model produces shorter line than original, user typed at end', () => { + // original: `const x = 1;` + // user typed `!` → current: `const x = 1;!` + // model: `const x;` (removed ` = 1`) + // → user edit at col 13, model edit at col 7–12 → ranges don't overlap → cancel + expect(isModelCursorLineCompatible( + 'const x = 1;', + 'const x = 1;!', + 'const x;', + )).toBe(false); + }); + }); +}); + +// ============================================================================ +// getCurrentCursorLine — unit tests +// ============================================================================ + +describe('getCurrentCursorLine', () => { + + function t(doc: string): PositionOffsetTransformer { + return new PositionOffsetTransformer(doc); + } + + /** + * Helper: builds a StringEdit that inserts `text` at `offset` in the + * original document (a pure insertion, no deletion). + */ + function insertAt(offset: number, text: string): StringEdit { + return StringEdit.single(new StringReplacement(OffsetRange.emptyAt(offset), text)); + } + + /** + * Helper: builds a StringEdit that deletes `length` characters starting at + * `offset` in the original document. + */ + function deleteAt(offset: number, length: number): StringEdit { + return StringEdit.single(new StringReplacement(new OffsetRange(offset, offset + length), '')); + } + + describe('no line-shifting edits', () => { + + it('returns the cursor line when the edit only modifies the cursor line', () => { + // Doc: "aaa\nbbb\nccc" (cursor on line 1 = "bbb") + // User typed "X" at offset 4 (start of "bbb") → "aaa\nXbbb\nccc" + const doc = 'aaa\nbbb\nccc'; + const edit = insertAt(4, 'X'); + + expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('Xbbb'); + }); + + it('returns the unmodified cursor line when the edit is empty', () => { + const doc = 'aaa\nbbb\nccc'; + + expect(getCurrentCursorLine(t(doc), 1, StringEdit.empty)).toBe('bbb'); + }); + }); + + describe('line inserted above cursor', () => { + + it('returns the correct cursor line after a newline is inserted above', () => { + // Doc: "aaa\nbbb\nccc" (cursor on line 2 = "ccc") + // User inserts "\nNEW" at offset 3 (end of "aaa") → "aaa\nNEW\nbbb\nccc" + // Cursor line 2 in the original ("ccc") is now at line 3. + // Without the fix, naively reading line 2 would give "bbb". + const doc = 'aaa\nbbb\nccc'; + const edit = insertAt(3, '\nNEW'); + + expect(getCurrentCursorLine(t(doc), 2, edit)).toBe('ccc'); + }); + + it('handles multiple lines inserted above the cursor', () => { + // Doc: "L0\nL1\nL2" (cursor on line 2 = "L2") + // Insert two new lines after L0: "\nA\nB" + // New doc: "L0\nA\nB\nL1\nL2" + // Cursor line should still resolve to "L2" + const doc = 'L0\nL1\nL2'; + const edit = insertAt(2, '\nA\nB'); + + expect(getCurrentCursorLine(t(doc), 2, edit)).toBe('L2'); + }); + }); + + describe('line deleted above cursor', () => { + + it('returns the correct cursor line after a line above is deleted', () => { + // Doc: "aaa\nbbb\nccc\nddd" (cursor on line 3 = "ddd") + // User deletes "bbb\n" (offsets 4..8) → "aaa\nccc\nddd" + // Cursor line 3 ("ddd") is now at line 2. + const doc = 'aaa\nbbb\nccc\nddd'; + const edit = deleteAt(4, 4); // delete "bbb\n" + + expect(getCurrentCursorLine(t(doc), 3, edit)).toBe('ddd'); + }); + }); + + describe('edit on a line below cursor', () => { + + it('does not affect the cursor line', () => { + // Doc: "aaa\nbbb\nccc" (cursor on line 0 = "aaa") + // User edits line 2 → "aaa\nbbb\nCCC" + const doc = 'aaa\nbbb\nccc'; + const edit = StringEdit.single(new StringReplacement(new OffsetRange(8, 11), 'CCC')); + + expect(getCurrentCursorLine(t(doc), 0, edit)).toBe('aaa'); + }); + }); + + describe('edge cases', () => { + + it('cursor on the first line', () => { + const doc = 'hello\nworld'; + const edit = insertAt(0, 'XY'); + + expect(getCurrentCursorLine(t(doc), 0, edit)).toBe('XYhello'); + }); + + it('cursor on the last line', () => { + const doc = 'aaa\nbbb'; + const edit = insertAt(3, '\nNEW'); + + expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('bbb'); + }); + + it('returns undefined for out-of-bounds line index', () => { + const doc = 'aaa\nbbb'; + + expect(getCurrentCursorLine(t(doc), 5, StringEdit.empty)).toBeUndefined(); + }); + + it('returns undefined when cursor line start is inside a replacement', () => { + // Doc: "aaa\nbbb\nccc" (cursor on line 1, starts at offset 4) + // Edit replaces offsets 2..6 (spans across line boundary including cursor line start) + const doc = 'aaa\nbbb\nccc'; + const edit = StringEdit.single(new StringReplacement(new OffsetRange(2, 6), 'Z')); + + expect(getCurrentCursorLine(t(doc), 1, edit)).toBeUndefined(); + }); + + it('single-line document, cursor on line 0', () => { + const doc = 'hello'; + const edit = insertAt(5, ' world'); + + expect(getCurrentCursorLine(t(doc), 0, edit)).toBe('hello world'); + }); + }); +}); diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index fb5c22837967d..a8c761492ecf0 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -1855,6 +1855,172 @@ describe('XtabProvider integration', () => { // Cursor prediction must not have been issued — only the main LLM call was made expect(streamingFetcher.callCount).toBe(1); }); + + it('same-file cursor jump with edit: retry yields edits with isFromCursorJump', async () => { + const provider = createProvider(); + await configService.setConfig(ConfigKey.InlineEditsNextCursorPredictionEnabled, true); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsNextCursorPredictionModelName, 'test-model'); + + // Document with 30 lines; cursor near the top. + // Cursor is after the inserted '\n' at the end of line 4 → cursorLineOffset=5. + // Edit window: [max(0,5-2), min(30,5+5+1)) = [3, 11) → lines 3..10. + const lines = Array.from({ length: 30 }, (_, i) => `line ${i} content`); + const cursorOffset = lines.slice(0, 5).join('\n').length; + const request = createRequestWithEdit(lines, { + insertionOffset: cursorOffset, + // insertedText defaults to afterText[cursorOffset] = '\n', so documentAfterEdits matches lines + }); + + // 1st call (main LLM): stream back edit-window lines unchanged → no diff + const mainEditWindowLines = lines.slice(3, 11); + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-main', + serverRequestId: 'srv-main', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: mainEditWindowLines.join('\n'), + resolvedModel: 'test-model', + }); + + // 2nd call (cursor prediction): return line 20 (outside the edit window) + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-cursor', + serverRequestId: 'srv-cursor', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: '20', + resolvedModel: 'test-model', + }); + + // 3rd call (retry at predicted cursor line 20): + // Retry edit window: [max(0,20-2), min(30,20+5+1)) = [18, 26) → lines 18..25. + // Return modified edit-window lines. + const retryEditWindowLines = lines.slice(18, 26).map((l, i) => i === 2 ? 'MODIFIED line 20 content' : l); + streamingFetcher.setStreamingLines(retryEditWindowLines); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + // Edits should have been yielded from the retry + expect(edits.length).toBeGreaterThan(0); + // All yielded edits should be marked as from cursor jump + for (const edit of edits) { + expect(edit.v.isFromCursorJump).toBe(true); + } + // All yielded edits should have an originalWindow (the pre-jump edit window) + for (const edit of edits) { + expect(edit.v.originalWindow).toBeDefined(); + } + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + // 3 total calls: main LLM + cursor prediction + retry + expect(streamingFetcher.callCount).toBe(3); + }); + + it('cursor jump retry does not double-retry when second call also yields no edits', async () => { + const provider = createProvider(); + await configService.setConfig(ConfigKey.InlineEditsNextCursorPredictionEnabled, true); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsNextCursorPredictionModelName, 'test-model'); + + const lines = Array.from({ length: 30 }, (_, i) => `line ${i} content`); + const cursorOffset = lines.slice(0, 5).join('\n').length; + const request = createRequestWithEdit(lines, { + insertionOffset: cursorOffset, + }); + + // 1st call (main LLM): edit-window lines unchanged → no edits → cursor jump + const mainEditWindowLines = lines.slice(3, 11); + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-main', + serverRequestId: 'srv-main', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: mainEditWindowLines.join('\n'), + resolvedModel: 'test-model', + }); + + // 2nd call (cursor prediction): return line 20 + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.Success, + requestId: 'req-cursor', + serverRequestId: 'srv-cursor', + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: 0 } }, + value: '20', + resolvedModel: 'test-model', + }); + + // 3rd call (retry at predicted cursor line 20): edit-window lines unchanged → no edits. + // On the retry, retryState is Retrying so doGetNextEditsWithCursorJump returns + // NoSuggestions immediately (no further recursion). + const retryEditWindowLines = lines.slice(18, 26); + streamingFetcher.setStreamingLines(retryEditWindowLines); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + // Exactly 3 calls: main + cursor prediction + retry (no further retry) + expect(streamingFetcher.callCount).toBe(3); + }); + + it('model fallback retry on NotFound then yields edits on second attempt', async () => { + const provider = createProvider(); + + const lines = ['function foo() {', ' return 1;', '}']; + const request = createRequestWithEdit(lines, { insertionOffset: 5 }); + + // 1st call → NotFound, triggers fallback to default model + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.NotFound, + reason: 'test', + requestId: 'req-1', + serverRequestId: undefined, + }); + + // 2nd call (retry with default model) → success with modification + const modifiedLines = ['function foo() {', ' return 42;', '}']; + streamingFetcher.setStreamingLines(modifiedLines); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + // Should have produced edits from the retry + expect(edits.length).toBeGreaterThan(0); + // Edits are NOT from cursor jump + for (const edit of edits) { + expect(edit.v.isFromCursorJump).toBe(false); + } + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + expect(streamingFetcher.callCount).toBe(2); + }); + + it('model fallback + identical content → NoSuggestions without looping', async () => { + const provider = createProvider(); + await configService.setConfig(ConfigKey.InlineEditsNextCursorPredictionEnabled, false); + + const lines = ['const a = 1;', 'const b = 2;', 'const c = 3;']; + const request = createRequestWithEdit(lines, { insertionOffset: 3 }); + + // 1st call → NotFound + streamingFetcher.enqueueResponse({ + type: ChatFetchResponseType.NotFound, + reason: 'test', + requestId: 'req-1', + serverRequestId: undefined, + }); + + // 2nd call (default model) → identical edit-window content → no edits + // With cursor prediction disabled, should return NoSuggestions directly + streamingFetcher.setStreamingLines(lines); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + // Exactly 2 calls: initial NotFound + retry with default model + expect(streamingFetcher.callCount).toBe(2); + }); }); // ======================================================================== @@ -2137,6 +2303,265 @@ describe('XtabProvider integration', () => { expect(captured.requestOptions?.stream).toBe(true); }); }); + + // ======================================================================== + // Group 10: Cursor-Line Divergence — Early Cancellation + // ======================================================================== + + describe('cursor-line divergence cancellation', () => { + + /** + * Creates a request for divergence tests. + * + * In the real system, `request.documentBeforeEdits` = the current document at + * request creation time (i.e. `documentAfterEdits`), and `intermediateUserEdit` + * tracks changes after that. `createRequestWithEdit` sets + * `request.documentBeforeEdits` to the doc *before* the trigger edit, so we + * construct a request with the intended value here to match reality. + */ + function createDivergenceRequest( + docAtRequestTime: string[], + opts: { insertionOffset: number; insertedText: string }, + ): StatelessNextEditRequest { + const base = createRequestWithEdit(docAtRequestTime, opts); + return new StatelessNextEditRequest( + base.headerRequestId, + base.opportunityId, + new StringText(docAtRequestTime.join('\n')), + base.documents, + base.activeDocumentIdx, + base.xtabEditHistory, + new DeferredPromise>(), + base.expandedEditWindowNLines, + base.isSpeculative, + base.logContext, + base.recordingBookmark, + base.recording, + base.providerRequestStartDateTime, + ); + } + + beforeEach(async () => { + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, true); + }); + + it('cancels when user typed a character that diverges from model output', async () => { + const provider = createProvider(); + + // Request created with document: `function fi` + // User typed `x` after request → document becomes `function fix` + // Model replies `function fibonacci(n: number): number` + // → "x" not in model's new text → cancel + const request = createDivergenceRequest( + ['function fi'], + { insertionOffset: 10, insertedText: 'i' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'x') + ); + + streamingFetcher.setStreamingLines(['function fibonacci(n: number): number']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('cursorLineDiverged'); + }); + + it('does not cancel when user typed a character consistent with model output', async () => { + const provider = createProvider(); + + // Request created with document: `function fi` + // User typed `b` after request → document becomes `function fib` + // Model replies `function fibonacci(n: number): number` + // → "b" is in model's new text → no cancel + const request = createDivergenceRequest( + ['function fi'], + { insertionOffset: 10, insertedText: 'i' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'b') + ); + + // Model output is a superset of user's typing + streamingFetcher.setStreamingLines(['function fibonacci(n: number): number']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + // Should produce an edit (the rest of the completion), not cancel + expect(edits.length).toBeGreaterThan(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + }); + + it('does not cancel when user has not typed since request started', async () => { + const provider = createProvider(); + + const lines = ['function foo() {', ' return 1;', '}']; + const request = createRequestWithEdit(lines, { + insertionOffset: 3, + insertedText: 'c', + }); + + // intermediateUserEdit is empty → user hasn't typed since request started + // The default is StringEdit.empty, so no divergence check should trigger + + // Model responds with a completely different line + streamingFetcher.setStreamingLines(['function bar() {', ' return 2;', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + // Should proceed normally with the edit, not cancel + expect(edits.length).toBeGreaterThan(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + }); + + it('does not cancel when intermediateUserEdit is undefined (consistency check failed)', async () => { + const provider = createProvider(); + + const lines = ['const x = 1;', 'const y = 2;']; + const request = createRequestWithEdit(lines, { + insertionOffset: 3, + insertedText: 'a', + }); + + // undefined means consistency check failed earlier — we should not + // attempt the divergence check + request.intermediateUserEdit = undefined; + + streamingFetcher.setStreamingLines(['completely different', 'content here']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + // Should proceed normally, not cancel via divergence + expect(edits.length).toBeGreaterThan(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.NoSuggestions); + }); + + it('does not cancel the request token (only the internal fetch token)', async () => { + const provider = createProvider(); + + const request = createDivergenceRequest( + ['hello world'], + { insertionOffset: 5, insertedText: ' ' }, + ); + + // User typed 'Z', diverging from model + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'Z') + ); + + streamingFetcher.setStreamingLines(['hello worlQ completely different']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + // The provider should NOT cancel the request's token — it doesn't own it. + // It creates its own internal CancellationTokenSource for the fetch. + expect(request.cancellationTokenSource.token.isCancellationRequested).toBe(false); + // But it should still report the divergence + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('cursorLineDiverged'); + }); + + it('does not false-cancel when user inserted a line above the cursor', async () => { + const provider = createProvider(); + + // Request created with 3-line document: + // line 0: "import foo" + // line 1: "function fi" ← cursor line (line index 1) + // line 2: "}" + // + // After the request, the user inserts a blank line after "import foo", + // shifting the cursor line down. Without mapping the cursor line + // through the edit, the code would read the wrong line ("") at + // index 1 and false-cancel. + // + // The user also typed "b" on the cursor line → "function fib" + // Model responds with a compatible continuation. + const request = createDivergenceRequest( + ['import foo', 'function fi', '}'], + { insertionOffset: 21, insertedText: 'i' }, + ); + + // intermediateUserEdit (in original doc coordinates): + // offset 10 = '\n' after "import foo" → insert extra '\n' (new blank line) + // offset 22 = '\n' after "function fi" → insert 'b' (user typing) + request.intermediateUserEdit = StringEdit.create([ + new StringReplacement(OffsetRange.emptyAt(10), '\n'), + new StringReplacement(OffsetRange.emptyAt(22), 'b'), + ]); + + // Model output: compatible with user's typing ("b" → "bonacci…") + streamingFetcher.setStreamingLines(['import foo', 'function fibonacci(n): number', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + // The key assertion: no false cancellation due to line-shift + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + + it('cancels on divergence when cursor is not on the first line of the edit window', async () => { + const provider = createProvider(); + + // Doc at request time: "const a = 1;\nfunction fi\n}" + // Offsets: "const a = 1;" = 0..11, \n = 12, "function fi" = 13..23, \n = 24, "}" = 25 + // Cursor on line 1 (0-based), insertionOffset 23 = last 'i' of "function fi" + // Edit window: all 3 lines, cursorOriginalLinesOffset = 1 + // + // User typed "x" at offset 24 (end of "function fi") → "function fix" + // Model responds with "function fibonacci(n): number" + // → "x" doesn't match model → cancel + const request = createDivergenceRequest( + ['const a = 1;', 'function fi', '}'], + { insertionOffset: 23, insertedText: 'i' }, + ); + + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(24), 'x') + ); + + streamingFetcher.setStreamingLines(['const a = 1;', 'function fibonacci(n): number', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('cursorLineDiverged'); + }); + + it('does not cancel on compatible typing when cursor is not on the first line', async () => { + const provider = createProvider(); + + // Same setup but user typed "b" → "function fib", compatible with model + const request = createDivergenceRequest( + ['const a = 1;', 'function fi', '}'], + { insertionOffset: 23, insertedText: 'i' }, + ); + + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(24), 'b') + ); + + streamingFetcher.setStreamingLines(['const a = 1;', 'function fibonacci(n): number', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + // Should not be cancelled due to cursor-line divergence + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + }); }); suite('filterOutEditsWithSubstrings', () => { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 7024743aed25a..edcbd5d8d246e 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -813,6 +813,7 @@ export namespace ConfigKey { export const InlineEditsXtabDiffUseRelativePaths = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffUseRelativePaths', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.diffHistory.useRelativePaths); export const InlineEditsXtabNNonSignificantLinesToConverge = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.nNonSignificantLinesToConverge', ConfigType.ExperimentBased, ResponseProcessor.DEFAULT_DIFF_PARAMS.nLinesToConverge); export const InlineEditsXtabNSignificantLinesToConverge = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.nSignificantLinesToConverge', ConfigType.ExperimentBased, ResponseProcessor.DEFAULT_DIFF_PARAMS.nSignificantLinesToConverge); + export const InlineEditsXtabEarlyCursorLineDivergenceCancellation = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.earlyCursorLineDivergenceCancellation', ConfigType.ExperimentBased, false); export const InlineEditsXtabLanguageContextEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.enabled', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.enabled); export const InlineEditsXtabLanguageContextMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.maxTokens); export const InlineEditsXtabMaxMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.maxMergeConflictLines', ConfigType.ExperimentBased, undefined); diff --git a/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts b/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts index 616032b13c282..20c17fdd5e2ea 100644 --- a/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts +++ b/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts @@ -439,9 +439,6 @@ export class CustomInstructionsService extends Disposable implements ICustomInst } public async isExternalInstructionsFile(uri: URI): Promise { - if (uri.scheme === 'vscode-chat-internal') { - return true; - } if (uri.scheme === Schemas.vscodeUserData && uri.path.endsWith(INSTRUCTION_FILE_EXTENSION)) { return true; } @@ -465,8 +462,7 @@ export class CustomInstructionsService extends Disposable implements ICustomInst } public isSkillFile(uri: URI): boolean { - return this._matchInstructionLocationsFromSkills.get()(uri) !== undefined - || this.getChatInternalSkillInfo(uri) !== undefined; + return this._matchInstructionLocationsFromSkills.get()(uri) !== undefined; } public isSkillMdFile(uri: URI): boolean { @@ -474,7 +470,7 @@ export class CustomInstructionsService extends Disposable implements ICustomInst } public getSkillDirectory(uri: URI): URI | undefined { - const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri) || this.getChatInternalSkillInfo(uri); + const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri); if (!skillInfo) { return undefined; } @@ -482,7 +478,7 @@ export class CustomInstructionsService extends Disposable implements ICustomInst } public getSkillName(uri: URI): string | undefined { - const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri) || this.getChatInternalSkillInfo(uri); + const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri); if (!skillInfo) { return undefined; } @@ -490,19 +486,7 @@ export class CustomInstructionsService extends Disposable implements ICustomInst } public getSkillInfo(uri: URI): ISkillInfo | undefined { - return this._matchInstructionLocationsFromSkills.get()(uri) || this.getChatInternalSkillInfo(uri); - } - - private getChatInternalSkillInfo(uri: URI): ISkillInfo | undefined { - if (uri.scheme !== 'vscode-chat-internal') { - return undefined; - } - if (extUriBiasedIgnorePathCase.basename(uri).toLowerCase() !== 'skill.md') { - return undefined; - } - const skillFolderUri = extUriBiasedIgnorePathCase.dirname(uri); - const skillName = extUriBiasedIgnorePathCase.basename(skillFolderUri); - return { skillName, skillFolderUri, storage: SkillStorage.Internal }; + return this._matchInstructionLocationsFromSkills.get()(uri); } } @@ -591,4 +575,4 @@ function xmlContents(text: string, tag: string): string[] { matches.push(match[1].trim()); } return matches; -} \ No newline at end of file +} diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 0e03a3e013a0c..e2b168a810c45 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -34,7 +34,7 @@ import { isGeminiFamily, modelSupportsContextEditing, modelSupportsToolSearch } import { IDomainService } from '../common/domainService'; import { CustomModel, IChatModelInformation, ModelSupportedEndpoint } from '../common/endpointProvider'; import { createMessagesRequestBody, processResponseFromMessagesEndpoint } from './messagesApi'; -import { createResponsesRequestBody, processResponseFromChatEndpoint } from './responsesApi'; +import { createResponsesRequestBody, getResponsesApiCompactionThreshold, processResponseFromChatEndpoint } from './responsesApi'; /** * The default processor for the stream format from CAPI @@ -366,7 +366,8 @@ export class ChatEndpoint implements IChatEndpoint { cancellationToken?: CancellationToken | undefined ): Promise> { if (this.useResponsesApi) { - return processResponseFromChatEndpoint(this._instantiationService, telemetryService, logService, response, expectedNumChoices, finishCallback, telemetryData); + const compactionThreshold = getResponsesApiCompactionThreshold(this._configurationService, this._expService, this); + return processResponseFromChatEndpoint(this._instantiationService, telemetryService, logService, response, expectedNumChoices, finishCallback, telemetryData, compactionThreshold); } else if (this.useMessagesApi) { return processResponseFromMessagesEndpoint(this._instantiationService, telemetryService, logService, response, finishCallback, telemetryData); } else if (!this._supportsStreaming) { diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index d878b152289fe..f34e6f805ca7f 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/logService'; import { FinishedCallback, IResponseDelta, OpenAiResponsesFunctionTool } from '../../networking/common/fetch'; import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody } from '../../networking/common/networking'; import { ChatCompletion, FinishedCompletionReason, modelsWithoutResponsesContextManagement, openAIContextManagementCompactionType, OpenAIContextManagementResponse, rawMessageToCAPI, TokenLogProb } from '../../networking/common/openai'; -import { sendEngineMessagesTelemetry } from '../../networking/node/chatStream'; +import { sendEngineMessagesTelemetry, sendResponsesApiCompactionTelemetry } from '../../networking/node/chatStream'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; @@ -29,10 +29,22 @@ import { rawPartAsPhaseData } from '../common/phaseDataContainer'; import { getStatefulMarkerAndIndex } from '../common/statefulMarkerContainer'; import { rawPartAsThinkingData } from '../common/thinkingDataContainer'; +export function getResponsesApiCompactionThreshold(configService: IConfigurationService, expService: IExperimentationService, endpoint: IChatEndpoint): number | undefined { + const contextManagementEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiContextManagementEnabled, expService) && !modelsWithoutResponsesContextManagement.has(endpoint.family); + if (!contextManagementEnabled) { + return undefined; + } + + return endpoint.modelMaxPromptTokens > 0 + ? Math.floor(endpoint.modelMaxPromptTokens * 0.9) + : 50000; +} + export function createResponsesRequestBody(accessor: ServicesAccessor, options: ICreateEndpointBodyOptions, model: string, endpoint: IChatEndpoint): IEndpointBody { const configService = accessor.get(IConfigurationService); const expService = accessor.get(IExperimentationService); const verbosity = getVerbosityForModelSync(endpoint); + const compactThreshold = getResponsesApiCompactionThreshold(configService, expService, endpoint); // compaction supported for all the models but works well for codex models and any future models after 5.3 const body: IEndpointBody = { @@ -56,11 +68,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: text: verbosity ? { verbosity } : undefined, }; - const contextManagementEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiContextManagementEnabled, expService) && !modelsWithoutResponsesContextManagement.has(endpoint.family); - if (contextManagementEnabled) { - const compactThreshold = endpoint.modelMaxPromptTokens > 0 - ? Math.floor(endpoint.modelMaxPromptTokens * 0.9) - : 50000; + if (compactThreshold !== undefined) { body.context_management = [{ 'type': openAIContextManagementCompactionType, // Trigger compaction at 90% of the model max prompt context to keep headroom for active turns. @@ -95,6 +103,21 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: return body; } +export function getResponsesApiCompactionThresholdFromBody(body: Pick): number | undefined { + const contextManagement = body.context_management; + if (!Array.isArray(contextManagement)) { + return undefined; + } + + for (const item of contextManagement) { + if (item.type === openAIContextManagementCompactionType && typeof item.compact_threshold === 'number') { + return item.compact_threshold; + } + } + + return undefined; +} + type ResponseOutputMessageWithPhase = OpenAI.Responses.ResponseOutputMessage & { phase?: string; }; @@ -103,17 +126,33 @@ interface ResponseOutputItemWithPhase { phase?: string; } +interface LatestCompactionOutput { + readonly item: OpenAIContextManagementResponse; + readonly outputIndex: number; +} + function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMessage[], ignoreStatefulMarker: boolean): { input: OpenAI.Responses.ResponseInputItem[]; previous_response_id?: string } { const latestCompactionMessageIndex = getLatestCompactionMessageIndex(messages); - if (latestCompactionMessageIndex !== undefined) { - messages = messages.slice(latestCompactionMessageIndex); - } - + const latestCompactionMessage = latestCompactionMessageIndex !== undefined ? createCompactionRoundTripMessage(messages[latestCompactionMessageIndex]) : undefined; const statefulMarkerAndIndex = !ignoreStatefulMarker && getStatefulMarkerAndIndex(modelId, messages); + let previousResponseId: string | undefined; - if (latestCompactionMessageIndex === undefined && statefulMarkerAndIndex) { + if (statefulMarkerAndIndex) { previousResponseId = statefulMarkerAndIndex.statefulMarker; + + // Requests that resume from previous_response_id send only post-marker history, + // but they still need the latest compaction item even when that item predates + // the marker. This keeps both websocket and non-websocket traffic aligned. messages = messages.slice(statefulMarkerAndIndex.index + 1); + if (latestCompactionMessageIndex !== undefined) { + if (latestCompactionMessageIndex > statefulMarkerAndIndex.index) { + messages = messages.slice(latestCompactionMessageIndex - (statefulMarkerAndIndex.index + 1)); + } else if (latestCompactionMessage) { + messages = [latestCompactionMessage, ...messages]; + } + } + } else if (latestCompactionMessageIndex !== undefined) { + messages = messages.slice(latestCompactionMessageIndex); } const input: OpenAI.Responses.ResponseInputItem[] = []; @@ -176,6 +215,22 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe return { input, previous_response_id: previousResponseId }; } +function createCompactionRoundTripMessage(message: Raw.ChatMessage): Raw.ChatMessage | undefined { + if (message.role !== Raw.ChatRole.Assistant) { + return undefined; + } + + const content = message.content.filter(part => part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsCompactionData(part)); + if (!content.length) { + return undefined; + } + + return { + role: Raw.ChatRole.Assistant, + content, + }; +} + function getLatestCompactionMessageIndex(messages: readonly Raw.ChatMessage[]): number | undefined { for (let idx = messages.length - 1; idx >= 0; idx--) { const message = messages[idx]; @@ -428,11 +483,44 @@ function responseFunctionOutputToRawContents(output: string | OpenAI.Responses.R return coalesce(output.map(responseContentToRawContent)); } -export async function processResponseFromChatEndpoint(instantiationService: IInstantiationService, telemetryService: ITelemetryService, logService: ILogService, response: Response, expectedNumChoices: number, finishCallback: FinishedCallback, telemetryData: TelemetryData): Promise> { +function isCompactionOutputItem(item: OpenAI.Responses.ResponseOutputItem): boolean { + return item.type.toString() === openAIContextManagementCompactionType; +} + +function getLatestCompactionOutput(output: OpenAI.Responses.ResponseOutputItem[], preferredOutputIndex: number | undefined): LatestCompactionOutput | undefined { + let latestCompactionOutput: LatestCompactionOutput | undefined; + for (let idx = output.length - 1; idx >= 0; idx--) { + const item = output[idx]; + if (isCompactionOutputItem(item)) { + latestCompactionOutput = { item: item as unknown as OpenAIContextManagementResponse, outputIndex: idx }; + break; + } + } + + if (preferredOutputIndex !== undefined) { + const preferredItem = output[preferredOutputIndex]; + if (preferredItem && isCompactionOutputItem(preferredItem) && (!latestCompactionOutput || preferredOutputIndex >= latestCompactionOutput.outputIndex)) { + return { item: preferredItem as unknown as OpenAIContextManagementResponse, outputIndex: preferredOutputIndex }; + } + } + + return latestCompactionOutput; +} + +function keepLatestCompactionOutput(output: OpenAI.Responses.ResponseOutputItem[], preferredOutputIndex: number | undefined): OpenAI.Responses.ResponseOutputItem[] { + const latestCompactionOutput = getLatestCompactionOutput(output, preferredOutputIndex); + if (!latestCompactionOutput) { + return output; + } + + return output.filter((item, idx) => !isCompactionOutputItem(item) || idx === latestCompactionOutput.outputIndex); +} + +export async function processResponseFromChatEndpoint(instantiationService: IInstantiationService, telemetryService: ITelemetryService, logService: ILogService, response: Response, expectedNumChoices: number, finishCallback: FinishedCallback, telemetryData: TelemetryData, compactionThreshold?: number): Promise> { return new AsyncIterableObject(async feed => { const requestId = response.headers.get('X-Request-ID') ?? generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; - const processor = instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, requestId, ghRequestId); + const processor = instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, requestId, ghRequestId, compactionThreshold); const parser = new SSEParser((ev) => { try { logService.trace(`SSE: ${ev.data}`); @@ -474,13 +562,19 @@ interface CapiResponsesTextDeltaEvent extends Omit(); constructor( private readonly telemetryData: TelemetryData, + private readonly telemetryService: ITelemetryService, private readonly requestId: string, private readonly ghRequestId: string, + private readonly compactionThreshold: number | undefined, + @ILogService private readonly logService: ILogService, ) { } public push(chunk: OpenAI.Responses.ResponseStreamEvent, _onProgress: FinishedCallback): ChatCompletion | undefined { @@ -532,6 +626,12 @@ export class OpenAIResponsesProcessor { case 'response.output_item.done': if (chunk.item.type.toString() === openAIContextManagementCompactionType) { const compactionItem = chunk.item as unknown as OpenAIContextManagementResponse; + if (this.latestCompactionOutputIndex !== undefined && chunk.output_index < this.latestCompactionOutputIndex) { + return; + } + this.latestCompactionOutputIndex = chunk.output_index; + this.latestCompactionItem = compactionItem; + this.sawCompactionMessage = true; return onProgress({ text: '', contextManagement: { @@ -588,8 +688,58 @@ export class OpenAIResponsesProcessor { id: chunk.item_id } }); - case 'response.completed': - onProgress({ text: '', statefulMarker: chunk.response.id }); + case 'response.completed': { + const normalizedOutput = keepLatestCompactionOutput(chunk.response.output, this.latestCompactionOutputIndex); + const latestCompactionOutput = getLatestCompactionOutput(normalizedOutput, this.latestCompactionOutputIndex); + const latestCompactionItem = latestCompactionOutput?.item; + const previousCompactionItem = this.latestCompactionItem; + if (latestCompactionItem) { + this.sawCompactionMessage = true; + this.latestCompactionOutputIndex = latestCompactionOutput.outputIndex; + } + + const shouldEmitResolvedCompaction = latestCompactionItem && ( + !previousCompactionItem || + previousCompactionItem.id !== latestCompactionItem.id || + previousCompactionItem.encrypted_content !== latestCompactionItem.encrypted_content + ); + if (latestCompactionItem) { + this.latestCompactionItem = latestCompactionItem; + } + if (this.compactionThreshold !== undefined && this.sawCompactionMessage) { + const promptTokens = chunk.response.usage?.input_tokens ?? 0; + const totalTokens = chunk.response.usage?.total_tokens ?? 0; + sendResponsesApiCompactionTelemetry(this.telemetryService, { + outcome: 'compaction_returned', + headerRequestId: this.requestId, + gitHubRequestId: this.ghRequestId, + model: chunk.response.model, + }, { + compactThreshold: this.compactionThreshold, + promptTokens, + totalTokens, + }); + this.logService.debug(`[responsesAPI_compaction] Compaction enabled. headerRequestId=${this.requestId}`); + } else if (this.compactionThreshold !== undefined && (chunk.response.usage?.input_tokens ?? 0) >= this.compactionThreshold) { + const promptTokens = chunk.response.usage?.input_tokens ?? 0; + const totalTokens = chunk.response.usage?.total_tokens ?? 0; + sendResponsesApiCompactionTelemetry(this.telemetryService, { + outcome: 'threshold_met_no_compaction', + headerRequestId: this.requestId, + gitHubRequestId: this.ghRequestId, + model: chunk.response.model, + }, { + compactThreshold: this.compactionThreshold, + promptTokens, + totalTokens, + }); + this.logService.debug(`[responsesAPI_compaction] Compaction enabled but context not compacted after threshold was met. headerRequestId=${this.requestId}, gitHubRequestId=${this.ghRequestId}, promptTokens=${promptTokens}, totalTokens=${totalTokens}`); + } + onProgress({ + text: '', + statefulMarker: chunk.response.id, + contextManagement: shouldEmitResolvedCompaction ? latestCompactionItem : undefined, + }); return { blockFinished: true, choiceIndex: 0, @@ -613,7 +763,7 @@ export class OpenAIResponsesProcessor { finishReason: FinishedCompletionReason.Stop, message: { role: Raw.ChatRole.Assistant, - content: chunk.response.output.map((item): Raw.ChatCompletionContentPart | undefined => { + content: normalizedOutput.map((item): Raw.ChatCompletionContentPart | undefined => { if (item.type === 'message') { return { type: Raw.ChatCompletionContentPartKind.Text, text: item.content.map(c => c.type === 'output_text' ? c.text : c.refusal).join('') }; } else if (item.type === 'image_generation_call' && item.result) { @@ -622,6 +772,7 @@ export class OpenAIResponsesProcessor { }).filter(isDefined), } }; + } } } } diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index 59d4498aa89bf..833ef99edcd6c 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -6,13 +6,94 @@ import { Raw } from '@vscode/prompt-tsx'; import type { OpenAI } from 'openai'; import { describe, expect, it } from 'vitest'; +import { TokenizerType } from '../../../../util/common/tokenizer'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { ILogService } from '../../../log/common/logService'; +import { isOpenAIContextManagementResponse } from '../../../networking/common/fetch'; +import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking'; +import { openAIContextManagementCompactionType, OpenAIContextManagementResponse } from '../../../networking/common/openai'; import { TelemetryData } from '../../../telemetry/common/telemetryData'; import { SpyingTelemetryService } from '../../../telemetry/node/spyingTelemetryService'; import { createFakeStreamResponse } from '../../../test/node/fetcher'; import { createPlatformServices } from '../../../test/node/services'; -import { processResponseFromChatEndpoint, responseApiInputToRawMessagesForLogging } from '../responsesApi'; +import { CustomDataPartMimeTypes } from '../../common/endpointTypes'; +import { createResponsesRequestBody, getResponsesApiCompactionThresholdFromBody, processResponseFromChatEndpoint, responseApiInputToRawMessagesForLogging } from '../responsesApi'; + +const testEndpoint: IChatEndpoint = { + urlOrRequestMetadata: 'https://example.test/chat', + modelMaxPromptTokens: 128000, + name: 'Test Endpoint', + version: '1', + family: 'gpt-5-mini', + tokenizer: TokenizerType.O200K, + maxOutputTokens: 4096, + model: 'gpt-5-mini', + modelProvider: 'openai', + supportsToolCalls: true, + supportsVision: true, + supportsPrediction: true, + showInModelPicker: true, + isFallback: false, + acquireTokenizer() { + throw new Error('Not implemented in test'); + }, + async processResponseFromChatEndpoint() { + throw new Error('Not implemented in test'); + }, + async makeChatRequest() { + throw new Error('Not implemented in test'); + }, + async makeChatRequest2() { + throw new Error('Not implemented in test'); + }, + createRequestBody() { + throw new Error('Not implemented in test'); + }, + cloneWithTokenOverride() { + return this; + } +}; + +const createRequestOptions = (messages: Raw.ChatMessage[], useWebSocket: boolean): ICreateEndpointBodyOptions => ({ + debugName: 'test', + messages, + requestId: 'req-1', + postOptions: {}, + finishedCb: undefined, + location: undefined as any, + useWebSocket, +}); + +const createStatefulMarkerMessage = (modelId: string, marker: string): Raw.ChatMessage => ({ + role: Raw.ChatRole.Assistant, + content: [{ + type: Raw.ChatCompletionContentPartKind.Opaque, + value: { + type: CustomDataPartMimeTypes.StatefulMarker, + value: { + modelId, + marker, + } + } + }] +}); + +const createCompactionResponse = (id: string, encrypted_content: string): OpenAIContextManagementResponse => ({ + type: openAIContextManagementCompactionType, + id, + encrypted_content, +}); + +const createCompactionAssistantMessage = (compaction: OpenAIContextManagementResponse): Raw.ChatMessage => ({ + role: Raw.ChatRole.Assistant, + content: [{ + type: Raw.ChatCompletionContentPartKind.Opaque, + value: { + type: CustomDataPartMimeTypes.ContextManagement, + compaction, + } + }] +}); describe('responseApiInputToRawMessagesForLogging', () => { @@ -214,6 +295,146 @@ describe('responseApiInputToRawMessagesForLogging', () => { }); }); +describe('createResponsesRequestBody', () => { + it('extracts compaction threshold from request body context management', () => { + expect(getResponsesApiCompactionThresholdFromBody({ + context_management: [{ + type: openAIContextManagementCompactionType, + compact_threshold: 1234, + }] + })).toBe(1234); + }); + + it('still slices websocket requests by stateful marker index when compaction is disabled', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const endpointWithoutCompaction = { ...testEndpoint, family: 'gpt-5' as const }; + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'before marker' }], + }, + createStatefulMarkerMessage(testEndpoint.model, 'resp-prev'), + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'after marker' }], + }, + ]; + + const webSocketBody = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, true), endpointWithoutCompaction.model, endpointWithoutCompaction)); + + expect(webSocketBody.previous_response_id).toBe('resp-prev'); + expect(webSocketBody.input).toHaveLength(1); + expect(webSocketBody.input?.[0]).toMatchObject({ + role: 'user', + content: [{ type: 'input_text', text: 'after marker' }], + }); + + accessor.dispose(); + services.dispose(); + }); + + it('includes the newest compaction item in websocket requests when it predates the stateful marker', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const latestCompaction = createCompactionResponse('cmp_ws', 'enc_ws'); + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'before compaction' }], + }, + createCompactionAssistantMessage(latestCompaction), + createStatefulMarkerMessage(testEndpoint.model, 'resp-prev'), + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'after marker' }], + }, + ]; + + const webSocketBody = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, true), testEndpoint.model, testEndpoint)); + + expect(webSocketBody.previous_response_id).toBe('resp-prev'); + expect(webSocketBody.input).toContainEqual({ + type: openAIContextManagementCompactionType, + id: 'cmp_ws', + encrypted_content: 'enc_ws', + }); + expect(webSocketBody.input).toContainEqual({ + role: 'user', + content: [{ type: 'input_text', text: 'after marker' }], + }); + + accessor.dispose(); + services.dispose(); + }); + + it('includes the newest compaction item in non-websocket requests when it predates the stateful marker', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const latestCompaction = createCompactionResponse('cmp_http', 'enc_http'); + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'before compaction' }], + }, + createCompactionAssistantMessage(latestCompaction), + createStatefulMarkerMessage(testEndpoint.model, 'resp-prev'), + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'after marker' }], + }, + ]; + + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), testEndpoint.model, testEndpoint)); + + expect(body.previous_response_id).toBe('resp-prev'); + expect(body.input).toContainEqual({ + type: openAIContextManagementCompactionType, + id: 'cmp_http', + encrypted_content: 'enc_http', + }); + expect(body.input).toContainEqual({ + role: 'user', + content: [{ type: 'input_text', text: 'after marker' }], + }); + + accessor.dispose(); + services.dispose(); + }); + + it('round-trips the newest stored compaction item', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const latestCompaction = createCompactionResponse('cmp_new', 'enc_new'); + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'earlier turn' }], + }, + createCompactionAssistantMessage(latestCompaction), + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'follow up' }], + }, + ]; + + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), testEndpoint.model, testEndpoint)); + + expect(body.input).toContainEqual({ + type: openAIContextManagementCompactionType, + id: 'cmp_new', + encrypted_content: 'enc_new', + }); + + accessor.dispose(); + services.dispose(); + }); +}); + describe('processResponseFromChatEndpoint telemetry', () => { it('emits engine.messages for Responses API assistant output', async () => { const services = createPlatformServices(); @@ -273,4 +494,279 @@ describe('processResponseFromChatEndpoint telemetry', () => { accessor.dispose(); services.dispose(); }); + + it('reconciles the newest compaction item from response.completed for the next request', async () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const logService = accessor.get(ILogService); + const telemetryService = new SpyingTelemetryService(); + const streamedCompactions: OpenAIContextManagementResponse[] = []; + + const olderCompaction = createCompactionResponse('cmp_old', 'enc_old'); + const newerCompaction = createCompactionResponse('cmp_new', 'enc_new'); + const compactionEvent = { + type: 'response.output_item.done', + output_index: 0, + item: olderCompaction, + }; + const completedEvent = { + type: 'response.completed', + response: { + id: 'resp_latest_compaction', + model: 'gpt-5-mini', + created_at: 123, + usage: { + input_tokens: 1200, + output_tokens: 9, + total_tokens: 1209, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + output: [ + olderCompaction, + { + type: 'message', + content: [{ type: 'output_text', text: 'reply' }], + }, + newerCompaction, + ], + } + }; + + const response = createFakeStreamResponse(`data: ${JSON.stringify(compactionEvent)}\n\ndata: ${JSON.stringify(completedEvent)}\n\n`); + const telemetryData = TelemetryData.createAndMarkAsIssued({ modelCallId: 'model-call-latest-compaction' }, {}); + + const stream = await processResponseFromChatEndpoint( + instantiationService, + telemetryService, + logService, + response, + 1, + async (_text, _unused, delta) => { + if (delta.contextManagement && isOpenAIContextManagementResponse(delta.contextManagement)) { + streamedCompactions.push(delta.contextManagement); + } + return undefined; + }, + telemetryData, + 1000 + ); + + for await (const _ of stream) { + // consume stream + } + + expect(streamedCompactions.map(item => item.id)).toEqual(['cmp_old', 'cmp_new']); + + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([ + createCompactionAssistantMessage(streamedCompactions[streamedCompactions.length - 1]), + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'continue' }], + }, + ], false), testEndpoint.model, testEndpoint)); + + expect(body.input).toContainEqual({ + type: openAIContextManagementCompactionType, + id: 'cmp_new', + encrypted_content: 'enc_new', + }); + expect(body.input).not.toContainEqual({ + type: openAIContextManagementCompactionType, + id: 'cmp_old', + encrypted_content: 'enc_old', + }); + + accessor.dispose(); + services.dispose(); + }); + + it('does not emit compaction telemetry when compaction is disabled', async () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const logService = accessor.get(ILogService); + const telemetryService = new SpyingTelemetryService(); + + const compactionEvent = { + type: 'response.output_item.done', + output_index: 0, + item: { + type: openAIContextManagementCompactionType, + id: 'cmp_disabled', + encrypted_content: 'enc', + } + }; + const completedEvent = { + type: 'response.completed', + response: { + id: 'resp_disabled', + model: 'gpt-5-mini', + created_at: 123, + usage: { + input_tokens: 1500, + output_tokens: 9, + total_tokens: 1509, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + output: [] + } + }; + + const response = createFakeStreamResponse(`data: ${JSON.stringify(compactionEvent)}\n\ndata: ${JSON.stringify(completedEvent)}\n\n`); + const telemetryData = TelemetryData.createAndMarkAsIssued({ modelCallId: 'model-call-4' }, {}); + + const stream = await processResponseFromChatEndpoint( + instantiationService, + telemetryService, + logService, + response, + 1, + async () => undefined, + telemetryData, + undefined + ); + + for await (const _ of stream) { + // consume stream + } + + const event = telemetryService.getEvents().telemetryServiceEvents.find(e => e.eventName === 'responsesApi.compactionOutcome'); + expect(event).toBeUndefined(); + + accessor.dispose(); + services.dispose(); + }); + + it('emits telemetry when the server returns a compaction item', async () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const logService = accessor.get(ILogService); + const telemetryService = new SpyingTelemetryService(); + + const compactionEvent = { + type: 'response.output_item.done', + output_index: 0, + item: { + type: openAIContextManagementCompactionType, + id: 'cmp_123', + encrypted_content: 'enc', + } + }; + const completedEvent = { + type: 'response.completed', + response: { + id: 'resp_456', + model: 'gpt-5-mini', + created_at: 123, + usage: { + input_tokens: 1200, + output_tokens: 7, + total_tokens: 1207, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + output: [] + } + }; + + const response = createFakeStreamResponse(`data: ${JSON.stringify(compactionEvent)}\n\ndata: ${JSON.stringify(completedEvent)}\n\n`); + const telemetryData = TelemetryData.createAndMarkAsIssued({ modelCallId: 'model-call-2' }, {}); + + const stream = await processResponseFromChatEndpoint( + instantiationService, + telemetryService, + logService, + response, + 1, + async () => undefined, + telemetryData, + 1000 + ); + + for await (const _ of stream) { + // consume stream + } + + const event = telemetryService.getEvents().telemetryServiceEvents.find(e => e.eventName === 'responsesApi.compactionOutcome'); + expect(event).toBeDefined(); + expect(event?.properties).toMatchObject({ + outcome: 'compaction_returned', + model: 'gpt-5-mini', + }); + expect(event?.measurements).toMatchObject({ + compactThreshold: 1000, + promptTokens: 1200, + totalTokens: 1207, + }); + + accessor.dispose(); + services.dispose(); + }); + + it('emits telemetry when the server exceeds threshold without returning a compaction item', async () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const logService = accessor.get(ILogService); + const telemetryService = new SpyingTelemetryService(); + + const completedEvent = { + type: 'response.completed', + response: { + id: 'resp_789', + model: 'gpt-5-mini', + created_at: 123, + usage: { + input_tokens: 1500, + output_tokens: 9, + total_tokens: 1509, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'reply' }], + } + ] + } + }; + + const response = createFakeStreamResponse(`data: ${JSON.stringify(completedEvent)}\n\n`); + const telemetryData = TelemetryData.createAndMarkAsIssued({ modelCallId: 'model-call-3' }, {}); + + const stream = await processResponseFromChatEndpoint( + instantiationService, + telemetryService, + logService, + response, + 1, + async () => undefined, + telemetryData, + 1000 + ); + + for await (const _ of stream) { + // consume stream + } + + const event = telemetryService.getEvents().telemetryServiceEvents.find(e => e.eventName === 'responsesApi.compactionOutcome'); + expect(event).toBeDefined(); + expect(event?.properties).toMatchObject({ + outcome: 'threshold_met_no_compaction', + model: 'gpt-5-mini', + }); + expect(event?.measurements).toMatchObject({ + compactThreshold: 1000, + promptTokens: 1500, + totalTokens: 1509, + }); + + accessor.dispose(); + services.dispose(); + }); }); diff --git a/extensions/copilot/src/platform/git/common/gitService.ts b/extensions/copilot/src/platform/git/common/gitService.ts index 51bf9a00d5765..e7e6fc117f0c4 100644 --- a/extensions/copilot/src/platform/git/common/gitService.ts +++ b/extensions/copilot/src/platform/git/common/gitService.ts @@ -69,7 +69,7 @@ export interface IGitService extends IDisposable { getMergeBase(uri: URI, ref1: string, ref2: string): Promise; restore(uri: URI, paths: string[], options?: { staged?: boolean; ref?: string }): Promise; - createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string }): Promise; + createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise; migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index af52e48102f6d..6c6f85e08fd83 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -347,7 +347,7 @@ export class GitServiceImpl extends Disposable implements IGitService { } } - async createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string }): Promise { + async createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise { const gitAPI = this.gitExtensionService.getExtensionApi(); const repository = gitAPI?.getRepository(uri); return await repository?.createWorktree(options); diff --git a/extensions/copilot/src/platform/git/vscode/git.d.ts b/extensions/copilot/src/platform/git/vscode/git.d.ts index 90e01bb80f36c..2ca0093e9dcd6 100644 --- a/extensions/copilot/src/platform/git/vscode/git.d.ts +++ b/extensions/copilot/src/platform/git/vscode/git.d.ts @@ -313,7 +313,7 @@ export interface Repository { popStash(index?: number): Promise; dropStash(index?: number): Promise; - createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise; + createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; deleteWorktree(path: string, options?: { force?: boolean }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts b/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts index e1dad5cbd2978..47c25ffba7957 100644 --- a/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts +++ b/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts @@ -114,7 +114,7 @@ export class MockGitService implements IGitService { return Promise.resolve(undefined); } - createWorktree(_uri: URI, _options?: { path?: string; commitish?: string; branch?: string }): Promise { + createWorktree(_uri: URI, _options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise { return Promise.resolve(undefined); } diff --git a/extensions/copilot/src/platform/networking/node/chatStream.ts b/extensions/copilot/src/platform/networking/node/chatStream.ts index 0d94539c06065..385ba7bf53c53 100644 --- a/extensions/copilot/src/platform/networking/node/chatStream.ts +++ b/extensions/copilot/src/platform/networking/node/chatStream.ts @@ -460,6 +460,45 @@ export function sendEngineMessagesTelemetry(telemetryService: ITelemetryService, sendEngineMessagesLengthTelemetry(telemetryService, messages, telemetryData, isOutput, logService); } +export function sendResponsesApiCompactionTelemetry( + telemetryService: ITelemetryService, + properties: { + outcome: 'compaction_returned' | 'threshold_met_no_compaction'; + headerRequestId: string; + gitHubRequestId: string; + model: string; + }, + measurements: { + compactThreshold?: number; + promptTokens: number; + totalTokens: number; + } +): void { + /* __GDPR__ + "responsesApi.compactionOutcome" : { + "owner": "dileepy", + "comment": "Tracks server-side Responses API compaction outcomes.", + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the server returned a compaction item or exceeded the threshold without returning one." }, + "headerRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Request ID from the response headers." }, + "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "GitHub request ID from the response headers if present." }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Model identifier reported by the response." }, + "compactThreshold": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Compaction threshold configured for the request." }, + "promptTokens": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Prompt token count reported by the response." }, + "totalTokens": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total token count reported by the response." } + } + */ + telemetryService.sendGHTelemetryEvent('responsesApi.compactionOutcome', { + outcome: properties.outcome, + headerRequestId: properties.headerRequestId, + gitHubRequestId: properties.gitHubRequestId, + model: properties.model, + }, { + compactThreshold: measurements.compactThreshold, + promptTokens: measurements.promptTokens, + totalTokens: measurements.totalTokens, + }); +} + export function prepareChatCompletionForReturn( telemetryService: ITelemetryService, logService: ILogService, diff --git a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts index a6c46843189ee..618eac5a7081c 100644 --- a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts +++ b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts @@ -798,7 +798,7 @@ export class TestingGitService implements IGitService { return; } - async createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string }): Promise { + async createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise { return undefined; } diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 18d558fdf9bfe..e089fe1fba9eb 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -348,7 +348,7 @@ export class ApiRepository implements Repository { return this.#repository.dropStash(index); } - createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise { + createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise { return this.#repository.createWorktree(options); } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 0941959b8cc28..612407a3ab7cc 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -324,7 +324,7 @@ export interface Repository { popStash(index?: number): Promise; dropStash(index?: number): Promise; - createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise; + createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; deleteWorktree(path: string, options?: { force?: boolean }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 83af8866c71da..7b7d9be3eda16 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -2245,13 +2245,17 @@ export class Repository { await this.exec(args); } - async addWorktree(options: { path: string; commitish: string; branch?: string }): Promise { + async addWorktree(options: { path: string; commitish: string; branch?: string; noTrack?: boolean }): Promise { const args = ['worktree', 'add']; if (options.branch) { args.push('-b', options.branch); } + if (options.noTrack) { + args.push('--no-track'); + } + args.push(options.path, options.commitish); await this.exec(args); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index da3c09215cfb1..2512a90a66e91 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1923,14 +1923,14 @@ export class Repository implements Disposable { await this.run(Operation.DeleteTag, () => this.repository.deleteTag(name)); } - async createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise { + async createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise { const defaultWorktreeRoot = this.globalState.get(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`); const config = workspace.getConfiguration('git', Uri.file(this.root)); const branchPrefix = config.get('branchPrefix', ''); return await this.run(Operation.Worktree(false), async () => { let worktreeName: string | undefined; - let { path: worktreePath, commitish, branch } = options || {}; + let { path: worktreePath, commitish, branch, noTrack } = options || {}; // Create worktree path based on the branch name if (worktreePath === undefined && branch !== undefined) { @@ -1954,7 +1954,7 @@ export class Repository implements Disposable { } // Create the worktree - await this.repository.addWorktree({ path: worktreePath!, commitish: commitish ?? 'HEAD', branch }); + await this.repository.addWorktree({ path: worktreePath!, commitish: commitish ?? 'HEAD', branch, noTrack }); // Update worktree root in global state const newWorktreeRoot = path.dirname(worktreePath!); diff --git a/package.json b/package.json index 5229795d94dba..4c25e394d11e1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.116.0", - "distro": "846b868cab05db8a093dad791caa5c61e330f7a9", + "distro": "a265239e421aed1dad7d0db84c68f905ebe9096e", "author": { "name": "Microsoft Corporation" }, @@ -266,4 +266,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index 4964af49280ac..d76df671aa8c8 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -307,10 +307,12 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant s?.tabAction.read(reader) ?? InlineEditTabAction.Inactive), - this._gutterIndicatorState.map((s, reader) => s?.gutterIndicatorOffset.read(reader) ?? 0), + this._gutterIndicatorState.map((s, reader) => s?.tabAction?.read(reader) ?? InlineEditTabAction.Inactive), + this._gutterIndicatorState.map((s, reader) => s?.gutterIndicatorOffset?.read(reader) ?? 0), this._inlineEditWidget.map((w, reader) => w?.view.inlineEditsIsHovered.read(reader) ?? false), this._focusIsInMenu, )); diff --git a/src/vs/platform/userDataSync/common/extensionsSync.ts b/src/vs/platform/userDataSync/common/extensionsSync.ts index 1c7a7fd49f71d..3403f3b6827ad 100644 --- a/src/vs/platform/userDataSync/common/extensionsSync.ts +++ b/src/vs/platform/userDataSync/common/extensionsSync.ts @@ -32,6 +32,7 @@ import { IMergeResult as IExtensionMergeResult, merge } from './extensionsMerge. import { IIgnoredExtensionsManagementService } from './ignoredExtensions.js'; import { Change, IRemoteUserData, ISyncData, ISyncExtension, IUserDataSyncLocalStoreService, IUserDataSynchroniser, IUserDataSyncLogService, IUserDataSyncEnablementService, IUserDataSyncStoreService, SyncResource, USER_DATA_SYNC_SCHEME, ILocalSyncExtension } from './userDataSync.js'; import { IUserDataProfileStorageService } from '../../userDataProfile/common/userDataProfileStorageService.js'; +import { IProductService } from '../../product/common/productService.js'; type IExtensionResourceMergeResult = IAcceptResult & IExtensionMergeResult; @@ -368,6 +369,7 @@ export class LocalExtensionsProvider { @IIgnoredExtensionsManagementService private readonly ignoredExtensionsManagementService: IIgnoredExtensionsManagementService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IUserDataSyncLogService private readonly logService: IUserDataSyncLogService, + @IProductService private readonly productService: IProductService, ) { } async getLocalExtensions(profile: IUserDataProfile): Promise<{ localExtensions: ILocalSyncExtension[]; ignoredExtensions: string[] }> { @@ -378,21 +380,24 @@ export class LocalExtensionsProvider { return installedExtensions .map(extension => { const { identifier, isBuiltin, manifest, preRelease, pinned, isApplicationScoped } = extension; - const syncExntesion: ILocalSyncExtension = { identifier, preRelease, version: manifest.version, pinned: !!pinned }; + const syncExtension: ILocalSyncExtension = { identifier, preRelease, version: manifest.version, pinned: !!pinned }; if (isApplicationScoped && !isApplicationScopedExtension(manifest)) { - syncExntesion.isApplicationScoped = isApplicationScoped; + syncExtension.isApplicationScoped = isApplicationScoped; + } + if (this.productService.builtInExtensionsEnabledWithAutoUpdates?.some(id => id.toLowerCase() === identifier.id.toLowerCase())) { + syncExtension.isApplicationScoped = true; } if (disabledExtensions.some(disabledExtension => areSameExtensions(disabledExtension, identifier))) { - syncExntesion.disabled = true; + syncExtension.disabled = true; } if (!isBuiltin) { - syncExntesion.installed = true; + syncExtension.installed = true; } try { const keys = extensionStorageService.getKeysForSync({ id: identifier.id, version: manifest.version }); if (keys) { const extensionStorageState = extensionStorageService.getExtensionState(extension, true) || {}; - syncExntesion.state = Object.keys(extensionStorageState).reduce((state: IStringDictionary, key) => { + syncExtension.state = Object.keys(extensionStorageState).reduce((state: IStringDictionary, key) => { if (keys.includes(key)) { state[key] = extensionStorageState[key]; } @@ -402,7 +407,7 @@ export class LocalExtensionsProvider { } catch (error) { this.logService.info(`${getSyncResourceLogLabel(SyncResource.Extensions, profile)}: Error while parsing extension state`, getErrorMessage(error)); } - return syncExntesion; + return syncExtension; }); }); return { localExtensions, ignoredExtensions }; diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index ba28b19cc58de..c37d39f34083f 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -195,8 +195,13 @@ export class ChangesViewModel extends Disposable { return constObservable(undefined); } + const metadata = this._activeSessionMetadataObs.read(reader); const activeSessionRepository = activeSessionRepositoryObs.read(reader); - const workingDirectory = activeSessionRepository?.workingDirectory ?? activeSessionRepository?.uri; + + const repositoryPath = metadata?.repositoryPath as string | undefined; + const workingDirectory = repositoryPath + ? URI.file(repositoryPath) + : activeSessionRepository?.workingDirectory ?? activeSessionRepository?.uri; if (!workingDirectory) { return constObservable(undefined); } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index f718503bce79b..0a5894f2b2257 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -247,6 +247,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, argumentHint: agent.argumentHint, + tools: agent.tools, + model: agent.model, userInvocable: agent.visibility.userInvocable, disableModelInvocation: !agent.visibility.agentInvocable, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index f5deb7a791c3a..e10f7f39d3e88 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1695,6 +1695,8 @@ export interface IChatResourceDto { export interface ICustomAgentDto extends IChatResourceDto { readonly argumentHint?: string; + readonly tools?: readonly string[]; + readonly model?: readonly string[]; readonly userInvocable: boolean; readonly disableModelInvocation: boolean; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index f6bdc8bce28d7..e73f6f9822881 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -546,6 +546,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS extensionId: dto.extensionId, pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, argumentHint: dto.argumentHint, + tools: dto.tools, + model: dto.model, userInvocable: dto.userInvocable, disableModelInvocation: dto.disableModelInvocation, }); diff --git a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts index 03bfed510efa8..b949732eb1f7d 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts @@ -26,7 +26,7 @@ interface WatermarkEntry { }; } -const showChatContextKey = ContextKeyExpr.and(ContextKeyExpr.equals('chatSetupHidden', false), ContextKeyExpr.equals('chatSetupDisabled', false)); +const showChatContextKey = ContextKeyExpr.and(ContextKeyExpr.equals('chatSetupHidden', false)); const openChat: WatermarkEntry = { text: localize('watermark.openChat', "Open Chat"), id: 'workbench.action.chat.open', when: { native: showChatContextKey, web: showChatContextKey } }; const showCommands: WatermarkEntry = { text: localize('watermark.showCommands', "Show All Commands"), id: 'workbench.action.showCommands' }; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index cd85b8ebc5841..75abd21edf75d 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -205,7 +205,6 @@ abstract class OpenChatGlobalAction extends Action2 { category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabled.negate() ) }); } @@ -1728,7 +1727,6 @@ MenuRegistry.appendMenuItem(MenuId.EditorContext, { title: localize('generateCode', "Generate Code"), when: ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabled.negate() ) }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 4e1d91d492711..8b8e25bb3d160 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -266,7 +266,6 @@ MenuRegistry.appendMenuItem(MenuId.TitleBar, { ChatContextKeys.supported, ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabled.negate() ), ContextKeyExpr.has('config.window.commandCenter').negate(), ), diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 32ee9a467296d..f1d4aaf4e4e46 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -543,6 +543,22 @@ configurationRegistry.registerConfiguration({ }, tags: ['experimental'] }, + [ChatConfiguration.ArtifactsRulesByMemoryFilePath]: { + default: { + '**/*plan*.md': { groupName: 'Plans' } + }, + description: nls.localize('chat.artifacts.rules.byMemoryFilePath', "Rules for extracting artifacts from memory tool calls by memory file path pattern. Maps glob patterns to group configuration."), + type: 'object', + additionalProperties: { + type: 'object', + properties: { + groupName: { type: 'string', description: nls.localize('chat.artifacts.rules.byMemoryFilePath.groupName', "Display name for the artifact group.") }, + onlyShowGroup: { type: 'boolean', description: nls.localize('chat.artifacts.rules.byMemoryFilePath.onlyShowGroup', "When true, show only the group header instead of individual items.") } + }, + required: ['groupName'] + }, + tags: ['experimental'] + }, 'chat.undoRequests.restoreInput': { default: true, markdownDescription: nls.localize('chat.undoRequests.restoreInput', "Controls whether the input of the chat should be restored when an undo request is made. The input will be filled with the text of the request that was restored."), diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index ddb5df4184759..6bbb105d0c7eb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -71,7 +71,6 @@ const chatViewDescriptor: IViewDescriptor = { when: ContextKeyExpr.or( ContextKeyExpr.or( ChatContextKeys.Setup.hidden, - ChatContextKeys.Setup.disabled )?.negate(), ChatContextKeys.panelParticipantRegistered, ChatContextKeys.extensionInvalid diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 24c12b387621c..c3e121d5dc23e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -113,7 +113,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr // Agent + Tools { - if (!context.state.hidden && !context.state.disabled) { + if (!context.state.hidden) { // Default Agents (always, even if installed to allow for speedy requests right on startup) if (!defaultAgentDisposables.value) { @@ -154,14 +154,14 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr vscodeAgentDisposables.clear(); } - if (context.state.completed && !context.state.disabled) { + if (context.state.completed) { vscodeAgentDisposables.clear(); // we need to do this to prevent showing duplicate agent/tool entries in the list } } // Rename Provider { - if (!context.state.completed && !context.state.hidden && !context.state.disabled) { + if (!context.state.completed && !context.state.hidden) { if (!renameProviderDisposables.value) { renameProviderDisposables.value = AINewSymbolNamesProvider.registerProvider(this.instantiationService, context, controller); } @@ -172,7 +172,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr // Code Actions Provider { - if (!context.state.completed && !context.state.hidden && !context.state.disabled) { + if (!context.state.completed && !context.state.hidden) { if (!codeActionsProviderDisposables.value) { codeActionsProviderDisposables.value = ChatCodeActionsProvider.registerProvider(this.instantiationService); } @@ -232,7 +232,6 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr f1: true, precondition: ContextKeyExpr.or( ChatContextKeys.Setup.hidden, - ChatContextKeys.Setup.disabled, ChatContextKeys.Setup.untrusted, ChatContextKeys.Setup.completed.negate(), ChatContextKeys.Entitlement.canSignUp @@ -559,7 +558,6 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr const internalGenerateCodeContext = ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabled.negate(), ChatContextKeys.Setup.completed.negate(), ); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts index d13f0c8634647..0e1ebc40121e5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts @@ -14,6 +14,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -21,6 +22,7 @@ import { IListAccessibilityProvider } from '../../../../../base/browser/ui/list/ import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ChatConfiguration } from '../../common/constants.js'; +import { ChatMemoryFileResource } from '../../common/chatArtifactExtraction.js'; import { IChatArtifact, IChatArtifacts, IChatArtifactsService } from '../../common/tools/chatArtifactsService.js'; import { IChatImageCarouselService } from '../chatImageCarouselService.js'; import { getEditorOverrideForChatResource } from './chatContentParts/chatInlineAnchorWidget.js'; @@ -67,6 +69,7 @@ export class ChatArtifactsWidget extends Disposable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IOpenerService private readonly _openerService: IOpenerService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @ICommandService private readonly _commandService: ICommandService, @IFileService private readonly _fileService: IFileService, @IFileDialogService private readonly _fileDialogService: IFileDialogService, @IChatImageCarouselService private readonly _chatImageCarouselService: IChatImageCarouselService, @@ -144,11 +147,15 @@ export class ChatArtifactsWidget extends Disposable { this._openScreenshotInCarousel(artifact); } else if (artifact.uri) { const uri = URI.parse(artifact.uri); - const editorOverride = getEditorOverrideForChatResource(uri, this._configurationService); - this._openerService.open(uri, { - fromUserGesture: true, - editorOptions: { override: editorOverride }, - }); + if (ChatMemoryFileResource.isChatMemoryFileUri(uri)) { + this._openMemoryFileArtifact(uri); + } else { + const editorOverride = getEditorOverrideForChatResource(uri, this._configurationService); + this._openerService.open(uri, { + fromUserGesture: true, + editorOptions: { override: editorOverride }, + }); + } } } })); @@ -200,6 +207,23 @@ export class ChatArtifactsWidget extends Disposable { } } + private async _openMemoryFileArtifact(uri: URI): Promise { + const { memoryPath, sessionResource } = ChatMemoryFileResource.parse(uri); + const resolvedUriStr: string | undefined = await this._commandService.executeCommand( + 'github.copilot.chat.tools.memory.resolveMemoryFileUri', + memoryPath, + sessionResource, + ); + if (resolvedUriStr) { + const resolvedUri = URI.parse(resolvedUriStr); + const editorOverride = getEditorOverrideForChatResource(resolvedUri, this._configurationService); + this._openerService.open(resolvedUri, { + fromUserGesture: true, + editorOptions: { override: editorOverride }, + }); + } + } + private _clearAllArtifacts(): void { if (!this._currentArtifacts?.mutable.get()) { return; diff --git a/src/vs/workbench/contrib/chat/common/chatArtifactExtraction.ts b/src/vs/workbench/contrib/chat/common/chatArtifactExtraction.ts index 6be6bf9bca4de..a13296f9e18ba 100644 --- a/src/vs/workbench/contrib/chat/common/chatArtifactExtraction.ts +++ b/src/vs/workbench/contrib/chat/common/chatArtifactExtraction.ts @@ -13,6 +13,30 @@ import { ChatResponseResource, IResponse } from './model/chatModel.js'; import { IArtifactGroupConfig, IChatArtifact } from './tools/chatArtifactsService.js'; import { isToolResultInputOutputDetails } from './tools/languageModelToolsService.js'; +const CHAT_MEMORY_FILE_SCHEME = 'chat-memory-file'; +const MEMORY_TOOL_ID = 'copilot_memory'; + +export namespace ChatMemoryFileResource { + export function createUri(memoryPath: string, sessionResource: URI): URI { + return URI.from({ + scheme: CHAT_MEMORY_FILE_SCHEME, + path: memoryPath, + query: sessionResource.toString(), + }); + } + + export function isChatMemoryFileUri(uri: URI): boolean { + return uri.scheme === CHAT_MEMORY_FILE_SCHEME; + } + + export function parse(uri: URI): { memoryPath: string; sessionResource: string } { + return { + memoryPath: uri.path, + sessionResource: uri.query, + }; + } +} + /** * Matches a MIME type against a pattern supporting wildcards. * E.g. `image/*` matches `image/png`, `image/jpeg`, etc. @@ -64,6 +88,24 @@ function isToolResultOutputDetailsSerialized(obj: unknown): obj is IToolResultOu && typeof (obj as IToolResultOutputDetailsSerialized).output?.mimeType === 'string'; } +function getMemoryPathFromParams(params: unknown): string | undefined { + if (typeof params !== 'object' || params === null) { + return undefined; + } + const path = (params as Record)['path']; + return typeof path === 'string' ? path : undefined; +} + +const memoryWriteCommands = new Set(['create', 'str_replace', 'insert']); + +function isMemoryWriteCommand(params: unknown): boolean { + if (typeof params !== 'object' || params === null) { + return false; + } + const command = (params as Record)['command']; + return typeof command === 'string' && memoryWriteCommands.has(command); +} + /** * Extracts artifacts from a single response's content parts, applying the given rules. * Pure function, no side effects. @@ -73,6 +115,7 @@ export function extractArtifactsFromResponse( sessionResource: URI, byMimeType: Record, byFilePath: Record, + byMemoryFilePath: Record = {}, ): IChatArtifact[] { const artifacts: IChatArtifact[] = []; const seenUris = new Set(); @@ -143,6 +186,28 @@ export function extractArtifactsFromResponse( } } + // Memory tool invocations + if ((part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && part.toolId === MEMORY_TOOL_ID) { + const params = IChatToolInvocation.getParameters(part); + const memoryPath = getMemoryPathFromParams(params); + if (memoryPath && isMemoryWriteCommand(params)) { + const rule = findFilePathRule(memoryPath, byMemoryFilePath); + if (rule) { + const key = `memory:${part.toolCallId}:${memoryPath}`; + if (!seenUris.has(key)) { + seenUris.add(key); + artifacts.push({ + label: pathBasename(memoryPath), + uri: ChatMemoryFileResource.createUri(memoryPath, sessionResource).toString(), + type: 'plan', + groupName: rule.groupName, + onlyShowGroup: rule.onlyShowGroup, + }); + } + } + } + } + // Image results from tool invocations if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { const details = IChatToolInvocation.resultDetails(part); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index e8270c38fda23..69d43d74fc43c 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -65,6 +65,7 @@ export enum ChatConfiguration { ArtifactsMode = 'chat.artifacts.mode', ArtifactsRulesByMimeType = 'chat.artifacts.rules.byMimeType', ArtifactsRulesByFilePath = 'chat.artifacts.rules.byFilePath', + ArtifactsRulesByMemoryFilePath = 'chat.artifacts.rules.byMemoryFilePath', CustomizationsProviderApi = 'chat.customizations.providerApi.enabled', ToolConfirmationCarousel = 'chat.tools.confirmationCarousel.enabled', DefaultNewSessionMode = 'chat.newSession.defaultMode', diff --git a/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts b/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts index 21c74e4c92a78..0489dbfeb53d8 100644 --- a/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts @@ -50,6 +50,7 @@ interface IResponseCache { readonly completedToolCount: number; readonly byMimeType: Record; readonly byFilePath: Record; + readonly byMemoryFilePath: Record; readonly artifacts: IChatArtifact[]; } @@ -104,6 +105,12 @@ class RulesChatArtifacts extends Disposable implements IChatArtifacts { () => configurationService.getValue>(ChatConfiguration.ArtifactsRulesByFilePath) ?? {}, ); + const configByMemoryFilePath = observableFromEvent>( + this, + configurationService.onDidChangeConfiguration, + () => configurationService.getValue>(ChatConfiguration.ArtifactsRulesByMemoryFilePath) ?? {}, + ); + const modelSignal = observableFromEvent( this, chatService.onDidCreateModel, @@ -113,6 +120,7 @@ class RulesChatArtifacts extends Disposable implements IChatArtifacts { this.artifacts = derived(reader => { const byMimeType = configByMimeType.read(reader); const byFilePath = configByFilePath.read(reader); + const byMemoryFilePath = configByMemoryFilePath.read(reader); const model = modelSignal.read(reader); if (!model) { return []; @@ -146,11 +154,11 @@ class RulesChatArtifacts extends Disposable implements IChatArtifacts { const cached = this._responseCache.get(response.id); let extracted: IChatArtifact[]; - if (cached && cached.partsLength === partsLength && cached.completedToolCount === completedToolCount && cached.byMimeType === byMimeType && cached.byFilePath === byFilePath) { + if (cached && cached.partsLength === partsLength && cached.completedToolCount === completedToolCount && cached.byMimeType === byMimeType && cached.byFilePath === byFilePath && cached.byMemoryFilePath === byMemoryFilePath) { extracted = cached.artifacts; } else { - extracted = extractArtifactsFromResponse(responseValue, sessionResource, byMimeType, byFilePath); - this._responseCache.set(response.id, { partsLength, completedToolCount, byMimeType, byFilePath, artifacts: extracted }); + extracted = extractArtifactsFromResponse(responseValue, sessionResource, byMimeType, byFilePath, byMemoryFilePath); + this._responseCache.set(response.id, { partsLength, completedToolCount, byMimeType, byFilePath, byMemoryFilePath, artifacts: extracted }); } for (const artifact of extracted) { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 9b849b7de8b0a..77a61703dc47d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -15,7 +15,6 @@ import { observableConfigValue } from '../../../../platform/observable/common/pl import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { InlineChatEditorAffordance } from './inlineChatEditorAffordance.js'; import { InlineChatInputWidget } from './inlineChatOverlayWidget.js'; -import { InlineChatGutterAffordance } from './inlineChatGutterAffordance.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; import { assertType } from '../../../../base/common/types.js'; import { CursorChangeReason } from '../../../../editor/common/cursorEvents.js'; @@ -23,7 +22,6 @@ import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { Event } from '../../../../base/common/event.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; type InlineChatAffordanceEvent = { @@ -33,7 +31,7 @@ type InlineChatAffordanceEvent = { }; type InlineChatAffordanceClassification = { - mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The affordance mode: gutter or editor.' }; + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The affordance mode: editor.' }; id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'UUID to correlate shown and selected events.' }; commandId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The command that was executed.' }; owner: 'jrieken'; @@ -64,7 +62,7 @@ export class InlineChatAffordance extends Disposable { this.#instantiationService = instantiationService; const editorObs = observableCodeEditor(this.#editor); - const affordance = observableConfigValue<'off' | 'gutter' | 'editor'>(InlineChatConfigKeys.Affordance, 'off', configurationService); + const affordance = observableConfigValue<'off' | 'editor'>(InlineChatConfigKeys.Affordance, 'off', configurationService); const debouncedSelection = debouncedObservable(editorObs.cursorSelection, 500); const selectionData = this.#selectionData; @@ -91,7 +89,7 @@ export class InlineChatAffordance extends Disposable { } affordanceId = generateUuid(); const mode = affordance.read(undefined); - if (mode === 'gutter' || mode === 'editor') { + if (mode === 'editor') { telemetryService.publicLog2('inlineChatAffordance/shown', { mode, id: affordanceId, commandId: '' }); } selectionData.set(value, undefined); @@ -130,15 +128,9 @@ export class InlineChatAffordance extends Disposable { this._store.add(autorun(r => { const sel = selectionData.read(r); const mode = affordance.read(r); - ctxAffordanceVisible.set(sel !== undefined && (mode === 'editor' || mode === 'gutter')); + ctxAffordanceVisible.set(sel !== undefined && mode === 'editor'); })); - const gutterAffordance = this._store.add(this.#instantiationService.createInstance( - InlineChatGutterAffordance, - editorObs, - derived(r => affordance.read(r) === 'gutter' ? selectionData.read(r) : undefined), - )); - const editorAffordance = this.#instantiationService.createInstance( InlineChatEditorAffordance, this.#editor, @@ -146,7 +138,7 @@ export class InlineChatAffordance extends Disposable { ); this._store.add(editorAffordance); - this._store.add(Event.any(editorAffordance.onDidRunAction, gutterAffordance.onDidRunAction)(commandId => { + this._store.add(editorAffordance.onDidRunAction(commandId => { if (affordanceId) { telemetryService.publicLog2('inlineChatAffordance/selected', { mode: affordance.get(), id: affordanceId, commandId }); } @@ -154,7 +146,7 @@ export class InlineChatAffordance extends Disposable { this._store.add(autorun(r => { const mode = affordance.read(r); - const hideWithSelection = mode === 'editor' || mode === 'gutter'; + const hideWithSelection = mode === 'editor'; const controller = CodeActionController.get(this.#editor); if (controller) { controller.onlyLightBulbWithEmptySelection = hideWithSelection; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts deleted file mode 100644 index 3d82cec90ec04..0000000000000 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts +++ /dev/null @@ -1,113 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Codicon } from '../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { constObservable, derived, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { ObservableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; -import { LineRange } from '../../../../editor/common/core/ranges/lineRange.js'; -import { Selection } from '../../../../editor/common/core/selection.js'; -import { InlineCompletionCommand } from '../../../../editor/common/languages.js'; -import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; -import { InlineEditsGutterIndicator, InlineEditsGutterIndicatorData, InlineSuggestionGutterMenuData, SimpleInlineSuggestModel } from '../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.js'; -import { InlineEditTabAction } from '../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; -import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { HoverService } from '../../../../platform/hover/browser/hoverService.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { IUserInteractionService } from '../../../../platform/userInteraction/browser/userInteractionService.js'; - -export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { - - private readonly _onDidRunAction = this._store.add(new Emitter()); - readonly onDidRunAction: Event = this._onDidRunAction.event; - - constructor( - myEditorObs: ObservableCodeEditor, - selection: IObservable, - @IKeybindingService _keybindingService: IKeybindingService, - @IHoverService hoverService: HoverService, - @IInstantiationService instantiationService: IInstantiationService, - @IAccessibilityService accessibilityService: IAccessibilityService, - @IThemeService themeService: IThemeService, - @IUserInteractionService userInteractionService: IUserInteractionService, - @IMenuService menuService: IMenuService, - @IContextKeyService contextKeyService: IContextKeyService, - ) { - - const menu = menuService.createMenu(MenuId.InlineChatEditorAffordance, contextKeyService); - const menuObs = observableFromEvent(menu.onDidChange, () => menu.getActions({ renderShortTitle: false })); - - const codeActionController = CodeActionController.get(myEditorObs.editor); - const lightBulbObs = codeActionController?.lightBulbState; - - const data = derived(r => { - const value = selection.read(r); - if (!value) { - return undefined; - } - - const commandGroups: InlineCompletionCommand[][] = []; - for (const [, groupActions] of menuObs.read(r)) { - const group: InlineCompletionCommand[] = []; - for (const action of groupActions) { - if (action instanceof MenuItemAction) { - group.push({ - command: { id: action.item.id, title: action.label }, - icon: ThemeIcon.isThemeIcon(action.item.icon) ? action.item.icon : undefined - }); - } - } - if (group.length > 0) { - commandGroups.push(group); - } - } - - // Use the cursor position (active end of selection) to determine the line - const cursorPosition = value.getPosition(); - const lineRange = new LineRange(cursorPosition.lineNumber, cursorPosition.lineNumber + 1); - - // Create minimal gutter menu data (empty for prototype) - const gutterMenuData = new InlineSuggestionGutterMenuData( - undefined, // action - '', // displayName - commandGroups, // extensionCommands - undefined, // alternativeAction - undefined, // modelInfo - undefined, // setModelId - true, // extensionCommandsOnly - ); - - // Use lightbulb icon/color when code actions are available, otherwise sparkle - const lightBulbInfo = lightBulbObs?.read(r); - const icon = lightBulbInfo ? lightBulbInfo.icon : Codicon.sparkle; - - return new InlineEditsGutterIndicatorData( - gutterMenuData, - lineRange, - new SimpleInlineSuggestModel(() => { }, () => { }), - undefined, // altAction - { icon } - ); - }); - - const focusIsInMenu = observableValue({}, false); - - super( - myEditorObs, data, constObservable(InlineEditTabAction.Inactive), constObservable(0), constObservable(false), focusIsInMenu, - hoverService, instantiationService, accessibilityService, themeService, userInteractionService - ); - - this._store.add(menu); - - - this._store.add(this.onDidCloseWithCommand(commandId => this._onDidRunAction.fire(commandId))); - } -} diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 8832cbe3cc985..4800795f75d5d 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -55,10 +55,9 @@ Registry.as(Extensions.Configuration).registerConfigurat description: localize('affordance', "Controls whether an inline chat affordance is shown when text is selected."), default: 'off', type: 'string', - enum: ['off', 'gutter', 'editor'], + enum: ['off', 'editor'], enumDescriptions: [ localize('affordance.off', "No affordance is shown."), - localize('affordance.gutter', "Show an affordance in the gutter."), localize('affordance.editor', "Show an affordance in the editor at the cursor position."), ], experiment: { diff --git a/src/vs/workbench/contrib/opener/browser/opener.contribution.ts b/src/vs/workbench/contrib/opener/browser/opener.contribution.ts index 881318a804de3..05726b7a7caed 100644 --- a/src/vs/workbench/contrib/opener/browser/opener.contribution.ts +++ b/src/vs/workbench/contrib/opener/browser/opener.contribution.ts @@ -28,6 +28,10 @@ class WorkbenchOpenerContribution extends Disposable implements IOpener { async open(link: URI | string, options?: OpenInternalOptions | OpenExternalOptions): Promise { try { + if ((options as OpenExternalOptions)?.openExternal) { + return false; + } + const uri = typeof link === 'string' ? URI.parse(link) : link; if (this.workspaceContextService.isInsideWorkspace(uri)) { if ((await this.fileService.stat(uri)).isDirectory) { diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index efcd3bac3cd26..3d6e43f40f8a0 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -702,7 +702,6 @@ registerAction2(class extends Action2 { id: MenuId.EditorContent, when: ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabled.negate(), ChatContextKeys.Setup.completed.negate(), ContextKeyExpr.in(ResourceContextKey.Resource.key, 'git.mergeChanges'), ContextKeyExpr.equals('git.activeResourceHasMergeConflicts', true) diff --git a/src/vs/workbench/contrib/scm/browser/scmInput.ts b/src/vs/workbench/contrib/scm/browser/scmInput.ts index 8c014fa20d402..ac5d91a43bc11 100644 --- a/src/vs/workbench/contrib/scm/browser/scmInput.ts +++ b/src/vs/workbench/contrib/scm/browser/scmInput.ts @@ -847,7 +847,6 @@ registerAction2(class extends Action2 { id: MenuId.SCMInputBox, when: ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabled.negate(), ChatContextKeys.Setup.completed.negate(), ContextKeyExpr.equals('scmProvider', 'git') ) diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 961247310655d..929fb8e1ab7c0 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -241,7 +241,7 @@ function isAnonymous(configurationService: IConfigurationService, entitlement: C return false; // only consider signed out users } - if (sentiment.hidden || sentiment.disabled) { + if (sentiment.hidden) { return false; // only consider enabled scenarios } diff --git a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts index 7ab99c1774418..944f45a82ba8c 100644 --- a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts +++ b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts @@ -33,6 +33,7 @@ import { isString } from '../../../../base/common/types.js'; import { Delayer } from '../../../../base/common/async.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { isWeb } from '../../../../base/common/platform.js'; +import { ChatEntitlementService, IChatEntitlementService } from '../../chat/common/chatEntitlementService.js'; const SOURCE = 'IWorkbenchExtensionEnablementService'; @@ -75,6 +76,7 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IExtensionManifestPropertiesService private readonly extensionManifestPropertiesService: IExtensionManifestPropertiesService, + @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @IProductService productService: IProductService @@ -133,6 +135,34 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench }); }); } + + if (!this.environmentService.isSessionsWindow) { + const builtinChatExtensionEnablementMigrationKey = 'builtinChatExtensionEnablementMigration'; + const builtinChatExtensionEnablementMigration = this.storageService.getBoolean(builtinChatExtensionEnablementMigrationKey, StorageScope.PROFILE) === true; + if (!builtinChatExtensionEnablementMigration) { + this.logService.debug('Running builtin chat extension enablement migration'); + this.storageService.store(builtinChatExtensionEnablementMigrationKey, true, StorageScope.PROFILE, StorageTarget.MACHINE); + const context = (chatEntitlementService as ChatEntitlementService).context; + if (context) { + if (context.value.state.completed) { + if (this._isDisabledGlobally({ id: this._chatExtensionId })) { + if (this.configurationService.getValue('chat.disableAIFeatures') !== true) { + this.logService.debug('Disabling AI features because builtin chat extension is disabled'); + this.configurationService.updateValue('chat.disableAIFeatures', true) + .catch(err => this.logService.error('Failed to update chat.disableAIFeatures setting during builtin chat extension enablement migration', err)); + } + } + } else { + try { + this.logService.debug('Disabling builtin chat extension as chat set up is not completed'); + this._disableExtension({ id: this._chatExtensionId }); + } catch (error) { + this.logService.error('Failed to disable builtin chat extension during enablement migration', error); + } + } + } + } + } } private get hasWorkspace(): boolean { diff --git a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts index 8a973961f2b30..c618f6fbd31e4 100644 --- a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts +++ b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts @@ -31,7 +31,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { IExtensionBisectService } from '../../browser/extensionBisect.js'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, WorkspaceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { ExtensionManifestPropertiesService, IExtensionManifestPropertiesService } from '../../../extensions/common/extensionManifestPropertiesService.js'; -import { TestContextService, TestProductService, TestWorkspaceTrustEnablementService, TestWorkspaceTrustManagementService } from '../../../../test/common/workbenchTestServices.js'; +import { TestChatEntitlementService, TestContextService, TestProductService, TestWorkspaceTrustEnablementService, TestWorkspaceTrustManagementService } from '../../../../test/common/workbenchTestServices.js'; import { TestWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; import { ExtensionManagementService } from '../../common/extensionManagementService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; @@ -97,6 +97,7 @@ export class TestExtensionEnablementService extends ExtensionEnablementService { workspaceTrustManagementService, new class extends mock() { override requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Promise { return Promise.resolve(true); } }, instantiationService.get(IExtensionManifestPropertiesService) || instantiationService.stub(IExtensionManifestPropertiesService, disposables.add(new ExtensionManifestPropertiesService(TestProductService, new TestConfigurationService(), new TestWorkspaceTrustEnablementService(), new NullLogService()))), + new TestChatEntitlementService(), instantiationService, new NullLogService(), productService diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletionsExtras.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions/other.fixture.ts similarity index 68% rename from src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletionsExtras.fixture.ts rename to src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions/other.fixture.ts index 5365c321c817b..55c97a33069ca 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletionsExtras.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions/other.fixture.ts @@ -3,29 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { constObservable, IObservableWithChange } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; -import { EditorExtensionsRegistry } from '../../../../../editor/browser/editorExtensions.js'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { observableCodeEditor } from '../../../../../editor/browser/observableCodeEditor.js'; -import { IEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; -import { Position } from '../../../../../editor/common/core/position.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; -import { InlineCompletionsController } from '../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; -import '../../../../../editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; -import { InlineCompletionsSource, InlineCompletionsState } from '../../../../../editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.js'; -import { InlineEditItem } from '../../../../../editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.js'; -import { TextModelValueReference } from '../../../../../editor/contrib/inlineCompletions/browser/model/textModelValueReference.js'; -import { JumpToView } from '../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/jumpToView.js'; -import { GutterIndicatorMenuContent } from '../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.js'; -import { InlineSuggestionGutterMenuData } from '../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.js'; -import { IUserInteractionService, MockUserInteractionService } from '../../../../../platform/userInteraction/browser/userInteractionService.js'; - -import '../../../../../editor/contrib/inlineCompletions/browser/hintsWidget/inlineCompletionsHintsWidget.css'; -import '../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css'; -import '../../../../../base/browser/ui/codicons/codiconStyles.js'; +import { constObservable, IObservableWithChange } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../fixtureUtils.js'; +import { EditorExtensionsRegistry } from '../../../../../../editor/browser/editorExtensions.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { observableCodeEditor } from '../../../../../../editor/browser/observableCodeEditor.js'; +import { IEditorOptions } from '../../../../../../editor/common/config/editorOptions.js'; +import { Position } from '../../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js'; +import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; +import '../../../../../../editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; +import { InlineCompletionsSource, InlineCompletionsState } from '../../../../../../editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.js'; +import { InlineEditItem } from '../../../../../../editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.js'; +import { TextModelValueReference } from '../../../../../../editor/contrib/inlineCompletions/browser/model/textModelValueReference.js'; +import { JumpToView } from '../../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/jumpToView.js'; +import { GutterIndicatorMenuContent } from '../../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.js'; +import { InlineSuggestionGutterMenuData } from '../../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.js'; +import { IUserInteractionService, MockUserInteractionService } from '../../../../../../platform/userInteraction/browser/userInteractionService.js'; + +import '../../../../../../editor/contrib/inlineCompletions/browser/hintsWidget/inlineCompletionsHintsWidget.css'; +import '../../../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css'; +import '../../../../../../base/browser/ui/codicons/codiconStyles.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; const SAMPLE_CODE = `function fibonacci(n: number): number { if (n <= 1) return n; @@ -201,8 +202,8 @@ function renderJumpToHint({ container, disposableStore, theme }: ComponentFixtur function createLongDistanceEditor(options: { container: HTMLElement; - disposableStore: import('../../../../../base/common/lifecycle.js').DisposableStore; - theme: import('../fixtureUtils.js').ComponentFixtureContext['theme']; + disposableStore: DisposableStore; + theme: ComponentFixtureContext['theme']; code: string; cursorLine: number; editRange: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; @@ -278,6 +279,79 @@ function createLongDistanceEditor(options: { controller?.model?.get(); } +function renderNextFileEdit({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '500px'; + container.style.height = '200px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + // The editor shows this file + const editorModel = disposableStore.add(createTextModel( + instantiationService, + `import { Config } from './config'; + +export function createApp(config: Config) { + const app = express(); + app.listen(config.port); +}`, + URI.parse('inmemory://app.ts'), + 'typescript' + )); + + // The suggestion targets a different file + const targetModel = disposableStore.add(createTextModel( + instantiationService, + `export interface Config { + port: number; + host: string; +}`, + URI.parse('inmemory://config.ts'), + 'typescript' + )); + + instantiationService.stubInstance(InlineCompletionsSource, { + cancelUpdate: () => { }, + clear: () => { }, + clearOperationOnTextModelChange: constObservable(undefined) as IObservableWithChange, + clearSuggestWidgetInlineCompletions: () => { }, + dispose: () => { }, + fetch: async () => true, + inlineCompletions: constObservable(new InlineCompletionsState([ + InlineEditItem.createForTest( + TextModelValueReference.snapshot(targetModel), + new Range(1, 1, 3, 100), + `export interface Config {\n\tport: number;\n\thost: string;\n\tdebug: boolean;\n}` + ) + ], undefined)), + loading: constObservable(false), + seedInlineCompletionsWithSuggestWidget: () => { }, + seedWithCompletion: () => { }, + suggestWidgetInlineCompletions: constObservable(InlineCompletionsState.createEmpty()), + }); + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + }, + { contributions: EditorExtensionsRegistry.getEditorContributions() } satisfies ICodeEditorWidgetOptions + )); + + editor.setModel(editorModel); + editor.setPosition({ lineNumber: 3, column: 1 }); + editor.focus(); + + const controller = InlineCompletionsController.get(editor); + controller?.model?.get(); +} + function renderGutterMenu({ container, disposableStore, theme }: ComponentFixtureContext): void { container.style.width = '250px'; container.style.height = '280px'; @@ -330,7 +404,7 @@ function renderGutterMenu({ container, disposableStore, theme }: ComponentFixtur container.appendChild(content.element); } -export default defineThemedFixtureGroup({ path: 'editor/' }, { +export default defineThemedFixtureGroup({ path: 'editor/inlineCompletions/' }, { HintsToolbar: defineComponentFixture({ labels: { kind: 'screenshot' }, render: (context) => renderHintsToolbar(context), @@ -361,6 +435,10 @@ export default defineThemedFixtureGroup({ path: 'editor/' }, { await writeFile(outputPath, processed);`, }), }), + NextFileEditSuggestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderNextFileEdit(context), + }), GutterMenu: defineComponentFixture({ labels: { kind: 'screenshot' }, render: renderGutterMenu, diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions/views.fixture.ts similarity index 62% rename from src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions.fixture.ts rename to src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions/views.fixture.ts index 432edf8777313..484cb369dbacf 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineCompletions/views.fixture.ts @@ -5,18 +5,18 @@ // Import to register the inline completions contribution -import { constObservable, IObservableWithChange } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ComponentFixtureContext, createEditorServices, defineThemedFixtureGroup, defineComponentFixture, createTextModel } from '../fixtureUtils.js'; -import { EditorExtensionsRegistry } from '../../../../../editor/browser/editorExtensions.js'; -import { ICodeEditorWidgetOptions, CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { IEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { InlineCompletionsController } from '../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; -import '../../../../../editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; -import { InlineCompletionsSource, InlineCompletionsState } from '../../../../../editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.js'; -import { InlineEditItem } from '../../../../../editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.js'; -import { TextModelValueReference } from '../../../../../editor/contrib/inlineCompletions/browser/model/textModelValueReference.js'; +import { constObservable, IObservableWithChange } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ComponentFixtureContext, createEditorServices, defineThemedFixtureGroup, defineComponentFixture, createTextModel } from '../../fixtureUtils.js'; +import { EditorExtensionsRegistry } from '../../../../../../editor/browser/editorExtensions.js'; +import { ICodeEditorWidgetOptions, CodeEditorWidget } from '../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IEditorOptions } from '../../../../../../editor/common/config/editorOptions.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; +import '../../../../../../editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; +import { InlineCompletionsSource, InlineCompletionsState } from '../../../../../../editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.js'; +import { InlineEditItem } from '../../../../../../editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.js'; +import { TextModelValueReference } from '../../../../../../editor/contrib/inlineCompletions/browser/model/textModelValueReference.js'; // ============================================================================ @@ -107,18 +107,35 @@ function renderInlineEdit(options: InlineEditOptions): void { // Fixtures // ============================================================================ -export default defineThemedFixtureGroup({ path: 'editor/' }, { - // Side-by-side view: Multi-line replacement - SideBySideView: defineComponentFixture({ +export default defineThemedFixtureGroup({ path: 'editor/inlineCompletions/' }, { + // Side-by-side view: Narrow editor with multi-line replacement + SideBySideViewSmall: defineComponentFixture({ labels: { kind: 'screenshot' }, render: (context) => renderInlineEdit({ ...context, - code: `function greet(name) { - console.log("Hello, " + name); + code: `function calculate(a, b) { + const sum = a + b; + return sum; }`, cursorLine: 2, - range: { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 100 }, - newText: '\tconsole.log(`Hello, ${name}!`);', + range: { startLineNumber: 2, startColumn: 1, endLineNumber: 3, endColumn: 100 }, + newText: '\tconst result = a * b + a + b;\n\tconsole.log(result);\n\treturn result;', + }), + }), + + // Side-by-side view: Wide editor with multi-line replacement + SideBySideViewWide: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderInlineEdit({ + ...context, + code: `function calculate(a, b) { + const sum = a + b; + return sum; +}`, + cursorLine: 2, + range: { startLineNumber: 2, startColumn: 1, endLineNumber: 3, endColumn: 100 }, + newText: '\tconst result = a * b + a + b;\n\tconsole.log(result);\n\treturn result;', + width: '800px', }), }), @@ -160,4 +177,37 @@ export default defineThemedFixtureGroup({ path: 'editor/' }, { } }), }), + + // Deletion view: Removing code + DeletionView: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderInlineEdit({ + ...context, + code: `function process(data: string[]) { + console.log("processing:", data); + const result = data.map(d => d.trim()); + return result; +}`, + cursorLine: 2, + range: { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 100 }, + newText: '', + height: '200px', + }), + }), + + // Line replacement view: Single-line with multiple changes + LineReplacementView: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderInlineEdit({ + ...context, + code: `function calculate(width: number, height: number): number { + const area = width * height; + return area; +}`, + cursorLine: 2, + range: { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 100 }, + newText: '\tconst volume = width * height * depth;', + height: '200px', + }), + }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index b43edc36e97a3..70fca406d12ac 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -500,8 +500,18 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre markPRReviewCommentConverted: () => { }, }); - // Allow additional services to be registered - options?.additionalServices?.({ define, defineInstance, definePartialInstance }); + // Allow additional services to override defaults + options?.additionalServices?.({ + define, + defineInstance: (id: ServiceIdentifier, instance: T) => { + services.set(id, instance); + serviceIdentifiers.push(id); + }, + definePartialInstance: (id: ServiceIdentifier, instance: Partial) => { + services.set(id, instance as T); + serviceIdentifiers.push(id); + }, + }); const instantiationService = disposables.add(new TestInstantiationService(services, true)); diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 6e9a425b8bca3..caa8863086096 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -62,6 +62,16 @@ declare module 'vscode' { */ readonly argumentHint?: string; + /** + * Optional tool restrictions declared by the custom agent. + */ + readonly tools?: readonly string[]; + + /** + * Optional model preferences declared by the custom agent. + */ + readonly model?: readonly string[]; + /** * Whether this custom agent should be shown to users as invocable. */ diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index cb9b2d3477584..70da676d9204c 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -22,9 +22,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.10", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", - "integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -702,9 +702,9 @@ } }, "node_modules/hono": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", - "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", "engines": { "node": ">=16.9.0"