Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
256a46f
Re-enable multifile-edit-claude stests with claude-sonnet-4.5 (#316126)
bhavyaus May 12, 2026
b87705a
Log Hydra per-dimension scores in automode.routerDecision telemetry
May 12, 2026
de31765
Update chat status dashboard for agent window (#316111)
pwang347 May 12, 2026
7c143e9
Merge pull request #316143 from microsoft/aashnagarg/log-hydra-scores
aashna May 12, 2026
78905ec
fix: enable copilot-local ESLint rules in PR CI (#316121)
benvillalobos May 13, 2026
5c43f5d
Agents web: hide harness picker and host suffix in web when single ha…
osortega May 13, 2026
4136011
Terminal output compression: argv-aware filters, dedup cache, structu…
meganrogge May 13, 2026
cfef5c2
Agents web: config picker order on mobile-aware subclass (#316151)
osortega May 13, 2026
a9aeb3d
fix: defer marking old chat model as read to prevent blocking new cha…
DonJayamanne May 13, 2026
c302942
Optimize agent mode instructions handling for efficiency (#316152)
DonJayamanne May 13, 2026
420d10c
Refactor getSDKAgents to avoid using SDK for finding agents temporari…
DonJayamanne May 13, 2026
ba911a6
send telemetry for CLI tool calls (#316135)
amunger May 13, 2026
6a0d8bc
Update manage models heading detection (#316028)
pwang347 May 13, 2026
57fa97f
Revert "run_in_terminal: promote sync command to background after idl…
meganrogge May 13, 2026
7e7b3b2
feat: add session history language model handling to ChatInputPart (#…
DonJayamanne May 13, 2026
d2ce926
Update to chat status dashboard contributed sections (#315134)
pwang347 May 13, 2026
bddbcf2
Bump @vscode/gulp-electron to 1.41.3 (retry transient network errors)…
dmitrivMS May 13, 2026
a3c6210
fix: update setModelId to accept undefined and adjust related session…
DonJayamanne May 13, 2026
efa9345
CI: kill lingering Windows smoke-test processes before Publish Log Fi…
dmitrivMS May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions build/azure-pipelines/win32/steps/product-build-win32-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,19 @@ steps:
testResultsFiles: "*-results.xml"
searchFolder: "$(Build.ArtifactStagingDirectory)/test-results"
condition: succeededOrFailed()

# Force-kill any lingering test processes that still hold smoke-test log
# files open and would otherwise break the 1ES "Publish Log Files" output.
- powershell: |
$ErrorActionPreference = "Continue"
$testRoot = "$(agent.builddirectory)\test"
Get-CimInstance Win32_Process -Filter "ExecutablePath IS NOT NULL" -ErrorAction SilentlyContinue |
Where-Object { $_.ExecutablePath -like "$testRoot\*" } |
ForEach-Object {
Write-Host "Killing lingering test process: pid=$($_.ProcessId), name=$($_.Name), path=$($_.ExecutablePath)"
try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop }
catch { Write-Host " failed: $($_.Exception.Message)" }
}
displayName: Kill lingering test processes
continueOnError: true
condition: succeededOrFailed()
6 changes: 3 additions & 3 deletions extensions/copilot/.eslintplugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { LooseRuleDefinition } from '@typescript-eslint/utils/ts-eslint';
import * as glob from 'glob';
import fs from 'fs';
import path from 'path';

// Re-export all .ts files as rules
const rules: Record<string, LooseRuleDefinition> = {};
await Promise.all(
glob.sync('*.ts', { cwd: import.meta.dirname })
.filter(file => !file.endsWith('index.ts') && !file.endsWith('utils.ts'))
fs.readdirSync(import.meta.dirname)
.filter(file => file.endsWith('.ts') && !file.endsWith('index.ts') && !file.endsWith('utils.ts'))
.map(async file => {
rules[path.basename(file, '.ts')] = (await import('./' + file)).default;
})
Expand Down
10 changes: 9 additions & 1 deletion extensions/copilot/.eslintplugin/no-unlayered-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ export default new class NoUnlayeredFiles implements eslint.Rule.RuleModule {

create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener {

const filenameParts = context.filename.split(path.sep);
// Use only the path relative to extensions/copilot/ to avoid false positives
// from the repo directory name (e.g., "vscode" is both a layer name and the
// checkout directory, so absolute paths always contain it).
const copilotPrefix = `extensions${path.sep}copilot${path.sep}`;
const idx = context.filename.indexOf(copilotPrefix);
const relativePath = idx >= 0
? context.filename.slice(idx + copilotPrefix.length)
: context.filename;
const filenameParts = relativePath.split(path.sep);

if (!filenameParts.find(part => layers.has(part))) {
context.report({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { ResourceSet } from '../../../../util/vs/base/common/map';
import { basename } from '../../../../util/vs/base/common/resources';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { getCopilotLogger } from './logger';
import { ensureNodePtyShim } from './nodePtyShim';
import { ensureRipgrepShim } from './ripgrepShim';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
Expand Down Expand Up @@ -307,7 +306,6 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
readonly onDidChangeAgents: Event<void> = this._onDidChangeAgents.event;
constructor(
@IPromptsService private readonly promptsService: IPromptsService,
@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
@ILogService private readonly logService: ILogService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
Expand Down Expand Up @@ -386,7 +384,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
async getAgentsImpl(): Promise<readonly CLIAgentInfo[]> {
const merged = new Map<string, CLIAgentInfo>();
const knownAgents = new ResourceSet();
const [sdkAgents, customAgents] = await Promise.all([this.getSDKAgents(), this.promptsService.getCustomAgents(CancellationToken.None)]);
const customAgents = await this.promptsService.getCustomAgents(CancellationToken.None);
const hiddenOrInvalidAgentUris = new ResourceSet();
const validCustomAgents = customAgents.filter(customAgent => {
if (!customAgent.enabled || !isEnabledForCopilotCLI(customAgent)) {
Expand All @@ -402,17 +400,6 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
return true;
});

for (const agent of sdkAgents) {
const sourceUri = agent.path ? URI.file(agent.path) : URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` });
if (hiddenOrInvalidAgentUris.has(sourceUri)) {
continue;
}
knownAgents.add(sourceUri);
merged.set(agent.name.toLowerCase(), {
agent: this.cloneAgent(agent),
sourceUri,
});
}
for (const customAgent of validCustomAgents) {
if (knownAgents.has(customAgent.uri)) {
continue;
Expand All @@ -427,18 +414,6 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
return [...merged.values()];
}

private async getSDKAgents(): Promise<Readonly<SweCustomAgent>[]> {
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
if (workspaceFolders.length === 0) {
return [];
}

const [auth, { getCustomAgents }] = await Promise.all([this.copilotCLISDK.getAuthInfo(), this.copilotCLISDK.getPackage()]);
const workingDirectory = workspaceFolders[0];
const agents = await getCustomAgents(auth, workingDirectory.fsPath, undefined, getCopilotLogger(this.logService));
return agents.map(agent => this.cloneAgent(agent));
}

private toCustomAgent(customAgent: vscode.ChatCustomAgent): CLIAgentInfo | undefined {
const agentName = getAgentFileNameFromFilePath(customAgent.uri);
const headerName = customAgent.name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import type { Attachment, LocalSession, SendOptions, Session, SessionOptions } from '@github/copilot/sdk';
import type { Attachment, LocalSession, SendOptions, Session, SessionOptions, ToolExecutionCompleteEvent } from '@github/copilot/sdk';
import * as l10n from '@vscode/l10n';
import * as cp from 'child_process';
import * as crypto from 'crypto';
Expand All @@ -18,6 +18,7 @@ import { GenAiMetrics } from '../../../../platform/otel/common/genAiMetrics';
import { CopilotChatAttr, GenAiAttr, GenAiOperationName, GenAiProviderName, IOTelService, ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel, resolveWorkspaceOTelMetadata, workspaceMetadataToOTelAttributes } from '../../../../platform/otel/common/index';
import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken';
import { IRequestLogger, LoggedRequestKind } from '../../../../platform/requestLogger/common/requestLogger';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
import { PromptTokenCategory, PromptTokenLabel } from '../../../../platform/tokenizer/node/promptTokenDetails';
import { IGitService } from '../../../../platform/git/common/gitService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
Expand Down Expand Up @@ -815,6 +816,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
@IGitService private readonly _gitService: IGitService,
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
@IChatQuotaService private readonly _chatQuotaService: IChatQuotaService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
) {
super();
this.sessionId = _sdkSession.sessionId;
Expand Down Expand Up @@ -955,7 +957,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
}

private async _handleRequestImpl(
request: { id: string; toolInvocationToken: ChatParticipantToolToken },
request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },
input: CopilotCLISessionInput,
attachments: Attachment[],
model: { model: string; reasoningEffort?: string } | undefined,
Expand Down Expand Up @@ -1010,7 +1012,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes

private async _handleRequestImplInner(
invokeAgentSpan: ISpanHandle,
request: { id: string; toolInvocationToken: ChatParticipantToolToken },
request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri },
input: CopilotCLISessionInput,
attachments: Attachment[],
modelId: string | undefined,
Expand Down Expand Up @@ -1062,6 +1064,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes

const editToolIds = new Set<string>();
const toolCalls = new Map<string, ToolCall>();
const toolStartTimes = new Map<string, number>();
const editTracker = new ExternalEditTracker();
let sdkRequestId: string | undefined;
let isQuotaError = false;
Expand Down Expand Up @@ -1332,6 +1335,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
})));
disposables.add(toDisposable(this._sdkSession.on('tool.execution_start', (event) => {
toolCalls.set(event.data.toolCallId, event.data as unknown as ToolCall);
toolStartTimes.set(event.data.toolCallId, Date.now());

if (isCopilotCliEditToolCall(event.data)) {
flushPendingInvocationMessages();
Expand Down Expand Up @@ -1359,18 +1363,24 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
}
})));
disposables.add(toDisposable(this._sdkSession.on('tool.execution_complete', (event) => {
const toolName = toolCalls.get(event.data.toolCallId)?.toolName || '<unknown>';
const toolCall = toolCalls.get(event.data.toolCallId);
const toolName = toolCall?.toolName || '<unknown>';
if (toolName.endsWith('create_pull_request') && event.data.success) {
const pullRequestUrl = extractPullRequestUrlFromToolResult(event.data.result);
if (pullRequestUrl) {
this._createdPullRequestUrl = pullRequestUrl;
GenAiMetrics.incrementPullRequestCount(this._otelService);
}
}
// Emit `languageModelToolInvoked` to mirror the workbench LanguageModelToolsService event
// for the Copilot CLI agent. CLI tools execute inside the SDK and never reach
// LanguageModelToolsService, so the workbench-side emission does not fire for them.
this._sendToolInvokedTelemetry(event, toolCall, toolStartTimes, request.sessionResource);

// Log tool call to request logger
const eventError = event.data.error ? { ...event.data.error, code: event.data.error.code || '' } : undefined;
const eventData = { ...event.data, error: eventError };
this._logToolCall(event.data.toolCallId, toolName, toolCalls.get(event.data.toolCallId)?.arguments, eventData);
this._logToolCall(event.data.toolCallId, toolName, toolCall?.arguments, eventData);

// Mark the end of the edit if this was an edit tool.
toolIdEditMap.set(event.data.toolCallId, editTracker.completeEdit(event.data.toolCallId));
Expand All @@ -1392,9 +1402,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
// When a sql tool execution completes that modifies the todos table,
// query the session database and update the todo list widget.
if (toolName === 'sql' && event.data.success) {
const toolCallData = toolCalls.get(event.data.toolCallId);
try {
const query = (toolCallData?.arguments as { query?: string } | undefined)?.query ?? '';
const query = (toolCall?.arguments as { query?: string } | undefined)?.query ?? '';
if (isTodoRelatedSqlQuery(query)) {
const sessionDir = getCopilotCLISessionDir(this.sessionId);
this._todoSqlQuery.queryTodos(sessionDir).then(items => {
Expand Down Expand Up @@ -2614,6 +2623,53 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
isConversationRequest: true
});
}

private _sendToolInvokedTelemetry(
event: ToolExecutionCompleteEvent,
toolCall: ToolCall | undefined,
toolStartTimes: Map<string, number>,
sessionResource: vscode.Uri | undefined,
): void {
const { toolCallId, success, error } = event.data;
const eventToolName = 'toolName' in event.data && typeof event.data.toolName === 'string' ? event.data.toolName : undefined;
const toolName = toolCall?.toolName ?? eventToolName ?? '<unknown>';
const startTime = toolStartTimes.get(toolCallId);
toolStartTimes.delete(toolCallId);
const invocationTimeMs = startTime !== undefined ? Date.now() - startTime : undefined;

let result: 'success' | 'error' | 'userCancelled';
if (success) {
result = 'success';
} else if (error?.code === 'rejected' || error?.code === 'denied' || error?.code === 'cancelled') {
// `rejected`/`denied` come from the user denying a permission prompt; `cancelled` comes
// from request cancellation.
result = 'userCancelled';
} else {
result = 'error';
}

const toolSourceKind = toolCall?.mcpServerName ? 'mcp' : 'copilotCli';

/* __GDPR__
"languageModelToolInvoked" : {
"owner": "zhichli",
"comment": "Provides insight into the usage of language model tools (Copilot CLI agent).",
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "success | error | userCancelled" },
"chatSessionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat session resource id." },
"toolId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The CLI/SDK tool name (e.g. bash, str_replace_editor, apply_patch)." },
"toolExtensionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Always undefined for CLI." },
"toolSourceKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "copilotCli | mcp" },
"invocationTimeMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time between tool.execution_start and tool.execution_complete (includes any permission wait)." }
}
*/
this._telemetryService.sendMSFTTelemetryEvent('languageModelToolInvoked', {
result,
chatSessionId: sessionResource?.toString(),
toolId: toolName,
toolExtensionId: undefined,
toolSourceKind,
}, invocationTimeMs !== undefined ? { invocationTimeMs } : undefined);
}
}

function extractPullRequestUrlFromToolResult(result: unknown): string | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1048,13 +1048,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
const detailsByCopilotId = new Map<string, RequestIdDetails>();
const defaultModeInstructions = agentId ? await this.resolveAgentModeInstructions(agentId) : undefined;

await Promise.all(storedDetails.map(async d => {
for (const d of storedDetails) {
if (d.copilotRequestId) {
const turnAgentId = d.modeInstructions?.uri || d.agentId;
const modeInstructions = (d.modeInstructions ?? (turnAgentId ? await this.resolveAgentModeInstructions(turnAgentId) : defaultModeInstructions)) ?? defaultModeInstructions;
// Agents from older requests isn't useful, hence to save time.
// Re-use the same custom agent from last request for all previous requests.
const modeInstructions = defaultModeInstructions;
detailsByCopilotId.set(d.copilotRequestId, { requestId: d.vscodeRequestId, toolIdEditMap: d.toolIdEditMap, modeInstructions, responseModelId: d.responseModelId, creditsUsed: d.creditsUsed });
}
}));
}
const getVSCodeRequestId = (sdkRequestId: string) => {
const stored = detailsByCopilotId.get(sdkRequestId);
if (stored) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import type { SweCustomAgent } from '@github/copilot/sdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';
import { ILogService } from '../../../../../platform/log/common/logService';
import { PromptFileParser } from '../../../../../platform/promptFiles/common/promptsService';
Expand All @@ -13,7 +13,7 @@ import { Event } from '../../../../../util/vs/base/common/event';
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../../util/vs/base/common/uri';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { CopilotCLIAgents, type ICopilotCLISDK } from '../copilotCli';
import { CopilotCLIAgents } from '../copilotCli';
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
import type { ChatCustomAgent } from 'vscode';

Expand Down Expand Up @@ -45,23 +45,6 @@ function mockPromptFile(fileName: string, content: string): PromptFileInfo {
return { uri: URI.file(`/workspace/.github/agents/${fileName}`), content };
}

function createMockSDK(agentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>): ICopilotCLISDK {
let index = 0;
const getCustomAgents = vi.fn(async () => {
const result = agentsByCall[Math.min(index, agentsByCall.length - 1)] ?? [];
index += 1;
return result;
});

return {
_serviceBrand: undefined,
getPackage: vi.fn(async () => ({ getCustomAgents })),
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),
getRequestId: vi.fn(() => undefined),
setRequestId: vi.fn(),
} as unknown as ICopilotCLISDK;
}

function createWorkspaceService(): IWorkspaceService {
return {
_serviceBrand: undefined,
Expand Down Expand Up @@ -98,7 +81,7 @@ describe('CopilotCLIAgents', () => {
};
}

function createAgents(options: { sdkAgentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>; customAgents?: PromptFileInfo[] }): { agents: CopilotCLIAgents; promptsService: MockPromptsService; sdk: ICopilotCLISDK } {
function createAgents(options: { sdkAgentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>; customAgents?: PromptFileInfo[] }): { agents: CopilotCLIAgents; promptsService: MockPromptsService } {
const promptsService = disposables.add(new MockPromptsService());
if (options.customAgents) {
const customAgents = [];
Expand All @@ -108,16 +91,14 @@ describe('CopilotCLIAgents', () => {
}
promptsService.setCustomAgents(customAgents);
}
const sdk = createMockSDK(options.sdkAgentsByCall);
const agents = new CopilotCLIAgents(
promptsService,
sdk,
createMockExtensionContext(),
logService,
createWorkspaceService(),
);
disposables.add(agents);
return { agents, promptsService, sdk };
return { agents, promptsService };
}

it('prefers prompt-derived agents over SDK agents with the same name', async () => {
Expand Down Expand Up @@ -173,7 +154,7 @@ Body`)]
});

it('refreshes cached agents when custom agents change', async () => {
const { agents, promptsService, sdk } = createAgents({
const { agents, promptsService } = createAgents({
sdkAgentsByCall: [[], []],
customAgents: [mockPromptFile('first.agent.md', `---
name: First
Expand All @@ -192,7 +173,6 @@ Second body`))]);

expect(first.map(a => a.agent.name)).toEqual(['First']);
expect(second.map(a => a.agent.name)).toEqual(['Second']);
expect(sdk.getPackage).toHaveBeenCalled();
});

it('filters out legacy .chatmode.md files', async () => {
Expand Down
Loading
Loading