From 78415d25068c542f2661a2771f4a32be34839239 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 15 Sep 2026 17:42:10 -0700 Subject: [PATCH 1/3] agentHost: fix scoped debug log export Group AHP wire-log segments by a stable logical-host ID while preserving each relay connection ID in JSONL metadata. Export reconnect and rotated segments, retain all output-channel logs, and fail explicitly instead of silently dropping client or host files. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d4768367-fde5-4eb9-b62c-d997ec420a6f --- .../browser/remoteAgentHostServiceImpl.ts | 2 +- .../agentHost/common/ahpJsonlLogger.ts | 22 ++++- .../agentHost/common/relayTransport.ts | 4 +- .../electron-browser/localAgentHostService.ts | 1 + .../sshRemoteAgentHostServiceImpl.ts | 6 +- .../wslRemoteAgentHostServiceImpl.ts | 4 +- .../agentHost/node/webSocketTransport.ts | 4 +- .../agentHostIpcChannelTransport.test.ts | 2 +- .../test/common/ahpJsonlLogger.test.ts | 15 +-- .../test/common/relayTransport.test.ts | 23 +++++ .../browser/cloudSandboxAgentHostService.ts | 1 + ...ontainerAgentHostConnector.contribution.ts | 6 +- .../tunnelAgentHostServiceImpl.ts | 5 +- .../actions/exportAgentHostDebugLogsAction.ts | 95 ++++++------------- .../browser/chatDebug/agentHostLogSources.ts | 62 +++++------- .../exportAgentHostDebugLogsService.ts | 42 +++----- .../browser/exportAgentHostDebugLogs.test.ts | 65 +++++++++++-- 17 files changed, 198 insertions(+), 161 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index 622809cdfdad09..459aa97f6ccab9 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -122,7 +122,7 @@ class WebSocketConnectionFactory extends Disposable implements IRemoteAgentHostC address, entry.connectionToken, ahpLoggingEnabled - ? { logsHome: this._environmentService.logsHome, connectionId: address, transport: 'websocket' } + ? { logsHome: this._environmentService.logsHome, logId: address, connectionId: address, transport: 'websocket' } : undefined, ); const connection = this._instantiationService.createInstance(AgentHostProtocolClient, address, transportFactory, { clientInfo: this._clientInfo() }); diff --git a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts index c1f91dbe30f9b5..4da07af23c9ccb 100644 --- a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts +++ b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { VSBuffer } from '../../../base/common/buffer.js'; +import { StringSHA1 } from '../../../base/common/hash.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { joinPath } from '../../../base/common/resources.js'; @@ -25,6 +26,8 @@ interface IAhpLogMeta { export interface IAhpJsonlLoggerOptions { readonly logsHome: URI; + /** Stable identity shared by every transport connection to the same logical host. */ + readonly logId: string; readonly connectionId: string; readonly transport: string; readonly maxFileSizeBytes?: number; @@ -32,6 +35,8 @@ export interface IAhpJsonlLoggerOptions { } const AHP_LOG_DIR = 'ahp'; +const AHP_LOG_FILE_PREFIX = 'ahp'; +const AHP_LOG_FILE_EXTENSION = '.jsonl'; const DEFAULT_MAX_FILE_SIZE_BYTES = 75 * 1024 * 1024; const DEFAULT_MAX_FILES = 5; // Cap the size of any single coalesced writeFile to avoid producing huge @@ -76,7 +81,7 @@ export class AhpJsonlLogger extends Disposable { this._directory = joinPath(this._options.logsHome, AHP_LOG_DIR); // Truncate connectionId to avoid filesystem filename length limits (e.g. 255 on ext4/APFS) const safeConnectionId = sanitizeFilePart(this._options.connectionId).slice(0, 64); - this._baseName = `ahp-${toFileTimestamp(new Date())}-${safeConnectionId}.jsonl`; + this._baseName = `${getAhpLogFilePrefix(this._options.logId)}${toFileTimestamp(new Date())}-${safeConnectionId}${AHP_LOG_FILE_EXTENSION}`; this._maxFileSizeBytes = this._options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES; this._maxFiles = this._options.maxFiles ?? DEFAULT_MAX_FILES; this._currentFile = joinPath(this._directory, this._baseName); @@ -195,8 +200,8 @@ export class AhpJsonlLogger extends Disposable { if (segment === 0) { return joinPath(this._directory, this._baseName); } - const currentBaseName = this._baseName.slice(0, -'.jsonl'.length); - return joinPath(this._directory, `${currentBaseName}.${segment}.jsonl`); + const currentBaseName = this._baseName.slice(0, -AHP_LOG_FILE_EXTENSION.length); + return joinPath(this._directory, `${currentBaseName}.${segment}${AHP_LOG_FILE_EXTENSION}`); } private async _getFileSize(resource: URI): Promise { @@ -212,6 +217,11 @@ export function getAhpLogByteLength(text: string): number { return VSBuffer.fromString(text).byteLength; } +/** Tests whether a JSONL filename belongs to the given logical Agent Host connection. */ +export function isAhpLogFileFor(logId: string, name: string): boolean { + return name.startsWith(getAhpLogFilePrefix(logId)) && name.endsWith(AHP_LOG_FILE_EXTENSION); +} + export function stringifyAhpLogEntry(value: unknown): string { return JSON.stringify(value, _ahpReplacer); } @@ -256,6 +266,12 @@ function toFileTimestamp(date: Date): string { return date.toISOString().replace(/[:.]/g, '-'); } +function getAhpLogFilePrefix(logId: string): string { + const hash = new StringSHA1(); + hash.update(logId); + return `${AHP_LOG_FILE_PREFIX}-${hash.digest()}-`; +} + function sanitizeFilePart(value: string): string { return value.replace(/[\\/:\*\?"<>\|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'connection'; } diff --git a/src/vs/platform/agentHost/common/relayTransport.ts b/src/vs/platform/agentHost/common/relayTransport.ts index c7dac140b2a572..101e82a30aac2c 100644 --- a/src/vs/platform/agentHost/common/relayTransport.ts +++ b/src/vs/platform/agentHost/common/relayTransport.ts @@ -141,7 +141,7 @@ export class ReconnectingRelayTransport extends ReconnectingTransport { constructor( establish: () => Promise, channel: IRelayChannel, - createAhpLogger: () => AhpJsonlLogger | undefined, + createAhpLogger: (connectionId: string) => AhpJsonlLogger | undefined, logService: ILogService, logPrefix: string, clientConnectionKind: AgentHostClientConnectionKind, @@ -153,7 +153,7 @@ export class ReconnectingRelayTransport extends ReconnectingTransport { // is what owns and disposes it, so a logger built before `establish` // resolves would be leaked on every failed attempt. return { - transport: new RelayTransport(connectionHandle.connectionId, channel, createAhpLogger(), logService, logPrefix, clientConnectionKind), + transport: new RelayTransport(connectionHandle.connectionId, channel, createAhpLogger(connectionHandle.connectionId), logService, logPrefix, clientConnectionKind), close: connectionHandle.close, }; }, diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index fb22bb9b79f037..cc5a2c1573dfb7 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -186,6 +186,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos this._ahpLogger = this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId) ? this._register(this._instantiationService.createInstance(AhpJsonlLogger, { logsHome: environmentService.logsHome, + logId: this.clientId, connectionId: this.clientId, transport: 'local', })) diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index d54829f1076774..f91b6e8f07590d 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -114,11 +114,9 @@ export class SSHRelayClientFactory implements ISSHRelayClientFactory { } }; return this._instantiationService.createInstance(AgentHostProtocolClient, address, () => { - // Logged under the seed channel id: the re-established id is not known - // until `establish()` resolves, after the logger has to exist. - const createLogger = () => ahpLoggingEnabled ? this._instantiationService.createInstance( + const createLogger = (activeConnectionId: string) => ahpLoggingEnabled ? this._instantiationService.createInstance( AhpJsonlLogger, - { logsHome: this._environmentService.logsHome, connectionId, transport: 'ssh' }, + { logsHome: this._environmentService.logsHome, logId: address, connectionId: activeConnectionId, transport: 'ssh' }, ) : undefined; return new ReconnectingRelayTransport( establish, diff --git a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts index 7162484488d9cb..01f724102adbe2 100644 --- a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts @@ -89,9 +89,9 @@ export class WSLRelayClientFactory implements IWSLRelayClientFactory { }; const transportFactory = () => { const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); - const createLogger = () => ahpLoggingEnabled ? this._instantiationService.createInstance( + const createLogger = (activeConnectionId: string) => ahpLoggingEnabled ? this._instantiationService.createInstance( AhpJsonlLogger, - { logsHome: this._environmentService.logsHome, connectionId, transport: 'wsl' }, + { logsHome: this._environmentService.logsHome, logId: address, connectionId: activeConnectionId, transport: 'wsl' }, ) : undefined; return this._instantiationService.createInstance( ReconnectingRelayTransport, diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts index 265292e25cc2f1..5846f3f6870a03 100644 --- a/src/vs/platform/agentHost/node/webSocketTransport.ts +++ b/src/vs/platform/agentHost/node/webSocketTransport.ts @@ -240,11 +240,13 @@ export class WebSocketProtocolServer extends Disposable implements IProtocolServ if (!this._ahpLogOptions) { return undefined; } + const connectionId = `agent-host-${++this._connectionCount}-${generateUuid()}`; return this._ahpLogOptions.instantiationService.createInstance( AhpJsonlLogger, { logsHome: this._ahpLogOptions.logsHome, - connectionId: `agent-host-${++this._connectionCount}-${generateUuid()}`, + logId: connectionId, + connectionId, transport: 'websocket', }, ); diff --git a/src/vs/platform/agentHost/test/browser/agentHostIpcChannelTransport.test.ts b/src/vs/platform/agentHost/test/browser/agentHostIpcChannelTransport.test.ts index 5c90fcfefe43b6..ed381c87431e2a 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostIpcChannelTransport.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostIpcChannelTransport.test.ts @@ -96,7 +96,7 @@ suite('AgentHostIpcChannelTransport', () => { const fileService = ds.add(new FileService(new NullLogService())); ds.add(fileService.registerProvider('file', ds.add(new InMemoryFileSystemProvider()))); const logger = ds.add(new AhpJsonlLogger( - { logsHome: URI.file('/logs'), connectionId: 'local-client', transport: 'local' }, + { logsHome: URI.file('/logs'), logId: 'local-client', connectionId: 'local-client', transport: 'local' }, fileService, new NullLogService(), )); diff --git a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts index 79ee5a79249fcc..3cb01c3c17fb48 100644 --- a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts +++ b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts @@ -11,7 +11,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { IFileWriteOptions } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; -import { AhpJsonlLogger, getAhpLogByteLength, stringifyAhpLogEntry } from '../../common/ahpJsonlLogger.js'; +import { AhpJsonlLogger, getAhpLogByteLength, isAhpLogFileFor, stringifyAhpLogEntry } from '../../common/ahpJsonlLogger.js'; suite('AhpJsonlLogger', () => { @@ -22,7 +22,7 @@ suite('AhpJsonlLogger', () => { store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); const logger = store.add(new AhpJsonlLogger( - { logsHome: URI.file('/logs'), connectionId: 'conn:1', transport: 'websocket' }, + { logsHome: URI.file('/logs'), logId: 'logical-host', connectionId: 'conn:1', transport: 'websocket' }, fileService, new NullLogService(), )); @@ -106,6 +106,7 @@ suite('AhpJsonlLogger', () => { }, }, ]); + assert.strictEqual(isAhpLogFileFor('logical-host', basename(logger.resource)), true); for (const entry of parsed) { assert.strictEqual(entry.jsonrpc, '2.0'); @@ -118,7 +119,7 @@ suite('AhpJsonlLogger', () => { store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); const logger = store.add(new AhpJsonlLogger( - { logsHome: URI.file('/logs'), connectionId: 'rotating', transport: 'websocket', maxFileSizeBytes: 1, maxFiles: 2 }, + { logsHome: URI.file('/logs'), logId: 'logical-host', connectionId: 'rotating', transport: 'websocket', maxFileSizeBytes: 1, maxFiles: 2 }, fileService, new NullLogService(), )); @@ -141,10 +142,12 @@ suite('AhpJsonlLogger', () => { assert.deepStrictEqual({ firstFileExists: await fileService.exists(firstResource), ids: parsed.map(entry => entry.id), + segmentsMatchLogicalHost: [rotated1, rotated2].every(resource => isAhpLogFileFor('logical-host', basename(resource))), rootsAreJsonRpc: parsed.every(entry => entry.jsonrpc === '2.0' && (entry.method !== undefined || (entry.id !== undefined && (Object.hasOwn(entry, 'result') || Object.hasOwn(entry, 'error'))))), }, { firstFileExists: false, ids: [2, 3], + segmentsMatchLogicalHost: true, rootsAreJsonRpc: true, }); }); @@ -155,7 +158,7 @@ suite('AhpJsonlLogger', () => { store.add(fileService.registerProvider('file', provider)); const logger = store.add(new AhpJsonlLogger( - { logsHome: URI.file('/logs'), connectionId: 'batched', transport: 'websocket' }, + { logsHome: URI.file('/logs'), logId: 'batched', connectionId: 'batched', transport: 'websocket' }, fileService, new NullLogService(), )); @@ -188,7 +191,7 @@ suite('AhpJsonlLogger', () => { store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); const logger = store.add(new AhpJsonlLogger( - { logsHome: URI.file('/logs'), connectionId: 'flush-order', transport: 'websocket' }, + { logsHome: URI.file('/logs'), logId: 'flush-order', connectionId: 'flush-order', transport: 'websocket' }, fileService, new NullLogService(), )); @@ -213,7 +216,7 @@ suite('AhpJsonlLogger', () => { store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); const logger = store.add(new AhpJsonlLogger( - { logsHome: URI.file('/logs'), connectionId: 'conn:1', transport: 'websocket' }, + { logsHome: URI.file('/logs'), logId: 'logical-host', connectionId: 'conn:1', transport: 'websocket' }, fileService, new NullLogService(), )); diff --git a/src/vs/platform/agentHost/test/common/relayTransport.test.ts b/src/vs/platform/agentHost/test/common/relayTransport.test.ts index 28030f3c7ed414..030c4ee4beea43 100644 --- a/src/vs/platform/agentHost/test/common/relayTransport.test.ts +++ b/src/vs/platform/agentHost/test/common/relayTransport.test.ts @@ -235,6 +235,29 @@ suite('ReconnectingRelayTransport', () => { assert.deepStrictEqual(received, [{ id: 'connected' }]); }); + test('creates each reconnect logger with the established connectionId', async () => { + const loggerConnectionIds: string[] = []; + const createTransport = (connectionId: string) => disposables.add(new ReconnectingRelayTransport( + async () => ({ connectionId }), + mockChannel, + activeConnectionId => { + loggerConnectionIds.push(activeConnectionId); + return undefined; + }, + new NullLogService(), + '[ReconnectingRelayTransport]', + AgentHostClientConnectionKind.DevTunnel + )); + + const initial = createTransport('relay-1'); + await initial.connect(); + initial.dispose(); + const reconnected = createTransport('relay-2'); + await reconnected.connect(); + + assert.deepStrictEqual(loggerConnectionIds, ['relay-1', 'relay-2']); + }); + test('warns and drops messages sent before adopting a channel', () => { const logService = new RecordingLogService(); const transport = disposables.add(new ReconnectingRelayTransport( diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index 45ce6c557ec03c..8439a6489fc866 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -126,6 +126,7 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo ahpLogger: ahpLoggingEnabled ? this._instantiationService.createInstance(AhpJsonlLogger, { logsHome: this._environmentService.logsHome, + logId: address, connectionId: staged.clientId, transport: 'webpubsub', }) diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts index 32ebf335e6628b..ce8b59e66cb4f0 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts @@ -276,11 +276,11 @@ export class DevContainerAgentHostConnector implements IDevContainerAgentHostCon } }; const transportFactory = () => { - // Post-reconnect logs use the original channel id because the new id is assigned asynchronously by `establish`. - const createLogger = () => this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId) + const createLogger = (activeConnectionId: string) => this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId) ? this._instantiationService.createInstance(AhpJsonlLogger, { logsHome: this._environmentService.logsHome, - connectionId, + logId: address, + connectionId: activeConnectionId, transport: 'devcontainer', }) : undefined; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts index 189d803a158964..abe48e07957613 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -242,6 +242,7 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo // Bind the narrowed connection before the closure: TypeScript does not // carry the discriminant narrowing into the `find` callback below. const connection = entry.connection; + const address = getEntryAddress(entry); const cachedTunnel = this._storage.getCachedTunnels().find(cached => cached.tunnelId === connection.tunnelId); const tunnel: ITunnelInfo = { tunnelId: connection.tunnelId, @@ -307,9 +308,9 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo () => new ReconnectingRelayTransport( establish, this._mainService, - () => ahpLoggingEnabled ? this._instantiationService.createInstance( + activeConnectionId => ahpLoggingEnabled ? this._instantiationService.createInstance( AhpJsonlLogger, - { logsHome: this._environmentService.logsHome, connectionId: result.connectionId, transport: 'tunnel' }, + { logsHome: this._environmentService.logsHome, logId: address, connectionId: activeConnectionId, transport: 'tunnel' }, ) : undefined, this._logService, LOG_PREFIX, diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index d2891a9ac06ba9..227b0bce74c193 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Action } from '../../../../../base/common/actions.js'; -import { VSBuffer, newWriteableBufferStream, streamToBuffer, type VSBufferReadableStream } from '../../../../../base/common/buffer.js'; +import { VSBuffer, newWriteableBufferStream, type VSBufferReadableStream } from '../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -14,6 +14,7 @@ import { Categories } from '../../../../../platform/action/common/actionCommonCa import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { isAhpLogFileFor } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; import { IAgentHostService, type AgentHostDebugLogsArtifactKind, type IAgentConnection, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService, remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { DEFAULT_CHAT_ID, getSessionChatResource, StateComponents, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; @@ -21,7 +22,7 @@ import { IClipboardService } from '../../../../../platform/clipboard/common/clip import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { ByteSize, IFileService } from '../../../../../platform/files/common/files.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; import { createDecorator, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; @@ -31,12 +32,11 @@ import { IWorkbenchEnvironmentService } from '../../../../services/environment/c import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme } from '../copilotCliEventsUri.js'; -import { getRemoteConnectionForSession, sanitizeFilePart } from '../chatDebug/agentHostLogSources.js'; +import { getRemoteConnectionForSession } from '../chatDebug/agentHostLogSources.js'; import { buildAgentHostCustomizationsUri, buildAgentHostUsageUri } from '../chatDebug/agentHostUsageSidecar.js'; const SHARED_PROCESS_LOG_FILE_NAME = 'sharedprocess.log'; const OUTPUT_LOG_FOLDER_PREFIX = 'output_'; -const MAX_INLINE_DEBUG_LOGS_BYTES = 30 * ByteSize.MB; /** * Description of the agent-host session whose logs should be exported. If @@ -226,21 +226,17 @@ export async function collectAgentHostDebugLogs( logService.warn(`[ExportAgentHostDebugLogs] Failed to collect Agent Host logs: ${error instanceof Error ? error.message : String(error)}; exporting client-owned logs only`); } } - let remainingInlineBytes = MAX_INLINE_DEBUG_LOGS_BYTES; - const forwardedAgentHostLogFileNames = new Set(); - let ahpLogNameFilter: ((name: string) => boolean) | undefined; + let ahpLogId: string | undefined; if (activeSession) { if (activeSession.isLocal) { - const localClientId = sanitizeFilePart(agentHostService.clientId); - ahpLogNameFilter = name => name.includes(localClientId); + ahpLogId = agentHostService.clientId; } else { const remoteConnection = getRemoteConnectionForSession(activeSession.resource, remoteAgentHostService.connections); if (remoteConnection) { forwardedAgentHostLogFileNames.add(getOutputChannelLogFileName(remoteAgentHostLogOutputChannelId(remoteConnection.address))); - const remoteConnectionId = sanitizeFilePart(remoteConnection.address); - ahpLogNameFilter = name => name.includes(remoteConnectionId); + ahpLogId = remoteConnection.address; } } } else { @@ -252,9 +248,6 @@ export async function collectAgentHostDebugLogs( const files: IAgentHostDebugLogFile[] = []; const appendFile = (file: IAgentHostDebugLogFile) => { files.push(file); - if (hasKey(file, { contents: true })) { - remainingInlineBytes -= file.size; - } }; const appendFiles = (collectedFiles: readonly IAgentHostDebugLogFile[]) => { for (const file of collectedFiles) { @@ -269,18 +262,21 @@ export async function collectAgentHostDebugLogs( ]; for (const processLog of processLogs) { try { - appendFiles(await collectRotatedLogFiles(`vscode-logs/${processLog.folder}`, processLog.resource, fileService, remainingInlineBytes)); + appendFiles(await collectRotatedLogFiles(`vscode-logs/${processLog.folder}`, processLog.resource, fileService)); } catch (error) { logService.warn(`[ExportAgentHostDebugLogs] Failed to collect rotated logs for '${processLog.resource.toString()}': ${error instanceof Error ? error.message : String(error)}`); } } try { const forwardedLogs = await findOutputChannelLogFiles(environmentService.windowLogsPath, forwardedAgentHostLogFileNames, fileService); + const seenFileNames = new Set(); for (const forwardedLog of forwardedLogs) { - const file = await createDebugLogFile(`vscode-logs/Agent Host/${basename(forwardedLog)}`, forwardedLog, fileService, undefined, remainingInlineBytes); - if (file) { - appendFile(file); - } + const name = basename(forwardedLog); + const path = seenFileNames.has(name) + ? `vscode-logs/Agent Host/${basename(dirname(forwardedLog))}/${name}` + : `vscode-logs/Agent Host/${name}`; + seenFileNames.add(name); + appendFile(await createDebugLogFile(path, forwardedLog, fileService)); } } catch (error) { logService.warn(`[ExportAgentHostDebugLogs] Failed to collect forwarded Agent Host logs: ${error instanceof Error ? error.message : String(error)}`); @@ -292,20 +288,19 @@ export async function collectAgentHostDebugLogs( const ahpDir = joinPath(environmentService.logsHome, 'ahp'); const stat = await fileService.resolve(ahpDir, { resolveMetadata: true }); for (const child of stat.children ?? []) { - if (child.isDirectory || !child.name.endsWith('.jsonl') || ahpLogNameFilter && !ahpLogNameFilter(child.name)) { + if (child.isDirectory || !child.name.endsWith('.jsonl') || activeSession && (!ahpLogId || !isAhpLogFileFor(ahpLogId, child.name))) { continue; } try { - const file = await createDebugLogFile(`ahp/${child.name}`, child.resource, fileService, child.size, remainingInlineBytes); - if (file) { - appendFile(file); - } + appendFile(await createDebugLogFile(`ahp/${child.name}`, child.resource, fileService, child.size)); } catch (error) { logService.warn(`[ExportAgentHostDebugLogs] Failed to read AHP log '${child.name}': ${error instanceof Error ? error.message : String(error)}`); } } - } catch { - // AHP log directory may not exist if no remote connection has been opened or if logging is disabled. + } catch (error) { + if (!(error instanceof Error) || toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to enumerate AHP logs: ${error instanceof Error ? error.message : String(error)}`); + } } const rawSessionId = getCopilotCliSessionRawId(activeSession?.resource); @@ -322,12 +317,11 @@ export async function collectAgentHostDebugLogs( ]; for (const sidecar of sidecars) { try { - const file = await createDebugLogFile(sidecar.path, sidecar.resource, fileService, undefined, remainingInlineBytes); - if (file) { - appendFile(file); + appendFile(await createDebugLogFile(sidecar.path, sidecar.resource, fileService)); + } catch (error) { + if (!(error instanceof Error) || toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to collect '${sidecar.path}': ${error instanceof Error ? error.message : String(error)}`); } - } catch { - // Absent when agent-host debug logging was off for this session. } } } @@ -582,43 +576,18 @@ async function copyHostArtifactDirectory( } } -async function createDebugLogFile(path: string, resource: URI, fileService: IFileService, size: number | undefined, maxInlineSize: number): Promise { - if (resource.scheme === Schemas.file || resource.scheme === Schemas.vscodeUserData) { - const observedSize = size ?? (await fileService.resolve(resource, { resolveMetadata: true })).size; - return { path, resource, size: observedSize }; - } +async function createDebugLogFile(path: string, resource: URI, fileService: IFileService, size?: number): Promise { const observedSize = size ?? (await fileService.resolve(resource, { resolveMetadata: true })).size; - const readSize = Math.min(observedSize, maxInlineSize); - if (readSize === 0) { - return undefined; - } - const stream = await fileService.readFileStream(resource, { position: observedSize - readSize, length: readSize }); - return createInlineDebugLogFile(path, await streamToBuffer(stream.value), maxInlineSize); -} - -function createInlineDebugLogFile(path: string, content: VSBuffer, maxInlineSize: number): IAgentHostDebugLogFile | undefined { - const size = Math.min(content.byteLength, maxInlineSize); - if (size === 0) { - return undefined; - } - const capturedContent = size === content.byteLength ? content : content.slice(content.byteLength - size); - return { path, contents: capturedContent.toString(), size }; + return { path, resource, size: observedSize }; } -export async function collectRotatedLogFiles(path: string, current: URI, fileService: IFileService, maxInlineSize = MAX_INLINE_DEBUG_LOGS_BYTES): Promise { +export async function collectRotatedLogFiles(path: string, current: URI, fileService: IFileService): Promise { const currentName = basename(current); const parent = await fileService.resolve(dirname(current), { resolveMetadata: true }); const files: IAgentHostDebugLogFile[] = []; - let remainingInlineSize = maxInlineSize; for (const child of parent.children ?? []) { if (child.isFile && !child.isSymbolicLink && isRotatedLogFile(child.name, currentName)) { - const file = await createDebugLogFile(`${path}/${child.name}`, child.resource, fileService, child.size, remainingInlineSize); - if (file) { - files.push(file); - if (hasKey(file, { contents: true })) { - remainingInlineSize -= file.size; - } - } + files.push(await createDebugLogFile(`${path}/${child.name}`, child.resource, fileService, child.size)); } } return files; @@ -632,18 +601,14 @@ export async function findOutputChannelLogFiles(windowLogsPath: URI, fileNames: const outputFolders = (windowLogs.children ?? []) .filter(child => child.isDirectory && child.name.startsWith(OUTPUT_LOG_FOLDER_PREFIX)) .sort((a, b) => b.name.localeCompare(a.name)); - const remaining = new Set(fileNames); const result: URI[] = []; for (const outputFolder of outputFolders) { const folder = await fileService.resolve(outputFolder.resource); for (const child of folder.children ?? []) { - if (child.isFile && !child.isSymbolicLink && remaining.delete(child.name)) { + if (child.isFile && !child.isSymbolicLink && fileNames.has(child.name)) { result.push(child.resource); } } - if (remaining.size === 0) { - break; - } } return result; } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts index 334c80114f2865..1f353312a78f2f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts @@ -7,9 +7,10 @@ import { VSBuffer, type VSBufferReadableStream } from '../../../../../base/commo import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isAhpLogFileFor } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; import { localize } from '../../../../../nls.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { AgentHostAhpJsonlLoggingSettingId, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_LOG_OUTPUT_CHANNEL_ID, IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; @@ -124,11 +125,6 @@ export function getRemoteConnectionForSession(sessionResource: URI, connections: return authority ? connections.find(connection => agentHostAuthority(connection.address) === authority) : undefined; } -/** Sanitizes a value for use as (part of) a file name. */ -export function sanitizeFilePart(value: string): string { - return value.replace(/[\\/:\*\?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'connection'; -} - /** * Enumerates the raw log sources available for a given agent-host session. * Cheap: performs at most a couple of directory stats and never reads file @@ -142,7 +138,7 @@ export async function enumerateAgentHostLogSources( return []; } - const { pathService, agentHostService, remoteAgentHostService, outputService, fileService, configurationService, environmentService } = services; + const { pathService, agentHostService, remoteAgentHostService, outputService, fileService, environmentService } = services; const userHome = pathService.userHome({ preferLocal: true }); const isLocal = sessionResource.scheme === COPILOT_CLI_LOCAL_AH_SCHEME; const remoteConnection = isLocal ? undefined : getRemoteConnectionForSession(sessionResource, remoteAgentHostService.connections); @@ -165,24 +161,20 @@ export async function enumerateAgentHostLogSources( }); } - // 2. AHP wire log(s) — only when wire logging is enabled. - if (configurationService.getValue(AgentHostAhpJsonlLoggingSettingId)) { - const nameToken = isLocal - ? sanitizeFilePart(agentHostService.clientId) - : remoteConnection ? sanitizeFilePart(remoteConnection.address) : undefined; - const wireFiles = await listWireLogFiles(fileService, environmentService, nameToken); - wireFiles.forEach((file, index) => { - sources.push({ - id: `wire:${file.resource.toString()}`, - label: index === 0 - ? localize('agentHostLogs.wire', "AHP Log") - : localize('agentHostLogs.wireN', "AHP Log — {0}", file.name), - kind: AgentHostLogSourceKind.WireLog, - isRemote: !isLocal, - resource: file.resource, - }); + // 2. Existing AHP wire log(s). The setting controls logger creation, not whether historical files remain discoverable. + const ahpLogId = isLocal ? agentHostService.clientId : remoteConnection?.address; + const wireFiles = await listWireLogFiles(fileService, environmentService, ahpLogId); + wireFiles.forEach((file, index) => { + sources.push({ + id: `wire:${file.resource.toString()}`, + label: index === 0 + ? localize('agentHostLogs.wire', "AHP Log") + : localize('agentHostLogs.wireN', "AHP Log — {0}", file.name), + kind: AgentHostLogSourceKind.WireLog, + isRemote: !isLocal, + resource: file.resource, }); - } + }); // 3. Agent host process log (output channel) + window/shared logs. const channelIds: string[] = []; @@ -297,17 +289,18 @@ export async function readAgentHostLogSourceContent( /** * Lists AHP wire log files for a session's connection. * - * When `nameToken` identifies the session's connection (its filenames embed - * `ahp--.jsonl`), only matching files are returned — - * so unrelated connections' logs are not surfaced as spurious "rotated" - * sources. Falls back to all AHP logs (newest first) when the token is absent - * or matches nothing. + * `logId` is the same stable logical-host identity supplied to the logger, so + * reconnect-created files and rotated segments are selected without including + * unrelated connections. */ async function listWireLogFiles( fileService: IFileService, environmentService: IEnvironmentService, - nameToken: string | undefined, + logId: string | undefined, ): Promise<{ resource: URI; name: string; mtime: number }[]> { + if (!logId) { + return []; + } const ahpDir = joinPath(environmentService.logsHome, 'ahp'); let children: IFileStatWithMetadata[] | undefined; try { @@ -316,16 +309,11 @@ async function listWireLogFiles( return []; } const files = (children ?? []) - .filter(child => !child.isDirectory && child.name.endsWith('.jsonl')) + .filter(child => !child.isDirectory && isAhpLogFileFor(logId, child.name)) .map(child => ({ resource: child.resource, name: child.name, mtime: child.mtime ?? 0 })); - // Restrict to the session's connection when it can be identified; otherwise - // fall back to all files so a session is never left without any log. - const matching = nameToken ? files.filter(file => file.name.includes(nameToken)) : []; - const selected = matching.length > 0 ? matching : files; - // Newest first. - return selected.sort((a, b) => b.mtime - a.mtime); + return files.sort((a, b) => b.mtime - a.mtime); } /** Reads at most `capBytes` from the tail of a file. */ diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts index 86ad540918181b..8c25e01a3781e5 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts @@ -44,42 +44,28 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport const zipFiles: INativeZipFile[] = files.map(file => { return hasKey(file, { contents: true }) ? file - : { path: file.path, source: file.resource.scheme === Schemas.vscodeUserData ? file.resource.with({ scheme: Schemas.file }) : file.resource, size: file.size, skipSourceErrors: true }; + : { path: file.path, source: file.resource.scheme === Schemas.vscodeUserData ? file.resource.with({ scheme: Schemas.file }) : file.resource, size: file.size }; }); const zipOptions = { maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES }; - let hostArchiveIncluded = false; let temporaryHostArchive: URI | undefined; try { if (hostArtifact) { - try { - const { artifact, readChunk } = hostArtifact; - if (artifact.kind !== 'archive') { - throw new Error(`Expected an Agent Host debug-log archive, got ${artifact.kind}`); - } - let localHostArchive = artifact.resource; - if (artifact.resource.scheme !== Schemas.file) { - // The archive lives on a remote agent host. Stream it down in - // bounded chunks rather than pulling the whole thing over in a - // single protocol message. - localHostArchive = joinPath(this.environmentService.tmpDir, `agent-host-debug-logs-${generateUuid()}.zip`); - temporaryHostArchive = localHostArchive; - await this.fileService.writeFile(localHostArchive, createHostArtifactStream(artifact, position => readChunk(artifact.resource, position))); - } - zipFiles.push({ sourceArchive: localHostArchive }); - hostArchiveIncluded = true; - } catch (error) { - this.logService.warn(`[ExportAgentHostDebugLogs] Failed to save Agent Host logs: ${error instanceof Error ? error.message : String(error)}; saving client-owned logs only`); + const { artifact, readChunk } = hostArtifact; + if (artifact.kind !== 'archive') { + throw new Error(`Expected an Agent Host debug-log archive, got ${artifact.kind}`); } - } - try { - await this.nativeHostService.createZipFile(destination, zipFiles, zipOptions); - } catch (error) { - if (!hostArchiveIncluded) { - throw error; + let localHostArchive = artifact.resource; + if (artifact.resource.scheme !== Schemas.file) { + // The archive lives on a remote agent host. Stream it down in + // bounded chunks rather than pulling the whole thing over in a + // single protocol message. + localHostArchive = joinPath(this.environmentService.tmpDir, `agent-host-debug-logs-${generateUuid()}.zip`); + temporaryHostArchive = localHostArchive; + await this.fileService.writeFile(localHostArchive, createHostArtifactStream(artifact, position => readChunk(artifact.resource, position))); } - this.logService.warn(`[ExportAgentHostDebugLogs] Failed to merge Agent Host logs: ${error instanceof Error ? error.message : String(error)}; saving client-owned logs only`); - await this.nativeHostService.createZipFile(destination, zipFiles.slice(0, -1), zipOptions); + zipFiles.push({ sourceArchive: localHostArchive }); } + await this.nativeHostService.createZipFile(destination, zipFiles, zipOptions); } finally { if (temporaryHostArchive) { // Best-effort: the download may have failed before the file was diff --git a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts index dc63e6dd4eb6df..9ae33ea5f91b9d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts @@ -11,6 +11,7 @@ import { Schemas } from '../../../../../base/common/network.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AhpJsonlLogger, isAhpLogFileFor } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; import type { IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; import { buildChatUri, buildDefaultChatUri, getSessionChatResource } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { TestClipboardService } from '../../../../../platform/clipboard/test/common/testClipboardService.js'; @@ -246,7 +247,7 @@ suite('collectRotatedLogFiles', () => { ]); }); - test('bounds inline content for non-local rotated logs', async () => { + test('keeps every non-local rotated log as a streamable resource', async () => { const fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); const logs = URI.from({ scheme: Schemas.inMemory, path: '/logs' }); @@ -256,20 +257,20 @@ suite('collectRotatedLogFiles', () => { fileService.writeFile(URI.joinPath(logs, 'renderer.1.log'), VSBuffer.fromString('efgh')), ]); - const files = await collectRotatedLogFiles('vscode-logs/Window', URI.joinPath(logs, 'renderer.log'), fileService, 6); + const files = await collectRotatedLogFiles('vscode-logs/Window', URI.joinPath(logs, 'renderer.log'), fileService); assert.deepStrictEqual({ count: files.length, - allInline: files.every(file => hasKey(file, { contents: true })), + allResources: files.every(file => hasKey(file, { resource: true })), totalSize: files.reduce((total, file) => total + file.size, 0), }, { count: 2, - allInline: true, - totalSize: 6, + allResources: true, + totalSize: 8, }); }); - test('finds the newest matching output channel backing files', async () => { + test('finds all matching output channel backing files', async () => { const fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); const windowLogs = URI.file('/logs/window1'); @@ -286,9 +287,61 @@ suite('collectRotatedLogFiles', () => { assert.deepStrictEqual(files.map(file => file.toString()), [ 'file:///logs/window1/output_20260825T090000/agentHost.otlp.remote.log', + 'file:///logs/window1/output_20260825T080000/agentHost.otlp.remote.log', ]); }); + test('selects tunnel reconnect and rotation logs by logical host without address filename matching', async () => { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const logsHome = URI.file('/logs'); + const tunnelAddress = 'tunnel:dev/name'; + const initial = disposables.add(new AhpJsonlLogger( + { logsHome, logId: tunnelAddress, connectionId: 'relay-uuid-1', transport: 'tunnel', maxFileSizeBytes: 1, maxFiles: 3 }, + fileService, + new NullLogService(), + )); + initial.log({ jsonrpc: '2.0', id: 1, result: 'initial' }, 's2c'); + initial.log({ jsonrpc: '2.0', id: 2, result: 'rotated' }, 's2c'); + await initial.flush(); + + const reconnected = disposables.add(new AhpJsonlLogger( + { logsHome, logId: tunnelAddress, connectionId: 'relay-uuid-2', transport: 'tunnel' }, + fileService, + new NullLogService(), + )); + reconnected.log({ jsonrpc: '2.0', id: 3, result: 'reconnected' }, 's2c'); + await reconnected.flush(); + + const collidingOldToken = disposables.add(new AhpJsonlLogger( + { logsHome, logId: 'tunnel:dev:name', connectionId: 'relay-uuid-other', transport: 'tunnel' }, + fileService, + new NullLogService(), + )); + collidingOldToken.log({ jsonrpc: '2.0', id: 4, result: 'other' }, 's2c'); + await collidingOldToken.flush(); + + const directory = await fileService.resolve(URI.joinPath(logsHome, 'ahp')); + const matching = (directory.children ?? []).filter(child => isAhpLogFileFor(tunnelAddress, child.name)); + const entries: Array<{ readonly id: number; readonly _ahpLog: { readonly connectionId: string } }> = []; + for (const file of matching) { + const content = (await fileService.readFile(file.resource)).value.toString(); + entries.push(...content.split('\n').filter(Boolean).map(line => JSON.parse(line))); + } + + assert.deepStrictEqual({ + fileCount: matching.length, + fileNamesContainLogicalAddress: matching.some(file => file.name.includes('tunnel-dev-name')), + connectionIds: entries.map(entry => entry._ahpLog.connectionId).sort(), + messageIds: entries.map(entry => entry.id).sort(), + }, { + fileCount: 3, + fileNamesContainLogicalAddress: false, + connectionIds: ['relay-uuid-1', 'relay-uuid-1', 'relay-uuid-2'], + messageIds: [1, 2, 3], + }); + }); + test('collects local user data logs as resources', async () => { const fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.vscodeUserData, disposables.add(new InMemoryFileSystemProvider()))); From 01ae218cf13ec43dccd074e32bd559eb662d22fc Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 15 Sep 2026 20:42:54 -0700 Subject: [PATCH 2/3] agentHost: test historical scoped log enumeration Cover the session log-source path so disabled wire logging still discovers existing matching segments without including logs from another logical host. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d4768367-fde5-4eb9-b62c-d997ec420a6f --- .../test/browser/agentHostLogSources.test.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHostLogSources.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHostLogSources.test.ts index 72648be4caa7e0..4da4910fddb59a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHostLogSources.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHostLogSources.test.ts @@ -8,12 +8,21 @@ import { timeout } from '../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../base/common/network.js'; import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AhpJsonlLogger } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; +import { AgentHostAhpJsonlLoggingSettingId, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; import { IStat } from '../../../../../platform/files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { findRelevantCopilotLogs, MAX_COPILOT_LOG_SCAN_FILE_SIZE } from '../../browser/chatDebug/agentHostLogSources.js'; +import { IOutputService } from '../../../../services/output/common/output.js'; +import { TestPathService } from '../../../../test/browser/workbenchTestServices.js'; +import { AgentHostLogSourceKind, enumerateAgentHostLogSources, findRelevantCopilotLogs, IAgentHostLogSourceServices, MAX_COPILOT_LOG_SCAN_FILE_SIZE } from '../../browser/chatDebug/agentHostLogSources.js'; +import { COPILOT_CLI_LOCAL_AH_SCHEME } from '../../browser/copilotCliEventsUri.js'; class TestLogFileSystemProvider extends InMemoryFileSystemProvider { readonly oversizedResources = new Set(); @@ -102,4 +111,50 @@ suite('AgentHostLogSources', () => { assert.deepStrictEqual(logs.map(log => log.path), ['copilot-logs/oversized.log']); }); + + test('enumerates matching historical AHP logs when logging is disabled', async () => { + const logId = 'local-client'; + const matchingLoggers = [ + disposables.add(new AhpJsonlLogger({ logsHome: logsDir, logId, connectionId: 'connection-1', transport: 'ipc' }, fileService, new NullLogService())), + disposables.add(new AhpJsonlLogger({ logsHome: logsDir, logId, connectionId: 'connection-2', transport: 'ipc' }, fileService, new NullLogService())), + ]; + const unrelatedLogger = disposables.add(new AhpJsonlLogger({ logsHome: logsDir, logId: 'another-client', connectionId: 'connection-3', transport: 'ipc' }, fileService, new NullLogService())); + for (const logger of [...matchingLoggers, unrelatedLogger]) { + logger.log({ jsonrpc: '2.0', method: 'test' }, 'c2s'); + await logger.flush(); + } + + const services = new class extends mock() { + override readonly pathService = new TestPathService(URI.from({ scheme: Schemas.inMemory, path: '/home' })); + override readonly agentHostService = new class extends mock() { + override readonly clientId = logId; + }(); + override readonly remoteAgentHostService = new class extends mock() { + override readonly connections = []; + }(); + override readonly outputService = new class extends mock() { + override getChannelDescriptor(_id: string): undefined { + return undefined; + } + }(); + override readonly fileService = fileService; + override readonly configurationService = new TestConfigurationService({ [AgentHostAhpJsonlLoggingSettingId]: false }); + override readonly environmentService = new class extends mock() { + override logsHome = logsDir; + }(); + }(); + + const sources = await enumerateAgentHostLogSources( + services, + URI.from({ scheme: COPILOT_CLI_LOCAL_AH_SCHEME, path: '/session-1' }), + ); + + assert.deepStrictEqual( + sources + .filter(source => source.kind === AgentHostLogSourceKind.WireLog) + .map(source => source.resource?.toString()) + .sort(), + matchingLoggers.map(logger => logger.resource.toString()).sort(), + ); + }); }); From 487b1e9ecc2e1205303d265d37d856a6464f855b Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 15 Sep 2026 21:39:54 -0700 Subject: [PATCH 3/3] test: update chat input screenshot hash Accept the CI-rendered baseline after confirming the image differs only by one-value anti-aliasing noise. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d4768367-fde5-4eb9-b62c-d997ec420a6f --- test/componentFixtures/blocks-ci-screenshots.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 1fb9bf00dcee75..069da2cba32da1 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -340,7 +340,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/input/chatInput/ResponsiveModelResizeCycleMinimal/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/631be336e1f478b7d82b4ba31febba1e6839de8e4afd47514478e236fbd828ce) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a93f2a2ea1061acc284b00a73f7e8a3540d96bedee059c62dbb0759e2d2975d0) #### sessions/chat/input/chatInput/ResponsiveModelResizeCycleMinimal/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/6e211515e25865a3b9943b74e8650d543d7268533f33bfe2dbd3a078eaa5d1d6)