From 3727d0cef92f7788874bde4316754cfdc5d6a516 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 27 Apr 2026 10:51:20 -0700 Subject: [PATCH 01/36] feat(chat): add isExpectedError flag to suppress expected agent errors from telemetry Generalizes the existing isRateLimited / isQuotaExceeded suppression added in #311582 so that any user-actionable / expected operational condition thrown from a chat participant can opt out of the chatAgentError telemetry event. - Adds errorDetails.isExpectedError to ChatErrorDetails (proposed API and internal IChatResponseErrorDetails). - extHostChatAgents2 sets the flag when the thrown Error has name === 'ChatExpectedError'. - mainThreadChatAgents2 skips publishing chatAgentError when the flag is set. Lets copilot-chat (and other participants) mark errors like 'cloud agent not enabled', missing git binary, network connectivity loss, EPERM, and UNC host blocked as expected, removing them from error telemetry while keeping the user-facing message. Refs #311582 #311583 #311584 #311585 #311586 #311587 --- .../workbench/api/browser/mainThreadChatAgents2.ts | 13 ++++++++++--- src/vs/workbench/api/common/extHostChatAgents2.ts | 3 ++- .../contrib/chat/common/chatService/chatService.ts | 6 ++++++ .../vscode.proposed.chatParticipantPrivate.d.ts | 9 +++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 7f7211ae2af4b5..10b6d60ecf4719 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -357,9 +357,16 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA chatSessionContext, }, token); - // Suppress expected operational errors (rate limiting, quota exceeded) from error telemetry - // to avoid noise in error reporting. See https://github.com/microsoft/vscode/issues/311582 - if (rpcResult?.errorCallstack && !rpcResult.errorDetails?.isRateLimited && !rpcResult.errorDetails?.isQuotaExceeded) { + // Suppress expected operational errors (rate limiting, quota exceeded, and other + // user-actionable conditions flagged via `isExpectedError`) from error telemetry + // to avoid noise in error reporting. + // See https://github.com/microsoft/vscode/issues/311582 (rate-limited precedent), + // https://github.com/microsoft/vscode/issues/311583 (spawn git ENOENT), + // https://github.com/microsoft/vscode/issues/311584 (network connectivity), + // https://github.com/microsoft/vscode/issues/311585 (EPERM/permission errors), + // https://github.com/microsoft/vscode/issues/311586 (UNC host access), + // https://github.com/microsoft/vscode/issues/311587 (cloud agent not enabled). + if (rpcResult?.errorCallstack && !rpcResult.errorDetails?.isRateLimited && !rpcResult.errorDetails?.isQuotaExceeded && !rpcResult.errorDetails?.isExpectedError) { type ChatAgentErrorEvent = { callstack: string; msg: string; errorName: string; agent: string; agentExtensionId: string }; type ChatAgentErrorClassification = { owner: 'bryanchen-d'; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 0b58d688ac7e42..21ea9150c5f023 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -1027,9 +1027,10 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const isQuotaExceeded = e instanceof Error && e.name === 'ChatQuotaExceeded'; const isRateLimited = e instanceof Error && e.name === 'ChatRateLimited'; + const isExpectedError = e instanceof Error && e.name === 'ChatExpectedError'; const { callstack: errorCallstack } = packErrorForTelemetry(e); const errorName = e instanceof Error ? e.name : undefined; - return { errorDetails: { message: toErrorMessage(e), responseIsIncomplete: true, isQuotaExceeded, isRateLimited }, errorCallstack, errorName }; + return { errorDetails: { message: toErrorMessage(e), responseIsIncomplete: true, isQuotaExceeded, isRateLimited, isExpectedError }, errorCallstack, errorName }; } finally { if (inFlightRequest) { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 96c7c595b2abe8..46396908560402 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -59,6 +59,12 @@ export interface IChatResponseErrorDetails { responseIsRedacted?: boolean; isQuotaExceeded?: boolean; isRateLimited?: boolean; + /** + * If true, the error is an expected operational condition (e.g. user-actionable + * configuration, network connectivity, missing dependency) and should not be + * logged as a `chatAgentError` telemetry event. + */ + isExpectedError?: boolean; level?: ChatErrorLevel; confirmationButtons?: IChatResponseErrorDetailsConfirmationButton[]; code?: string; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 5e354883f77856..2276e22cba05c1 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -256,6 +256,15 @@ declare module 'vscode' { isRateLimited?: boolean; + /** + * If true, the error is an expected operational condition (e.g. user-actionable + * configuration, network connectivity, missing dependency) and should not be + * logged as a `chatAgentError` telemetry event. The error is still surfaced to + * the user. Throwing an `Error` whose `name` is `'ChatExpectedError'` from a + * chat participant handler will set this flag automatically. + */ + isExpectedError?: boolean; + level?: ChatErrorLevel; code?: string; From 332691dde88049bebf944c9324907a4668aee7f7 Mon Sep 17 00:00:00 2001 From: Harald Kirschner Date: Mon, 27 Apr 2026 11:55:28 -0700 Subject: [PATCH 02/36] Fix empty session_files/refs by queuing early tool spans (#312822) * Fix empty session_files/refs by queuing early tool spans * fix: use correct ISpanEventRecord/ICompletedSpanData types * test: simplify tests to use real helpers, address review feedback --- .../chronicle/common/sessionStoreTracking.ts | 20 ++ .../vscode-node/sessionStoreTracker.ts | 62 +++--- .../test/sessionStoreTracker.spec.ts | 184 ++++++++++++++++++ 3 files changed, 242 insertions(+), 24 deletions(-) create mode 100644 extensions/copilot/src/extension/chronicle/vscode-node/test/sessionStoreTracker.spec.ts diff --git a/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts b/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts index 7ad681f532bfbf..b4590d519c2ca4 100644 --- a/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts +++ b/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts @@ -3,10 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { GenAiAttr } from '../../../platform/otel/common/genAiAttributes'; +import type { ICompletedSpanData } from '../../../platform/otel/common/otelService'; + /** * Helpers for extracting file paths and refs from tool calls. */ +/** + * Extract tool arguments from an OTel span. + * Parses the serialized JSON from gen_ai.tool.call.arguments attribute. + * @internal Exported for testing. + */ +export function extractToolArgs(span: ICompletedSpanData): Record { + const serialized = span.attributes[GenAiAttr.TOOL_CALL_ARGUMENTS]; + if (typeof serialized === 'string') { + try { + return JSON.parse(serialized) as Record; + } catch { + // ignore parse errors + } + } + return {}; +} + /** Tools whose arguments contain a file path being modified or read. */ const FILE_TRACKING_TOOLS = new Set([ // VS Code model-facing tool names (from ToolName enum) diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts index 02ea1213fa56b7..b80a3f22197e2b 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts @@ -20,6 +20,7 @@ import { extractRefsFromMcpTool, extractRefsFromTerminal, extractRepoFromMcpTool, + extractToolArgs, isGitHubMcpTool, } from '../common/sessionStoreTracking'; @@ -70,6 +71,9 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib /** Per-session turn counter to avoid collisions between buffered writes and DB state. */ private readonly _turnCounters = new Map(); + /** Tool spans received before session was initialized, keyed by session ID. */ + private readonly _pendingToolSpans = new Map(); + constructor( @ISessionStore private readonly _sessionStore: ISessionStore, @IOTelService private readonly _otelService: IOTelService, @@ -102,7 +106,10 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib "sessionSource": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The agent name/source for the session, or unknown if unavailable." }, "success": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the operation succeeded." }, "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message if failed." }, -"opsCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of buffered operations in a failed flush." } +"opsCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of buffered operations in a failed flush." }, +"filesCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of files tracked in first write." }, +"refsCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of refs tracked in first write." }, +"pendingSpansProcessed": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of pending tool spans processed on session init." } } */ this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle.localStore', { @@ -126,6 +133,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib this._initializedSessions.delete(sessionId); this._lastSessionTimestamp.delete(sessionId); this._turnCounters.delete(sessionId); + this._pendingToolSpans.delete(sessionId); })); })); } @@ -153,6 +161,17 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib // Only track sessions that have an invoke_agent span (real user interactions). // Skip internal LLM calls (title generation, progress messages, etc.) if (!this._initializedSessions.has(sessionId)) { + // Queue tool spans to process after session initialization + // (tool spans complete before their parent invoke_agent span) + if (operationName === GenAiOperationName.EXECUTE_TOOL) { + let pending = this._pendingToolSpans.get(sessionId); + if (!pending) { + pending = []; + this._pendingToolSpans.set(sessionId, pending); + } + pending.push(span); + return; + } if (operationName !== GenAiOperationName.INVOKE_AGENT) { return; } @@ -197,10 +216,22 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib this._firstWriteSessionSource = sessionSource; } + // Process any tool spans that arrived before session was initialized + const pendingSpans = this._pendingToolSpans.get(sessionId); + const pendingCount = pendingSpans?.length ?? 0; + if (pendingSpans) { + this._pendingToolSpans.delete(sessionId); + for (const toolSpan of pendingSpans) { + this._handleToolSpan(sessionId, toolSpan); + } + } + this._telemetryService.sendMSFTTelemetryEvent('chronicle.localStore', { operation: 'sessionInit', sessionSource, - }, {}); + }, { + pendingSpansProcessed: pendingCount, + }); } private _backfillFromSpanAttributes(sessionId: string, span: ICompletedSpanData): void { @@ -229,7 +260,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib } const turnIndex = span.attributes[CopilotChatAttr.TURN_INDEX] as number | undefined; - const toolArgs = this._extractToolArgs(span); + const toolArgs = extractToolArgs(span); // Extract file path const filePath = extractFilePath(toolName, toolArgs); @@ -407,7 +438,10 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib this._telemetryService.sendMSFTTelemetryEvent('chronicle.localStore', { operation: 'firstWrite', sessionSource: this._firstWriteSessionSource ?? 'unknown', - }, {}); + }, { + filesCount: filesToFlush.length, + refsCount: refsToFlush.length, + }); } } catch (err) { @@ -418,24 +452,4 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib }, { opsCount: totalOps }); } } - - // ── Utilities ──────────────────────────────────────────────────────── - - private _extractToolArgs(span: ICompletedSpanData): Record { - const args: Record = {}; - for (const [key, value] of Object.entries(span.attributes)) { - if (key.startsWith('gen_ai.tool.input.')) { - args[key.slice('gen_ai.tool.input.'.length)] = value; - } - } - const serialized = span.attributes['gen_ai.tool.input']; - if (typeof serialized === 'string') { - try { - return JSON.parse(serialized); - } catch { - // ignore parse errors - } - } - return args; - } } diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/test/sessionStoreTracker.spec.ts b/extensions/copilot/src/extension/chronicle/vscode-node/test/sessionStoreTracker.spec.ts new file mode 100644 index 00000000000000..6d4d93d671c774 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/vscode-node/test/sessionStoreTracker.spec.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { GenAiAttr } from '../../../../platform/otel/common/genAiAttributes'; +import type { ICompletedSpanData } from '../../../../platform/otel/common/otelService'; +import { extractFilePath, extractToolArgs } from '../../common/sessionStoreTracking'; + +/** + * These tests verify the span data processing logic used by SessionStoreTracker. + * + * The tests focus on: + * 1. Tool argument extraction from OTel span attributes using the real extractToolArgs helper + * 2. File path extraction using the real extractFilePath helper + * + * Note: Full integration tests of SessionStoreTracker require mocking multiple + * services (ISessionStore, IOTelService, IChatSessionService, etc.) and are + * covered by manual testing and telemetry validation. + */ + +// Create a minimal mock span for testing +function makeSpan(overrides: Partial = {}): ICompletedSpanData { + return { + name: 'test', + traceId: 'trace-1', + spanId: 'span-1', + startTime: 0, + endTime: 1, + attributes: {}, + events: [], + status: { code: 0 }, + ...overrides, + }; +} + +describe('SessionStoreTracker span processing', () => { + describe('tool argument extraction from OTel attributes', () => { + it('uses gen_ai.tool.call.arguments attribute (not gen_ai.tool.input)', () => { + // This test documents the fix for using the correct OTel attribute + const span = makeSpan({ + attributes: { + // The correct attribute that OTel uses + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + filePath: '/src/file.ts', + content: 'test', + }), + // This was incorrectly used before - should be ignored + 'gen_ai.tool.input': JSON.stringify({ wrong: 'data' }), + }, + }); + + const args = extractToolArgs(span); + + expect(args).toEqual({ + filePath: '/src/file.ts', + content: 'test', + }); + // Verify we're not reading from the wrong attribute + expect(args).not.toHaveProperty('wrong'); + }); + + it('returns empty object when attribute is missing', () => { + const span = makeSpan({ attributes: {} }); + expect(extractToolArgs(span)).toEqual({}); + }); + + it('returns empty object for malformed JSON', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: 'not valid json {', + }, + }); + expect(extractToolArgs(span)).toEqual({}); + }); + + it('returns empty object for non-string attribute', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: 12345 as unknown as string, + }, + }); + expect(extractToolArgs(span)).toEqual({}); + }); + }); + + describe('file path extraction pipeline', () => { + // These tests verify the full pipeline: span -> extractToolArgs -> extractFilePath + + it('extracts file from replace_string_in_file span', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + filePath: '/workspace/src/utils.ts', + oldString: 'old', + newString: 'new', + }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('replace_string_in_file', args); + + expect(filePath).toBe('/workspace/src/utils.ts'); + }); + + it('extracts file from create_file span', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + filePath: '/new/module.ts', + content: 'export {}', + }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('create_file', args); + + expect(filePath).toBe('/new/module.ts'); + }); + + it('extracts file from apply_patch span using input field', () => { + const patchInput = '*** Begin Patch\n*** Update File: /lib/helpers.ts\n@@export\n-old\n+new\n*** End Patch'; + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ input: patchInput }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('apply_patch', args); + + expect(filePath).toBe('/lib/helpers.ts'); + }); + + it('extracts file from multi_replace_string_in_file span', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ + explanation: 'fix imports', + replacements: [ + { filePath: '/src/a.ts', oldString: 'x', newString: 'y' }, + { filePath: '/src/b.ts', oldString: 'x', newString: 'y' }, + ], + }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('multi_replace_string_in_file', args); + + // extractFilePath returns first file from replacements array + expect(filePath).toBe('/src/a.ts'); + }); + + it('returns undefined for non-file tools', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ command: 'ls -la' }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('run_in_terminal', args); + + expect(filePath).toBeUndefined(); + }); + + it('returns undefined when args are missing filePath', () => { + const span = makeSpan({ + attributes: { + [GenAiAttr.TOOL_CALL_ARGUMENTS]: JSON.stringify({ content: 'no path' }), + }, + }); + + const args = extractToolArgs(span); + const filePath = extractFilePath('create_file', args); + + expect(filePath).toBeUndefined(); + }); + }); +}); From bdce27da50e1ea12f87fb18fc3f8e989ad73ca3a Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 27 Apr 2026 12:11:16 -0700 Subject: [PATCH 03/36] Gate isExpectedError on chatParticipantPrivate proposed API Address review feedback on #312852: include isExpectedError in the proposed-API gate alongside the other private errorDetails flags so extensions without 'chatParticipantPrivate' enabled cannot suppress chatAgentError telemetry by setting the flag on a returned errorDetails. --- src/vs/workbench/api/common/extHostChatAgents2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 21ea9150c5f023..08e75067940b4b 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -1012,7 +1012,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS responseIsIncomplete: true }; } - if (errorDetails?.responseIsRedacted || errorDetails?.isQuotaExceeded || errorDetails?.isRateLimited || errorDetails?.confirmationButtons || errorDetails?.code) { + if (errorDetails?.responseIsRedacted || errorDetails?.isQuotaExceeded || errorDetails?.isRateLimited || errorDetails?.isExpectedError || errorDetails?.confirmationButtons || errorDetails?.code) { checkProposedApiEnabled(agent.extension, 'chatParticipantPrivate'); } From bdc70906e7dd7d36972dc90355052e326b5ea943 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:22:00 +0000 Subject: [PATCH 04/36] Agents - use etag when polling pull request information (#312819) * Agents - use etag when polling pull request information * Fix tests --- .../browser/fetchers/githubPRFetcher.ts | 32 ++++++--- .../contrib/github/browser/githubApiClient.ts | 65 +++++++++++++++++++ .../browser/models/githubPullRequestModel.ts | 37 +++++++++-- .../test/browser/githubFetchers.test.ts | 24 ++++--- .../github/test/browser/githubModels.test.ts | 8 +-- 5 files changed, 139 insertions(+), 27 deletions(-) diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts index 9197d31a274d45..e285ad41515e31 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts @@ -14,7 +14,7 @@ import { MergeBlockerKind, IGitHubPullRequestReviewThread, } from '../../common/types.js'; -import { GitHubApiClient } from '../githubApiClient.js'; +import { GitHubApiClient, IGitHubApiResponse } from '../githubApiClient.js'; //#region GitHub API response types @@ -159,22 +159,38 @@ export class GitHubPRFetcher { private readonly _apiClient: GitHubApiClient, ) { } - async getPullRequest(owner: string, repo: string, prNumber: number): Promise { - const data = await this._apiClient.request( + async getPullRequest(owner: string, repo: string, prNumber: number, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}`, - 'githubApi.getPullRequest' + 'githubApi.getPullRequest', + undefined, + etag ); - return mapPullRequest(data); + + return { + ...response, + data: response.data + ? mapPullRequest(response.data) + : undefined + }; } - async getReviews(owner: string, repo: string, prNumber: number): Promise { - const data = await this._apiClient.request( + async getReviews(owner: string, repo: string, prNumber: number, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/reviews`, 'githubApi.getReviews', + undefined, + etag ); - return data.map(mapReview); + + return { + ...response, + data: response.data + ? response.data.map(mapReview) + : undefined + }; } async getReviewThreads(owner: string, repo: string, prNumber: number): Promise { diff --git a/src/vs/sessions/contrib/github/browser/githubApiClient.ts b/src/vs/sessions/contrib/github/browser/githubApiClient.ts index 9b02e854ffc8e6..7fcf6ae5d665c4 100644 --- a/src/vs/sessions/contrib/github/browser/githubApiClient.ts +++ b/src/vs/sessions/contrib/github/browser/githubApiClient.ts @@ -13,6 +13,12 @@ const LOG_PREFIX = '[GitHubApiClient]'; const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`; +export interface IGitHubApiResponse { + readonly data: T | undefined; + readonly statusCode: number; + readonly etag?: string; +} + interface IGitHubGraphQLError { readonly message: string; } @@ -54,6 +60,10 @@ export class GitHubApiClient extends Disposable { return this._request(method, `${GITHUB_API_BASE}${path}`, path, 'application/vnd.github.v3+json', callSite, body); } + async request2(method: string, path: string, callSite: string, body?: unknown, etag?: string): Promise> { + return this._request2(method, `${GITHUB_API_BASE}${path}`, path, 'application/vnd.github.v3+json', callSite, body, etag); + } + async graphql(query: string, callSite: string, variables?: Record): Promise { const response = await this._request>( 'POST', @@ -128,6 +138,61 @@ export class GitHubApiClient extends Disposable { return data; } + private async _request2(method: string, url: string, pathForLogging: string, accept: string, callSite: string, body?: unknown, etag?: string): Promise> { + const token = await this._getAuthToken(); + + this._logService.trace(`${LOG_PREFIX} ${method} ${pathForLogging}`); + + const response = await this._requestService.request({ + type: method, + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': accept, + 'User-Agent': 'VSCode-Sessions-GitHub', + ...(etag !== undefined ? { 'If-None-Match': etag } : {}), + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + data: body !== undefined ? JSON.stringify(body) : undefined, + callSite + }, CancellationToken.None); + + const rateLimitRemaining = parseRateLimitHeader(response.res.headers?.['x-ratelimit-remaining']); + if (rateLimitRemaining !== undefined && rateLimitRemaining < 100) { + this._logService.warn(`${LOG_PREFIX} GitHub API rate limit low: ${rateLimitRemaining} remaining`); + } + + const statusCode = response.res.statusCode ?? 0; + const responseETag = response.res.headers?.['etag']; + + if ( + statusCode === 204 /* No Content */ || + statusCode === 304 /* Not Modified */ + ) { + return { data: undefined, statusCode, etag: responseETag }; + } + + if (statusCode < 200 || statusCode >= 300) { + const errorBody = await asJson<{ message?: string }>(response).catch(() => undefined); + throw new GitHubApiError( + errorBody?.message ?? `GitHub API request failed: ${method} ${pathForLogging} (${statusCode})`, + statusCode, + rateLimitRemaining, + ); + } + + const data = await asJson(response); + if (!data) { + throw new GitHubApiError( + `Failed to parse response for ${method} ${pathForLogging}`, + statusCode, + rateLimitRemaining, + ); + } + + return { data, statusCode, etag: responseETag }; + } + private async _getAuthToken(): Promise { let sessions = await this._authenticationService.getSessions('github', [], { silent: true }); if (!sessions || sessions.length === 0) { diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts index 415b0f60a6328c..21c77de511bbfb 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -7,7 +7,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IGitHubPRComment, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubPullRequestReviewThread } from '../../common/types.js'; +import { IGitHubPRComment, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubPullRequestReview, IGitHubPullRequestReviewThread } from '../../common/types.js'; import { computeMergeability, GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestModel]'; @@ -19,9 +19,14 @@ const DEFAULT_POLL_INTERVAL_MS = 60_000; */ export class GitHubPullRequestModel extends Disposable { + private _pullRequestEtag: string | undefined = undefined; private readonly _pullRequest = observableValue(this, undefined); readonly pullRequest: IObservable = this._pullRequest; + private _reviewsEtag: string | undefined = undefined; + private readonly _reviews = observableValue(this, undefined); + readonly reviews: IObservable = this._reviews; + private readonly _mergeability = observableValue(this, undefined); readonly mergeability: IObservable = this._mergeability; @@ -112,15 +117,33 @@ export class GitHubPullRequestModel extends Disposable { private async _refreshPullRequestAndMergeability(): Promise { try { const [pr, reviews] = await Promise.all([ - this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber), - this._fetcher.getReviews(this.owner, this.repo, this.prNumber), + this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber, this._pullRequestEtag), + this._fetcher.getReviews(this.owner, this.repo, this.prNumber, this._reviewsEtag), ]); - const mergeability = computeMergeability(pr, reviews); - transaction(tx => { - this._pullRequest.set(pr, tx); - this._mergeability.set(mergeability, tx); + if (pr.statusCode === 200 && pr.data) { + this._pullRequestEtag = pr.etag; + this._pullRequest.set(pr.data, tx); + } + + if (reviews.statusCode === 200 && reviews.data) { + this._reviewsEtag = reviews.etag; + this._reviews.set(reviews.data, tx); + } + + // Recompute mergeability if either the pull request or reviews changed. Both + // are needed to compute mergeability, so we wait until both requests complete + // before updating. + if (pr.statusCode === 200 || reviews.statusCode === 200) { + const prData = pr.data ?? this._pullRequest.get(); + const reviewsData = reviews.data ?? this._reviews.get(); + + if (prData && reviewsData) { + const mergeability = computeMergeability(prData, reviewsData); + this._mergeability.set(mergeability, tx); + } + } }); } catch (err) { this._logService.error(`${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); diff --git a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts index e3eddbc105157e..3daf688ee34055 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -37,6 +37,14 @@ class MockApiClient { return this._nextResponse as T; } + async request2(_method: string, _path: string, _callSite: string, _body?: unknown, _etag?: string): Promise<{ data: T | undefined; statusCode: number; etag?: string }> { + this.requestCalls.push({ method: _method, path: _path, body: _body }); + if (this._nextError) { + throw this._nextError; + } + return { data: this._nextResponse as T, statusCode: 200 }; + } + async graphql(query: string, _callSite: string, variables?: Record): Promise { this.graphqlCalls.push({ query, variables }); if (this._nextError) { @@ -125,25 +133,25 @@ suite('GitHubPRFetcher', () => { mockApi.setNextResponse(makePRResponse({ state: 'open', merged: false, draft: false })); const pr = await fetcher.getPullRequest('owner', 'repo', 1); - assert.strictEqual(pr.state, GitHubPullRequestState.Open); - assert.strictEqual(pr.isDraft, false); - assert.strictEqual(pr.number, 1); - assert.strictEqual(pr.title, 'Test PR'); + assert.strictEqual(pr.data?.state, GitHubPullRequestState.Open); + assert.strictEqual(pr.data?.isDraft, false); + assert.strictEqual(pr.data?.number, 1); + assert.strictEqual(pr.data?.title, 'Test PR'); }); test('getPullRequest maps merged PR', async () => { mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: true, draft: false })); const pr = await fetcher.getPullRequest('owner', 'repo', 1); - assert.strictEqual(pr.state, GitHubPullRequestState.Merged); - assert.ok(pr.mergedAt); + assert.strictEqual(pr.data?.state, GitHubPullRequestState.Merged); + assert.ok(pr.data?.mergedAt); }); test('getPullRequest maps closed PR', async () => { mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: false, draft: false })); const pr = await fetcher.getPullRequest('owner', 'repo', 1); - assert.strictEqual(pr.state, GitHubPullRequestState.Closed); + assert.strictEqual(pr.data?.state, GitHubPullRequestState.Closed); }); test('getReviewThreads returns GraphQL thread metadata', async () => { @@ -205,7 +213,7 @@ suite('GitHubPRFetcher', () => { ]); const reviews = await fetcher.getReviews('owner', 'repo', 1); - assert.deepStrictEqual(reviews, [ + assert.deepStrictEqual(reviews.data, [ { id: 1, author: { login: 'reviewer', avatarUrl: '' }, state: 'APPROVED', submittedAt: '2024-01-01T00:00:00Z' }, { id: 2, author: { login: 'other', avatarUrl: '' }, state: 'CHANGES_REQUESTED', submittedAt: '2024-01-02T00:00:00Z' }, ]); diff --git a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts index 7cd1239391c342..1e712366931de9 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -35,15 +35,15 @@ class MockPRFetcher { postReviewCommentCalls: { body: string; inReplyTo: number }[] = []; postIssueCommentCalls: { body: string }[] = []; - async getPullRequest(_owner: string, _repo: string, _prNumber: number): Promise { + async getPullRequest(_owner: string, _repo: string, _prNumber: number, _etag?: string): Promise<{ data: IGitHubPullRequest | undefined; statusCode: number; etag?: string }> { if (!this.nextPR) { throw new Error('No mock PR'); } - return this.nextPR; + return { data: this.nextPR, statusCode: 200 }; } - async getReviews(_owner: string, _repo: string, _prNumber: number): Promise { - return this.nextReviews; + async getReviews(_owner: string, _repo: string, _prNumber: number, _etag?: string): Promise<{ data: readonly IGitHubPullRequestReview[] | undefined; statusCode: number; etag?: string }> { + return { data: this.nextReviews, statusCode: 200 }; } async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise { From 8894b02366ed4f13bb28c836f42e312a872b36c2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 27 Apr 2026 15:50:46 -0400 Subject: [PATCH 05/36] fix: downgrade rich to basic execute strategy when shell integration breaks mid-command (#312854) --- .../executeStrategy/executeStrategy.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index ef5f463b2a4b58..8f7950e28d38c9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -197,6 +197,21 @@ export async function trackIdleOnPrompt( scheduler.schedule(); }, 10_000)); initialFallbackScheduler.schedule(); + // Fallback for when shell integration breaks mid-command: data arrives and + // C/D sequences transition us to Executing, but no A (prompt) sequence ever + // follows. Both initialFallbackScheduler and promptFallbackScheduler get + // cancelled in that state, causing a permanent hang. This scheduler is + // rescheduled on every data event while in the Executing state, so it only + // fires after 30s of data-idle — long enough that actively-outputting + // commands won't be cut off, but short enough to prevent indefinite hangs + // when shell integration breaks. When shell integration is working, + // onCommandFinished in the rich strategy's race wins before this fires. + const executingFallbackScheduler = store.add(new RunOnceScheduler(() => { + if (state === TerminalState.Executing) { + state = TerminalState.PromptAfterExecuting; + scheduler.schedule(); + } + }, 30_000)); // Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents // cases where the command is executed before the prompt is written. While not perfect, sitting // on an A without a C following shortly after is a very good indicator that the command is done @@ -222,14 +237,17 @@ export async function trackIdleOnPrompt( state = TerminalState.Prompt; } else if (state === TerminalState.Executing) { state = TerminalState.PromptAfterExecuting; + executingFallbackScheduler.cancel(); } } else if (match.groups?.type === 'C' || match.groups?.type === 'D') { state = TerminalState.Executing; + executingFallbackScheduler.schedule(); } } // Re-schedule on every data event as we're tracking data idle if (state === TerminalState.PromptAfterExecuting) { promptFallbackScheduler.cancel(); + executingFallbackScheduler.cancel(); scheduler.schedule(); } else { scheduler.cancel(); @@ -237,6 +255,9 @@ export async function trackIdleOnPrompt( promptFallbackScheduler.schedule(); } else { promptFallbackScheduler.cancel(); + // Re-schedule on every data event so it only fires after 30s + // of data-idle while in the Executing state. + executingFallbackScheduler.schedule(); } } })); From 442fb27caeac93bc1b4c5585315389f974dd2459 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 27 Apr 2026 15:55:24 -0400 Subject: [PATCH 06/36] Add `/requires-eval-assessment` comment command (#312877) Add /requires-eval-assessment comment command --- .github/commands.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/commands.json b/.github/commands.json index c52e21eeb8dec8..4a214f62e8ee11 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -670,5 +670,11 @@ "addLabel": "accessibility-sla", "removeLabel": "~accessibility-sla", "comment": "The Visual Studio and VS Code teams have an agreement with the Accessibility team that 3:1 contrast is enough for inside the editor." + }, + { + "type": "comment", + "name": "requires-eval-assessment", + "action": "updateLabels", + "addLabel": "~requires-eval-assessment" } ] From 76cfc09358cf701f05dce6e2b7ddbbc68c781681 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Mon, 27 Apr 2026 22:02:46 +0200 Subject: [PATCH 07/36] Sessions: Add cross-app trust note to all workspace trust dialogs (#312882) When trusting a folder or workspace from the Sessions (Agents) window, users had no indication that the trust decision also persists to the parent VS Code install. This adds an explanatory note to all four trust dialog paths: - Resources trust dialog (requestResourcesTrust) - Workspace trust request dialog (onDidInitiateWorkspaceTrustRequest) - Startup trust modal (onDidInitiateWorkspaceTrustRequestOnStartup) - Add workspace folder confirmation (onWillChangeWorkspaceFolders) The note derives the parent app name from productService.quality (stable/insider/exploration) with a fallback to productService.nameLong. A shared getSessionsWindowTrustNote() function avoids duplication across all dialog handlers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../welcome/browser/sessionsWalkthrough.ts | 4 +- .../browser/workspace.contribution.ts | 75 +++++++++++++++---- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts index 246f89eb07208b..436040e7446741 100644 --- a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts +++ b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts @@ -195,7 +195,7 @@ export class SessionsWalkthroughOverlay extends Disposable { // Always show the welcome title/subtitle with sign-in buttons, // whether it's the first launch or a returning user who is signed out. const titleEl = append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName))); - const subtitleEl = append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered coding agent that builds, tests, and iterates for you."))); + const subtitleEl = append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you."))); append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!"))); this._renderSignInButtons(stepDisposables, right, titleEl, subtitleEl); @@ -283,7 +283,7 @@ export class SessionsWalkthroughOverlay extends Disposable { this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0); append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName))); - append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered coding agent that builds, tests, and iterates for you."))); + append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you."))); append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!"))); const actions = append(right, $('.sessions-walkthrough-welcome-actions')); diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index d13453552f1b29..1d831a025a592f 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -48,13 +48,34 @@ import { IRemoteAgentService } from '../../../services/remote/common/remoteAgent import { securityConfigurationNodeBase } from '../../../common/configuration.js'; import { basename, dirname as uriDirname } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; const BANNER_RESTRICTED_MODE = 'workbench.banner.restrictedMode'; const STARTUP_PROMPT_SHOWN_KEY = 'workspace.trust.startupPrompt.shown'; const BANNER_RESTRICTED_MODE_DISMISSED_KEY = 'workbench.banner.restrictedMode.dismissed'; +/** + * Returns a trust note string for the sessions window explaining that trusting + * a folder/workspace also persists trust to the parent VS Code install. + * Returns `undefined` when not running in the sessions window. + */ +function getSessionsWindowTrustNote(environmentService: IWorkbenchEnvironmentService, productService: IProductService, isWorkspace: boolean): string | undefined { + if (!environmentService.isSessionsWindow) { + return undefined; + } + const parentAppName = productService.quality === 'stable' + ? 'Visual Studio Code' + : productService.quality === 'insider' + ? 'Visual Studio Code Insiders' + : productService.quality === 'exploration' + ? 'Visual Studio Code Exploration' + : productService.nameLong; + if (isWorkspace) { + return localize('sessionsWindowWorkspaceTrustNote', "Trusting this workspace will also mark it as trusted in {0}.", parentAppName); + } + return localize('sessionsWindowFolderTrustNote', "Trusting this folder will also mark it as trusted in {0}.", parentAppName); +} + export class WorkspaceTrustContextKeys extends Disposable implements IWorkbenchContribution { private readonly _ctxWorkspaceTrustEnabled: IContextKey; @@ -94,7 +115,9 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben @ILabelService private readonly labelService: ILabelService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, - @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService) { + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IProductService private readonly productService: IProductService) { super(); this.registerListeners(); @@ -159,6 +182,11 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben `\`${this.labelService.getUriLabel(options.uri)}\`` ]; + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, false); + if (sessionsTrustNote) { + markdownDetails.push(sessionsTrustNote); + } + // Dialog await this.dialogService.prompt({ type: Severity.Info, @@ -204,15 +232,20 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben } // Dialog + const markdownDetails = [ + { markdown: new MarkdownString(details) }, + { markdown: new MarkdownString(localize('immediateTrustRequestLearnMore', "If you don't trust the authors of these files, we do not recommend continuing as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more.")) } + ]; + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, this.useWorkspaceLanguage); + if (sessionsTrustNote) { + markdownDetails.push({ markdown: new MarkdownString(sessionsTrustNote) }); + } const { result } = await this.dialogService.prompt({ type: Severity.Info, message, custom: { icon: Codicon.shield, - markdownDetails: [ - { markdown: new MarkdownString(details) }, - { markdown: new MarkdownString(localize('immediateTrustRequestLearnMore', "If you don't trust the authors of these files, we do not recommend continuing as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more.")) } - ] + markdownDetails }, buttons: buttons.filter(b => b.type !== 'Cancel').map(button => { return { @@ -278,7 +311,7 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon @IHostService private readonly hostService: IHostService, @IProductService private readonly productService: IProductService, @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, - @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IFileService private readonly fileService: IFileService, ) { super(); @@ -325,10 +358,15 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon const addedFoldersTrustInfo = await Promise.all(e.changes.added.map(folder => this.workspaceTrustManagementService.getUriTrustInfo(folder.uri))); if (!addedFoldersTrustInfo.map(info => info.trusted).every(trusted => trusted)) { + let detail = localize('addWorkspaceFolderDetail', "You are adding files that are not currently trusted to a trusted workspace. Do you trust the authors of these new files?"); + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, false); + if (sessionsTrustNote) { + detail += '\n\n' + sessionsTrustNote; + } const { confirmed } = await this.dialogService.confirm({ type: Severity.Info, message: localize('addWorkspaceFolderMessage', "Do you trust the authors of the files in this folder?"), - detail: localize('addWorkspaceFolderDetail', "You are adding files that are not currently trusted to a trusted workspace. Do you trust the authors of these new files?"), + detail, cancelButton: localize('no', 'No'), custom: { icon: Codicon.shield } }); @@ -376,18 +414,23 @@ export class WorkspaceTrustUXHandler extends Disposable implements IWorkbenchCon } // Show Workspace Trust Start Dialog + const markdownStrings = [ + !isSingleFolderWorkspace ? + localize('workspaceStartupTrustDetails', "{0} provides features that may automatically execute files in this workspace.", this.productService.nameShort) : + localize('folderStartupTrustDetails', "{0} provides features that may automatically execute files in this folder.", this.productService.nameShort), + learnMoreString ?? localize('startupTrustRequestLearnMore', "If you don't trust the authors of these files, we recommend to continue in restricted mode as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more."), + !isEmptyWindow ? + `\`${this.labelService.getWorkspaceLabel(workspaceIdentifier, { verbose: Verbosity.LONG })}\`` : '', + ]; + const sessionsTrustNote = getSessionsWindowTrustNote(this.environmentService, this.productService, !isSingleFolderWorkspace); + if (sessionsTrustNote) { + markdownStrings.push(sessionsTrustNote); + } this.doShowModal( title, { label: trustOption ?? localize({ key: 'trustOption', comment: ['&& denotes a mnemonic'] }, "&&Yes, I trust the authors"), sublabel: isSingleFolderWorkspace ? localize('trustFolderOptionDescription', "Trust folder and enable all features") : localize('trustWorkspaceOptionDescription', "Trust workspace and enable all features") }, { label: dontTrustOption ?? localize({ key: 'dontTrustOption', comment: ['&& denotes a mnemonic'] }, "&&No, I don't trust the authors"), sublabel: isSingleFolderWorkspace ? localize('dontTrustFolderOptionDescription', "Open folder in restricted mode") : localize('dontTrustWorkspaceOptionDescription', "Open workspace in restricted mode") }, - [ - !isSingleFolderWorkspace ? - localize('workspaceStartupTrustDetails', "{0} provides features that may automatically execute files in this workspace.", this.productService.nameShort) : - localize('folderStartupTrustDetails', "{0} provides features that may automatically execute files in this folder.", this.productService.nameShort), - learnMoreString ?? localize('startupTrustRequestLearnMore', "If you don't trust the authors of these files, we recommend to continue in restricted mode as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more."), - !isEmptyWindow ? - `\`${this.labelService.getWorkspaceLabel(workspaceIdentifier, { verbose: Verbosity.LONG })}\`` : '', - ], + markdownStrings, checkboxText ); })); From 42a65577f141772f8e0b73c12e8f0b7bf07d368a Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:30:59 +0000 Subject: [PATCH 08/36] Agents - adopting etags for additional REST API calls (#312893) * More etags adoption * Remove the usage of request() --- .../browser/fetchers/githubChangesFetcher.ts | 6 ++-- .../browser/fetchers/githubPRCIFetcher.ts | 36 +++++++++++-------- .../browser/fetchers/githubPRFetcher.ts | 13 +++++-- .../fetchers/githubRepositoryFetcher.ts | 28 +++++++++------ .../models/githubPullRequestCIModel.ts | 10 ++++-- .../browser/models/githubRepositoryModel.ts | 8 +++-- .../test/browser/githubFetchers.test.ts | 10 +++--- .../github/test/browser/githubModels.test.ts | 8 ++--- 8 files changed, 75 insertions(+), 44 deletions(-) diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts index bc53ff1b4eb7a9..c991541331f27b 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts @@ -26,19 +26,19 @@ export class GitHubChangesFetcher { ) { } async getChangedFiles(owner: string, repo: string, base: string, head: string): Promise { - const data = await this._apiClient.request( + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/compare/${e(base)}...${e(head)}`, 'githubApi.getChangedFiles' ); - return data.files.map(file => ({ + return response.data?.files.map(file => ({ filename: file.filename, previous_filename: file.previous_filename, status: file.status, additions: file.additions, deletions: file.deletions, - })); + })) ?? []; } } diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts index 3b46c17564ae0c..81b172be84509a 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../common/types.js'; -import { GitHubApiClient } from '../githubApiClient.js'; +import { GitHubApiClient, IGitHubApiResponse } from '../githubApiClient.js'; //#region GitHub API response types @@ -59,20 +59,28 @@ export class GitHubPRCIFetcher { private readonly _apiClient: GitHubApiClient, ) { } - async getCheckRuns(owner: string, repo: string, ref: string): Promise { - const data = await this._apiClient.request( + async getCheckRuns(owner: string, repo: string, ref: string, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/commits/${e(ref)}/check-runs`, - 'githubApi.getCheckRuns' + 'githubApi.getCheckRuns', + undefined, + etag ); - return data.check_runs.map(mapCheckRun); + + return { + ...response, + data: response.data + ? response.data.check_runs.map(mapCheckRun) + : undefined + }; } /** * Rerun failed jobs in a GitHub Actions workflow run. */ async rerunFailedJobs(owner: string, repo: string, runId: number): Promise { - await this._apiClient.request( + await this._apiClient.request2( 'POST', `/repos/${e(owner)}/${e(repo)}/actions/runs/${runId}/rerun-failed-jobs`, 'githubApi.rerunFailedJobs' @@ -90,23 +98,22 @@ export class GitHubPRCIFetcher { */ async getCheckRunAnnotations(owner: string, repo: string, checkRunId: number): Promise { const sections: string[] = []; - let detail: IGitHubCheckRunDetailResponse | undefined; // 1. Fetch check run detail for output fields try { - detail = await this._apiClient.request( + const detailResponse = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}`, 'githubApi.getCheckRunAnnotations' ); - const output = detail.output; - if (output.title) { + const output = detailResponse.data?.output; + if (output?.title) { sections.push(`# ${output.title}`); } - if (output.summary) { + if (output?.summary) { sections.push(output.summary); } - if (output.text) { + if (output?.text) { sections.push(output.text); } } catch { @@ -115,12 +122,13 @@ export class GitHubPRCIFetcher { // 2. Fetch annotations try { - const annotations = await this._apiClient.request( + const annotationsResponse = await this._apiClient.request2( 'GET', `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}/annotations`, 'githubApi.getCheckRunAnnotations.annotations' ); - if (annotations.length > 0) { + const annotations = annotationsResponse.data; + if (annotations && annotations.length > 0) { sections.push( annotations.map(a => `[${a.annotation_level}] ${a.path}:${a.start_line}${a.end_line !== a.start_line ? `-${a.end_line}` : ''} ${a.title ? `(${a.title}) ` : ''}${a.message}` diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts index e285ad41515e31..37fdfc8849ece4 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts @@ -215,13 +215,16 @@ export class GitHubPRFetcher { body: string, inReplyTo: number, ): Promise { - const data = await this._apiClient.request( + const response = await this._apiClient.request2( 'POST', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/comments`, 'githubApi.postReviewComment', { body, in_reply_to: inReplyTo }, ); - return mapReviewComment(data); + if (!response.data) { + throw new Error(`Failed to post review comment to ${owner}/${repo}#${prNumber}`); + } + return mapReviewComment(response.data); } async postIssueComment( @@ -230,12 +233,16 @@ export class GitHubPRFetcher { prNumber: number, body: string, ): Promise { - const data = await this._apiClient.request( + const response = await this._apiClient.request2( 'POST', `/repos/${e(owner)}/${e(repo)}/issues/${prNumber}/comments`, 'githubApi.postIssueComment', { body }, ); + const data = response.data; + if (!data) { + throw new Error(`Failed to post issue comment to ${owner}/${repo}#${prNumber}`); + } return { id: data.id, body: data.body ?? '', diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts index 5e4a90dfa90d18..2f4cb1bbfef73b 100644 --- a/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IGitHubRepository } from '../../common/types.js'; -import { GitHubApiClient } from '../githubApiClient.js'; +import { GitHubApiClient, IGitHubApiResponse } from '../githubApiClient.js'; interface IGitHubRepoResponse { readonly name: string; @@ -25,19 +25,27 @@ export class GitHubRepositoryFetcher { private readonly _apiClient: GitHubApiClient, ) { } - async getRepository(owner: string, repo: string): Promise { - const data = await this._apiClient.request( + async getRepository(owner: string, repo: string, etag?: string): Promise> { + const response = await this._apiClient.request2( 'GET', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, - 'githubApi.getRepository' + 'githubApi.getRepository', + undefined, + etag ); + return { - owner: data.owner.login, - name: data.name, - fullName: data.full_name, - defaultBranch: data.default_branch, - isPrivate: data.private, - description: data.description ?? '', + ...response, + data: response.data + ? { + owner: response.data.owner.login, + name: response.data.name, + fullName: response.data.full_name, + defaultBranch: response.data.default_branch, + isPrivate: response.data.private, + description: response.data.description ?? '', + } + : undefined }; } } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts index 3f0cec668525eb..ba12e62e611113 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts @@ -19,6 +19,7 @@ const DEFAULT_POLL_INTERVAL_MS = 60_000; */ export class GitHubPullRequestCIModel extends Disposable { + private _checksEtag: string | undefined = undefined; private readonly _checks = observableValue(this, []); readonly checks: IObservable = this._checks; @@ -45,9 +46,12 @@ export class GitHubPullRequestCIModel extends Disposable { */ async refresh(): Promise { try { - const checks = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headRef); - this._checks.set(checks, undefined); - this._overallStatus.set(computeOverallCIStatus(checks), undefined); + const response = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headRef, this._checksEtag); + if (response.statusCode === 200 && response.data) { + this._checksEtag = response.etag; + this._checks.set(response.data, undefined); + this._overallStatus.set(computeOverallCIStatus(response.data), undefined); + } } catch (err) { this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}@${this.headRef}:`, err); } diff --git a/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts index 9e2c368a329ab3..e34399eff78a15 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts @@ -17,6 +17,7 @@ const LOG_PREFIX = '[GitHubRepositoryModel]'; */ export class GitHubRepositoryModel extends Disposable { + private _repositoryEtag: string | undefined = undefined; private readonly _repository = observableValue(this, undefined); readonly repository: IObservable = this._repository; @@ -31,8 +32,11 @@ export class GitHubRepositoryModel extends Disposable { async refresh(): Promise { try { - const data = await this._fetcher.getRepository(this.owner, this.repo); - this._repository.set(data, undefined); + const response = await this._fetcher.getRepository(this.owner, this.repo, this._repositoryEtag); + if (response.statusCode === 200 && response.data) { + this._repositoryEtag = response.etag; + this._repository.set(response.data, undefined); + } } catch (err) { this._logService.error(`${LOG_PREFIX} Failed to refresh repository ${this.owner}/${this.repo}:`, err); } diff --git a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts index 3daf688ee34055..c9c6f7d54253e0 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -80,7 +80,7 @@ suite('GitHubRepositoryFetcher', () => { }); const repo = await fetcher.getRepository('microsoft', 'vscode'); - assert.deepStrictEqual(repo, { + assert.deepStrictEqual(repo.data, { owner: 'microsoft', name: 'vscode', fullName: 'microsoft/vscode', @@ -102,7 +102,7 @@ suite('GitHubRepositoryFetcher', () => { }); const repo = await fetcher.getRepository('owner', 'test'); - assert.strictEqual(repo.description, ''); + assert.strictEqual(repo.data?.description, ''); }); test('getRepository propagates API errors', async () => { @@ -278,8 +278,8 @@ suite('GitHubPRCIFetcher', () => { }); const checks = await fetcher.getCheckRuns('owner', 'repo', 'abc123'); - assert.strictEqual(checks.length, 2); - assert.deepStrictEqual(checks[0], { + assert.strictEqual(checks.data?.length, 2); + assert.deepStrictEqual(checks.data?.[0], { id: 1, name: 'build', status: GitHubCheckStatus.Completed, @@ -288,7 +288,7 @@ suite('GitHubPRCIFetcher', () => { completedAt: '2024-01-01T00:10:00Z', detailsUrl: 'https://example.com/1', }); - assert.strictEqual(checks[1].conclusion, undefined); + assert.strictEqual(checks.data?.[1].conclusion, undefined); }); test('getCheckRunAnnotations returns formatted annotations', async () => { diff --git a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts index 1e712366931de9..a8618eefec6c0b 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -20,11 +20,11 @@ import { GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHub class MockRepositoryFetcher { nextResult: IGitHubRepository | undefined; - async getRepository(_owner: string, _repo: string): Promise { + async getRepository(_owner: string, _repo: string, _etag?: string): Promise<{ data: IGitHubRepository | undefined; statusCode: number; etag?: string }> { if (!this.nextResult) { throw new Error('No mock result'); } - return this.nextResult; + return { data: this.nextResult, statusCode: 200 }; } } @@ -68,8 +68,8 @@ class MockPRFetcher { class MockCIFetcher { nextChecks: IGitHubCICheck[] = []; - async getCheckRuns(_owner: string, _repo: string, _ref: string): Promise { - return this.nextChecks; + async getCheckRuns(_owner: string, _repo: string, _ref: string, _etag?: string): Promise<{ data: readonly IGitHubCICheck[] | undefined; statusCode: number; etag?: string }> { + return { data: this.nextChecks, statusCode: 200 }; } async getCheckRunAnnotations(_owner: string, _repo: string, _checkRunId: number): Promise { From 015b0b4d78f3d2018f45cfb041e7ac7665ede78b Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:46:57 -0700 Subject: [PATCH 09/36] chat: remove Troubleshoot button from AI Customizations UI (#312861) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationListWidget.ts | 5 +- .../aiCustomizationManagement.contribution.ts | 59 +------------------ .../aiCustomizationManagement.ts | 4 -- 3 files changed, 2 insertions(+), 66 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 2fb6b9783a63b3..241a0072b995ad 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -20,7 +20,7 @@ import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../. import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, sectionToPromptType } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, sectionToPromptType } from './aiCustomizationManagement.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -235,7 +235,6 @@ class AICustomizationItemRenderer implements IListRenderer { - const commandService = accessor.get(ICommandService); - const editorService = accessor.get(IEditorService); - const rawName = extractName(context); - const displayName = rawName?.replace(/\.md$/i, ''); - const query = displayName - ? `/troubleshoot ${displayName}` - : '/troubleshoot'; - - // Close any open Agent Customizations editors before sending the chat. - const customizationEditors = editorService.getEditors(EditorsOrder.SEQUENTIAL) - .filter(({ editor }) => editor instanceof AICustomizationManagementEditorInput); - if (customizationEditors.length) { - await editorService.closeEditors(customizationEditors); - } - - await commandService.executeCommand('workbench.action.chat.open', { - query, - isPartialQuery: false, - }); - } -}); - // Reveal in Finder/Explorer action const REVEAL_IN_OS_LABEL = isWindows ? localize2('revealInWindows', "Reveal in File Explorer") @@ -492,13 +449,6 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: WHEN_ITEM_IS_DELETABLE, }); -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: TROUBLESHOOT_AI_CUSTOMIZATION_ID, title: localize('troubleshootInline', "Troubleshoot"), icon: Codicon.bug }, - group: 'inline', - order: 2, - when: ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), -}); - // Context menu items (shown on right-click) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID, title: localize('open', "Open") }, @@ -513,13 +463,6 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.prompt), }); -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: TROUBLESHOOT_AI_CUSTOMIZATION_ID, title: localize('troubleshootItem', "Troubleshoot") }, - group: '2_run', - order: 2, - when: ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), -}); - MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID, title: REVEAL_IN_OS_LABEL.value }, group: '3_file', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index f2a30634514fcf..cea174c6d5835d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -123,10 +123,6 @@ export const AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY = 'aiCustomizationManagementIt */ export const AI_CUSTOMIZATION_ITEM_DISABLED_KEY = 'aiCustomizationManagementItemDisabled'; -/** - * Context key indicating whether the active harness supports troubleshooting. - */ -export const AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY = 'aiCustomizationManagementSupportsTroubleshoot'; /** * Storage key for persisting the selected section. From f7cbcbc279dd9f20e2d528350f98ef4683b63044 Mon Sep 17 00:00:00 2001 From: Courtney Webster <60238438+cwebster-99@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:04:54 -0500 Subject: [PATCH 10/36] Update default to chatAndAgent (#312880) --- extensions/git/package.json | 2 +- extensions/git/src/repository.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/git/package.json b/extensions/git/package.json index 17ddbced72d93a..d56de56d47675e 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -3740,7 +3740,7 @@ "%config.addAICoAuthor.all%" ], "scope": "resource", - "default": "all", + "default": "chatAndAgent", "description": "%config.addAICoAuthor%" }, "git.ignoreSubmodules": { diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 21b98bfd8399b4..2d385b7b98ee02 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1498,7 +1498,7 @@ export class Repository implements Disposable { } const config = workspace.getConfiguration('git', Uri.file(this.root)); - const addAICoAuthor = config.get<'off' | 'chatAndAgent' | 'all'>('addAICoAuthor', 'all'); + const addAICoAuthor = config.get<'off' | 'chatAndAgent' | 'all'>('addAICoAuthor', 'chatAndAgent'); if (addAICoAuthor === 'off') { return message; From 8ba16db2d69de01ef4917142328702ac052ffbcf Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 27 Apr 2026 17:08:43 -0400 Subject: [PATCH 11/36] Fix leaked disposable in OutputMonitor._setupIdleInputListener (#312898) --- .../chatAgentTools/browser/tools/monitoring/outputMonitor.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index c3020a95f7bb69..5b417b85289b42 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -473,6 +473,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { * This ensures we catch any input that happens between idle detection and prompt creation. */ private _setupIdleInputListener(): void { + if (this._store.isDisposed) { + return; + } this._userInputtedSinceIdleDetected = false; this._logService.trace('OutputMonitor: Setting up idle input listener'); From fbd09caf89cc9f47eb2b8672ee3073bb7bcaacf9 Mon Sep 17 00:00:00 2001 From: Michael Lively <12552271+Yoyokrazy@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:12:05 -0700 Subject: [PATCH 12/36] fix(commands): close dedup labels with state_reason='duplicate' (#312894) * fix(commands): close ~chat-* dedup labels with state_reason='duplicate' * fix(commands): close *duplicate with state_reason='duplicate' --- .github/commands.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/commands.json b/.github/commands.json index 4a214f62e8ee11..c8afbde389730b 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -115,7 +115,7 @@ "type": "label", "name": "*duplicate", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Thanks for creating this issue! We figured it's covering the same as another one we already have. Thus, we closed this one as a duplicate. You can search for [similar existing issues](${duplicateQuery}). See also our [issue reporting guidelines](https://aka.ms/vscodeissuereporting).\n\nHappy Coding!" }, { @@ -544,7 +544,7 @@ "name": "~chat-rate-limiting", "removeLabel": "~chat-rate-limiting", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253124. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -552,7 +552,7 @@ "name": "~chat-request-failed", "removeLabel": "~chat-request-failed", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253136. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -560,7 +560,7 @@ "name": "~chat-rai-content-filters", "removeLabel": "~chat-rai-content-filters", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253130. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -568,7 +568,7 @@ "name": "~chat-public-code-blocking", "removeLabel": "~chat-public-code-blocking", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253129. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -576,7 +576,7 @@ "name": "~chat-lm-unavailable", "removeLabel": "~chat-lm-unavailable", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "This issue is a duplicate of https://github.com/microsoft/vscode/issues/253137. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -584,7 +584,7 @@ "name": "~chat-authentication", "removeLabel": "~chat-authentication", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253132, if the bug you are experiencing is not there, please comment on this closed issue thread so we can re-open it.", "assign": [ "TylerLeonhardt" @@ -595,7 +595,7 @@ "name": "~chat-no-response-returned", "removeLabel": "~chat-no-response-returned", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253126. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -604,7 +604,7 @@ "removeLabel": "~chat-billing", "addLabel": "chat-billing", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/252230. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { @@ -613,7 +613,7 @@ "removeLabel": "~chat-infinite-response-loop", "addLabel": "chat-infinite-response-loop", "action": "close", - "reason": "not_planned", + "reason": "duplicate", "comment": "Please look at the following meta issue: https://github.com/microsoft/vscode/issues/253134. Please refer to that issue for updates and discussions. Feel free to open a new issue if you think this is a different problem." }, { From c2235ee239d004b659473beae4afb8c822a36b20 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:12:21 -0700 Subject: [PATCH 13/36] Be less aggressive about removing terminal in agents app (#312873) * Be less aggressive about removing terminal in agents app * address copilot feedbacks Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../browser/sessionsTerminalContribution.ts | 29 +++++++++-- .../sessionsTerminalContribution.test.ts | 50 ++++++++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 87032a4efda170..4fdb3346c19570 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -64,7 +64,9 @@ function getSessionTerminalInfo(session: ISession | undefined): ISessionTerminal * Manages terminal instances in the sessions window, ensuring: * - A terminal exists for the active session's worktree (or repository if no worktree). * - Terminals are shown/hidden based on their initial cwd matching the active path. - * - All terminals for a worktree are closed when the session is archived. + * - Terminals for an archived/removed session are closed only when no other + * live session still owns the same cwd (terminals are reused across sessions + * at the same worktree). */ export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { @@ -152,11 +154,32 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } })); - // When a session is archived or removed, close all terminals for its cwd + // Close terminals for archived/removed sessions, but only when no other + // live session still owns that cwd. Terminals are reused across sessions + // at the same cwd, so a plain cwd match would kill a terminal still in use + // (e.g. the committed session from `onDidReplaceSession`). + // TODO: Consider removing the logic for trying to "delete/clean-up" terminal. + // Or consider tag terminals by sessionId + refcount instead of guarding here. + this._register(this._sessionsManagementService.onDidChangeSessions(e => { - for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) { + const archivedChanged = e.changed.filter(s => s.isArchived.get()); + if (e.removed.length === 0 && archivedChanged.length === 0) { + return; + } + const removedIds = new Set(e.removed.map(s => s.sessionId)); + const liveCwdKeys = new Set(); + for (const session of this._sessionsManagementService.getSessions()) { + if (removedIds.has(session.sessionId) || session.isArchived.get()) { + continue; + } const info = getSessionTerminalInfo(session); if (info) { + liveCwdKeys.add(info.cwd.fsPath.toLowerCase()); + } + } + for (const session of [...e.removed, ...archivedChanged]) { + const info = getSessionTerminalInfo(session); + if (info && !liveCwdKeys.has(info.cwd.fsPath.toLowerCase())) { this._closeTerminalsForPath(info.cwd.fsPath); } } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 9e2ec230e58d79..f725fe4177e2d3 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -50,6 +50,7 @@ function makeAgentSession(opts: { worktree?: URI; providerType?: string; isArchived?: boolean; + sessionId?: string; }): IActiveSession { const repo = opts.repository || opts.worktree ? { uri: opts.repository ?? opts.worktree!, @@ -72,7 +73,7 @@ function makeAgentSession(opts: { description: observableValue('test.description', undefined), }; const session: IActiveSession = { - sessionId: 'test:session', + sessionId: opts.sessionId ?? 'test:session', resource: chat.resource, providerId: 'test', sessionType: opts.providerType ?? AgentSessionProviders.Local, @@ -198,6 +199,7 @@ suite('SessionsTerminalContribution', () => { let showBackgroundCalls: number[]; let disposeOnCreatePaths: Set; let logService: TestLogService; + let allSessions: ISession[]; setup(() => { createdTerminals = []; @@ -211,6 +213,7 @@ suite('SessionsTerminalContribution', () => { showBackgroundCalls = []; disposeOnCreatePaths = new Set(); logService = new TestLogService(); + allSessions = []; const instantiationService = store.add(new TestInstantiationService()); @@ -223,6 +226,7 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(ISessionsManagementService, new class extends mock() { override activeSession = activeSessionObs; override readonly onDidChangeSessions = onDidChangeSessions.event; + override getSessions(): ISession[] { return [...allSessions]; } }); instantiationService.stub(ITerminalService, new class extends mock() { @@ -553,6 +557,50 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(disposedInstances.length, 1); }); + test('does not close terminal when another live session still owns the cwd (replace case)', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + // Simulate the onDidReplaceSession flow: `from` (untitled) is reported as + // removed while `to` (committed) is still live at the same cwd. + const fromSession = makeAgentSession({ sessionId: 'test:untitled', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + const toSession = makeAgentSession({ sessionId: 'test:committed', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + allSessions = [toSession]; + + onDidChangeSessions.fire({ added: [], removed: [fromSession], changed: [toSession] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept alive for the surviving session'); + }); + + test('does not close terminal when archiving one of two sessions sharing a cwd', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const liveSession = makeAgentSession({ sessionId: 'test:live', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + const archivedSession = makeAgentSession({ sessionId: 'test:archived', worktree: worktreeUri, providerType: AgentSessionProviders.Background, isArchived: true }); + allSessions = [liveSession, archivedSession]; + + onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept for the still-live session'); + }); + + test('closes terminal when the only session at a cwd is removed even if other live sessions exist elsewhere', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const otherLive = makeAgentSession({ sessionId: 'test:other', worktree: URI.file('/other'), providerType: AgentSessionProviders.Background }); + const removedSession = makeAgentSession({ sessionId: 'test:gone', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + allSessions = [otherLive]; + + onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 1, 'no live session owns this cwd, terminal should be closed'); + }); + // --- switching back to previously used path reuses terminal --- test('switching back to a previously used background path reuses the existing terminal', async () => { From 6e2f44b135a366df9e9fc4a9507763fabc253f4c Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Wed, 15 Apr 2026 23:08:01 -0700 Subject: [PATCH 14/36] feat(telemetry): enhance OTel tracing and session state management for Claude agent --- .../claude/common/claudeMessageDispatch.ts | 5 +- .../common/claudeSessionStateService.ts | 12 ++ .../claude/node/claudeCodeAgent.ts | 111 +++++++++++++- .../claude/node/claudeLanguageModelServer.ts | 12 +- .../claude/node/claudeSessionStateService.ts | 24 +++ .../extension/prompt/node/chatMLFetcher.ts | 3 + .../src/platform/endpoint/node/messagesApi.ts | 1 + .../copilot/src/platform/endpoint/node/tmp.md | 143 ++++++++++++++++++ .../src/platform/networking/common/openai.ts | 1 + .../src/platform/otel/common/agentOTelEnv.ts | 12 ++ .../otel/common/test/agentOTelEnv.spec.ts | 4 + 11 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 extensions/copilot/src/platform/endpoint/node/tmp.md diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index a9931c1ab9d258..6b388e08ab3b21 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -10,7 +10,7 @@ import * as l10n from '@vscode/l10n'; import type * as vscode from 'vscode'; import { vBoolean, vLiteral, vObj, vString, type ValidatorType } from '../../../../platform/configuration/common/validator'; import { ILogService } from '../../../../platform/log/common/logService'; -import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle } from '../../../../platform/otel/common/index'; +import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle, type TraceContext } from '../../../../platform/otel/common/index'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger'; import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation'; @@ -37,6 +37,7 @@ export interface MessageHandlerState { readonly unprocessedToolCalls: Map; readonly otelToolSpans: Map; readonly otelHookSpans: Map; + readonly parentTraceContext?: TraceContext; } export interface MessageHandlerResult { @@ -164,6 +165,7 @@ export function handleAssistantMessage( [GenAiAttr.TOOL_CALL_ID]: item.id, [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, }, + parentTraceContext: state.parentTraceContext, }); if (item.input !== undefined) { try { @@ -368,6 +370,7 @@ export function handleHookStarted( 'copilot_chat.hook_id': message.hook_id, [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, }, + parentTraceContext: state.parentTraceContext, }); state.otelHookSpans.set(message.hook_id, span); } diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts index 5b93a75029cf47..43c9ecdd0c5440 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts @@ -6,6 +6,7 @@ import { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import type * as vscode from 'vscode'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; +import type { TraceContext } from '../../../../platform/otel/common/otelService'; import { createServiceIdentifier } from '../../../../util/common/services'; import { Event } from '../../../../util/vs/base/common/event'; import type { ClaudeFolderInfo } from './claudeFolderInfo'; @@ -23,6 +24,7 @@ export interface SessionState { folderInfo: ClaudeFolderInfo | undefined; usageHandler: UsageHandler | undefined; reasoningEffort: EffortLevel | undefined; + traceContext: TraceContext | undefined; } /** @@ -102,6 +104,16 @@ export interface IClaudeSessionStateService { * Sets the reasoning effort for a session. */ setReasoningEffortForSession(sessionId: string, effort: EffortLevel | undefined): void; + + /** + * Gets the OTel trace context for a session (used to parent chat spans to invoke_agent). + */ + getTraceContextForSession(sessionId: string): TraceContext | undefined; + + /** + * Sets the OTel trace context for a session. + */ + setTraceContextForSession(sessionId: string, traceContext: TraceContext | undefined): void; } export const IClaudeSessionStateService = createServiceIdentifier('IClaudeSessionStateService'); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index e2b82b4beeee0b..5588b490247178 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -11,7 +11,8 @@ import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/ch import { INativeEnvService } from '../../../../platform/env/common/envService'; import { ILogService } from '../../../../platform/log/common/logService'; import { IMcpService } from '../../../../platform/mcp/common/mcpService'; -import { CopilotChatAttr, GenAiAttr, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel } from '../../../../platform/otel/common/index'; +import { CopilotChatAttr, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, type TraceContext, truncateForOTel } from '../../../../platform/otel/common/index'; +import { deriveClaudeOTelEnv } from '../../../../platform/otel/common/agentOTelEnv'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { DeferredPromise } from '../../../../util/vs/base/common/async'; @@ -169,6 +170,11 @@ export class ClaudeCodeSession extends Disposable { private _currentToolNames: ReadonlySet | undefined; private _gateway: vscode.McpGateway | undefined; private _gatewayIdleTimeout: ReturnType | undefined; + private _currentInvokeAgentSpan: ISpanHandle | undefined; + private _currentInvokeAgentTraceContext: TraceContext | undefined; + private _currentInvokeAgentStartTime: number | undefined; + private _isFirstRequest = true; + private _turnCount = 0; /** * Sets the model on the active SDK session, or stores it for the next session start. @@ -476,7 +482,9 @@ export class ClaudeCodeSession extends Disposable { ANTHROPIC_AUTH_TOKEN: `${this.serverConfig.nonce}.${this.sessionId}`, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', USE_BUILTIN_RIPGREP: '0', - PATH: `${this.envService.appRoot}/node_modules/@vscode/ripgrep/bin${pathSep}${process.env.PATH}` + PATH: `${this.envService.appRoot}/node_modules/@vscode/ripgrep/bin${pathSep}${process.env.PATH}`, + // Forward OTel configuration to the Claude SDK subprocess + ...deriveClaudeOTelEnv(this._otelService.config), }, attribution: { commit: '', @@ -546,15 +554,46 @@ export class ClaudeCodeSession extends Disposable { new CapturingToken(promptLabel, 'claude', undefined, undefined, this.sessionId) ); - // Emit a user_message span event for the debug panel - // Use a non-standard operation name so completedSpanToDebugEvent ignores this span - // (avoids a "Model Turn · 0 tokens" entry); only the user_message event is rendered. + // End any previous invoke_agent span (e.g., from a prior turn in this session) + this._endInvokeAgentSpan(); + + // Start the invoke_agent span for this request + const modelId = this._currentModelId.toEndpointModelId(); + this._currentInvokeAgentSpan = this._otelService.startSpan('invoke_agent claude', { + kind: SpanKind.INTERNAL, + attributes: { + [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, + [GenAiAttr.AGENT_NAME]: 'claude', + [GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB, + [GenAiAttr.CONVERSATION_ID]: this.sessionId, + [CopilotChatAttr.SESSION_ID]: this.sessionId, + [CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId, + [GenAiAttr.REQUEST_MODEL]: modelId, + }, + }); + this._currentInvokeAgentTraceContext = this._currentInvokeAgentSpan.getSpanContext(); + this._currentInvokeAgentStartTime = Date.now(); + this._turnCount = 0; + + // Store trace context in session state so the language model server + // can parent chat spans to this invoke_agent span + this.sessionStateService.setTraceContextForSession(this.sessionId, this._currentInvokeAgentTraceContext); + + // Emit session start event and metric for the first request in this session + if (this._isFirstRequest) { + this._isFirstRequest = false; + GenAiMetrics.incrementSessionCount(this._otelService); + emitSessionStartEvent(this._otelService, this.sessionId, modelId, 'claude'); + } + + // Emit user_message span event for the debug panel under the invoke_agent context const userMsgSpan = this._otelService.startSpan('user_message', { kind: SpanKind.INTERNAL, attributes: { [GenAiAttr.OPERATION_NAME]: 'user_message', [CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId, }, + parentTraceContext: this._currentInvokeAgentTraceContext, }); const userContent = truncateForOTel(promptLabel); userMsgSpan.setAttribute(CopilotChatAttr.USER_REQUEST, userContent); @@ -625,6 +664,32 @@ export class ClaudeCodeSession extends Disposable { continue; } + // Track turn count for assistant messages (each assistant message = one LLM round-trip) + if (message.type === 'assistant') { + this._turnCount++; + } + + // Capture aggregated token usage from result messages for the invoke_agent span + if (message.type === 'result' && this._currentInvokeAgentSpan) { + const usage = message.usage; + if (usage) { + this._currentInvokeAgentSpan.setAttributes({ + [GenAiAttr.USAGE_INPUT_TOKENS]: usage.input_tokens ?? 0, + [GenAiAttr.USAGE_OUTPUT_TOKENS]: usage.output_tokens ?? 0, + }); + } + if (message.num_turns !== undefined) { + this._currentInvokeAgentSpan.setAttribute(CopilotChatAttr.TURN_COUNT, message.num_turns); + } + if (message.total_cost_usd !== undefined) { + this._currentInvokeAgentSpan.setAttribute('copilot_chat.total_cost_usd', message.total_cost_usd); + } + const responseModel = message.modelUsage ? Object.keys(message.modelUsage)[0] : undefined; + if (responseModel) { + this._currentInvokeAgentSpan.setAttribute(GenAiAttr.RESPONSE_MODEL, responseModel); + } + } + this.logService.trace(`claude-agent-sdk Message: ${JSON.stringify(message, null, 2)}`); const result = this.instantiationService.invokeFunction(dispatchMessage, message, this.sessionId, { stream: this._currentRequest.stream, @@ -635,9 +700,12 @@ export class ClaudeCodeSession extends Disposable { unprocessedToolCalls, otelToolSpans, otelHookSpans, + parentTraceContext: this._currentInvokeAgentTraceContext, }); if (result?.requestComplete) { + // End the invoke_agent span for this request + this._endInvokeAgentSpan(); // Clear the capturing token so subsequent requests get their own this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); // Resolve and remove the completed request @@ -665,12 +733,45 @@ export class ClaudeCodeSession extends Disposable { span.end(); } otelHookSpans.clear(); + // End any lingering invoke_agent span + this._endInvokeAgentSpan(SpanStatusCode.ERROR, 'session ended'); } } + /** + * Ends the current invoke_agent span and records metrics. + */ + private _endInvokeAgentSpan(statusCode?: SpanStatusCode, statusMessage?: string): void { + if (!this._currentInvokeAgentSpan) { + return; + } + const span = this._currentInvokeAgentSpan; + span.setAttribute(CopilotChatAttr.TURN_COUNT, this._turnCount); + if (statusCode !== undefined) { + span.setStatus(statusCode, statusMessage); + } else { + span.setStatus(SpanStatusCode.OK); + } + span.end(); + + // Record agent-level metrics + if (this._currentInvokeAgentStartTime) { + const durationSec = (Date.now() - this._currentInvokeAgentStartTime) / 1000; + GenAiMetrics.recordAgentDuration(this._otelService, 'claude', durationSec); + } + GenAiMetrics.recordAgentTurnCount(this._otelService, 'claude', this._turnCount); + + this._currentInvokeAgentSpan = undefined; + this._currentInvokeAgentTraceContext = undefined; + this._currentInvokeAgentStartTime = undefined; + this.sessionStateService.setTraceContextForSession(this.sessionId, undefined); + } + private _cleanup(error: Error): void { // Clear the capturing token so it doesn't leak across sessions or error boundaries this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); + // End invoke_agent span with error if still open + this._endInvokeAgentSpan(SpanStatusCode.ERROR, error.message); this._resetSessionState(); const wasYielding = this._yieldInProgress; diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts index 27c4e851f9010f..d9ac0c66ed0c53 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts @@ -12,6 +12,7 @@ import { ChatLocation, ChatResponse } from '../../../../platform/chat/common/com import { CustomModel, EndpointEditToolName } from '../../../../platform/endpoint/common/endpointProvider'; import { AnthropicMessagesProcessor } from '../../../../platform/endpoint/node/messagesApi'; import { ILogService } from '../../../../platform/log/common/logService'; +import { IOTelService } from '../../../../platform/otel/common/otelService'; import { FinishedCallback, getRequestId, OptionalChatRequestParams } from '../../../../platform/networking/common/fetch'; import { Response } from '../../../../platform/networking/common/fetcherService'; import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody, IEndpointFetchOptions, IMakeChatRequestOptions } from '../../../../platform/networking/common/networking'; @@ -80,6 +81,7 @@ export class ClaudeLanguageModelServer extends Disposable { @IRequestLogger private readonly requestLogger: IRequestLogger, @IInstantiationService private readonly instantiationService: IInstantiationService, @IClaudeCodeModels private readonly claudeCodeModels: IClaudeCodeModels, + @IOTelService private readonly _otelService: IOTelService, ) { super(); this.config = { @@ -229,10 +231,16 @@ export class ClaudeLanguageModelServer extends Disposable { userInitiatedRequest: isUserInitiatedMessage }, tokenSource.token); + // Wrap in trace context so chat spans are parented to the invoke_agent span + const traceContext = sessionId ? this.sessionStateService.getTraceContextForSession(sessionId) : undefined; + const doRequestInContext = traceContext + ? () => this._otelService.runWithTraceContext(traceContext, doRequest) + : doRequest; + if (capturingToken) { - await this.requestLogger.captureInvocation(capturingToken, doRequest); + await this.requestLogger.captureInvocation(capturingToken, doRequestInContext); } else { - await doRequest(); + await doRequestInContext(); } requestComplete = true; diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts index 2dcf78c549b20b..f54975c4ff79f3 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts @@ -5,6 +5,7 @@ import { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; +import type { TraceContext } from '../../../../platform/otel/common/otelService'; import { arrayEquals } from '../../../../util/vs/base/common/equals'; import { Emitter } from '../../../../util/vs/base/common/event'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; @@ -46,6 +47,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); this._onDidChangeSessionState.fire({ sessionId, modelId }); } @@ -66,6 +68,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); this._onDidChangeSessionState.fire({ sessionId, permissionMode: mode }); } @@ -83,6 +86,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); } @@ -102,6 +106,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); this._onDidChangeSessionState.fire({ sessionId, folderInfo }); } @@ -119,6 +124,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: handler, reasoningEffort: existing?.reasoningEffort, + traceContext: existing?.traceContext, }); } @@ -138,6 +144,24 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: effort, + traceContext: existing?.traceContext, + }); + } + + getTraceContextForSession(sessionId: string): TraceContext | undefined { + return this._sessionState.get(sessionId)?.traceContext; + } + + setTraceContextForSession(sessionId: string, traceContext: TraceContext | undefined): void { + const existing = this._sessionState.get(sessionId); + this._sessionState.set(sessionId, { + modelId: existing?.modelId, + permissionMode: existing?.permissionMode ?? 'acceptEdits', + capturingToken: existing?.capturingToken, + folderInfo: existing?.folderInfo, + usageHandler: existing?.usageHandler, + reasoningEffort: existing?.reasoningEffort, + traceContext, }); } diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 934d544ed3c102..8207853f597c4d 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -390,6 +390,9 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { ...(result.usage.prompt_tokens_details?.cached_tokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: result.usage.prompt_tokens_details.cached_tokens } : {}), + ...(result.usage.prompt_tokens_details?.cache_creation_input_tokens + ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: result.usage.prompt_tokens_details.cache_creation_input_tokens } + : {}), [CopilotChatAttr.TIME_TO_FIRST_TOKEN]: timeToFirstToken, ...(result.serverRequestId ? { [CopilotChatAttr.SERVER_REQUEST_ID]: result.serverRequestId } : {}), ...(result.usage.completion_tokens_details?.reasoning_tokens diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index c029e9981b50e5..5c8b0be0542b94 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -993,6 +993,7 @@ export class AnthropicMessagesProcessor { total_tokens: computedPromptTokens + this.outputTokens, prompt_tokens_details: { cached_tokens: this.cacheReadTokens, + cache_creation_input_tokens: this.cacheCreationTokens, }, completion_tokens_details: { reasoning_tokens: 0, diff --git a/extensions/copilot/src/platform/endpoint/node/tmp.md b/extensions/copilot/src/platform/endpoint/node/tmp.md new file mode 100644 index 00000000000000..d429838b2fd9f6 --- /dev/null +++ b/extensions/copilot/src/platform/endpoint/node/tmp.md @@ -0,0 +1,143 @@ +# Claude Agent Request Flow + +```mermaid +sequenceDiagram + participant UI as VS Code Chat UI + participant CM as ClaudeAgentManager + participant CS as ClaudeCodeSession + participant SDK as ClaudeCodeSdkService
(sdk.mjs, in-process) + participant CLI as Claude CLI Subprocess
(cli.js, forked process) + participant LMS as ClaudeLanguageModelServer
(HTTP localhost:{port}) + participant FET as chatMLFetcher + participant CAPI as CAPI (GitHub Copilot API) + participant OTEL as OTLP Collector + + Note over UI,OTEL: === User sends message === + + UI->>CM: handleRequest(sessionId, request) + CM->>CS: invoke(request, prompt, stream, token) + CS->>CS: Queue request in _promptQueue + + Note over CS: _createPromptIterable() picks up request
Creates invoke_agent claude span
Stores traceContext in SessionStateService + + CS->>SDK: query({ prompt: asyncIterable, options }) + SDK->>CLI: fork(cli.js) with env:
ANTHROPIC_BASE_URL=localhost:{port}
ANTHROPIC_AUTH_TOKEN={nonce}.{sessionId}
CLAUDE_CODE_ENABLE_TELEMETRY=1
OTEL_EXPORTER_OTLP_ENDPOINT=... + + Note over CS,CLI: === SDK subprocess starts agent loop === + + loop Each LLM turn + CLI->>LMS: POST /v1/messages
{model, messages, tools, stream:true} + + LMS->>LMS: Auth via nonce.sessionId
Resolve model via ClaudeCodeModels + + Note over LMS: runWithTraceContext(invokeAgentCtx)
→ parents chat span to invoke_agent + + LMS->>FET: fetchOne({ endpoint: PassThroughEndpoint }) + + Note over FET: Creates chat {model} span
(child of invoke_agent via traceContext) + + FET->>CAPI: HTTPS POST /chat/completions
(Messages API format) + CAPI-->>FET: SSE stream begins + + Note over FET: Records TTFT on chat span + + FET-->>LMS: ChatResponse (streaming) + + Note over LMS: processResponse() dual-parse:
1. Write raw SSE → CLI (responseStream)
2. Parse via AnthropicMessagesProcessor + + loop Each SSE chunk + LMS-->>CLI: raw SSE bytes (HTTP response) + LMS->>LMS: AnthropicMessagesProcessor.push(chunk) + end + + Note over LMS: message_stop → ChatCompletion
with usage {input_tokens, output_tokens,
cache_read, cache_creation} + + LMS-->>FET: ChatCompletion result + + Note over FET: Sets on chat span:
gen_ai.usage.input_tokens
gen_ai.usage.output_tokens
gen_ai.usage.cache_read.input_tokens
copilot_chat.time_to_first_token
gen_ai.response.model
Ends chat span + + CLI->>CLI: Parse assistant response
Decide: text / tool_use / thinking + + alt Tool call needed + CLI->>CS: yield SDKAssistantMessage (tool_use) + + Note over CS: dispatchMessage() creates
execute_tool {name} span
(child of invoke_agent via parentTraceContext) + + CLI->>CLI: Execute tool locally
(Read, Write, Grep, Bash, Agent...) + + opt Needs permission + CLI->>CS: canUseTool(name, input)
(IPC callback) + CS-->>CLI: {behavior: allow/deny} + end + + CLI->>CS: yield SDKUserMessage (tool_result) + + Note over CS: Sets tool span result + ends it + + else Final response (no more tools) + CLI->>CS: yield SDKAssistantMessage (text) + CS->>UI: stream.markdown(text) + CLI->>CS: yield SDKResultMessage + Note over CS: Ends invoke_agent span
Records agent duration metric
Records turn count metric + end + end + + Note over CLI,OTEL: === Native OTel (parallel, separate trace) === + CLI-->>OTEL: claude-code service spans
(claude_code.interaction,
claude_code.llm_request,
claude_code.tool) + + Note over FET,OTEL: === Custom OTel (our trace) === + FET-->>OTEL: copilot-chat service spans
(invoke_agent claude,
chat {model},
execute_tool {name}) +``` + +```mermaid +graph TB + subgraph "Extension Host Process" + subgraph "Custom OTel Trace (copilot-chat service)" + IA["invoke_agent claude
32.89s
gen_ai.agent.name=claude
copilot_chat.turn_count=12
copilot_chat.chat_session_id=..."] + C1["chat claude-haiku-4.5
7.42s
gen_ai.usage.input_tokens=...
gen_ai.usage.output_tokens=...
copilot_chat.time_to_first_token=..."] + ET1["execute_tool Agent
6.21s
gen_ai.tool.name=Agent
gen_ai.tool.call.id=tu-1"] + C2["chat claude-haiku-4.5
3.02s"] + ET2["execute_tool Grep
20ms"] + C3["chat claude-haiku-4.5
3.13s"] + UH["user_hook Stop:Stop
10ms"] + + IA --> C1 + IA --> ET1 + IA --> C2 + IA --> ET2 + IA --> C3 + IA --> UH + end + end + + subgraph "Claude CLI Subprocess" + subgraph "Native OTel Trace (claude-code service)" + INT["claude_code.interaction
32.1s
session.id=...
user_prompt=...
span.type=interaction"] + LLM1["claude_code.llm_request
7.44s
input_tokens=10
output_tokens=699
cache_creation_tokens=25190
ttft_ms=2623
model=claude-haiku-4-5"] + T1["claude_code.tool
6.2s"] + TB1["claude_code.tool.blocked_on_user
1.16ms"] + TE1["claude_code.tool.execution
6.2s"] + LLM2["claude_code.llm_request
3.03s"] + + INT --> LLM1 + INT --> T1 + T1 --> TB1 + T1 --> TE1 + INT --> LLM2 + end + end + + style IA fill:#2563eb,color:#fff + style INT fill:#7c3aed,color:#fff + style C1 fill:#0891b2,color:#fff + style C2 fill:#0891b2,color:#fff + style C3 fill:#0891b2,color:#fff + style LLM1 fill:#a855f7,color:#fff + style LLM2 fill:#a855f7,color:#fff + style ET1 fill:#059669,color:#fff + style ET2 fill:#059669,color:#fff + style T1 fill:#8b5cf6,color:#fff + style TB1 fill:#f59e0b,color:#000 + style TE1 fill:#8b5cf6,color:#fff + style UH fill:#dc2626,color:#fff +``` \ No newline at end of file diff --git a/extensions/copilot/src/platform/networking/common/openai.ts b/extensions/copilot/src/platform/networking/common/openai.ts index 22f404c0288e8b..66abbd60f6bf7a 100644 --- a/extensions/copilot/src/platform/networking/common/openai.ts +++ b/extensions/copilot/src/platform/networking/common/openai.ts @@ -42,6 +42,7 @@ export interface APIUsage { */ prompt_tokens_details?: { cached_tokens: number; + cache_creation_input_tokens?: number; }; /** * Breakdown of tokens used in a completion. diff --git a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts index 682c66885486c7..c773315b60bd14 100644 --- a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts +++ b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts @@ -60,12 +60,21 @@ export function deriveClaudeOTelEnv(config: OTelConfig, env: Record { const result = deriveClaudeOTelEnv(makeConfig(), emptyEnv); expect(result).toEqual({ CLAUDE_CODE_ENABLE_TELEMETRY: '1', + CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: '1', OTEL_METRICS_EXPORTER: 'otlp', OTEL_LOGS_EXPORTER: 'otlp', + OTEL_TRACES_EXPORTER: 'otlp', OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318', OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json', }); @@ -101,12 +103,14 @@ describe('deriveClaudeOTelEnv', () => { const result = deriveClaudeOTelEnv(makeConfig({ captureContent: true }), emptyEnv); expect(result['OTEL_LOG_USER_PROMPTS']).toBe('1'); expect(result['OTEL_LOG_TOOL_DETAILS']).toBe('1'); + expect(result['OTEL_LOG_TOOL_CONTENT']).toBe('1'); }); it('does not include content capture vars when captureContent is false', () => { const result = deriveClaudeOTelEnv(makeConfig({ captureContent: false }), emptyEnv); expect(result['OTEL_LOG_USER_PROMPTS']).toBeUndefined(); expect(result['OTEL_LOG_TOOL_DETAILS']).toBeUndefined(); + expect(result['OTEL_LOG_TOOL_CONTENT']).toBeUndefined(); }); it('does not overwrite existing env vars', () => { From 5aeeca0534585a26c51f4f3ef28f32ae5c61128e Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Wed, 15 Apr 2026 23:24:30 -0700 Subject: [PATCH 15/36] feat(tracing): add support for subagent trace contexts in message handling --- .../claude/common/claudeMessageDispatch.ts | 21 ++++++++++++++++++- .../common/test/claudeMessageDispatch.spec.ts | 1 + .../claude/node/claudeCodeAgent.ts | 16 ++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index 6b388e08ab3b21..ba81e372fbe3be 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -38,6 +38,9 @@ export interface MessageHandlerState { readonly otelToolSpans: Map; readonly otelHookSpans: Map; readonly parentTraceContext?: TraceContext; + /** Trace contexts for subagent tool spans, keyed by tool_use_id. Used to parent + * child spans (chat, tool) from subagent messages under the Agent tool span. */ + readonly subagentTraceContexts: Map; } export interface MessageHandlerResult { @@ -149,6 +152,13 @@ export function handleAssistantMessage( const { stream } = request; const { otelToolSpans, unprocessedToolCalls } = state; + // Resolve the OTel parent context for spans in this message. + // If the message is from a subagent (parent_tool_use_id is set), parent spans + // under the Agent tool's execute_tool span. Otherwise, use the root invoke_agent context. + const spanParentContext = (message.parent_tool_use_id + ? state.subagentTraceContexts.get(message.parent_tool_use_id) + : undefined) ?? state.parentTraceContext; + for (const item of message.message.content) { if (item.type === 'text') { stream.markdown(item.text); @@ -165,7 +175,7 @@ export function handleAssistantMessage( [GenAiAttr.TOOL_CALL_ID]: item.id, [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, }, - parentTraceContext: state.parentTraceContext, + parentTraceContext: spanParentContext, }); if (item.input !== undefined) { try { @@ -178,6 +188,15 @@ export function handleAssistantMessage( } otelToolSpans.set(item.id, toolSpan); + // For Agent/Task (subagent) tool calls, store the span's trace context so that + // child messages (with parent_tool_use_id = this tool's id) are parented here. + if (item.name === ClaudeToolNames.Task || item.name === 'Agent') { + const toolSpanCtx = toolSpan.getSpanContext(); + if (toolSpanCtx) { + state.subagentTraceContexts.set(item.id, toolSpanCtx); + } + } + if (request.editTracker && claudeEditTools.includes(item.name)) { try { const uris = getAffectedUrisForEditTool(item.name, item.input); diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts index 02ea03fa380464..c092b68ddbbb93 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts @@ -98,6 +98,7 @@ function createState(): MessageHandlerState { unprocessedToolCalls: new Map(), otelToolSpans: new Map(), otelHookSpans: new Map(), + subagentTraceContexts: new Map(), }; } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index 5588b490247178..d29ef0c6bfd41a 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -639,6 +639,7 @@ export class ClaudeCodeSession extends Disposable { private async _processMessages(): Promise { const otelToolSpans = new Map(); const otelHookSpans = new Map(); + const subagentTraceContexts = new Map(); try { const unprocessedToolCalls = new Map(); for await (const message of this._queryGenerator!) { @@ -691,6 +692,20 @@ export class ClaudeCodeSession extends Disposable { } this.logService.trace(`claude-agent-sdk Message: ${JSON.stringify(message, null, 2)}`); + + // Update the session trace context based on whether this message is from a subagent. + // This ensures that chat spans (created by chatMLFetcher via runWithTraceContext) + // are parented under the correct Agent tool span during subagent execution. + if ('parent_tool_use_id' in message && message.parent_tool_use_id) { + const subagentCtx = subagentTraceContexts.get(message.parent_tool_use_id); + if (subagentCtx) { + this.sessionStateService.setTraceContextForSession(this.sessionId, subagentCtx); + } + } else if ('parent_tool_use_id' in message) { + // Message is from the main agent (parent_tool_use_id is null) — restore root context + this.sessionStateService.setTraceContextForSession(this.sessionId, this._currentInvokeAgentTraceContext); + } + const result = this.instantiationService.invokeFunction(dispatchMessage, message, this.sessionId, { stream: this._currentRequest.stream, toolInvocationToken: this._currentRequest.toolInvocationToken, @@ -701,6 +716,7 @@ export class ClaudeCodeSession extends Disposable { otelToolSpans, otelHookSpans, parentTraceContext: this._currentInvokeAgentTraceContext, + subagentTraceContexts, }); if (result?.requestComplete) { From 185f5d2e49b98457cdc161618a299dca2f963512 Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Thu, 16 Apr 2026 15:30:22 -0700 Subject: [PATCH 16/36] feat(token tracking): accumulate parent-only and cache token usage for gen_ai.usage consistency --- .../claude/node/claudeCodeAgent.ts | 46 +++++++++++++++---- .../extension/intents/node/toolCallingLoop.ts | 6 +++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index d29ef0c6bfd41a..c0a69d5322ef1c 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -175,6 +175,12 @@ export class ClaudeCodeSession extends Disposable { private _currentInvokeAgentStartTime: number | undefined; private _isFirstRequest = true; private _turnCount = 0; + // Parent-only token accumulators (excludes subagent turns) for gen_ai.usage.* consistency + // with the foreground agent which also reports parent-only tokens on the root span. + private _parentInputTokens = 0; + private _parentOutputTokens = 0; + private _parentCacheReadTokens = 0; + private _parentCacheCreationTokens = 0; /** * Sets the model on the active SDK session, or stores it for the next session start. @@ -574,6 +580,10 @@ export class ClaudeCodeSession extends Disposable { this._currentInvokeAgentTraceContext = this._currentInvokeAgentSpan.getSpanContext(); this._currentInvokeAgentStartTime = Date.now(); this._turnCount = 0; + this._parentInputTokens = 0; + this._parentOutputTokens = 0; + this._parentCacheReadTokens = 0; + this._parentCacheCreationTokens = 0; // Store trace context in session state so the language model server // can parent chat spans to this invoke_agent span @@ -668,17 +678,24 @@ export class ClaudeCodeSession extends Disposable { // Track turn count for assistant messages (each assistant message = one LLM round-trip) if (message.type === 'assistant') { this._turnCount++; + // Accumulate parent-only token usage (exclude subagent turns). + // This keeps gen_ai.usage.* on the root span comparable with the + // foreground agent which also reports parent-only tokens. + if (!message.parent_tool_use_id) { + const msgUsage = message.message?.usage; + if (msgUsage) { + this._parentInputTokens += (msgUsage.input_tokens ?? 0) + + (msgUsage.cache_creation_input_tokens ?? 0) + + (msgUsage.cache_read_input_tokens ?? 0); + this._parentOutputTokens += (msgUsage.output_tokens ?? 0); + this._parentCacheReadTokens += (msgUsage.cache_read_input_tokens ?? 0); + this._parentCacheCreationTokens += (msgUsage.cache_creation_input_tokens ?? 0); + } + } } - // Capture aggregated token usage from result messages for the invoke_agent span + // Set token usage and cost on the invoke_agent span from result messages. if (message.type === 'result' && this._currentInvokeAgentSpan) { - const usage = message.usage; - if (usage) { - this._currentInvokeAgentSpan.setAttributes({ - [GenAiAttr.USAGE_INPUT_TOKENS]: usage.input_tokens ?? 0, - [GenAiAttr.USAGE_OUTPUT_TOKENS]: usage.output_tokens ?? 0, - }); - } if (message.num_turns !== undefined) { this._currentInvokeAgentSpan.setAttribute(CopilotChatAttr.TURN_COUNT, message.num_turns); } @@ -763,6 +780,19 @@ export class ClaudeCodeSession extends Disposable { } const span = this._currentInvokeAgentSpan; span.setAttribute(CopilotChatAttr.TURN_COUNT, this._turnCount); + + // Set parent-only token usage (comparable with foreground agent). + // Note: output_tokens from message.usage at message_start may undercount + // since streaming hasn't finished. The per-chat spans (from chatMLFetcher) + // have accurate output tokens. This is a known limitation — the root span + // output count may be slightly lower than the sum of chat span outputs. + span.setAttributes({ + [GenAiAttr.USAGE_INPUT_TOKENS]: this._parentInputTokens, + [GenAiAttr.USAGE_OUTPUT_TOKENS]: this._parentOutputTokens, + ...(this._parentCacheReadTokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: this._parentCacheReadTokens } : {}), + ...(this._parentCacheCreationTokens ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: this._parentCacheCreationTokens } : {}), + }); + if (statusCode !== undefined) { span.setStatus(statusCode, statusMessage); } else { diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 403a3ae61c145b..57e1b1ae66c373 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -839,6 +839,8 @@ export abstract class ToolCallingLoop { @@ -847,6 +849,8 @@ export abstract class ToolCallingLoop Date: Thu, 16 Apr 2026 16:55:48 -0700 Subject: [PATCH 17/36] feat(monitoring): enhance agent monitoring documentation for Claude agent tracing and usage metrics --- .../docs/monitoring/agent_monitoring.md | 75 ++++++++- .../copilot/src/platform/endpoint/node/tmp.md | 143 ------------------ 2 files changed, 73 insertions(+), 145 deletions(-) delete mode 100644 extensions/copilot/src/platform/endpoint/node/tmp.md diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 535451320340f3..358667b2d17e3f 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -558,14 +558,85 @@ In your trace viewer, filter by `service.name` to see traces from specific agent | `service.name` | Source | |---|---| -| `copilot-chat` | Foreground agent + CLI wrapper spans | +| `copilot-chat` | Foreground agent + CLI wrapper spans + Claude agent spans | | `github-copilot` | CLI SDK native spans + CLI terminal | +| `claude-code` | Claude SDK native traces (supplementary) | + +--- + +## Claude Agent + +When OTel is enabled, Claude agent sessions produce two complementary trace trees: extension-level spans (service `copilot-chat`) following GenAI semantic conventions, and optional SDK-native spans (service `claude-code`) with Claude-specific detail. + +### Extension Traces (Primary) + +The extension creates synthetic spans by intercepting Claude SDK messages and proxying LLM calls through a local HTTP server to CAPI: + +``` +copilot-chat invoke_agent claude [~33s] + ├── chat claude-haiku-4.5 [~5s] (LLM call via CAPI proxy) + ├── execute_tool Agent [~11s] (subagent invocation) + │ ├── chat claude-haiku-4.5 [~4s] (subagent LLM call) + │ ├── execute_tool Grep [~20ms] (subagent tool) + │ └── chat claude-haiku-4.5 [~7s] (subagent LLM call) + ├── chat claude-haiku-4.5 [~3s] + ├── execute_tool Write [~40ms] + ├── chat claude-haiku-4.5 [~3s] + └── user_hook Stop:Stop [~10ms] (hook execution) +``` + +**`invoke_agent claude`** — root span per user request. + +| Attribute | Example | +|---|---| +| `gen_ai.operation.name` | `invoke_agent` | +| `gen_ai.agent.name` | `claude` | +| `gen_ai.provider.name` | `github` | +| `gen_ai.request.model` | `claude-haiku-4.5` | +| `gen_ai.response.model` | `claude-haiku-4-5` | +| `gen_ai.usage.input_tokens` | `103739` (parent-only, excludes subagent tokens) | +| `gen_ai.usage.output_tokens` | `1100` | +| `gen_ai.usage.cache_read.input_tokens` | `64062` | +| `gen_ai.usage.cache_creation.input_tokens` | `39629` | +| `copilot_chat.turn_count` | `8` | +| `copilot_chat.total_cost_usd` | `0.067` (session-wide, includes subagents) | +| `copilot_chat.chat_session_id` | VS Code session ID | + +**`chat`** — one span per LLM API call, created by `chatMLFetcher` via the Claude language model proxy server. Same attributes as foreground agent `chat` spans (token usage, TTFT, response model, cache breakdown). + +**`execute_tool`** — one span per tool invocation. When the tool is `Agent` (subagent), child `chat` and `execute_tool` spans are nested underneath, giving full subagent visibility. + +**`user_hook`** — one span per Claude hook execution (e.g., `Stop` hooks). + +### Claude SDK Native Traces (Supplementary) + +When OTel is enabled, the extension also forwards telemetry configuration to the Claude SDK subprocess via environment variables (`CLAUDE_CODE_ENABLE_TELEMETRY`, `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA`, `OTEL_TRACES_EXPORTER`). This produces a separate trace tree with Claude-specific span names: + +``` +claude-code claude_code.interaction [~33s] + ├── claude_code.llm_request [~5s] + ├── claude_code.tool [~11s] + │ ├── claude_code.tool.blocked_on_user [~1ms] (permission wait) + │ └── claude_code.tool.execution [~11s] (actual execution) + ├── claude_code.llm_request [~3s] + └── ... +``` + +Native traces provide data not available in extension traces: +- `tool.blocked_on_user` sub-spans showing permission prompt wait time +- `tool.execution` sub-spans isolating actual tool runtime +- `user_prompt` content and `user.id` on the root span +- `terminal.type` and `interaction.sequence` metadata + +Both trace trees share the same `session.id` for correlation in your tracing backend. They are **independent root traces** (different trace IDs) because the Claude SDK does not accept incoming `TRACEPARENT` for parent linking. + +> **Content capture**: Set `github.copilot.chat.otel.captureContent` to `true` to include prompt/response content in extension spans and tool input/output in native Claude traces (`OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_TOOL_DETAILS`, `OTEL_LOG_TOOL_CONTENT` are forwarded automatically). --- ## Interpreting the Data -**Traces** — Visualize the full agent execution in Jaeger or Grafana Tempo. Each `invoke_agent` span contains child `chat` and `execute_tool` spans, making it easy to identify bottlenecks and debug failures. Subagent invocations appear as nested `invoke_agent` spans under `execute_tool runSubagent`. +**Traces** — Visualize the full agent execution in Jaeger or Grafana Tempo. Each `invoke_agent` span contains child `chat` and `execute_tool` spans, making it easy to identify bottlenecks and debug failures. Subagent invocations appear as nested `invoke_agent` spans under `execute_tool runSubagent` (foreground agent) or under `execute_tool Agent` (Claude agent). **Metrics** — Track token usage trends by model and provider, monitor tool success rates via `copilot_chat.tool.call.count`, and watch perceived latency with `copilot_chat.time_to_first_token`. Agent activity metrics (`copilot_chat.edit.acceptance.count`, `copilot_chat.edit.survival.four_gram`, `copilot_chat.lines_of_code.count`) power accept rate and edit survival dashboards. All metrics carry the same resource attributes (`service.name`, `service.version`, `session.id`) for consistent filtering. diff --git a/extensions/copilot/src/platform/endpoint/node/tmp.md b/extensions/copilot/src/platform/endpoint/node/tmp.md deleted file mode 100644 index d429838b2fd9f6..00000000000000 --- a/extensions/copilot/src/platform/endpoint/node/tmp.md +++ /dev/null @@ -1,143 +0,0 @@ -# Claude Agent Request Flow - -```mermaid -sequenceDiagram - participant UI as VS Code Chat UI - participant CM as ClaudeAgentManager - participant CS as ClaudeCodeSession - participant SDK as ClaudeCodeSdkService
(sdk.mjs, in-process) - participant CLI as Claude CLI Subprocess
(cli.js, forked process) - participant LMS as ClaudeLanguageModelServer
(HTTP localhost:{port}) - participant FET as chatMLFetcher - participant CAPI as CAPI (GitHub Copilot API) - participant OTEL as OTLP Collector - - Note over UI,OTEL: === User sends message === - - UI->>CM: handleRequest(sessionId, request) - CM->>CS: invoke(request, prompt, stream, token) - CS->>CS: Queue request in _promptQueue - - Note over CS: _createPromptIterable() picks up request
Creates invoke_agent claude span
Stores traceContext in SessionStateService - - CS->>SDK: query({ prompt: asyncIterable, options }) - SDK->>CLI: fork(cli.js) with env:
ANTHROPIC_BASE_URL=localhost:{port}
ANTHROPIC_AUTH_TOKEN={nonce}.{sessionId}
CLAUDE_CODE_ENABLE_TELEMETRY=1
OTEL_EXPORTER_OTLP_ENDPOINT=... - - Note over CS,CLI: === SDK subprocess starts agent loop === - - loop Each LLM turn - CLI->>LMS: POST /v1/messages
{model, messages, tools, stream:true} - - LMS->>LMS: Auth via nonce.sessionId
Resolve model via ClaudeCodeModels - - Note over LMS: runWithTraceContext(invokeAgentCtx)
→ parents chat span to invoke_agent - - LMS->>FET: fetchOne({ endpoint: PassThroughEndpoint }) - - Note over FET: Creates chat {model} span
(child of invoke_agent via traceContext) - - FET->>CAPI: HTTPS POST /chat/completions
(Messages API format) - CAPI-->>FET: SSE stream begins - - Note over FET: Records TTFT on chat span - - FET-->>LMS: ChatResponse (streaming) - - Note over LMS: processResponse() dual-parse:
1. Write raw SSE → CLI (responseStream)
2. Parse via AnthropicMessagesProcessor - - loop Each SSE chunk - LMS-->>CLI: raw SSE bytes (HTTP response) - LMS->>LMS: AnthropicMessagesProcessor.push(chunk) - end - - Note over LMS: message_stop → ChatCompletion
with usage {input_tokens, output_tokens,
cache_read, cache_creation} - - LMS-->>FET: ChatCompletion result - - Note over FET: Sets on chat span:
gen_ai.usage.input_tokens
gen_ai.usage.output_tokens
gen_ai.usage.cache_read.input_tokens
copilot_chat.time_to_first_token
gen_ai.response.model
Ends chat span - - CLI->>CLI: Parse assistant response
Decide: text / tool_use / thinking - - alt Tool call needed - CLI->>CS: yield SDKAssistantMessage (tool_use) - - Note over CS: dispatchMessage() creates
execute_tool {name} span
(child of invoke_agent via parentTraceContext) - - CLI->>CLI: Execute tool locally
(Read, Write, Grep, Bash, Agent...) - - opt Needs permission - CLI->>CS: canUseTool(name, input)
(IPC callback) - CS-->>CLI: {behavior: allow/deny} - end - - CLI->>CS: yield SDKUserMessage (tool_result) - - Note over CS: Sets tool span result + ends it - - else Final response (no more tools) - CLI->>CS: yield SDKAssistantMessage (text) - CS->>UI: stream.markdown(text) - CLI->>CS: yield SDKResultMessage - Note over CS: Ends invoke_agent span
Records agent duration metric
Records turn count metric - end - end - - Note over CLI,OTEL: === Native OTel (parallel, separate trace) === - CLI-->>OTEL: claude-code service spans
(claude_code.interaction,
claude_code.llm_request,
claude_code.tool) - - Note over FET,OTEL: === Custom OTel (our trace) === - FET-->>OTEL: copilot-chat service spans
(invoke_agent claude,
chat {model},
execute_tool {name}) -``` - -```mermaid -graph TB - subgraph "Extension Host Process" - subgraph "Custom OTel Trace (copilot-chat service)" - IA["invoke_agent claude
32.89s
gen_ai.agent.name=claude
copilot_chat.turn_count=12
copilot_chat.chat_session_id=..."] - C1["chat claude-haiku-4.5
7.42s
gen_ai.usage.input_tokens=...
gen_ai.usage.output_tokens=...
copilot_chat.time_to_first_token=..."] - ET1["execute_tool Agent
6.21s
gen_ai.tool.name=Agent
gen_ai.tool.call.id=tu-1"] - C2["chat claude-haiku-4.5
3.02s"] - ET2["execute_tool Grep
20ms"] - C3["chat claude-haiku-4.5
3.13s"] - UH["user_hook Stop:Stop
10ms"] - - IA --> C1 - IA --> ET1 - IA --> C2 - IA --> ET2 - IA --> C3 - IA --> UH - end - end - - subgraph "Claude CLI Subprocess" - subgraph "Native OTel Trace (claude-code service)" - INT["claude_code.interaction
32.1s
session.id=...
user_prompt=...
span.type=interaction"] - LLM1["claude_code.llm_request
7.44s
input_tokens=10
output_tokens=699
cache_creation_tokens=25190
ttft_ms=2623
model=claude-haiku-4-5"] - T1["claude_code.tool
6.2s"] - TB1["claude_code.tool.blocked_on_user
1.16ms"] - TE1["claude_code.tool.execution
6.2s"] - LLM2["claude_code.llm_request
3.03s"] - - INT --> LLM1 - INT --> T1 - T1 --> TB1 - T1 --> TE1 - INT --> LLM2 - end - end - - style IA fill:#2563eb,color:#fff - style INT fill:#7c3aed,color:#fff - style C1 fill:#0891b2,color:#fff - style C2 fill:#0891b2,color:#fff - style C3 fill:#0891b2,color:#fff - style LLM1 fill:#a855f7,color:#fff - style LLM2 fill:#a855f7,color:#fff - style ET1 fill:#059669,color:#fff - style ET2 fill:#059669,color:#fff - style T1 fill:#8b5cf6,color:#fff - style TB1 fill:#f59e0b,color:#000 - style TE1 fill:#8b5cf6,color:#fff - style UH fill:#dc2626,color:#fff -``` \ No newline at end of file From c9807ed4513bd1e87d0da94b77156ef22198bfee Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Thu, 16 Apr 2026 17:03:05 -0700 Subject: [PATCH 18/36] feat(telemetry): simplify Claude OTel environment configuration by removing beta tracing and content capture variables --- .../docs/monitoring/agent_monitoring.md | 41 +++++-------------- .../src/platform/otel/common/agentOTelEnv.ts | 12 ------ .../otel/common/test/agentOTelEnv.spec.ts | 4 -- 3 files changed, 11 insertions(+), 46 deletions(-) diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 358667b2d17e3f..a519510bec5b77 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -558,19 +558,24 @@ In your trace viewer, filter by `service.name` to see traces from specific agent | `service.name` | Source | |---|---| -| `copilot-chat` | Foreground agent + CLI wrapper spans + Claude agent spans | +| `copilot-chat` | Foreground agent, CLI wrapper, and Claude agent spans | | `github-copilot` | CLI SDK native spans + CLI terminal | -| `claude-code` | Claude SDK native traces (supplementary) | + +Within the `copilot-chat` service, distinguish agent types by `gen_ai.agent.name`: + +| `gen_ai.agent.name` | Agent Type | +|---|---| +| `GitHub Copilot Chat` | Foreground agent (agent mode) | +| `copilotcli` | CLI wrapper span | +| `claude` | Claude agent | --- ## Claude Agent -When OTel is enabled, Claude agent sessions produce two complementary trace trees: extension-level spans (service `copilot-chat`) following GenAI semantic conventions, and optional SDK-native spans (service `claude-code`) with Claude-specific detail. +When OTel is enabled, Claude agent sessions produce extension-level spans (service `copilot-chat`) following GenAI semantic conventions. -### Extension Traces (Primary) - -The extension creates synthetic spans by intercepting Claude SDK messages and proxying LLM calls through a local HTTP server to CAPI: +The extension creates spans by intercepting Claude SDK messages and proxying LLM calls through a local HTTP server to CAPI: ``` copilot-chat invoke_agent claude [~33s] @@ -608,30 +613,6 @@ copilot-chat invoke_agent claude [~33s] **`user_hook`** — one span per Claude hook execution (e.g., `Stop` hooks). -### Claude SDK Native Traces (Supplementary) - -When OTel is enabled, the extension also forwards telemetry configuration to the Claude SDK subprocess via environment variables (`CLAUDE_CODE_ENABLE_TELEMETRY`, `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA`, `OTEL_TRACES_EXPORTER`). This produces a separate trace tree with Claude-specific span names: - -``` -claude-code claude_code.interaction [~33s] - ├── claude_code.llm_request [~5s] - ├── claude_code.tool [~11s] - │ ├── claude_code.tool.blocked_on_user [~1ms] (permission wait) - │ └── claude_code.tool.execution [~11s] (actual execution) - ├── claude_code.llm_request [~3s] - └── ... -``` - -Native traces provide data not available in extension traces: -- `tool.blocked_on_user` sub-spans showing permission prompt wait time -- `tool.execution` sub-spans isolating actual tool runtime -- `user_prompt` content and `user.id` on the root span -- `terminal.type` and `interaction.sequence` metadata - -Both trace trees share the same `session.id` for correlation in your tracing backend. They are **independent root traces** (different trace IDs) because the Claude SDK does not accept incoming `TRACEPARENT` for parent linking. - -> **Content capture**: Set `github.copilot.chat.otel.captureContent` to `true` to include prompt/response content in extension spans and tool input/output in native Claude traces (`OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_TOOL_DETAILS`, `OTEL_LOG_TOOL_CONTENT` are forwarded automatically). - --- ## Interpreting the Data diff --git a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts index c773315b60bd14..682c66885486c7 100644 --- a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts +++ b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts @@ -60,21 +60,12 @@ export function deriveClaudeOTelEnv(config: OTelConfig, env: Record { const result = deriveClaudeOTelEnv(makeConfig(), emptyEnv); expect(result).toEqual({ CLAUDE_CODE_ENABLE_TELEMETRY: '1', - CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: '1', OTEL_METRICS_EXPORTER: 'otlp', OTEL_LOGS_EXPORTER: 'otlp', - OTEL_TRACES_EXPORTER: 'otlp', OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318', OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json', }); @@ -103,14 +101,12 @@ describe('deriveClaudeOTelEnv', () => { const result = deriveClaudeOTelEnv(makeConfig({ captureContent: true }), emptyEnv); expect(result['OTEL_LOG_USER_PROMPTS']).toBe('1'); expect(result['OTEL_LOG_TOOL_DETAILS']).toBe('1'); - expect(result['OTEL_LOG_TOOL_CONTENT']).toBe('1'); }); it('does not include content capture vars when captureContent is false', () => { const result = deriveClaudeOTelEnv(makeConfig({ captureContent: false }), emptyEnv); expect(result['OTEL_LOG_USER_PROMPTS']).toBeUndefined(); expect(result['OTEL_LOG_TOOL_DETAILS']).toBeUndefined(); - expect(result['OTEL_LOG_TOOL_CONTENT']).toBeUndefined(); }); it('does not overwrite existing env vars', () => { From eda411ebd80d9d76d29376d4e37179b7e556edb3 Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Thu, 16 Apr 2026 17:07:32 -0700 Subject: [PATCH 19/36] feat(monitoring): add cache token usage metrics to agent monitoring documentation --- extensions/copilot/docs/monitoring/agent_monitoring.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index a519510bec5b77..932b6cb4b93f6b 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -129,6 +129,8 @@ invoke_agent copilot [~15s] | `gen_ai.response.model` | Recommended | `gpt-4o-2024-08-06` | | `gen_ai.usage.input_tokens` | Recommended | `12500` | | `gen_ai.usage.output_tokens` | Recommended | `3200` | +| `gen_ai.usage.cache_read.input_tokens` | When available | `12000` | +| `gen_ai.usage.cache_creation.input_tokens` | When available | `12385` | | `copilot_chat.turn_count` | Always | `4` | | `error.type` | On error | `Error` | | `gen_ai.input.messages` | Opt-in (captureContent) | `[{"role":"user",...}]` | @@ -152,6 +154,8 @@ invoke_agent copilot [~15s] | `gen_ai.response.finish_reasons` | On response | `["stop"]` | | `gen_ai.usage.input_tokens` | On response | `1500` | | `gen_ai.usage.output_tokens` | On response | `250` | +| `gen_ai.usage.cache_read.input_tokens` | When available | `12000` | +| `gen_ai.usage.cache_creation.input_tokens` | When available | `12385` | | `copilot_chat.time_to_first_token` | On response | `450` | | `server.address` | When available | `api.github.com` | | `copilot_chat.debug_name` | When available | `agentMode` | From 685ca6ca28c133034d7373e4b6b7070d6d48d3db Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Thu, 16 Apr 2026 17:17:58 -0700 Subject: [PATCH 20/36] feat(tracing): update hook started span attributes for improved telemetry --- .../chatSessions/claude/common/claudeMessageDispatch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index ba81e372fbe3be..a4d616a3adf641 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -380,12 +380,12 @@ export function handleHookStarted( state: MessageHandlerState, ): void { const otelService = accessor.get(IOTelService); - const span = otelService.startSpan(`user_hook ${message.hook_event}:${message.hook_name}`, { + const span = otelService.startSpan(`${GenAiOperationName.EXECUTE_HOOK} ${message.hook_event}:${message.hook_name}`, { kind: SpanKind.INTERNAL, attributes: { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK, 'copilot_chat.hook_type': message.hook_event, - 'copilot_chat.hook_command': message.hook_name, + 'copilot_chat.hook_name': message.hook_name, 'copilot_chat.hook_id': message.hook_id, [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, }, From 2072d5fa13bd3ca2dc0b040a183f8ccaa2438efc Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Thu, 16 Apr 2026 17:23:16 -0700 Subject: [PATCH 21/36] feat(tracing): refine span name in handleHookStarted for clarity --- .../chatSessions/claude/common/claudeMessageDispatch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index a4d616a3adf641..55937e30baa6eb 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -380,7 +380,7 @@ export function handleHookStarted( state: MessageHandlerState, ): void { const otelService = accessor.get(IOTelService); - const span = otelService.startSpan(`${GenAiOperationName.EXECUTE_HOOK} ${message.hook_event}:${message.hook_name}`, { + const span = otelService.startSpan(`${GenAiOperationName.EXECUTE_HOOK} ${message.hook_name}`, { kind: SpanKind.INTERNAL, attributes: { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK, From 8c2b6f073729bb1c249fef0e596a6a84da06005f Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Thu, 16 Apr 2026 18:11:04 -0700 Subject: [PATCH 22/36] feat(tracing): update span name in handleHookStarted for improved clarity --- .../claude/common/test/claudeMessageDispatch.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts index c092b68ddbbb93..7677977e724626 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts @@ -636,7 +636,7 @@ describe('handleHookStarted', () => { handleHookStarted(makeHookStarted('hook-42', 'lint-check', 'PreToolUse'), accessor, TEST_SESSION_ID, state); expect(startSpanSpy).toHaveBeenCalledWith( - 'user_hook PreToolUse:lint-check', + 'execute_hook lint-check', expect.objectContaining({ attributes: expect.any(Object) }), ); expect(state.otelHookSpans.has('hook-42')).toBe(true); From 6e32d80c1d7d95a7885512d529379d35aa82819e Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Thu, 16 Apr 2026 18:15:59 -0700 Subject: [PATCH 23/36] feat(monitoring): update cache token usage metrics for accuracy and clarity feat(tracing): rename hook_name attribute to hook_command for better context fix(agent): clear subagent trace contexts after request completion --- .../copilot/docs/monitoring/agent_monitoring.md | 12 ++++++------ .../claude/common/claudeMessageDispatch.ts | 2 +- .../chatSessions/claude/node/claudeCodeAgent.ts | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 932b6cb4b93f6b..e618a481369880 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -129,8 +129,8 @@ invoke_agent copilot [~15s] | `gen_ai.response.model` | Recommended | `gpt-4o-2024-08-06` | | `gen_ai.usage.input_tokens` | Recommended | `12500` | | `gen_ai.usage.output_tokens` | Recommended | `3200` | -| `gen_ai.usage.cache_read.input_tokens` | When available | `12000` | -| `gen_ai.usage.cache_creation.input_tokens` | When available | `12385` | +| `gen_ai.usage.cache_read.input_tokens` | When available | `8000` | +| `gen_ai.usage.cache_creation.input_tokens` | When available | `4200` | | `copilot_chat.turn_count` | Always | `4` | | `error.type` | On error | `Error` | | `gen_ai.input.messages` | Opt-in (captureContent) | `[{"role":"user",...}]` | @@ -154,8 +154,8 @@ invoke_agent copilot [~15s] | `gen_ai.response.finish_reasons` | On response | `["stop"]` | | `gen_ai.usage.input_tokens` | On response | `1500` | | `gen_ai.usage.output_tokens` | On response | `250` | -| `gen_ai.usage.cache_read.input_tokens` | When available | `12000` | -| `gen_ai.usage.cache_creation.input_tokens` | When available | `12385` | +| `gen_ai.usage.cache_read.input_tokens` | When available | `1200` | +| `gen_ai.usage.cache_creation.input_tokens` | When available | `300` | | `copilot_chat.time_to_first_token` | On response | `450` | | `server.address` | When available | `api.github.com` | | `copilot_chat.debug_name` | When available | `agentMode` | @@ -591,7 +591,7 @@ copilot-chat invoke_agent claude [~33s] ├── chat claude-haiku-4.5 [~3s] ├── execute_tool Write [~40ms] ├── chat claude-haiku-4.5 [~3s] - └── user_hook Stop:Stop [~10ms] (hook execution) + └── execute_hook Stop [~10ms] (hook execution) ``` **`invoke_agent claude`** — root span per user request. @@ -615,7 +615,7 @@ copilot-chat invoke_agent claude [~33s] **`execute_tool`** — one span per tool invocation. When the tool is `Agent` (subagent), child `chat` and `execute_tool` spans are nested underneath, giving full subagent visibility. -**`user_hook`** — one span per Claude hook execution (e.g., `Stop` hooks). +**`execute_hook`** — one span per Claude hook execution (e.g., `Stop` hooks). --- diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index 55937e30baa6eb..a7e93dd1c841f4 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -385,7 +385,7 @@ export function handleHookStarted( attributes: { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK, 'copilot_chat.hook_type': message.hook_event, - 'copilot_chat.hook_name': message.hook_name, + 'copilot_chat.hook_command': message.hook_name, 'copilot_chat.hook_id': message.hook_id, [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, }, diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index c0a69d5322ef1c..1519cb53b21eda 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -748,6 +748,7 @@ export class ClaudeCodeSession extends Disposable { } this._currentRequest = undefined; this._startGatewayIdleTimer(); + subagentTraceContexts.clear(); } } // Generator ended normally - clean up so next invoke starts fresh From bb13080fa98c1be93796dee31c6400489a01f4c4 Mon Sep 17 00:00:00 2001 From: Zhichao Li Date: Fri, 17 Apr 2026 14:28:44 -0700 Subject: [PATCH 24/36] refactor: extract ClaudeOTelTracker from ClaudeCodeSession --- .../claude/node/claudeCodeAgent.ts | 164 ++----------- .../claude/node/claudeOTelTracker.ts | 220 ++++++++++++++++++ 2 files changed, 234 insertions(+), 150 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index 1519cb53b21eda..c341926fa12567 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -11,7 +11,7 @@ import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/ch import { INativeEnvService } from '../../../../platform/env/common/envService'; import { ILogService } from '../../../../platform/log/common/logService'; import { IMcpService } from '../../../../platform/mcp/common/mcpService'; -import { CopilotChatAttr, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, type TraceContext, truncateForOTel } from '../../../../platform/otel/common/index'; +import { IOTelService, type ISpanHandle, SpanStatusCode, type TraceContext } from '../../../../platform/otel/common/index'; import { deriveClaudeOTelEnv } from '../../../../platform/otel/common/agentOTelEnv'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; @@ -34,6 +34,7 @@ import { resolvePromptToContentBlocks } from './claudePromptResolver'; import { ClaudeSettingsChangeTracker } from './claudeSettingsChangeTracker'; import { ParsedClaudeModelId } from '../common/claudeModelId'; import { IClaudeSessionStateService } from '../common/claudeSessionStateService'; +import { ClaudeOTelTracker } from './claudeOTelTracker'; // Manages Claude Code agent interactions and language model server lifecycle export class ClaudeAgentManager extends Disposable { @@ -170,17 +171,7 @@ export class ClaudeCodeSession extends Disposable { private _currentToolNames: ReadonlySet | undefined; private _gateway: vscode.McpGateway | undefined; private _gatewayIdleTimeout: ReturnType | undefined; - private _currentInvokeAgentSpan: ISpanHandle | undefined; - private _currentInvokeAgentTraceContext: TraceContext | undefined; - private _currentInvokeAgentStartTime: number | undefined; - private _isFirstRequest = true; - private _turnCount = 0; - // Parent-only token accumulators (excludes subagent turns) for gen_ai.usage.* consistency - // with the foreground agent which also reports parent-only tokens on the root span. - private _parentInputTokens = 0; - private _parentOutputTokens = 0; - private _parentCacheReadTokens = 0; - private _parentCacheCreationTokens = 0; + private _otelTracker: ClaudeOTelTracker | undefined; /** * Sets the model on the active SDK session, or stores it for the next session start. @@ -235,6 +226,7 @@ export class ClaudeCodeSession extends Disposable { this._currentModelId = initialModelId; this._currentPermissionMode = initialPermissionMode; this._isResumed = !isNewSession; + this._otelTracker = new ClaudeOTelTracker(this.sessionId, this._otelService, this.sessionStateService); this._debugFileLogger.startSession(this.sessionId).catch(err => { this.logService.error('[ClaudeCodeSession] Failed to start debug log session', err); }); @@ -560,55 +552,12 @@ export class ClaudeCodeSession extends Disposable { new CapturingToken(promptLabel, 'claude', undefined, undefined, this.sessionId) ); - // End any previous invoke_agent span (e.g., from a prior turn in this session) - this._endInvokeAgentSpan(); - - // Start the invoke_agent span for this request + // Start OTel tracking for this request const modelId = this._currentModelId.toEndpointModelId(); - this._currentInvokeAgentSpan = this._otelService.startSpan('invoke_agent claude', { - kind: SpanKind.INTERNAL, - attributes: { - [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, - [GenAiAttr.AGENT_NAME]: 'claude', - [GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB, - [GenAiAttr.CONVERSATION_ID]: this.sessionId, - [CopilotChatAttr.SESSION_ID]: this.sessionId, - [CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId, - [GenAiAttr.REQUEST_MODEL]: modelId, - }, - }); - this._currentInvokeAgentTraceContext = this._currentInvokeAgentSpan.getSpanContext(); - this._currentInvokeAgentStartTime = Date.now(); - this._turnCount = 0; - this._parentInputTokens = 0; - this._parentOutputTokens = 0; - this._parentCacheReadTokens = 0; - this._parentCacheCreationTokens = 0; - - // Store trace context in session state so the language model server - // can parent chat spans to this invoke_agent span - this.sessionStateService.setTraceContextForSession(this.sessionId, this._currentInvokeAgentTraceContext); - - // Emit session start event and metric for the first request in this session - if (this._isFirstRequest) { - this._isFirstRequest = false; - GenAiMetrics.incrementSessionCount(this._otelService); - emitSessionStartEvent(this._otelService, this.sessionId, modelId, 'claude'); - } + this._otelTracker!.startRequest(modelId); - // Emit user_message span event for the debug panel under the invoke_agent context - const userMsgSpan = this._otelService.startSpan('user_message', { - kind: SpanKind.INTERNAL, - attributes: { - [GenAiAttr.OPERATION_NAME]: 'user_message', - [CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId, - }, - parentTraceContext: this._currentInvokeAgentTraceContext, - }); - const userContent = truncateForOTel(promptLabel); - userMsgSpan.setAttribute(CopilotChatAttr.USER_REQUEST, userContent); - userMsgSpan.addEvent('user_message', { content: userContent, [CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId }); - userMsgSpan.end(); + // Emit user_message span event for the debug panel + this._otelTracker!.emitUserMessage(promptLabel); yield { type: 'user', @@ -675,54 +624,11 @@ export class ClaudeCodeSession extends Disposable { continue; } - // Track turn count for assistant messages (each assistant message = one LLM round-trip) - if (message.type === 'assistant') { - this._turnCount++; - // Accumulate parent-only token usage (exclude subagent turns). - // This keeps gen_ai.usage.* on the root span comparable with the - // foreground agent which also reports parent-only tokens. - if (!message.parent_tool_use_id) { - const msgUsage = message.message?.usage; - if (msgUsage) { - this._parentInputTokens += (msgUsage.input_tokens ?? 0) - + (msgUsage.cache_creation_input_tokens ?? 0) - + (msgUsage.cache_read_input_tokens ?? 0); - this._parentOutputTokens += (msgUsage.output_tokens ?? 0); - this._parentCacheReadTokens += (msgUsage.cache_read_input_tokens ?? 0); - this._parentCacheCreationTokens += (msgUsage.cache_creation_input_tokens ?? 0); - } - } - } - - // Set token usage and cost on the invoke_agent span from result messages. - if (message.type === 'result' && this._currentInvokeAgentSpan) { - if (message.num_turns !== undefined) { - this._currentInvokeAgentSpan.setAttribute(CopilotChatAttr.TURN_COUNT, message.num_turns); - } - if (message.total_cost_usd !== undefined) { - this._currentInvokeAgentSpan.setAttribute('copilot_chat.total_cost_usd', message.total_cost_usd); - } - const responseModel = message.modelUsage ? Object.keys(message.modelUsage)[0] : undefined; - if (responseModel) { - this._currentInvokeAgentSpan.setAttribute(GenAiAttr.RESPONSE_MODEL, responseModel); - } - } + // Track OTel metrics from SDK messages + this._otelTracker!.onMessage(message, subagentTraceContexts); this.logService.trace(`claude-agent-sdk Message: ${JSON.stringify(message, null, 2)}`); - // Update the session trace context based on whether this message is from a subagent. - // This ensures that chat spans (created by chatMLFetcher via runWithTraceContext) - // are parented under the correct Agent tool span during subagent execution. - if ('parent_tool_use_id' in message && message.parent_tool_use_id) { - const subagentCtx = subagentTraceContexts.get(message.parent_tool_use_id); - if (subagentCtx) { - this.sessionStateService.setTraceContextForSession(this.sessionId, subagentCtx); - } - } else if ('parent_tool_use_id' in message) { - // Message is from the main agent (parent_tool_use_id is null) — restore root context - this.sessionStateService.setTraceContextForSession(this.sessionId, this._currentInvokeAgentTraceContext); - } - const result = this.instantiationService.invokeFunction(dispatchMessage, message, this.sessionId, { stream: this._currentRequest.stream, toolInvocationToken: this._currentRequest.toolInvocationToken, @@ -732,13 +638,13 @@ export class ClaudeCodeSession extends Disposable { unprocessedToolCalls, otelToolSpans, otelHookSpans, - parentTraceContext: this._currentInvokeAgentTraceContext, + parentTraceContext: this._otelTracker!.traceContext, subagentTraceContexts, }); if (result?.requestComplete) { // End the invoke_agent span for this request - this._endInvokeAgentSpan(); + this._otelTracker!.endRequest(); // Clear the capturing token so subsequent requests get their own this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); // Resolve and remove the completed request @@ -768,57 +674,15 @@ export class ClaudeCodeSession extends Disposable { } otelHookSpans.clear(); // End any lingering invoke_agent span - this._endInvokeAgentSpan(SpanStatusCode.ERROR, 'session ended'); - } - } - - /** - * Ends the current invoke_agent span and records metrics. - */ - private _endInvokeAgentSpan(statusCode?: SpanStatusCode, statusMessage?: string): void { - if (!this._currentInvokeAgentSpan) { - return; + this._otelTracker!.endRequestWithError('session ended'); } - const span = this._currentInvokeAgentSpan; - span.setAttribute(CopilotChatAttr.TURN_COUNT, this._turnCount); - - // Set parent-only token usage (comparable with foreground agent). - // Note: output_tokens from message.usage at message_start may undercount - // since streaming hasn't finished. The per-chat spans (from chatMLFetcher) - // have accurate output tokens. This is a known limitation — the root span - // output count may be slightly lower than the sum of chat span outputs. - span.setAttributes({ - [GenAiAttr.USAGE_INPUT_TOKENS]: this._parentInputTokens, - [GenAiAttr.USAGE_OUTPUT_TOKENS]: this._parentOutputTokens, - ...(this._parentCacheReadTokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: this._parentCacheReadTokens } : {}), - ...(this._parentCacheCreationTokens ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: this._parentCacheCreationTokens } : {}), - }); - - if (statusCode !== undefined) { - span.setStatus(statusCode, statusMessage); - } else { - span.setStatus(SpanStatusCode.OK); - } - span.end(); - - // Record agent-level metrics - if (this._currentInvokeAgentStartTime) { - const durationSec = (Date.now() - this._currentInvokeAgentStartTime) / 1000; - GenAiMetrics.recordAgentDuration(this._otelService, 'claude', durationSec); - } - GenAiMetrics.recordAgentTurnCount(this._otelService, 'claude', this._turnCount); - - this._currentInvokeAgentSpan = undefined; - this._currentInvokeAgentTraceContext = undefined; - this._currentInvokeAgentStartTime = undefined; - this.sessionStateService.setTraceContextForSession(this.sessionId, undefined); } private _cleanup(error: Error): void { // Clear the capturing token so it doesn't leak across sessions or error boundaries this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); // End invoke_agent span with error if still open - this._endInvokeAgentSpan(SpanStatusCode.ERROR, error.message); + this._otelTracker!.endRequestWithError(error.message); this._resetSessionState(); const wasYielding = this._yieldInProgress; diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts new file mode 100644 index 00000000000000..f5690a1a215160 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import { CopilotChatAttr, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, type TraceContext, truncateForOTel } from '../../../../platform/otel/common/index'; +import { IClaudeSessionStateService } from '../common/claudeSessionStateService'; + +/** + * Manages OTel span lifecycle for a Claude agent session. + * + * Extracted from ClaudeCodeSession to keep tracing concerns separate from + * session orchestration. Tracks the invoke_agent root span, accumulates + * parent-only token usage, and manages trace context for subagent nesting. + */ +export class ClaudeOTelTracker { + private _currentSpan: ISpanHandle | undefined; + private _currentTraceContext: TraceContext | undefined; + private _startTime: number | undefined; + private _isFirstRequest = true; + private _turnCount = 0; + private _parentInputTokens = 0; + private _parentOutputTokens = 0; + private _parentCacheReadTokens = 0; + private _parentCacheCreationTokens = 0; + + constructor( + private readonly _sessionId: string, + private readonly _otelService: IOTelService, + private readonly _sessionStateService: IClaudeSessionStateService, + ) { } + + /** The trace context of the current invoke_agent span, used to parent child spans. */ + get traceContext(): TraceContext | undefined { + return this._currentTraceContext; + } + + /** + * Starts a new invoke_agent span for a user request. + * Ends any previous span and resets accumulators. + */ + startRequest(modelId: string): void { + this.endRequest(); + + this._currentSpan = this._otelService.startSpan('invoke_agent claude', { + kind: SpanKind.INTERNAL, + attributes: { + [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, + [GenAiAttr.AGENT_NAME]: 'claude', + [GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB, + [GenAiAttr.CONVERSATION_ID]: this._sessionId, + [CopilotChatAttr.SESSION_ID]: this._sessionId, + [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId, + [GenAiAttr.REQUEST_MODEL]: modelId, + }, + }); + this._currentTraceContext = this._currentSpan.getSpanContext(); + this._startTime = Date.now(); + this._turnCount = 0; + this._parentInputTokens = 0; + this._parentOutputTokens = 0; + this._parentCacheReadTokens = 0; + this._parentCacheCreationTokens = 0; + + // Store trace context so the language model server can parent chat spans + this._sessionStateService.setTraceContextForSession(this._sessionId, this._currentTraceContext); + + // Emit session start event and metric for the first request + if (this._isFirstRequest) { + this._isFirstRequest = false; + GenAiMetrics.incrementSessionCount(this._otelService); + emitSessionStartEvent(this._otelService, this._sessionId, modelId, 'claude'); + } + } + + /** + * Emits a user_message span event for the debug panel. + */ + emitUserMessage(promptLabel: string): void { + const userMsgSpan = this._otelService.startSpan('user_message', { + kind: SpanKind.INTERNAL, + attributes: { + [GenAiAttr.OPERATION_NAME]: 'user_message', + [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId, + }, + parentTraceContext: this._currentTraceContext, + }); + const userContent = truncateForOTel(promptLabel); + userMsgSpan.setAttribute(CopilotChatAttr.USER_REQUEST, userContent); + userMsgSpan.addEvent('user_message', { content: userContent, [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId }); + userMsgSpan.end(); + } + + /** + * Processes an SDK message for OTel tracking. + * Call this for every message in the processing loop. + */ + onMessage(message: SDKMessage, subagentTraceContexts: Map): void { + if (message.type === 'assistant') { + this._turnCount++; + this._accumulateParentTokenUsage(message); + } + + if (message.type === 'result' && this._currentSpan) { + this._setResultAttributes(message); + } + + this._updateTraceContextForMessage(message, subagentTraceContexts); + } + + /** + * Ends the current invoke_agent span with OK status and records metrics. + */ + endRequest(): void { + this._endSpan(); + } + + /** + * Ends the current invoke_agent span with ERROR status. + */ + endRequestWithError(message: string): void { + this._endSpan(SpanStatusCode.ERROR, message); + } + + // ── Private ────────────────────────────────────────────────────────────── + + private _endSpan(statusCode?: SpanStatusCode, statusMessage?: string): void { + if (!this._currentSpan) { + return; + } + const span = this._currentSpan; + span.setAttribute(CopilotChatAttr.TURN_COUNT, this._turnCount); + + // Set parent-only token usage (comparable with foreground agent). + span.setAttributes({ + [GenAiAttr.USAGE_INPUT_TOKENS]: this._parentInputTokens, + [GenAiAttr.USAGE_OUTPUT_TOKENS]: this._parentOutputTokens, + ...(this._parentCacheReadTokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: this._parentCacheReadTokens } : {}), + ...(this._parentCacheCreationTokens ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: this._parentCacheCreationTokens } : {}), + }); + + if (statusCode !== undefined) { + span.setStatus(statusCode, statusMessage); + } else { + span.setStatus(SpanStatusCode.OK); + } + span.end(); + + // Record agent-level metrics + if (this._startTime) { + const durationSec = (Date.now() - this._startTime) / 1000; + GenAiMetrics.recordAgentDuration(this._otelService, 'claude', durationSec); + } + GenAiMetrics.recordAgentTurnCount(this._otelService, 'claude', this._turnCount); + + this._currentSpan = undefined; + this._currentTraceContext = undefined; + this._startTime = undefined; + this._sessionStateService.setTraceContextForSession(this._sessionId, undefined); + } + + /** + * Accumulates parent-only token usage from an assistant message. + * Excludes subagent turns so gen_ai.usage.* on the root span is comparable + * with the foreground agent. + */ + private _accumulateParentTokenUsage(message: SDKMessage & { type: 'assistant' }): void { + if (message.parent_tool_use_id) { + return; + } + const msgUsage = message.message?.usage; + if (msgUsage) { + this._parentInputTokens += (msgUsage.input_tokens ?? 0) + + (msgUsage.cache_creation_input_tokens ?? 0) + + (msgUsage.cache_read_input_tokens ?? 0); + this._parentOutputTokens += (msgUsage.output_tokens ?? 0); + this._parentCacheReadTokens += (msgUsage.cache_read_input_tokens ?? 0); + this._parentCacheCreationTokens += (msgUsage.cache_creation_input_tokens ?? 0); + } + } + + /** + * Sets cost, turn count, and response model on the invoke_agent span from a result message. + */ + private _setResultAttributes(message: SDKMessage & { type: 'result' }): void { + if (!this._currentSpan) { + return; + } + if (message.num_turns !== undefined) { + this._currentSpan.setAttribute(CopilotChatAttr.TURN_COUNT, message.num_turns); + } + if (message.total_cost_usd !== undefined) { + this._currentSpan.setAttribute('copilot_chat.total_cost_usd', message.total_cost_usd); + } + const responseModel = message.modelUsage ? Object.keys(message.modelUsage)[0] : undefined; + if (responseModel) { + this._currentSpan.setAttribute(GenAiAttr.RESPONSE_MODEL, responseModel); + } + } + + /** + * Updates the session trace context based on whether a message is from a subagent. + * Ensures chat spans created by chatMLFetcher are parented under the correct + * Agent tool span during subagent execution. + */ + private _updateTraceContextForMessage(message: SDKMessage, subagentTraceContexts: Map): void { + if (!('parent_tool_use_id' in message)) { + return; + } + if (message.parent_tool_use_id) { + const subagentCtx = subagentTraceContexts.get(message.parent_tool_use_id); + if (subagentCtx) { + this._sessionStateService.setTraceContextForSession(this._sessionId, subagentCtx); + } + } else { + this._sessionStateService.setTraceContextForSession(this._sessionId, this._currentTraceContext); + } + } +} From 5e5cde126922985088dc507651d94e08dcc89c58 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:24:42 -0700 Subject: [PATCH 25/36] Fix harness dropdown picker theming in AI Customizations editor (#312867) The native