From edd60d461942993e0a9739cf0847e65afe14bd6a Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 24 Jul 2026 12:45:03 -0700 Subject: [PATCH 1/7] Include Claude debug artifacts in Export Agent Host Debug Logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export command previously surfaced only Copilot's logs. Advertise the Claude SDK `--debug` log and the session transcript JSONL through the AHP session `_meta` bag (well-known `debugArtifacts` key, mirroring `agentHost.git`), so the client reads concrete host-side paths instead of hard-coding a provider's on-disk layout — no protocol change. - Shared log-naming helpers (`agentHostLogNaming`) reused by the AHP JSONL logger and Claude debug-file naming (sanitize + file-safe timestamp). - `ClaudeAgentSession` points the SDK at a per-run debug file and publishes the discovered artifacts (log + transcript) into `_meta`. Discovery/publishing is extracted into a testable `ClaudeDebugArtifacts` class backed by an observable that is a pure projection of disk truth. - Export resolves the `AgentHostSessionAdapter` that carries `_meta` and streams each artifact via the resource proxy (`file://` local / `vscode-agent-host://` remote), with provider-agnostic remote authority resolution. Co-Authored-By: Claude Opus 4.8 --- .../agentHost/common/agentHostLogNaming.ts | 90 +++++++++++++ .../agentHost/common/ahpJsonlLogger.ts | 9 +- .../agentHost/common/state/sessionState.ts | 62 +++++++++ .../node/claude/claudeAgentSession.ts | 55 +++++++- .../node/claude/claudeDebugArtifacts.ts | 120 ++++++++++++++++++ .../agentHost/node/claude/claudeSdkOptions.ts | 10 ++ .../test/common/agentHostLogNaming.test.ts | 58 +++++++++ .../common/sessionDebugArtifactsMeta.test.ts | 45 +++++++ .../test/node/claudeDebugArtifacts.test.ts | 102 +++++++++++++++ .../test/node/claudeSdkOptions.test.ts | 10 ++ .../browser/baseAgentHostSessionsProvider.ts | 6 +- .../browser/exportDebugLogsAction.ts | 13 +- .../actions/exportAgentHostDebugLogsAction.ts | 101 +++++++++++++-- .../browser/chatDebug/agentHostLogSources.ts | 120 ++++++++++-------- 14 files changed, 722 insertions(+), 79 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostLogNaming.ts create mode 100644 src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts create mode 100644 src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts create mode 100644 src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts create mode 100644 src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostLogNaming.ts b/src/vs/platform/agentHost/common/agentHostLogNaming.ts new file mode 100644 index 00000000000000..b78322715c0acb --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostLogNaming.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { type IDebugArtifact } from './state/sessionState.js'; + +/** + * Subdirectory of the agent host's `logsHome` under which Claude SDK debug + * logs are written (one file per session materialize), mirroring the sibling + * `ahp/` directory used by {@link AhpJsonlLogger}. + */ +export const CLAUDE_LOG_DIR = 'claude'; + +/** Cap embedded id tokens so composed file names stay within FS limits (255 on ext4/APFS). */ +const MAX_ID_TOKEN_LENGTH = 64; + +/** + * Formats a {@link Date} as a file-name-safe timestamp (`:`/`.` → `-`), e.g. + * `2026-07-21T12-34-56-789Z`. Shared by the AHP JSONL logger and the Claude + * debug-file naming so rotated/segmented files sort lexicographically. + */ +export function toFileTimestamp(date: Date): string { + return date.toISOString().replace(/[:.]/g, '-'); +} + +/** + * Sanitizes an arbitrary value (connection id, session id, …) for safe use as + * part of a file name: any run of path/glob-hostile characters or whitespace + * collapses to a single `-`, with leading/trailing dashes trimmed. Falls back + * to `connection` when the input reduces to empty. + */ +export function sanitizeFilePart(value: string): string { + return value.replace(/[\\/:\*\?"<>\|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'connection'; +} + +/** + * The sanitized, length-capped token embedded in a Claude debug-log file name + * to identify its owning session. Kept as a single helper so the write side + * ({@link buildClaudeDebugFilePath}) and the export/read side filter agree on + * the exact token. + */ +export function claudeDebugLogSessionToken(sessionId: string): string { + return sanitizeFilePart(sessionId).slice(0, MAX_ID_TOKEN_LENGTH); +} + +/** + * Computes the `/claude/` directory and the per-session debug-log + * file URI (`claude--.log`) for a Claude session + * startup. `timestamp` is supplied by the caller (via {@link toFileTimestamp}) + * so this stays a pure, deterministic projection. + * + * The returned `file.fsPath` is passed to the SDK's `Options.debugFile`; the + * caller should ensure `dir` exists first (`IFileService.createFolder`). + */ +export function buildClaudeDebugFilePath(logsHome: URI, sessionId: string, timestamp: string): { readonly dir: URI; readonly file: URI } { + const dir = joinPath(logsHome, CLAUDE_LOG_DIR); + const file = joinPath(dir, `claude-${timestamp}-${claudeDebugLogSessionToken(sessionId)}.log`); + return { dir, file }; +} + +/** + * Labels for a Claude session's advertised debug artifacts. Each label also + * derives the export file name (via {@link sanitizeFilePart}), so debug logs are + * numbered to keep those names unique per session. + */ +export const CLAUDE_DEBUG_LOG_LABEL = 'Claude debug log'; +export const CLAUDE_TRANSCRIPT_LABEL = 'Claude transcript'; + +/** + * Build the advertised {@link IDebugArtifact} list for a Claude session from its + * on-disk debug-log paths plus an optional transcript path. A pure projection, + * so the labeling/numbering is unit-testable without any session state: logs are + * sorted lexically (their file names embed the timestamp, so this is + * chronological) and numbered only when there is more than one — keeping the + * single-log common case clean while ensuring unique export file names. + */ +export function buildClaudeDebugArtifacts(logPaths: readonly string[], transcriptPath: string | undefined): IDebugArtifact[] { + const sorted = [...logPaths].sort(); + const artifacts: IDebugArtifact[] = sorted.map((path, i) => ({ + label: sorted.length > 1 ? `${CLAUDE_DEBUG_LOG_LABEL} ${i + 1}` : CLAUDE_DEBUG_LOG_LABEL, + path, + })); + if (transcriptPath) { + artifacts.push({ label: CLAUDE_TRANSCRIPT_LABEL, path: transcriptPath }); + } + return artifacts; +} diff --git a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts index c1f91dbe30f9b5..c410bbdcc20f94 100644 --- a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts +++ b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts @@ -10,6 +10,7 @@ import { joinPath } from '../../../base/common/resources.js'; import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js'; import { IFileService, IFileStatWithMetadata } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; +import { sanitizeFilePart, toFileTimestamp } from './agentHostLogNaming.js'; export type AhpLogDirection = 'c2s' | 's2c'; @@ -251,11 +252,3 @@ function _ahpReplacer(this: unknown, _key: string, value: unknown): unknown { } return value; } - -function toFileTimestamp(date: Date): string { - return date.toISOString().replace(/[:.]/g, '-'); -} - -function sanitizeFilePart(value: string): string { - return value.replace(/[\\/:\*\?"<>\|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'connection'; -} diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 98068c9d3a0db7..53f8c6ce33288f 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1075,6 +1075,17 @@ export function withSessionPromptCacheState(meta: SessionMeta | undefined, promp return Object.keys(next).length > 0 ? next : undefined; } +/** + * Reserved key under {@link SessionMeta} for the well-known debug-artifacts + * payload. Value at this key, when present, MUST be an array shaped like + * {@link IDebugArtifact}. The host (which owns the on-disk layout of its own + * logs and the provider SDK's transcript) advertises the concrete file paths + * here so clients — e.g. the "Export Agent Host Debug Logs" command — can + * collect them via the resource proxy without hard-coding provider-specific + * path conventions. + */ +export const SESSION_META_DEBUG_ARTIFACTS_KEY = 'debugArtifacts'; + /** * Git state of a session's working directory, carried under * {@link SessionMeta} at {@link SESSION_META_GIT_KEY}. Used by clients to @@ -1223,6 +1234,57 @@ export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, git return Object.keys(next).length > 0 ? next : undefined; } +/** + * A single debug artifact the host advertises for a session, carried under + * {@link SessionMeta} at {@link SESSION_META_DEBUG_ARTIFACTS_KEY}. The + * {@link path} is an absolute filesystem path **on the agent-host machine** + * (local host or remote); the client resolves it to a readable resource + * (`file://` locally, `vscode-agent-host://` remotely) via the resource proxy. + */ +export interface IDebugArtifact { + /** Human-readable label, used to derive the file name in the export. */ + readonly label: string; + /** Absolute filesystem path of the artifact on the agent-host machine. */ + readonly path: string; +} + +/** + * Reads the well-known debug-artifacts payload from a {@link SessionMeta} bag. + * Returns `undefined` when the key is absent or not an array; entries missing a + * string `label`/`path` are dropped so partial state still propagates. + */ +export function readSessionDebugArtifacts(meta: SessionMeta | undefined): IDebugArtifact[] | undefined { + const value = meta?.[SESSION_META_DEBUG_ARTIFACTS_KEY]; + if (!Array.isArray(value)) { + return undefined; + } + const artifacts: IDebugArtifact[] = []; + for (const entry of value) { + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + const raw = entry as Record; + if (typeof raw['label'] === 'string' && typeof raw['path'] === 'string') { + artifacts.push({ label: raw['label'], path: raw['path'] }); + } + } + } + return artifacts; +} + +/** + * Returns a new {@link SessionMeta} with the debug-artifacts payload set to + * `artifacts`, or with the slot removed if `artifacts` is `undefined`/empty. + * Returns `undefined` if the result would be empty. + */ +export function withSessionDebugArtifacts(meta: SessionMeta | undefined, artifacts: readonly IDebugArtifact[] | undefined): SessionMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (artifacts !== undefined && artifacts.length > 0) { + next[SESSION_META_DEBUG_ARTIFACTS_KEY] = artifacts; + } else { + delete next[SESSION_META_DEBUG_ARTIFACTS_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + /** * Reserved key under {@link SessionSummaryMeta} recording how deeply a session * was spawned via the `create_session` host tool (0 for a top-level, user-created diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index cdccbf8e909f14..55e17797fc61ab 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -7,7 +7,9 @@ import type { McpSdkServerConfigWithInstance, OnElicitation, Options, Permission import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -24,8 +26,9 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallContributorKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { isDefaultChatUri, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; +import { isDefaultChatUri, withSessionDebugArtifacts, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; +import { ClaudeDebugArtifacts } from './claudeDebugArtifacts.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; import { toSdkModelId } from './claudeModelId.js'; import { buildServerToolMcpServer, CLAUDE_SERVER_TOOL_MCP_SERVER_NAME, serverToolAllowList } from './claudeServerToolMcpServer.js'; @@ -349,6 +352,7 @@ export class ClaudeAgentSession extends Disposable { this._provisionalAgent = agent; this.provisionalConfig = config; this.abortController = abortController; + this._debugArtifacts = new ClaudeDebugArtifacts(this.sessionId, this._environmentService.logsHome, this._environmentService.userHome, this._fileService, this._logService); this.toolDiff = this._register(toolDiff); this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire())); @@ -366,6 +370,35 @@ export class ClaudeAgentSession extends Disposable { this._customizationWatcher.add(watcher.onDidChange(() => this._onDidCustomizationsChange.fire())); } + /** + * Discovery + publishing of this session's debug artifacts (SDK debug logs + + * transcript). The session's only artifact state; assigned in the constructor + * and bridged into `_meta` by {@link _watchDebugArtifacts}. + */ + private readonly _debugArtifacts: ClaudeDebugArtifacts; + + /** + * Reactively mirror this session's debug artifacts into the well-known `_meta` + * key whenever they change, so the export command reads concrete host-side + * paths instead of re-deriving the on-disk layout. Merges with existing `_meta` + * slots (git/github). Host-local absolute paths; the client resolves them via + * the resource proxy (`file://` local / `vscode-agent-host://` remote). + * + * Returns an {@link IDisposable} for the caller to register after materialize + * commits, so `setSessionMeta` always targets a live, registered session. + */ + private _watchDebugArtifacts(): IDisposable { + return autorun(reader => { + const artifacts = this._debugArtifacts.artifacts.read(reader); + if (artifacts.length === 0) { + return; + } + const sessionKey = this.sessionUri.toString(); + const current = this._stateManager.getSessionState(sessionKey)?._meta; + this._stateManager.setSessionMeta(sessionKey, withSessionDebugArtifacts(current, artifacts)); + }); + } + /** * One-shot SDK assistant-message uuid that the next materialize / rebuild * resumes *up to and including* (the SDK's `Options.resumeSessionAt`). @@ -446,6 +479,7 @@ export class ClaudeAgentSession extends Disposable { const { mcpServers, allowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); const agentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); + const debugFile = await this._debugArtifacts.prepareDebugFile(); const options = await buildOptions( { sessionId: this.sessionId, @@ -461,6 +495,7 @@ export class ClaudeAgentSession extends Disposable { allowedTools, plugins: this.clientCustomizationsDiff.consume(this._desiredClientPluginPaths()), agent: agentName, + debugFile, }, ctx.transport, data => this._logService.error(`[Claude SDK stderr] ${data}`), @@ -496,6 +531,17 @@ export class ClaudeAgentSession extends Disposable { } this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithMcpContributor(this._enrichSignalWithCredits(s))))); this._pipeline = pipeline; + // Mirror the debug artifacts into `_meta`. The locations never change per + // turn: debug logs are created at materialize/rematerialize (refreshed there), + // and the transcript's path is fixed at materialize. The one thing that isn't + // on disk at materialize is a fresh session's transcript FILE — the SDK writes + // it during the first turn — so refresh exactly ONCE more, after the first turn + // completes (a short debounce lets it flush). The artifact set dedupes + // structurally, so nothing re-pushes once it is stable. + this._register(this._watchDebugArtifacts()); + const refreshDebugArtifacts = this._register(new RunOnceScheduler(() => void this._debugArtifacts.refresh(), 500)); + this._register(Event.once(Event.filter(pipeline.onDidProduceSignal, s => s.kind === 'action' && s.action.type === ActionType.ChatTurnComplete))(() => refreshDebugArtifacts.schedule())); + void this._debugArtifacts.refresh(); // The materialize succeeded with the staged anchor applied to `Options` // — clear it now so it isn't re-applied. A throw before this point (e.g. // `startup` / pipeline-create) leaves it staged for the next retry. @@ -543,6 +589,7 @@ export class ClaudeAgentSession extends Disposable { const { mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); const rebuildAgentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); const rebuildAbort = new AbortController(); + const rebuildDebugFile = await this._debugArtifacts.prepareDebugFile(); const rebuildOptions = await buildOptions( { sessionId: this.sessionId, @@ -558,6 +605,7 @@ export class ClaudeAgentSession extends Disposable { allowedTools: rebuildAllowedTools, plugins: this.clientCustomizationsDiff.consume(this._desiredClientPluginPaths()), agent: rebuildAgentName, + debugFile: rebuildDebugFile, }, ctx.transport, data => this._logService.error(`[Claude SDK stderr] ${data}`), @@ -569,6 +617,9 @@ export class ClaudeAgentSession extends Disposable { // catch alongside the tool/customization diffs) so the next send // retries the truncation instead of dropping the restore. this._pendingResumeSessionAt = undefined; + // Rebuild started, so the SDK will write a fresh debug log; re-read from + // disk and re-publish (the autorun mirrors the change into `_meta`). + void this._debugArtifacts.refresh(); return { warm: rebuildWarm, abortController: rebuildAbort }; } catch (err) { this.toolDiff.markDirty(); diff --git a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts new file mode 100644 index 00000000000000..dd2ea5c5a898b7 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { structuralEquals } from '../../../../base/common/equals.js'; +import { IObservable, observableValueOpts } from '../../../../base/common/observable.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +import { ILogService } from '../../../log/common/log.js'; +import { buildClaudeDebugArtifacts, buildClaudeDebugFilePath, CLAUDE_LOG_DIR, claudeDebugLogSessionToken, toFileTimestamp } from '../../common/agentHostLogNaming.js'; +import { type IDebugArtifact } from '../../common/state/sessionState.js'; + +/** + * Owns discovery + publishing of a Claude session's debug artifacts (the SDK + * `--debug` log files plus the session transcript JSONL). Kept separate from + * {@link ClaudeAgentSession} so the disk I/O is unit-testable against an + * in-memory {@link IFileService}, and so the session holds no artifact state of + * its own beyond bridging {@link artifacts} into `_meta`. + * + * {@link artifacts} is the single source of truth and a pure projection of what + * is on disk: {@link refresh} re-reads both locations and republishes, and its + * structural equality means a refresh that finds nothing new does not fire. + * + * Depends only on the two home URIs (not the environment service) so a test + * needs nothing more than an in-memory file service and two paths. + */ +export class ClaudeDebugArtifacts { + + private readonly _artifacts = observableValueOpts({ equalsFn: structuralEquals }, []); + + /** The advertised artifact set, rebuilt from disk truth by {@link refresh}. */ + readonly artifacts: IObservable = this._artifacts; + + constructor( + private readonly _sessionId: string, + private readonly _logsHome: URI, + private readonly _userHome: URI, + private readonly _fileService: IFileService, + private readonly _logService: ILogService, + ) { } + + /** + * Create `/claude/` and compute the per-run debug-log path handed to + * the SDK's `Options.debugFile` (which implicitly enables `--debug`). Every + * materialize/rematerialize points the SDK at its own + * `/claude/claude--.log`. + * + * Only prepares the path — the file is advertised later by {@link refresh}, + * once `sdk.startup()` has actually written it, so an aborted startup never + * advertises a log the SDK never produced. + * + * Best-effort: a folder-create failure warn-logs and returns `undefined` so a + * logging problem never blocks session startup. + */ + async prepareDebugFile(): Promise { + try { + const { dir, file } = buildClaudeDebugFilePath(this._logsHome, this._sessionId, toFileTimestamp(new Date())); + await this._fileService.createFolder(dir); + return file.fsPath; + } catch (err) { + this._logService.warn(`[Claude] session ${this._sessionId}: failed to prepare debug log file`, err); + return undefined; + } + } + + /** + * Re-read the two on-disk locations the host/SDK own for this session and + * republish the projected artifact set via the pure, unit-tested + * {@link buildClaudeDebugArtifacts}. No cached state: the truth lives on disk + * and {@link artifacts} dedupes redundant refreshes structurally. + */ + async refresh(): Promise { + const [logPaths, transcriptPath] = await Promise.all([this._readDebugLogPaths(), this._readTranscriptPath()]); + this._artifacts.set(buildClaudeDebugArtifacts(logPaths, transcriptPath), undefined); + } + + /** This session's debug-log files (current + prior runs). The host owns `/claude/`, so scanning it is not cross-component path-guessing. */ + private async _readDebugLogPaths(): Promise { + const token = claudeDebugLogSessionToken(this._sessionId); + try { + const stat = await this._fileService.resolve(joinPath(this._logsHome, CLAUDE_LOG_DIR)); + return (stat.children ?? []).filter(c => !c.isDirectory && c.name.endsWith('.log') && c.name.includes(token)).map(c => c.resource.fsPath); + } catch { + return []; // /claude may not exist yet. + } + } + + /** + * The SDK session transcript JSONL, once written. The SDK stores it at + * `~/.claude/projects//.jsonl`; rather than replicate + * the cwd-encoding we glob the `projects/*` dirs for `.jsonl`, + * preferring the newest (the cwd can change mid-session, e.g. a worktree + * adoption). `undefined` until the first turn writes it. + */ + private async _readTranscriptPath(): Promise { + const fileName = `${this._sessionId}.jsonl`; + let best: { path: string; mtime: number } | undefined; + try { + const stat = await this._fileService.resolve(joinPath(this._userHome, '.claude', 'projects')); + for (const child of stat.children ?? []) { + if (!child.isDirectory) { + continue; + } + try { + const meta = await this._fileService.resolve(joinPath(child.resource, fileName), { resolveMetadata: true }); + if (!meta.isDirectory && (!best || (meta.mtime ?? 0) > best.mtime)) { + best = { path: meta.resource.fsPath, mtime: meta.mtime ?? 0 }; + } + } catch { + // No transcript for this session under this project dir. + } + } + } catch { + // ~/.claude/projects may not exist yet. + } + return best?.path; + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts index 62f35739911df2..0ea4d47ff6f865 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts @@ -70,6 +70,15 @@ export interface IBuildOptionsInput { * Omit when no custom agent is selected (SDK default behavior). */ readonly agent?: string; + /** + * Absolute filesystem path the SDK writes verbose debug logs to + * (projected onto `Options.debugFile`, which implicitly enables `--debug`). + * Always set by {@link ClaudeAgentSession} to a per-session file under + * `/claude/`, so the "Export Agent Host Debug Logs" command can + * collect it (see {@link buildClaudeDebugFilePath}). Omitted from the + * returned options when unset so the SDK keeps its default (no debug file). + */ + readonly debugFile?: string; } /** @@ -137,6 +146,7 @@ export async function buildOptions( ? { plugins: input.plugins.map(p => ({ type: 'local' as const, path: p.fsPath })) } : {}), ...(input.agent ? { agent: input.agent } : {}), + ...(input.debugFile ? { debugFile: input.debugFile } : {}), settingSources: ['user', 'project', 'local'], settings: { env: settingsEnv }, systemPrompt: { type: 'preset', preset: 'claude_code' }, diff --git a/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts new file mode 100644 index 00000000000000..1f451feeba8105 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { buildClaudeDebugArtifacts, buildClaudeDebugFilePath, CLAUDE_DEBUG_LOG_LABEL, CLAUDE_TRANSCRIPT_LABEL, claudeDebugLogSessionToken, sanitizeFilePart, toFileTimestamp } from '../../common/agentHostLogNaming.js'; + +suite('agentHostLogNaming', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('buildClaudeDebugFilePath is a deterministic projection of logsHome/sessionId/timestamp', () => { + const { dir, file } = buildClaudeDebugFilePath(URI.file('/logs'), 'sess-123', '2026-07-21T12-34-56-789Z'); + assert.deepStrictEqual( + { dir: dir.path, file: file.path }, + { dir: '/logs/claude', file: '/logs/claude/claude-2026-07-21T12-34-56-789Z-sess-123.log' }, + ); + }); + + test('claudeDebugLogSessionToken sanitizes and caps the id at 64 chars', () => { + assert.deepStrictEqual( + { + hostile: claudeDebugLogSessionToken('a/b:c d'), + capped: claudeDebugLogSessionToken('x'.repeat(100)).length, + }, + { hostile: 'a-b-c-d', capped: 64 }, + ); + }); + + test('sanitizeFilePart collapses hostile runs and falls back to "connection" when empty', () => { + assert.deepStrictEqual( + { hostile: sanitizeFilePart('a\\b/c:d*e?f"gi|j k'), empty: sanitizeFilePart('///') }, + { hostile: 'a-b-c-d-e-f-g-h-i-j-k', empty: 'connection' }, + ); + }); + + test('toFileTimestamp renders a file-name-safe ISO timestamp', () => { + assert.strictEqual(toFileTimestamp(new Date('2026-07-21T12:34:56.789Z')), '2026-07-21T12-34-56-789Z'); + }); + + test('buildClaudeDebugArtifacts: unnumbered lone log, sorted+numbered multiples, transcript appended last', () => { + assert.deepStrictEqual(buildClaudeDebugArtifacts([], undefined), []); + assert.deepStrictEqual(buildClaudeDebugArtifacts(['/logs/claude/claude-b.log'], undefined), [ + { label: CLAUDE_DEBUG_LOG_LABEL, path: '/logs/claude/claude-b.log' }, + ]); + assert.deepStrictEqual( + buildClaudeDebugArtifacts(['/logs/claude/claude-b.log', '/logs/claude/claude-a.log'], '/home/.claude/projects/p/s.jsonl'), + [ + { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, path: '/logs/claude/claude-a.log' }, + { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, path: '/logs/claude/claude-b.log' }, + { label: CLAUDE_TRANSCRIPT_LABEL, path: '/home/.claude/projects/p/s.jsonl' }, + ], + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts b/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts new file mode 100644 index 00000000000000..0a78a60f994f7b --- /dev/null +++ b/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { readSessionDebugArtifacts, SESSION_META_DEBUG_ARTIFACTS_KEY, withSessionDebugArtifacts, withSessionGitHubState } from '../../common/state/sessionState.js'; + +suite('Session debug-artifacts meta', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('readSessionDebugArtifacts returns undefined for absent / non-array, and drops malformed entries', () => { + assert.strictEqual(readSessionDebugArtifacts(undefined), undefined); + assert.strictEqual(readSessionDebugArtifacts({}), undefined); + assert.strictEqual(readSessionDebugArtifacts({ [SESSION_META_DEBUG_ARTIFACTS_KEY]: 'nope' }), undefined); + // Entries missing a string label/path are dropped; well-formed ones survive. + assert.deepStrictEqual( + readSessionDebugArtifacts({ + [SESSION_META_DEBUG_ARTIFACTS_KEY]: [ + { label: 'debug', path: '/logs/claude/x.log' }, + { label: 'no path' }, + { path: '/no/label' }, + 'garbage', + ], + }), + [{ label: 'debug', path: '/logs/claude/x.log' }], + ); + }); + + test('withSessionDebugArtifacts round-trips artifacts and preserves other slots', () => { + const artifacts = [{ label: 'Claude debug log', path: '/logs/claude/a.log' }, { label: 'Claude transcript', path: '/home/.claude/projects/p/s.jsonl' }]; + const withOther = withSessionGitHubState(undefined, { owner: 'octo' }); + const tagged = withSessionDebugArtifacts(withOther, artifacts); + + assert.deepStrictEqual(readSessionDebugArtifacts(tagged), artifacts); + // Co-existing well-known slots are preserved. + assert.deepStrictEqual(tagged?.['github'], { owner: 'octo' }); + + // Clearing (undefined or empty) removes only the artifacts slot; an otherwise empty bag collapses to undefined. + assert.strictEqual(withSessionDebugArtifacts({ [SESSION_META_DEBUG_ARTIFACTS_KEY]: artifacts }, undefined), undefined); + assert.strictEqual(withSessionDebugArtifacts({ [SESSION_META_DEBUG_ARTIFACTS_KEY]: artifacts }, []), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts new file mode 100644 index 00000000000000..9abbf4b72ab410 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { CLAUDE_DEBUG_LOG_LABEL, CLAUDE_TRANSCRIPT_LABEL } from '../../common/agentHostLogNaming.js'; +import { ClaudeDebugArtifacts } from '../../node/claude/claudeDebugArtifacts.js'; + +suite('ClaudeDebugArtifacts', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const LOGS_HOME = URI.file('/logs'); + const USER_HOME = URI.file('/home'); + const SESSION_ID = 'sess-abc'; + + const logDir = joinPath(LOGS_HOME, 'claude'); + const projectsDir = joinPath(USER_HOME, '.claude', 'projects'); + + function setup(): { fileService: IFileService; artifacts: ClaudeDebugArtifacts } { + const fileService = store.add(new FileService(new NullLogService())); + store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); + const artifacts = new ClaudeDebugArtifacts(SESSION_ID, LOGS_HOME, USER_HOME, fileService, new NullLogService()); + return { fileService, artifacts }; + } + + async function write(fileService: IFileService, resource: URI): Promise { + await fileService.writeFile(resource, VSBuffer.fromString('x')); + } + + test('refresh publishes nothing when neither location exists on disk', async () => { + const { artifacts } = setup(); + await artifacts.refresh(); + assert.deepStrictEqual(artifacts.artifacts.get(), []); + }); + + test('refresh discovers this session\'s log, ignoring other sessions and non-log files', async () => { + const { fileService, artifacts } = setup(); + const mine = joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}.log`); + await write(fileService, mine); + await write(fileService, joinPath(logDir, 'claude-2026-01-01T00-00-00-000Z-other.log')); + await write(fileService, joinPath(logDir, `${SESSION_ID}.txt`)); + await artifacts.refresh(); + assert.deepStrictEqual(artifacts.artifacts.get(), [{ label: CLAUDE_DEBUG_LOG_LABEL, path: mine.fsPath }]); + }); + + test('refresh numbers multiple runs in chronological (lexical) order', async () => { + const { fileService, artifacts } = setup(); + const older = joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}.log`); + const newer = joinPath(logDir, `claude-2026-01-02T00-00-00-000Z-${SESSION_ID}.log`); + await write(fileService, newer); + await write(fileService, older); + await artifacts.refresh(); + assert.deepStrictEqual(artifacts.artifacts.get(), [ + { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, path: older.fsPath }, + { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, path: newer.fsPath }, + ]); + }); + + test('refresh appends the transcript after the log, matching by session id under projects/*', async () => { + const { fileService, artifacts } = setup(); + const log = joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}.log`); + const transcript = joinPath(projectsDir, 'encoded-cwd', `${SESSION_ID}.jsonl`); + await write(fileService, log); + await write(fileService, transcript); + await write(fileService, joinPath(projectsDir, 'encoded-cwd', 'other-session.jsonl')); + await artifacts.refresh(); + assert.deepStrictEqual(artifacts.artifacts.get(), [ + { label: CLAUDE_DEBUG_LOG_LABEL, path: log.fsPath }, + { label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }, + ]); + }); + + test('refresh surfaces the transcript alone when no debug log has been written yet', async () => { + const { fileService, artifacts } = setup(); + const transcript = joinPath(projectsDir, 'encoded-cwd', `${SESSION_ID}.jsonl`); + await write(fileService, transcript); + await artifacts.refresh(); + assert.deepStrictEqual(artifacts.artifacts.get(), [{ label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }]); + }); + + test('prepareDebugFile creates the log directory and returns this session\'s per-run path', async () => { + const { fileService, artifacts } = setup(); + const path = await artifacts.prepareDebugFile(); + assert.ok(path, 'expected a debug-file path'); + const file = URI.file(path); + assert.deepStrictEqual( + { dir: dirname(file).fsPath, dirExists: await fileService.exists(logDir) }, + { dir: logDir.fsPath, dirExists: true }, + ); + assert.match(basename(file), /^claude-.+-sess-abc\.log$/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts index e6105cdac1b880..274928647dc5c0 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts @@ -168,6 +168,16 @@ suite('claudeSdkOptions / buildOptions plugins projection', () => { assert.strictEqual(opts.plugins, undefined); }); + test('debugFile projects onto Options.debugFile', async () => { + const opts = await buildOptions({ ...input(undefined), debugFile: '/logs/claude/claude-x.log' }, proxyTransport, () => { }); + assert.strictEqual(opts.debugFile, '/logs/claude/claude-x.log'); + }); + + test('absent debugFile omits Options.debugFile', async () => { + const opts = await buildOptions(input(undefined), proxyTransport, () => { }); + assert.strictEqual(opts.debugFile, undefined); + }); + test('proxy transport sets ANTHROPIC_BASE_URL + per-session ANTHROPIC_AUTH_TOKEN', async () => { const opts = await buildOptions(input(undefined), proxyTransport, () => { }); const env = (opts.settings as { env?: Record }).env ?? {}; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index b1aa2d6b8eec99..a1f80f278ef39c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -26,7 +26,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationType, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, readSessionWorkspaceless, ROOT_STATE_URI, SessionMeta, StateComponents, withSessionWorkspaceless, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionDebugArtifacts, readSessionGitHubState, readSessionGitState, readSessionWorkspaceless, ROOT_STATE_URI, SessionMeta, StateComponents, withSessionWorkspaceless, type ChatSummary, type IDebugArtifact, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -513,6 +513,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { private readonly _changesSummary = observableValueOpts({ equalsFn: structuralEquals }, undefined); get changesSummary(): IObservable { return this._changesSummary; } + + /** Debug artifacts (log / transcript file paths) the host advertised for this session via `_meta`. */ + get debugArtifacts(): readonly IDebugArtifact[] { return readSessionDebugArtifacts(this._meta) ?? []; } + setChangesSummary(changes: ChangesSummary | undefined): boolean { if (!changes) { return false; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts index 3d5efaadfeb93d..e782e525673cbc 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize2 } from '../../../../../nls.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; @@ -16,7 +17,7 @@ import { type ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; +import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; export class ExportAgentHostDebugLogsAction extends Action2 { @@ -44,11 +45,21 @@ export class ExportAgentHostDebugLogsAction extends Action2 { const activeAgentHostSession = isAgentHostSession(activeSession, sessionsProvidersService) ? activeSession : undefined; const sessionForEvents = activeAgentHostSession ?? getMostRecentAgentHostSession(sessionsManagementService.getSessions(), sessionsProvidersService); + // `sessionForEvents` is either the active-slot VisibleSession (a view wrapper that + // delegates ISession members to the real session but is NOT the adapter and has no + // `debugArtifacts`) or, in the fallback, the adapter itself. Either way, resolve the + // AgentHostSessionAdapter from getSessions() by resource — that's the object the host + // populates with `_meta` debug artifacts. + const adapter = sessionForEvents + ? sessionsManagementService.getSessions().find((s): s is AgentHostSessionAdapter => s instanceof AgentHostSessionAdapter && isEqual(s.resource, sessionForEvents.resource)) + : undefined; + const activeSessionContext: IActiveAgentHostSessionForExport | undefined = sessionForEvents ? { resource: sessionForEvents.resource, title: activeAgentHostSession?.title.get(), isLocal: sessionForEvents.resource.scheme.startsWith('agent-host-'), + debugArtifacts: adapter?.debugArtifacts, } : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index 806a349ca317ed..bfc325bccb1440 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -5,13 +5,13 @@ import { VSBuffer, streamToBuffer } from '../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { joinPath } from '../../../../../base/common/resources.js'; +import { extname, joinPath } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; -import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { agentHostAuthority, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, remoteAgentHostLogOutputChannelId, AGENT_HOST_LOG_OUTPUT_CHANNEL_ID } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; @@ -20,7 +20,7 @@ import { IsWebContext } from '../../../../../platform/contextkey/common/contextk import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { createDecorator, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -30,8 +30,11 @@ import { IOutputService } from '../../../../services/output/common/output.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { buildLocalCopilotLogsUri, buildRemoteCopilotLogsUri, COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme, resolveEventsUri } from '../copilotCliEventsUri.js'; -import { findCopilotLogsForSession, getRemoteConnectionForSession, readRemoteAgentHostLog, sanitizeFilePart } from '../chatDebug/agentHostLogSources.js'; +import { buildLocalCopilotLogsUri, buildRemoteCopilotLogsUri, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme, resolveEventsUri } from '../copilotCliEventsUri.js'; +import { findCopilotLogsForSession, getRemoteConnectionForSession, readRemoteAgentHostLog } from '../chatDebug/agentHostLogSources.js'; +import { sanitizeFilePart } from '../../../../../platform/agentHost/common/agentHostLogNaming.js'; +import { LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { readSessionDebugArtifacts, type IDebugArtifact } from '../../../../../platform/agentHost/common/state/sessionState.js'; /** Output channel ID for the agent host process logger (forwarded via RemoteLoggerChannelClient). */ const AGENT_HOST_LOGGER_CHANNEL_ID = AGENT_HOST_LOG_OUTPUT_CHANNEL_ID; @@ -52,6 +55,14 @@ export interface IActiveAgentHostSessionForExport { readonly title: string | undefined; /** True for local agent-host sessions (`agent-host-*` scheme). */ readonly isLocal: boolean; + /** + * Debug artifacts the host advertised for this session via session `_meta` + * (`agentHost.debugArtifacts`) — host-local absolute paths to provider log / + * transcript files. The export resolves each through the resource proxy + * instead of re-deriving provider-specific on-disk layouts. Omitted when the + * caller can't read the session's `_meta` (e.g. the workbench-side action). + */ + readonly debugArtifacts?: readonly IDebugArtifact[]; } export type IAgentHostDebugLogFile = @@ -192,6 +203,34 @@ export async function collectAgentHostDebugLogs( // AHP log directory may not exist if no remote connection has been opened or if logging is disabled. } + // 3b. Provider debug artifacts (e.g. Claude SDK debug logs + session transcript) + // advertised by the host via session `_meta`. The host reports concrete + // host-local absolute paths — no client-side knowledge of provider on-disk + // layout — which we resolve through the resource proxy: `file://` for a local + // session, `vscode-agent-host://` for a remote one. + const artifactAuthority = remoteConnection ? agentHostAuthority(remoteConnection.address) : undefined; + for (const artifact of activeSession?.debugArtifacts ?? []) { + // `artifact.path` is an absolute path on the agent-host machine. Locally + // that's this platform (`URI.file`); remotely it's the remote platform, so + // preserve it verbatim via `URI.from` rather than let `URI.file` apply the + // client's separator/drive rules to a foreign path. + const resource = activeSession?.isLocal + ? URI.file(artifact.path) + : artifactAuthority ? toAgentHostUri(URI.from({ scheme: Schemas.file, path: artifact.path }), artifactAuthority) : undefined; + if (!resource) { + continue; + } + try { + // Capture the size so remote artifacts use the bounded read path (a large + // `--debug` log shouldn't be slurped whole over the proxy), and a missing + // file (e.g. one whose SDK subprocess hasn't written it yet) is skipped. + const { size } = await fileService.resolve(resource, { resolveMetadata: true }); + files.push(await createDebugLogFile(`${sanitizeFilePart(artifact.label)}${extname(resource)}`, resource, fileService, size)); + } catch (error) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to read debug artifact '${artifact.label}': ${error instanceof Error ? error.message : String(error)}`); + } + } + // 4. For remote agent hosts, also download the agenthost.log file directly from // the remote machine. The CLI launches the server with its default data dir, // which lives at `//data/logs//agenthost.log`. @@ -291,22 +330,56 @@ export class ExportAgentHostDebugLogsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { + // Extract services synchronously up front — the accessor is only valid + // before the first `await` below. const chatWidgetService = accessor.get(IChatWidgetService); - const widget = chatWidgetService.lastFocusedWidget; - const model = widget?.viewModel?.model; - const activeSession = model ? toActiveAgentHostSession(model.sessionResource, model.title) : undefined; - await exportAgentHostDebugLogs(accessor, activeSession); + const agentHostService = accessor.get(IAgentHostService); + const instantiationService = accessor.get(IInstantiationService); + const model = chatWidgetService.lastFocusedWidget?.viewModel?.model; + let activeSession = model ? toActiveAgentHostSession(model.sessionResource, model.title) : undefined; + // Unlike the Agents window, the workbench has no session adapter to read + // `_meta` off of, so resolve the host-advertised debug artifacts from the + // local agent host's session summaries (matched by the unique session id). + // Best-effort: a miss just omits provider artifacts — the general logs + // (channels, AHP, remote agenthost.log) still export. + if (activeSession?.isLocal) { + const debugArtifacts = await resolveLocalDebugArtifacts(agentHostService, activeSession.resource); + if (debugArtifacts?.length) { + activeSession = { ...activeSession, debugArtifacts }; + } + } + // The original `accessor` is invalid after the await above, so re-enter with + // a fresh one for the (accessor-threading) export helper. + await instantiationService.invokeFunction(accessor => exportAgentHostDebugLogs(accessor, activeSession)); + } +} + +/** + * Reads the host-advertised {@link IDebugArtifact}s for a local session from the + * local agent host's session summaries, matched by the session's unique id (the + * summary's `session` URI may use the backend scheme, so we compare ids, not the + * whole URI). Best-effort: any failure resolves to `undefined`. + */ +async function resolveLocalDebugArtifacts(agentHostService: IAgentHostService, resource: URI): Promise { + try { + const rawId = resource.path.substring(1); + const sessions = await agentHostService.listSessions(); + return readSessionDebugArtifacts(sessions.find(session => session.session.path.substring(1) === rawId)?._meta); + } catch { + return undefined; } } /** - * Translates a chat session URI scheme into an agent-host session context, - * or `undefined` if the scheme does not belong to a Copilot CLI agent-host - * session (i.e. local AH or remote AH; the EH CLI extension's own - * `copilotcli:` sessions are excluded). + * Translates a chat session URI scheme into an agent-host session context, or + * `undefined` if the scheme does not belong to an agent-host session. Covers any + * local agent host (`agent-host-`, e.g. Copilot CLI or Claude) and + * remote agent hosts; the EH CLI extension's own `copilotcli:` sessions are + * excluded. Provider-specific collection (events.jsonl, Copilot/Claude logs) + * self-selects downstream by scheme/session id. */ export function toActiveAgentHostSession(resource: URI, title: string | undefined): IActiveAgentHostSessionForExport | undefined { - if (resource.scheme === COPILOT_CLI_LOCAL_AH_SCHEME) { + if (resource.scheme.startsWith(LOCAL_AGENT_HOST_SCHEME_PREFIX)) { return { resource, title, isLocal: true }; } if (parseRemoteAuthorityFromScheme(resource.scheme)) { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts index 32681c3674aeed..323eaabaac7933 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts @@ -9,6 +9,7 @@ import { joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { sanitizeFilePart } from '../../../../../platform/agentHost/common/agentHostLogNaming.js'; import { AgentHostAhpJsonlLoggingSettingId, 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'; @@ -20,6 +21,7 @@ import { ITextModelService } from '../../../../../editor/common/services/resolve import { IOutputService } from '../../../../services/output/common/output.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { buildLocalCopilotLogsUri, buildRemoteCopilotLogsUri, COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme, resolveEventsUri } from '../copilotCliEventsUri.js'; +import { findRemoteAgentHostSessionTypeAuthority } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; /** Output channel ID for the current window's renderer log. */ const WINDOW_LOG_CHANNEL_ID = 'rendererLog'; @@ -116,18 +118,16 @@ export function isAgentHostSession(resource: URI | undefined): boolean { /** * Resolves the remote agent-host connection that backs a given remote session - * URI, or `undefined` for local/unknown sessions. + * URI, or `undefined` for local/unknown sessions. Provider-agnostic: matches the + * `remote--` scheme against the set of known connection + * authorities (an authority may itself contain `-`), so it resolves Claude, + * Copilot, and any other provider's remote sessions — not just `copilotcli`. */ export function getRemoteConnectionForSession(sessionResource: URI, connections: readonly IRemoteAgentHostConnectionInfo[]): IRemoteAgentHostConnectionInfo | undefined { - const authority = parseRemoteAuthorityFromScheme(sessionResource.scheme); + const authority = findRemoteAgentHostSessionTypeAuthority(sessionResource.scheme, connections.map(connection => agentHostAuthority(connection.address))); 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 @@ -485,28 +485,13 @@ async function logStreamContains( } /** - * Reads the remote agent host's `agenthost.log` from the remote machine via the - * `vscode-agent-host://` filesystem proxy. The CLI launches the server with its - * default data dir at `//data/logs//`, - * so we list the logs directory and pick the most recent date-stamped folder. + * The remote server-data-folder name candidates to probe. The renderer's own + * `serverDataFolderName` (which the user is running) is the most likely match, + * but the remote agent host may have been launched by a different quality of + * CLI. Dev builds also append `-dev`, which won't exist on any real built + * remote, so we strip that suffix as well. */ -export async function readRemoteAgentHostLog( - connection: IRemoteAgentHostConnectionInfo, - serverDataFolderName: string | undefined, - fileService: IFileService, -): Promise { - const homePath = connection.defaultDirectory; - if (!homePath) { - return undefined; - } - const authority = agentHostAuthority(connection.address); - const homeUri = toAgentHostUri(URI.from({ scheme: 'file', path: homePath }), authority); - - // Possible server data folder candidates. The renderer's own - // `serverDataFolderName` (which the user is running) is the most likely - // match, but the remote agent host may have been launched by a different - // quality of CLI. Dev builds also append `-dev`, which won't exist on - // any real built remote, so we strip that suffix as well. +function remoteServerDataFolderCandidates(serverDataFolderName: string | undefined): string[] { const candidates = new Set(); if (serverDataFolderName) { candidates.add(serverDataFolderName); @@ -518,39 +503,68 @@ export async function readRemoteAgentHostLog( candidates.add('.vscode-server-insiders'); candidates.add('.vscode-server-oss'); candidates.add('.vscode-server-exploration'); + return [...candidates]; +} - // Enumerate every `//data/logs//agenthost.log` - // across all candidates and pick the one with the newest mtime. This avoids - // picking up a stale stable-quality folder when an insiders folder has a - // more recent log (or vice versa). - let best: { uri: URI; mtime: number } | undefined; - for (const folderName of candidates) { +/** + * Enumerates the remote `//data/logs/` directories + * across all server-data-folder quality candidates. Shared by the readers for + * `agenthost.log` and the Claude debug logs, both of which live under a + * date-stamped folder written by the remote agent host. + */ +async function enumerateRemoteLogDatestampDirs( + connection: IRemoteAgentHostConnectionInfo, + serverDataFolderName: string | undefined, + fileService: IFileService, +): Promise { + const homePath = connection.defaultDirectory; + if (!homePath) { + return []; + } + const authority = agentHostAuthority(connection.address); + const homeUri = toAgentHostUri(URI.from({ scheme: 'file', path: homePath }), authority); + const dirs: URI[] = []; + for (const folderName of remoteServerDataFolderCandidates(serverDataFolderName)) { const logsDirUri = joinPath(homeUri, folderName, 'data', 'logs'); - let entries; + let entries: IFileStatWithMetadata[] | undefined; try { - const stat = await fileService.resolve(logsDirUri, { resolveMetadata: true }); - entries = stat.children; + entries = (await fileService.resolve(logsDirUri, { resolveMetadata: true })).children; } catch { continue; } - if (!entries) { + for (const dir of entries ?? []) { + if (dir.isDirectory) { + dirs.push(dir.resource); + } + } + } + return dirs; +} + +/** + * Reads the remote agent host's `agenthost.log` from the remote machine via the + * `vscode-agent-host://` filesystem proxy. The CLI launches the server with its + * default data dir at `//data/logs//`, so + * we pick the `agenthost.log` with the newest mtime across all date-stamped + * folders (avoiding a stale folder of a different CLI quality). + */ +export async function readRemoteAgentHostLog( + connection: IRemoteAgentHostConnectionInfo, + serverDataFolderName: string | undefined, + fileService: IFileService, +): Promise { + let best: { uri: URI; mtime: number } | undefined; + for (const dir of await enumerateRemoteLogDatestampDirs(connection, serverDataFolderName, fileService)) { + const logUri = joinPath(dir, 'agenthost.log'); + let logStat; + try { + logStat = await fileService.resolve(logUri, { resolveMetadata: true }); + } catch { continue; } - for (const dir of entries) { - if (!dir.isDirectory) { - continue; - } - const logUri = joinPath(dir.resource, 'agenthost.log'); - let logStat; - try { - logStat = await fileService.resolve(logUri, { resolveMetadata: true }); - } catch { - continue; - } - const mtime = logStat.mtime ?? 0; - if (!best || mtime > best.mtime) { - best = { uri: logUri, mtime }; - } + const mtime = logStat.mtime ?? 0; + if (!best || mtime > best.mtime) { + best = { uri: logUri, mtime }; } } From 668628a7635a04abad75ca2502f8efa35ecbff23 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 24 Jul 2026 13:41:08 -0700 Subject: [PATCH 2/7] Address PR review: exact log match, clear stale _meta, remote artifacts, drop invokeFunction - claudeDebugArtifacts: match the exact `-.log` suffix instead of `includes(token)`, so a sibling session whose id merely contains this token (e.g. `sess-abc` vs `sess-abc-extra`) can't leak its debug log into the export. - claudeAgentSession: let an empty projection clear the `_meta` debugArtifacts slot (withSessionDebugArtifacts already removes only this slot), keeping the advertised state a true projection of disk; skip only the initial empty write. - export action: resolve debug artifacts inside collectAgentHostDebugLogs for both local and remote sessions (remote via the matching IAgentConnection), and drop the invokeFunction re-entry so run() uses the accessor synchronously. Co-Authored-By: Claude Opus 4.8 --- .../node/claude/claudeAgentSession.ts | 13 ++-- .../node/claude/claudeDebugArtifacts.ts | 10 +++- .../test/node/claudeDebugArtifacts.test.ts | 3 + .../actions/exportAgentHostDebugLogsAction.ts | 59 ++++++++++--------- 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 55e17797fc61ab..7a9df4e4df02a7 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -26,7 +26,7 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallContributorKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { isDefaultChatUri, withSessionDebugArtifacts, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; +import { isDefaultChatUri, readSessionDebugArtifacts, withSessionDebugArtifacts, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { ClaudeDebugArtifacts } from './claudeDebugArtifacts.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; @@ -390,11 +390,16 @@ export class ClaudeAgentSession extends Disposable { private _watchDebugArtifacts(): IDisposable { return autorun(reader => { const artifacts = this._debugArtifacts.artifacts.read(reader); - if (artifacts.length === 0) { - return; - } const sessionKey = this.sessionUri.toString(); const current = this._stateManager.getSessionState(sessionKey)?._meta; + // Publish disk truth: a non-empty set advertises the artifacts; an empty + // set clears a previously-advertised slot (`withSessionDebugArtifacts` + // removes only this slot, preserving git/github). Skip only the initial + // nothing-advertised-and-nothing-to-clear case, to avoid a redundant + // `_meta` write before the first refresh lands. + if (artifacts.length === 0 && readSessionDebugArtifacts(current) === undefined) { + return; + } this._stateManager.setSessionMeta(sessionKey, withSessionDebugArtifacts(current, artifacts)); }); } diff --git a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts index dd2ea5c5a898b7..d00b216ac3d94d 100644 --- a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts +++ b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts @@ -78,10 +78,16 @@ export class ClaudeDebugArtifacts { /** This session's debug-log files (current + prior runs). The host owns `/claude/`, so scanning it is not cross-component path-guessing. */ private async _readDebugLogPaths(): Promise { - const token = claudeDebugLogSessionToken(this._sessionId); + // Match the exact `claude--.log` shape emitted by + // buildClaudeDebugFilePath. A substring `includes(token)` check would also + // match another session whose id merely contains this token (e.g. `sess-abc` + // vs `sess-abc-extra`); debug logs can hold sensitive content, so exporting + // a sibling session's log would be an unsafe cross-session leak. The leading + // `-` in the suffix keeps the token boundary exact. + const suffix = `-${claudeDebugLogSessionToken(this._sessionId)}.log`; try { const stat = await this._fileService.resolve(joinPath(this._logsHome, CLAUDE_LOG_DIR)); - return (stat.children ?? []).filter(c => !c.isDirectory && c.name.endsWith('.log') && c.name.includes(token)).map(c => c.resource.fsPath); + return (stat.children ?? []).filter(c => !c.isDirectory && c.name.startsWith('claude-') && c.name.endsWith(suffix)).map(c => c.resource.fsPath); } catch { return []; // /claude may not exist yet. } diff --git a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts index 9abbf4b72ab410..1a6c1e7387d2a9 100644 --- a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts @@ -48,6 +48,9 @@ suite('ClaudeDebugArtifacts', () => { const mine = joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}.log`); await write(fileService, mine); await write(fileService, joinPath(logDir, 'claude-2026-01-01T00-00-00-000Z-other.log')); + // A sibling session whose id merely contains ours as a prefix must NOT match + // (exact suffix, not substring) — debug logs can hold sensitive content. + await write(fileService, joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}-extra.log`)); await write(fileService, joinPath(logDir, `${SESSION_ID}.txt`)); await artifacts.refresh(); assert.deepStrictEqual(artifacts.artifacts.get(), [{ label: CLAUDE_DEBUG_LOG_LABEL, path: mine.fsPath }]); diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index bfc325bccb1440..bd3d109d4c73d9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -13,14 +13,14 @@ import { Categories } from '../../../../../platform/action/common/actionCommonCa import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; -import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, remoteAgentHostLogOutputChannelId, AGENT_HOST_LOG_OUTPUT_CHANNEL_ID } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; 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 { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { createDecorator, IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.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'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -208,8 +208,25 @@ export async function collectAgentHostDebugLogs( // host-local absolute paths — no client-side knowledge of provider on-disk // layout — which we resolve through the resource proxy: `file://` for a local // session, `vscode-agent-host://` for a remote one. + // + // Resolve the artifacts from the owning host's `_meta` when the caller didn't + // already supply them: the Agents window passes them from the live session + // adapter, whereas the workbench window (no adapter) reads them here — from the + // local host or the matching remote connection. Best-effort: a miss just omits + // the provider artifacts; the general logs still export. + let debugArtifacts = activeSession?.debugArtifacts; + if (activeSession && debugArtifacts === undefined) { + if (activeSession.isLocal) { + debugArtifacts = await resolveSessionDebugArtifacts(() => agentHostService.listSessions(), activeSession.resource); + } else { + const connection = remoteConnection && remoteAgentHostService.getConnection(remoteConnection.address); + if (connection) { + debugArtifacts = await resolveSessionDebugArtifacts(() => connection.listSessions(), activeSession.resource); + } + } + } const artifactAuthority = remoteConnection ? agentHostAuthority(remoteConnection.address) : undefined; - for (const artifact of activeSession?.debugArtifacts ?? []) { + for (const artifact of debugArtifacts ?? []) { // `artifact.path` is an absolute path on the agent-host machine. Locally // that's this platform (`URI.file`); remotely it's the remote platform, so // preserve it verbatim via `URI.from` rather than let `URI.file` apply the @@ -330,40 +347,28 @@ export class ExportAgentHostDebugLogsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - // Extract services synchronously up front — the accessor is only valid - // before the first `await` below. const chatWidgetService = accessor.get(IChatWidgetService); - const agentHostService = accessor.get(IAgentHostService); - const instantiationService = accessor.get(IInstantiationService); const model = chatWidgetService.lastFocusedWidget?.viewModel?.model; - let activeSession = model ? toActiveAgentHostSession(model.sessionResource, model.title) : undefined; - // Unlike the Agents window, the workbench has no session adapter to read - // `_meta` off of, so resolve the host-advertised debug artifacts from the - // local agent host's session summaries (matched by the unique session id). - // Best-effort: a miss just omits provider artifacts — the general logs - // (channels, AHP, remote agenthost.log) still export. - if (activeSession?.isLocal) { - const debugArtifacts = await resolveLocalDebugArtifacts(agentHostService, activeSession.resource); - if (debugArtifacts?.length) { - activeSession = { ...activeSession, debugArtifacts }; - } - } - // The original `accessor` is invalid after the await above, so re-enter with - // a fresh one for the (accessor-threading) export helper. - await instantiationService.invokeFunction(accessor => exportAgentHostDebugLogs(accessor, activeSession)); + const activeSession = model ? toActiveAgentHostSession(model.sessionResource, model.title) : undefined; + // `collectAgentHostDebugLogs` resolves the host-advertised debug artifacts + // itself (it already holds the local + remote agent host services), so the + // accessor is used synchronously here with no `invokeFunction` re-entry. + await exportAgentHostDebugLogs(accessor, activeSession); } } /** - * Reads the host-advertised {@link IDebugArtifact}s for a local session from the - * local agent host's session summaries, matched by the session's unique id (the + * Reads the host-advertised {@link IDebugArtifact}s for a session from the owning + * agent host's session summaries, matched by the session's unique id (the * summary's `session` URI may use the backend scheme, so we compare ids, not the - * whole URI). Best-effort: any failure resolves to `undefined`. + * whole URI). The caller supplies the matching `listSessions` — the local host or + * a specific remote connection — so this serves both local and remote export. + * Best-effort: any failure resolves to `undefined`. */ -async function resolveLocalDebugArtifacts(agentHostService: IAgentHostService, resource: URI): Promise { +async function resolveSessionDebugArtifacts(listSessions: () => Promise, resource: URI): Promise { try { const rawId = resource.path.substring(1); - const sessions = await agentHostService.listSessions(); + const sessions = await listSessions(); return readSessionDebugArtifacts(sessions.find(session => session.session.path.substring(1) === rawId)?._meta); } catch { return undefined; From 15e89e7114c48c790657076f3727e70fab2f74ac Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 24 Jul 2026 13:59:07 -0700 Subject: [PATCH 3/7] Pass the session lister object instead of a closure Both IAgentHostService and a remote IAgentConnection structurally expose `listSessions()`, so resolveSessionDebugArtifacts can take the owning host directly rather than a `() => host.listSessions()` thunk. Pick the lister once (local service or remote connection) and pass it, collapsing the two branches. Co-Authored-By: Claude Opus 4.8 --- .../actions/exportAgentHostDebugLogsAction.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index bd3d109d4c73d9..3537ec57ed10df 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -216,13 +216,13 @@ export async function collectAgentHostDebugLogs( // the provider artifacts; the general logs still export. let debugArtifacts = activeSession?.debugArtifacts; if (activeSession && debugArtifacts === undefined) { - if (activeSession.isLocal) { - debugArtifacts = await resolveSessionDebugArtifacts(() => agentHostService.listSessions(), activeSession.resource); - } else { - const connection = remoteConnection && remoteAgentHostService.getConnection(remoteConnection.address); - if (connection) { - debugArtifacts = await resolveSessionDebugArtifacts(() => connection.listSessions(), activeSession.resource); - } + // The local host and a remote connection both expose `listSessions()`; pick + // whichever owns this session and read its advertised `_meta` artifacts. + const sessionLister = activeSession.isLocal + ? agentHostService + : remoteConnection ? remoteAgentHostService.getConnection(remoteConnection.address) : undefined; + if (sessionLister) { + debugArtifacts = await resolveSessionDebugArtifacts(sessionLister, activeSession.resource); } } const artifactAuthority = remoteConnection ? agentHostAuthority(remoteConnection.address) : undefined; @@ -361,14 +361,15 @@ export class ExportAgentHostDebugLogsAction extends Action2 { * Reads the host-advertised {@link IDebugArtifact}s for a session from the owning * agent host's session summaries, matched by the session's unique id (the * summary's `session` URI may use the backend scheme, so we compare ids, not the - * whole URI). The caller supplies the matching `listSessions` — the local host or - * a specific remote connection — so this serves both local and remote export. + * whole URI). `sessionLister` is whichever host owns the session — the local + * {@link IAgentHostService} or a remote {@link IAgentConnection}, both of which + * expose `listSessions()` — so this serves both local and remote export. * Best-effort: any failure resolves to `undefined`. */ -async function resolveSessionDebugArtifacts(listSessions: () => Promise, resource: URI): Promise { +async function resolveSessionDebugArtifacts(sessionLister: { listSessions(): Promise }, resource: URI): Promise { try { const rawId = resource.path.substring(1); - const sessions = await listSessions(); + const sessions = await sessionLister.listSessions(); return readSessionDebugArtifacts(sessions.find(session => session.session.path.substring(1) === rawId)?._meta); } catch { return undefined; From 8878f7c763f1b4bb0549c29e43c85a916bd19c3e Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 24 Jul 2026 14:05:01 -0700 Subject: [PATCH 4/7] Inline the single-use session-artifact resolution resolveSessionDebugArtifacts had one call site and ~3 functional lines; fold it into collectAgentHostDebugLogs. `sessions` now infers its type from the picked lister's listSessions(), so the IAgentSessionMetadata import drops too. Co-Authored-By: Claude Opus 4.8 --- .../actions/exportAgentHostDebugLogsAction.ts | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index 3537ec57ed10df..7175a5aac8f847 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -13,7 +13,7 @@ import { Categories } from '../../../../../platform/action/common/actionCommonCa import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; -import { IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, remoteAgentHostLogOutputChannelId, AGENT_HOST_LOG_OUTPUT_CHANNEL_ID } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; @@ -217,12 +217,19 @@ export async function collectAgentHostDebugLogs( let debugArtifacts = activeSession?.debugArtifacts; if (activeSession && debugArtifacts === undefined) { // The local host and a remote connection both expose `listSessions()`; pick - // whichever owns this session and read its advertised `_meta` artifacts. + // whichever owns this session and read its advertised `_meta` artifacts, + // matched by the session's unique id (a summary's `session` URI may use the + // backend scheme, so compare ids, not the whole URI). Best-effort: a miss + // just omits the provider artifacts; the general logs still export. const sessionLister = activeSession.isLocal ? agentHostService : remoteConnection ? remoteAgentHostService.getConnection(remoteConnection.address) : undefined; - if (sessionLister) { - debugArtifacts = await resolveSessionDebugArtifacts(sessionLister, activeSession.resource); + try { + const rawId = activeSession.resource.path.substring(1); + const sessions = await sessionLister?.listSessions() ?? []; + debugArtifacts = readSessionDebugArtifacts(sessions.find(session => session.session.path.substring(1) === rawId)?._meta); + } catch { + // Ignore: provider artifacts are best-effort. } } const artifactAuthority = remoteConnection ? agentHostAuthority(remoteConnection.address) : undefined; @@ -357,25 +364,6 @@ export class ExportAgentHostDebugLogsAction extends Action2 { } } -/** - * Reads the host-advertised {@link IDebugArtifact}s for a session from the owning - * agent host's session summaries, matched by the session's unique id (the - * summary's `session` URI may use the backend scheme, so we compare ids, not the - * whole URI). `sessionLister` is whichever host owns the session — the local - * {@link IAgentHostService} or a remote {@link IAgentConnection}, both of which - * expose `listSessions()` — so this serves both local and remote export. - * Best-effort: any failure resolves to `undefined`. - */ -async function resolveSessionDebugArtifacts(sessionLister: { listSessions(): Promise }, resource: URI): Promise { - try { - const rawId = resource.path.substring(1); - const sessions = await sessionLister.listSessions(); - return readSessionDebugArtifacts(sessions.find(session => session.session.path.substring(1) === rawId)?._meta); - } catch { - return undefined; - } -} - /** * Translates a chat session URI scheme into an agent-host session context, or * `undefined` if the scheme does not belong to an agent-host session. Covers any From bb589c6ac3a94b21f02b12c43e49ebcb1ee10775 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 24 Jul 2026 14:31:41 -0700 Subject: [PATCH 5/7] Compute the transcript path and drop the debug-artifact observable Three cleanups from PR review: - Compute the transcript path from the cwd instead of scanning every ~/.claude/projects directory. The CLI slug is `cwd.replace(/[^a-zA-Z0-9]/g, '-')` (verified against the on-disk project dirs), so resolving the transcript is a single `stat`, not O(projects). claudeProjectSlug / buildClaudeTranscriptPath are pure and unit-tested. - Drop the observable + autorun. ClaudeDebugArtifacts.refresh(cwd) now returns the projected artifacts and the session's _publishDebugArtifacts writes them to `_meta` directly, at the three change points (materialize / rematerialize / first turn). With one producer and one consumer the observable was pure indirection, and its autorun fired a wasted no-op run with [] on registration. - ClaudeDebugArtifacts is now stateless; tests await refresh() rather than reading an observable, plus a new test proves the transcript is addressed by cwd slug (a same-id file under a different project dir is ignored). Co-Authored-By: Claude Opus 4.8 --- .../agentHost/common/agentHostLogNaming.ts | 21 ++++++ .../node/claude/claudeAgentSession.ts | 75 ++++++++----------- .../node/claude/claudeDebugArtifacts.ts | 74 +++++++----------- .../test/common/agentHostLogNaming.test.ts | 17 ++++- .../test/node/claudeDebugArtifacts.test.ts | 36 +++++---- 5 files changed, 115 insertions(+), 108 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostLogNaming.ts b/src/vs/platform/agentHost/common/agentHostLogNaming.ts index b78322715c0acb..90b0f5acc40036 100644 --- a/src/vs/platform/agentHost/common/agentHostLogNaming.ts +++ b/src/vs/platform/agentHost/common/agentHostLogNaming.ts @@ -88,3 +88,24 @@ export function buildClaudeDebugArtifacts(logPaths: readonly string[], transcrip } return artifacts; } + +/** + * Encodes a working directory into the Claude CLI's transcript "project" slug — + * every non-alphanumeric character becomes `-`, e.g. `/Users/foo/my-proj` → + * `-Users-foo-my-proj`. The CLI derives the slug from its *resolved* process cwd + * (so a symlinked path like `/tmp` → `/private/tmp` encodes to `-private-tmp`); + * a caller holding an unresolved path therefore gets a best-effort match. + */ +export function claudeProjectSlug(cwd: string): string { + return cwd.replace(/[^a-zA-Z0-9]/g, '-'); +} + +/** + * The Claude CLI session transcript path for a working directory + session id: + * `/.claude/projects//.jsonl` (see + * {@link claudeProjectSlug}). A pure projection, so resolving the transcript is a + * single deterministic `stat` rather than a scan of every project directory. + */ +export function buildClaudeTranscriptPath(userHome: URI, cwd: string, sessionId: string): URI { + return joinPath(userHome, '.claude', 'projects', claudeProjectSlug(cwd), `${sessionId}.jsonl`); +} diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 7a9df4e4df02a7..ca18bb61b2cfe3 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -7,8 +7,7 @@ import type { McpSdkServerConfigWithInstance, OnElicitation, Options, Permission import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -26,7 +25,7 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallContributorKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { isDefaultChatUri, readSessionDebugArtifacts, withSessionDebugArtifacts, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; +import { isDefaultChatUri, withSessionDebugArtifacts, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { ClaudeDebugArtifacts } from './claudeDebugArtifacts.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; @@ -371,37 +370,32 @@ export class ClaudeAgentSession extends Disposable { } /** - * Discovery + publishing of this session's debug artifacts (SDK debug logs + - * transcript). The session's only artifact state; assigned in the constructor - * and bridged into `_meta` by {@link _watchDebugArtifacts}. + * Discovery of this session's debug artifacts (SDK debug logs + transcript), + * assigned in the constructor. Stateless — {@link _publishDebugArtifacts} reads + * it from disk and mirrors the result into `_meta`. */ private readonly _debugArtifacts: ClaudeDebugArtifacts; /** - * Reactively mirror this session's debug artifacts into the well-known `_meta` - * key whenever they change, so the export command reads concrete host-side - * paths instead of re-deriving the on-disk layout. Merges with existing `_meta` - * slots (git/github). Host-local absolute paths; the client resolves them via - * the resource proxy (`file://` local / `vscode-agent-host://` remote). - * - * Returns an {@link IDisposable} for the caller to register after materialize - * commits, so `setSessionMeta` always targets a live, registered session. + * Re-read this session's debug artifacts from disk and publish them into the + * well-known `_meta` key, so the export command reads concrete host-side paths + * instead of re-deriving the on-disk layout. `withSessionDebugArtifacts` merges + * with the existing `_meta` slots (git/github) and, on an empty set, clears only + * this slot — so the advertised state stays a projection of disk truth. Called + * at the moments the set can change: materialize/rematerialize (a new debug log) + * and once after the first turn (the transcript file appears). Host-local + * absolute paths; the client resolves them via the resource proxy (`file://` + * local / `vscode-agent-host://` remote). */ - private _watchDebugArtifacts(): IDisposable { - return autorun(reader => { - const artifacts = this._debugArtifacts.artifacts.read(reader); - const sessionKey = this.sessionUri.toString(); - const current = this._stateManager.getSessionState(sessionKey)?._meta; - // Publish disk truth: a non-empty set advertises the artifacts; an empty - // set clears a previously-advertised slot (`withSessionDebugArtifacts` - // removes only this slot, preserving git/github). Skip only the initial - // nothing-advertised-and-nothing-to-clear case, to avoid a redundant - // `_meta` write before the first refresh lands. - if (artifacts.length === 0 && readSessionDebugArtifacts(current) === undefined) { - return; - } - this._stateManager.setSessionMeta(sessionKey, withSessionDebugArtifacts(current, artifacts)); - }); + private async _publishDebugArtifacts(): Promise { + const cwd = this.workingDirectory; + if (!cwd) { + return; // Not materialized yet; nothing on disk to advertise. + } + const artifacts = await this._debugArtifacts.refresh(cwd); + const sessionKey = this.sessionUri.toString(); + const current = this._stateManager.getSessionState(sessionKey)?._meta; + this._stateManager.setSessionMeta(sessionKey, withSessionDebugArtifacts(current, artifacts)); } /** @@ -536,17 +530,14 @@ export class ClaudeAgentSession extends Disposable { } this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithMcpContributor(this._enrichSignalWithCredits(s))))); this._pipeline = pipeline; - // Mirror the debug artifacts into `_meta`. The locations never change per - // turn: debug logs are created at materialize/rematerialize (refreshed there), - // and the transcript's path is fixed at materialize. The one thing that isn't - // on disk at materialize is a fresh session's transcript FILE — the SDK writes - // it during the first turn — so refresh exactly ONCE more, after the first turn - // completes (a short debounce lets it flush). The artifact set dedupes - // structurally, so nothing re-pushes once it is stable. - this._register(this._watchDebugArtifacts()); - const refreshDebugArtifacts = this._register(new RunOnceScheduler(() => void this._debugArtifacts.refresh(), 500)); - this._register(Event.once(Event.filter(pipeline.onDidProduceSignal, s => s.kind === 'action' && s.action.type === ActionType.ChatTurnComplete))(() => refreshDebugArtifacts.schedule())); - void this._debugArtifacts.refresh(); + // Publish the debug artifacts into `_meta` at the moments the set can change. + // The debug log exists now (the SDK wrote it during startup); the transcript + // FILE of a fresh session isn't on disk until the first turn writes it, so + // publish once more after the first turn completes (a short debounce lets it + // flush). Locations never change per turn, so no steady-state re-publishing. + const publishDebugArtifacts = this._register(new RunOnceScheduler(() => void this._publishDebugArtifacts(), 500)); + this._register(Event.once(Event.filter(pipeline.onDidProduceSignal, s => s.kind === 'action' && s.action.type === ActionType.ChatTurnComplete))(() => publishDebugArtifacts.schedule())); + void this._publishDebugArtifacts(); // The materialize succeeded with the staged anchor applied to `Options` // — clear it now so it isn't re-applied. A throw before this point (e.g. // `startup` / pipeline-create) leaves it staged for the next retry. @@ -623,8 +614,8 @@ export class ClaudeAgentSession extends Disposable { // retries the truncation instead of dropping the restore. this._pendingResumeSessionAt = undefined; // Rebuild started, so the SDK will write a fresh debug log; re-read from - // disk and re-publish (the autorun mirrors the change into `_meta`). - void this._debugArtifacts.refresh(); + // disk and re-publish it into `_meta`. + void this._publishDebugArtifacts(); return { warm: rebuildWarm, abortController: rebuildAbort }; } catch (err) { this.toolDiff.markDirty(); diff --git a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts index d00b216ac3d94d..ac9c874f59aec1 100644 --- a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts +++ b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts @@ -3,36 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { structuralEquals } from '../../../../base/common/equals.js'; -import { IObservable, observableValueOpts } from '../../../../base/common/observable.js'; import { joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; -import { buildClaudeDebugArtifacts, buildClaudeDebugFilePath, CLAUDE_LOG_DIR, claudeDebugLogSessionToken, toFileTimestamp } from '../../common/agentHostLogNaming.js'; +import { buildClaudeDebugArtifacts, buildClaudeDebugFilePath, buildClaudeTranscriptPath, CLAUDE_LOG_DIR, claudeDebugLogSessionToken, toFileTimestamp } from '../../common/agentHostLogNaming.js'; import { type IDebugArtifact } from '../../common/state/sessionState.js'; /** - * Owns discovery + publishing of a Claude session's debug artifacts (the SDK - * `--debug` log files plus the session transcript JSONL). Kept separate from + * Discovers a Claude session's debug artifacts (the SDK `--debug` log files plus + * the session transcript JSONL) from disk. Kept separate from * {@link ClaudeAgentSession} so the disk I/O is unit-testable against an - * in-memory {@link IFileService}, and so the session holds no artifact state of - * its own beyond bridging {@link artifacts} into `_meta`. + * in-memory {@link IFileService}. * - * {@link artifacts} is the single source of truth and a pure projection of what - * is on disk: {@link refresh} re-reads both locations and republishes, and its - * structural equality means a refresh that finds nothing new does not fire. - * - * Depends only on the two home URIs (not the environment service) so a test - * needs nothing more than an in-memory file service and two paths. + * Stateless: {@link refresh} reads the two on-disk locations and returns the + * projected set — the session owns publishing it into `_meta`. Depends only on + * the two home URIs (not the environment service), so a test needs nothing more + * than an in-memory file service and two paths. */ export class ClaudeDebugArtifacts { - private readonly _artifacts = observableValueOpts({ equalsFn: structuralEquals }, []); - - /** The advertised artifact set, rebuilt from disk truth by {@link refresh}. */ - readonly artifacts: IObservable = this._artifacts; - constructor( private readonly _sessionId: string, private readonly _logsHome: URI, @@ -66,14 +56,15 @@ export class ClaudeDebugArtifacts { } /** - * Re-read the two on-disk locations the host/SDK own for this session and - * republish the projected artifact set via the pure, unit-tested - * {@link buildClaudeDebugArtifacts}. No cached state: the truth lives on disk - * and {@link artifacts} dedupes redundant refreshes structurally. + * Read the two on-disk locations the host/SDK own for this session and return + * the projected artifact set via the pure, unit-tested + * {@link buildClaudeDebugArtifacts}. `cwd` is the session's working directory, + * used to address the transcript directly. Stateless: the truth lives on disk, + * so callers just re-read whenever the set might have changed. */ - async refresh(): Promise { - const [logPaths, transcriptPath] = await Promise.all([this._readDebugLogPaths(), this._readTranscriptPath()]); - this._artifacts.set(buildClaudeDebugArtifacts(logPaths, transcriptPath), undefined); + async refresh(cwd: URI): Promise { + const [logPaths, transcriptPath] = await Promise.all([this._readDebugLogPaths(), this._readTranscriptPath(cwd)]); + return buildClaudeDebugArtifacts(logPaths, transcriptPath); } /** This session's debug-log files (current + prior runs). The host owns `/claude/`, so scanning it is not cross-component path-guessing. */ @@ -94,33 +85,18 @@ export class ClaudeDebugArtifacts { } /** - * The SDK session transcript JSONL, once written. The SDK stores it at - * `~/.claude/projects//.jsonl`; rather than replicate - * the cwd-encoding we glob the `projects/*` dirs for `.jsonl`, - * preferring the newest (the cwd can change mid-session, e.g. a worktree - * adoption). `undefined` until the first turn writes it. + * The SDK session transcript JSONL, once written. The CLI stores it at a + * deterministic `~/.claude/projects//.jsonl` derived from the + * cwd (see {@link buildClaudeTranscriptPath}), so this is a single `stat` — not + * a scan of every project directory. `undefined` until the first turn writes it + * (or if the cwd's resolved slug differs, e.g. a symlinked path — best-effort). */ - private async _readTranscriptPath(): Promise { - const fileName = `${this._sessionId}.jsonl`; - let best: { path: string; mtime: number } | undefined; + private async _readTranscriptPath(cwd: URI): Promise { try { - const stat = await this._fileService.resolve(joinPath(this._userHome, '.claude', 'projects')); - for (const child of stat.children ?? []) { - if (!child.isDirectory) { - continue; - } - try { - const meta = await this._fileService.resolve(joinPath(child.resource, fileName), { resolveMetadata: true }); - if (!meta.isDirectory && (!best || (meta.mtime ?? 0) > best.mtime)) { - best = { path: meta.resource.fsPath, mtime: meta.mtime ?? 0 }; - } - } catch { - // No transcript for this session under this project dir. - } - } + const meta = await this._fileService.resolve(buildClaudeTranscriptPath(this._userHome, cwd.fsPath, this._sessionId), { resolveMetadata: true }); + return meta.isDirectory ? undefined : meta.resource.fsPath; } catch { - // ~/.claude/projects may not exist yet. + return undefined; // Not written yet, or the cwd doesn't map to an on-disk project dir. } - return best?.path; } } diff --git a/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts index 1f451feeba8105..408d32757c624f 100644 --- a/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildClaudeDebugArtifacts, buildClaudeDebugFilePath, CLAUDE_DEBUG_LOG_LABEL, CLAUDE_TRANSCRIPT_LABEL, claudeDebugLogSessionToken, sanitizeFilePart, toFileTimestamp } from '../../common/agentHostLogNaming.js'; +import { buildClaudeDebugArtifacts, buildClaudeDebugFilePath, buildClaudeTranscriptPath, CLAUDE_DEBUG_LOG_LABEL, CLAUDE_TRANSCRIPT_LABEL, claudeDebugLogSessionToken, claudeProjectSlug, sanitizeFilePart, toFileTimestamp } from '../../common/agentHostLogNaming.js'; suite('agentHostLogNaming', () => { @@ -55,4 +55,19 @@ suite('agentHostLogNaming', () => { ], ); }); + + test('claudeProjectSlug + buildClaudeTranscriptPath encode the cwd like the CLI', () => { + assert.deepStrictEqual( + { + slug: claudeProjectSlug('/Users/tyleonha/Code/Microsoft/vscode-2'), + symlinked: claudeProjectSlug('/private/tmp'), + transcript: buildClaudeTranscriptPath(URI.file('/home'), '/work/my-proj', 'sess-1').path, + }, + { + slug: '-Users-tyleonha-Code-Microsoft-vscode-2', + symlinked: '-private-tmp', + transcript: '/home/.claude/projects/-work-my-proj/sess-1.jsonl', + }, + ); + }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts index 1a6c1e7387d2a9..206ecc5e96a6dd 100644 --- a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts @@ -21,10 +21,12 @@ suite('ClaudeDebugArtifacts', () => { const LOGS_HOME = URI.file('/logs'); const USER_HOME = URI.file('/home'); + const CWD = URI.file('/work/my-proj'); const SESSION_ID = 'sess-abc'; const logDir = joinPath(LOGS_HOME, 'claude'); - const projectsDir = joinPath(USER_HOME, '.claude', 'projects'); + // The CLI transcript slug for `/work/my-proj` — every non-alphanumeric char → `-`. + const transcriptDir = joinPath(USER_HOME, '.claude', 'projects', '-work-my-proj'); function setup(): { fileService: IFileService; artifacts: ClaudeDebugArtifacts } { const fileService = store.add(new FileService(new NullLogService())); @@ -37,10 +39,9 @@ suite('ClaudeDebugArtifacts', () => { await fileService.writeFile(resource, VSBuffer.fromString('x')); } - test('refresh publishes nothing when neither location exists on disk', async () => { + test('refresh returns nothing when neither location exists on disk', async () => { const { artifacts } = setup(); - await artifacts.refresh(); - assert.deepStrictEqual(artifacts.artifacts.get(), []); + assert.deepStrictEqual(await artifacts.refresh(CWD), []); }); test('refresh discovers this session\'s log, ignoring other sessions and non-log files', async () => { @@ -52,8 +53,7 @@ suite('ClaudeDebugArtifacts', () => { // (exact suffix, not substring) — debug logs can hold sensitive content. await write(fileService, joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}-extra.log`)); await write(fileService, joinPath(logDir, `${SESSION_ID}.txt`)); - await artifacts.refresh(); - assert.deepStrictEqual(artifacts.artifacts.get(), [{ label: CLAUDE_DEBUG_LOG_LABEL, path: mine.fsPath }]); + assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_DEBUG_LOG_LABEL, path: mine.fsPath }]); }); test('refresh numbers multiple runs in chronological (lexical) order', async () => { @@ -62,33 +62,37 @@ suite('ClaudeDebugArtifacts', () => { const newer = joinPath(logDir, `claude-2026-01-02T00-00-00-000Z-${SESSION_ID}.log`); await write(fileService, newer); await write(fileService, older); - await artifacts.refresh(); - assert.deepStrictEqual(artifacts.artifacts.get(), [ + assert.deepStrictEqual(await artifacts.refresh(CWD), [ { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, path: older.fsPath }, { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, path: newer.fsPath }, ]); }); - test('refresh appends the transcript after the log, matching by session id under projects/*', async () => { + test('refresh appends the transcript addressed by the cwd slug, after the log', async () => { const { fileService, artifacts } = setup(); const log = joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}.log`); - const transcript = joinPath(projectsDir, 'encoded-cwd', `${SESSION_ID}.jsonl`); + const transcript = joinPath(transcriptDir, `${SESSION_ID}.jsonl`); await write(fileService, log); await write(fileService, transcript); - await write(fileService, joinPath(projectsDir, 'encoded-cwd', 'other-session.jsonl')); - await artifacts.refresh(); - assert.deepStrictEqual(artifacts.artifacts.get(), [ + assert.deepStrictEqual(await artifacts.refresh(CWD), [ { label: CLAUDE_DEBUG_LOG_LABEL, path: log.fsPath }, { label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }, ]); }); + test('refresh addresses the transcript by cwd slug, ignoring one filed under a different project', async () => { + const { fileService, artifacts } = setup(); + // Same session id, but under a project dir that isn't this cwd's slug: a scan + // would wrongly pick it up; a computed path does not. + await write(fileService, joinPath(USER_HOME, '.claude', 'projects', '-some-other-proj', `${SESSION_ID}.jsonl`)); + assert.deepStrictEqual(await artifacts.refresh(CWD), []); + }); + test('refresh surfaces the transcript alone when no debug log has been written yet', async () => { const { fileService, artifacts } = setup(); - const transcript = joinPath(projectsDir, 'encoded-cwd', `${SESSION_ID}.jsonl`); + const transcript = joinPath(transcriptDir, `${SESSION_ID}.jsonl`); await write(fileService, transcript); - await artifacts.refresh(); - assert.deepStrictEqual(artifacts.artifacts.get(), [{ label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }]); + assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }]); }); test('prepareDebugFile creates the log directory and returns this session\'s per-run path', async () => { From 65a38e813527102ae8ed0fb11fcea4cef20d6f93 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 24 Jul 2026 15:26:14 -0700 Subject: [PATCH 6/7] Transcript path: compute + scan-on-miss, fix Windows drive slug Applies two learnings recovered from the old copilot-chat computeFolderSlug (which was removed when Claude session handling moved to the SDK's listSessions): - Windows: VS Code lowercases the URI drive letter, but the CLI slugs its native uppercase process.cwd(), so claudeProjectSlug now re-uppercases a leading `:` (`c:\x` -> `C--x`). Without this the transcript was silently missed on Windows. - The slug is only a best-effort match (a symlinked cwd, an unusual path char the encoder gets wrong, or a CLI version change all make it miss), so _readTranscriptPath now computes the path first (one stat) and, only on a miss, falls back to a bounded scan of ~/.claude/projects/* for .jsonl (newest). O(1) in the common case; correct in the edge cases. Co-Authored-By: Claude Opus 4.8 --- .../agentHost/common/agentHostLogNaming.ts | 11 ++++-- .../node/claude/claudeDebugArtifacts.ts | 38 ++++++++++++++++--- .../test/common/agentHostLogNaming.test.ts | 3 ++ .../test/node/claudeDebugArtifacts.test.ts | 12 +++--- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostLogNaming.ts b/src/vs/platform/agentHost/common/agentHostLogNaming.ts index 90b0f5acc40036..7e4a4cad3c607c 100644 --- a/src/vs/platform/agentHost/common/agentHostLogNaming.ts +++ b/src/vs/platform/agentHost/common/agentHostLogNaming.ts @@ -92,12 +92,15 @@ export function buildClaudeDebugArtifacts(logPaths: readonly string[], transcrip /** * Encodes a working directory into the Claude CLI's transcript "project" slug — * every non-alphanumeric character becomes `-`, e.g. `/Users/foo/my-proj` → - * `-Users-foo-my-proj`. The CLI derives the slug from its *resolved* process cwd - * (so a symlinked path like `/tmp` → `/private/tmp` encodes to `-private-tmp`); - * a caller holding an unresolved path therefore gets a best-effort match. + * `-Users-foo-my-proj` and `C:\foo\bar` → `C--foo-bar`. The CLI slugs its native + * `process.cwd()`, so two caveats for a caller holding a VS Code URI's `fsPath`: + * it resolves symlinks (`/tmp` → `/private/tmp`), and on Windows it keeps the + * drive letter uppercase while VS Code lowercases it — so we re-uppercase a + * leading `:` here. A mismatch is best-effort: the caller should have a + * scan fallback. */ export function claudeProjectSlug(cwd: string): string { - return cwd.replace(/[^a-zA-Z0-9]/g, '-'); + return cwd.replace(/^([a-zA-Z]):/, (_, drive: string) => `${drive.toUpperCase()}:`).replace(/[^a-zA-Z0-9]/g, '-'); } /** diff --git a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts index ac9c874f59aec1..d4086b24a4d26f 100644 --- a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts +++ b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts @@ -87,16 +87,42 @@ export class ClaudeDebugArtifacts { /** * The SDK session transcript JSONL, once written. The CLI stores it at a * deterministic `~/.claude/projects//.jsonl` derived from the - * cwd (see {@link buildClaudeTranscriptPath}), so this is a single `stat` — not - * a scan of every project directory. `undefined` until the first turn writes it - * (or if the cwd's resolved slug differs, e.g. a symlinked path — best-effort). + * cwd (see {@link buildClaudeTranscriptPath}), so the fast path is a single + * `stat`. The slug is only a best-effort match, though — a symlinked cwd, an + * unusual path char the encoder gets wrong, or a CLI version change all make it + * miss — so on a miss we fall back to scanning the project dirs for this + * session's file (newest first). `undefined` until the first turn writes it. */ private async _readTranscriptPath(cwd: URI): Promise { + const direct = await this._resolveTranscriptFile(buildClaudeTranscriptPath(this._userHome, cwd.fsPath, this._sessionId)); + if (direct) { + return direct.path; + } + let best: { path: string; mtime: number } | undefined; try { - const meta = await this._fileService.resolve(buildClaudeTranscriptPath(this._userHome, cwd.fsPath, this._sessionId), { resolveMetadata: true }); - return meta.isDirectory ? undefined : meta.resource.fsPath; + const stat = await this._fileService.resolve(joinPath(this._userHome, '.claude', 'projects')); + for (const child of stat.children ?? []) { + if (!child.isDirectory) { + continue; + } + const found = await this._resolveTranscriptFile(joinPath(child.resource, `${this._sessionId}.jsonl`)); + if (found && (!best || found.mtime > best.mtime)) { + best = found; + } + } } catch { - return undefined; // Not written yet, or the cwd doesn't map to an on-disk project dir. + // ~/.claude/projects may not exist yet. + } + return best?.path; + } + + /** Resolve a candidate transcript file to `{ path, mtime }`, or `undefined` if it is absent or a directory. */ + private async _resolveTranscriptFile(file: URI): Promise<{ path: string; mtime: number } | undefined> { + try { + const meta = await this._fileService.resolve(file, { resolveMetadata: true }); + return meta.isDirectory ? undefined : { path: meta.resource.fsPath, mtime: meta.mtime ?? 0 }; + } catch { + return undefined; } } } diff --git a/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts index 408d32757c624f..de90f0a3883c13 100644 --- a/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts @@ -61,11 +61,14 @@ suite('agentHostLogNaming', () => { { slug: claudeProjectSlug('/Users/tyleonha/Code/Microsoft/vscode-2'), symlinked: claudeProjectSlug('/private/tmp'), + windows: claudeProjectSlug('c:\\Users\\test\\project'), transcript: buildClaudeTranscriptPath(URI.file('/home'), '/work/my-proj', 'sess-1').path, }, { slug: '-Users-tyleonha-Code-Microsoft-vscode-2', symlinked: '-private-tmp', + // VS Code lowercases the URI drive, but the CLI keeps it uppercase. + windows: 'C--Users-test-project', transcript: '/home/.claude/projects/-work-my-proj/sess-1.jsonl', }, ); diff --git a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts index 206ecc5e96a6dd..44ca1e95689d71 100644 --- a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts @@ -80,12 +80,14 @@ suite('ClaudeDebugArtifacts', () => { ]); }); - test('refresh addresses the transcript by cwd slug, ignoring one filed under a different project', async () => { + test('refresh falls back to scanning when the transcript is filed under a different slug', async () => { const { fileService, artifacts } = setup(); - // Same session id, but under a project dir that isn't this cwd's slug: a scan - // would wrongly pick it up; a computed path does not. - await write(fileService, joinPath(USER_HOME, '.claude', 'projects', '-some-other-proj', `${SESSION_ID}.jsonl`)); - assert.deepStrictEqual(await artifacts.refresh(CWD), []); + // A symlinked/renamed cwd (or an encoder mismatch) can land the transcript + // under a project dir that isn't this cwd's slug: the direct path misses, so + // the scan fallback still finds it by session id. + const transcript = joinPath(USER_HOME, '.claude', 'projects', '-some-other-proj', `${SESSION_ID}.jsonl`); + await write(fileService, transcript); + assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }]); }); test('refresh surfaces the transcript alone when no debug log has been written yet', async () => { From e7a7618760bbcb75193204bdade01edecda836ca Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Fri, 24 Jul 2026 19:14:55 -0700 Subject: [PATCH 7/7] Host-encode artifact file URIs; cap oversized remote reads Two remote-path review comments: - A Windows agent host advertised raw fsPaths like `C:\Users\...`, which the client cannot wrap (`URI.from`/`URI.file` mangle a foreign drive/UNC path). The host now encodes each artifact as a `file://` URI string (it owns its own path semantics); the client just parses it and, for remote, wraps it for the connection authority. Renamed the IDebugArtifact field `path` -> `uri`. - The AHP filesystem provider has no ranged read, so `readFileStream({length})` still fetched the whole remote file into the renderer (then re-buffered it). Cap by stat size in createDebugLogFile: above 50 MiB, export a short note instead of slurping a runaway `--debug` log. Co-Authored-By: Claude Opus 4.8 --- .../agentHost/common/agentHostLogNaming.ts | 23 +++++----- .../agentHost/common/state/sessionState.ts | 17 +++---- .../node/claude/claudeDebugArtifacts.ts | 44 +++++++++--------- .../test/common/agentHostLogNaming.test.ts | 8 ++-- .../common/sessionDebugArtifactsMeta.test.ts | 12 ++--- .../test/node/claudeDebugArtifacts.test.ts | 14 +++--- .../actions/exportAgentHostDebugLogsAction.ts | 45 ++++++++++++------- 7 files changed, 89 insertions(+), 74 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostLogNaming.ts b/src/vs/platform/agentHost/common/agentHostLogNaming.ts index 7e4a4cad3c607c..8f209faf48b228 100644 --- a/src/vs/platform/agentHost/common/agentHostLogNaming.ts +++ b/src/vs/platform/agentHost/common/agentHostLogNaming.ts @@ -71,20 +71,21 @@ export const CLAUDE_TRANSCRIPT_LABEL = 'Claude transcript'; /** * Build the advertised {@link IDebugArtifact} list for a Claude session from its - * on-disk debug-log paths plus an optional transcript path. A pure projection, - * so the labeling/numbering is unit-testable without any session state: logs are - * sorted lexically (their file names embed the timestamp, so this is - * chronological) and numbered only when there is more than one — keeping the - * single-log common case clean while ensuring unique export file names. + * on-disk debug-log URIs plus an optional transcript URI (each a host-encoded + * `file://` string). A pure projection, so the labeling/numbering is unit-testable + * without any session state: logs are sorted lexically (their file names embed the + * timestamp, so this is chronological) and numbered only when there is more than + * one — keeping the single-log common case clean while ensuring unique export + * file names. */ -export function buildClaudeDebugArtifacts(logPaths: readonly string[], transcriptPath: string | undefined): IDebugArtifact[] { - const sorted = [...logPaths].sort(); - const artifacts: IDebugArtifact[] = sorted.map((path, i) => ({ +export function buildClaudeDebugArtifacts(logUris: readonly string[], transcriptUri: string | undefined): IDebugArtifact[] { + const sorted = [...logUris].sort(); + const artifacts: IDebugArtifact[] = sorted.map((uri, i) => ({ label: sorted.length > 1 ? `${CLAUDE_DEBUG_LOG_LABEL} ${i + 1}` : CLAUDE_DEBUG_LOG_LABEL, - path, + uri, })); - if (transcriptPath) { - artifacts.push({ label: CLAUDE_TRANSCRIPT_LABEL, path: transcriptPath }); + if (transcriptUri) { + artifacts.push({ label: CLAUDE_TRANSCRIPT_LABEL, uri: transcriptUri }); } return artifacts; } diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 53f8c6ce33288f..5d6d51a68208d1 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1237,21 +1237,22 @@ export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, git /** * A single debug artifact the host advertises for a session, carried under * {@link SessionMeta} at {@link SESSION_META_DEBUG_ARTIFACTS_KEY}. The - * {@link path} is an absolute filesystem path **on the agent-host machine** - * (local host or remote); the client resolves it to a readable resource - * (`file://` locally, `vscode-agent-host://` remotely) via the resource proxy. + * {@link uri} is a serialized `file://` URI **encoded on the agent-host machine** + * (so drive letters, UNC roots and separators are the host's, not the client's); + * the client parses it and resolves it to a readable resource (`file://` locally, + * `vscode-agent-host://` remotely) via the resource proxy. */ export interface IDebugArtifact { /** Human-readable label, used to derive the file name in the export. */ readonly label: string; - /** Absolute filesystem path of the artifact on the agent-host machine. */ - readonly path: string; + /** Serialized `file://` URI of the artifact, encoded on the agent-host machine. */ + readonly uri: string; } /** * Reads the well-known debug-artifacts payload from a {@link SessionMeta} bag. * Returns `undefined` when the key is absent or not an array; entries missing a - * string `label`/`path` are dropped so partial state still propagates. + * string `label`/`uri` are dropped so partial state still propagates. */ export function readSessionDebugArtifacts(meta: SessionMeta | undefined): IDebugArtifact[] | undefined { const value = meta?.[SESSION_META_DEBUG_ARTIFACTS_KEY]; @@ -1262,8 +1263,8 @@ export function readSessionDebugArtifacts(meta: SessionMeta | undefined): IDebug for (const entry of value) { if (entry && typeof entry === 'object' && !Array.isArray(entry)) { const raw = entry as Record; - if (typeof raw['label'] === 'string' && typeof raw['path'] === 'string') { - artifacts.push({ label: raw['label'], path: raw['path'] }); + if (typeof raw['label'] === 'string' && typeof raw['uri'] === 'string') { + artifacts.push({ label: raw['label'], uri: raw['uri'] }); } } } diff --git a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts index d4086b24a4d26f..9c220a25d2ba44 100644 --- a/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts +++ b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts @@ -58,17 +58,19 @@ export class ClaudeDebugArtifacts { /** * Read the two on-disk locations the host/SDK own for this session and return * the projected artifact set via the pure, unit-tested - * {@link buildClaudeDebugArtifacts}. `cwd` is the session's working directory, - * used to address the transcript directly. Stateless: the truth lives on disk, - * so callers just re-read whenever the set might have changed. + * {@link buildClaudeDebugArtifacts}. Each artifact carries a host-encoded + * `file://` URI string (so the client can wrap it for a remote host without + * knowing the host's platform). `cwd` is the session's working directory, used + * to address the transcript directly. Stateless: the truth lives on disk, so + * callers just re-read whenever the set might have changed. */ async refresh(cwd: URI): Promise { - const [logPaths, transcriptPath] = await Promise.all([this._readDebugLogPaths(), this._readTranscriptPath(cwd)]); - return buildClaudeDebugArtifacts(logPaths, transcriptPath); + const [logUris, transcriptUri] = await Promise.all([this._readDebugLogUris(), this._readTranscriptUri(cwd)]); + return buildClaudeDebugArtifacts(logUris, transcriptUri); } - /** This session's debug-log files (current + prior runs). The host owns `/claude/`, so scanning it is not cross-component path-guessing. */ - private async _readDebugLogPaths(): Promise { + /** This session's debug-log files (current + prior runs) as `file://` URI strings. The host owns `/claude/`, so scanning it is not cross-component path-guessing. */ + private async _readDebugLogUris(): Promise { // Match the exact `claude--.log` shape emitted by // buildClaudeDebugFilePath. A substring `includes(token)` check would also // match another session whose id merely contains this token (e.g. `sess-abc` @@ -78,27 +80,27 @@ export class ClaudeDebugArtifacts { const suffix = `-${claudeDebugLogSessionToken(this._sessionId)}.log`; try { const stat = await this._fileService.resolve(joinPath(this._logsHome, CLAUDE_LOG_DIR)); - return (stat.children ?? []).filter(c => !c.isDirectory && c.name.startsWith('claude-') && c.name.endsWith(suffix)).map(c => c.resource.fsPath); + return (stat.children ?? []).filter(c => !c.isDirectory && c.name.startsWith('claude-') && c.name.endsWith(suffix)).map(c => c.resource.toString()); } catch { return []; // /claude may not exist yet. } } /** - * The SDK session transcript JSONL, once written. The CLI stores it at a - * deterministic `~/.claude/projects//.jsonl` derived from the - * cwd (see {@link buildClaudeTranscriptPath}), so the fast path is a single - * `stat`. The slug is only a best-effort match, though — a symlinked cwd, an - * unusual path char the encoder gets wrong, or a CLI version change all make it - * miss — so on a miss we fall back to scanning the project dirs for this + * The SDK session transcript JSONL (a `file://` URI string), once written. The + * CLI stores it at a deterministic `~/.claude/projects//.jsonl` + * derived from the cwd (see {@link buildClaudeTranscriptPath}), so the fast path + * is a single `stat`. The slug is only a best-effort match, though — a symlinked + * cwd, an unusual path char the encoder gets wrong, or a CLI version change all + * make it miss — so on a miss we fall back to scanning the project dirs for this * session's file (newest first). `undefined` until the first turn writes it. */ - private async _readTranscriptPath(cwd: URI): Promise { + private async _readTranscriptUri(cwd: URI): Promise { const direct = await this._resolveTranscriptFile(buildClaudeTranscriptPath(this._userHome, cwd.fsPath, this._sessionId)); if (direct) { - return direct.path; + return direct.uri; } - let best: { path: string; mtime: number } | undefined; + let best: { uri: string; mtime: number } | undefined; try { const stat = await this._fileService.resolve(joinPath(this._userHome, '.claude', 'projects')); for (const child of stat.children ?? []) { @@ -113,14 +115,14 @@ export class ClaudeDebugArtifacts { } catch { // ~/.claude/projects may not exist yet. } - return best?.path; + return best?.uri; } - /** Resolve a candidate transcript file to `{ path, mtime }`, or `undefined` if it is absent or a directory. */ - private async _resolveTranscriptFile(file: URI): Promise<{ path: string; mtime: number } | undefined> { + /** Resolve a candidate transcript file to `{ uri, mtime }` (a `file://` URI string), or `undefined` if it is absent or a directory. */ + private async _resolveTranscriptFile(file: URI): Promise<{ uri: string; mtime: number } | undefined> { try { const meta = await this._fileService.resolve(file, { resolveMetadata: true }); - return meta.isDirectory ? undefined : { path: meta.resource.fsPath, mtime: meta.mtime ?? 0 }; + return meta.isDirectory ? undefined : { uri: meta.resource.toString(), mtime: meta.mtime ?? 0 }; } catch { return undefined; } diff --git a/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts index de90f0a3883c13..545017c48a4327 100644 --- a/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts @@ -44,14 +44,14 @@ suite('agentHostLogNaming', () => { test('buildClaudeDebugArtifacts: unnumbered lone log, sorted+numbered multiples, transcript appended last', () => { assert.deepStrictEqual(buildClaudeDebugArtifacts([], undefined), []); assert.deepStrictEqual(buildClaudeDebugArtifacts(['/logs/claude/claude-b.log'], undefined), [ - { label: CLAUDE_DEBUG_LOG_LABEL, path: '/logs/claude/claude-b.log' }, + { label: CLAUDE_DEBUG_LOG_LABEL, uri: '/logs/claude/claude-b.log' }, ]); assert.deepStrictEqual( buildClaudeDebugArtifacts(['/logs/claude/claude-b.log', '/logs/claude/claude-a.log'], '/home/.claude/projects/p/s.jsonl'), [ - { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, path: '/logs/claude/claude-a.log' }, - { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, path: '/logs/claude/claude-b.log' }, - { label: CLAUDE_TRANSCRIPT_LABEL, path: '/home/.claude/projects/p/s.jsonl' }, + { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, uri: '/logs/claude/claude-a.log' }, + { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, uri: '/logs/claude/claude-b.log' }, + { label: CLAUDE_TRANSCRIPT_LABEL, uri: '/home/.claude/projects/p/s.jsonl' }, ], ); }); diff --git a/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts b/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts index 0a78a60f994f7b..ab9db31c4f4fa4 100644 --- a/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts +++ b/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts @@ -15,22 +15,22 @@ suite('Session debug-artifacts meta', () => { assert.strictEqual(readSessionDebugArtifacts(undefined), undefined); assert.strictEqual(readSessionDebugArtifacts({}), undefined); assert.strictEqual(readSessionDebugArtifacts({ [SESSION_META_DEBUG_ARTIFACTS_KEY]: 'nope' }), undefined); - // Entries missing a string label/path are dropped; well-formed ones survive. + // Entries missing a string label/uri are dropped; well-formed ones survive. assert.deepStrictEqual( readSessionDebugArtifacts({ [SESSION_META_DEBUG_ARTIFACTS_KEY]: [ - { label: 'debug', path: '/logs/claude/x.log' }, - { label: 'no path' }, - { path: '/no/label' }, + { label: 'debug', uri: '/logs/claude/x.log' }, + { label: 'no uri' }, + { uri: '/no/label' }, 'garbage', ], }), - [{ label: 'debug', path: '/logs/claude/x.log' }], + [{ label: 'debug', uri: '/logs/claude/x.log' }], ); }); test('withSessionDebugArtifacts round-trips artifacts and preserves other slots', () => { - const artifacts = [{ label: 'Claude debug log', path: '/logs/claude/a.log' }, { label: 'Claude transcript', path: '/home/.claude/projects/p/s.jsonl' }]; + const artifacts = [{ label: 'Claude debug log', uri: '/logs/claude/a.log' }, { label: 'Claude transcript', uri: '/home/.claude/projects/p/s.jsonl' }]; const withOther = withSessionGitHubState(undefined, { owner: 'octo' }); const tagged = withSessionDebugArtifacts(withOther, artifacts); diff --git a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts index 44ca1e95689d71..2e0ecd6a29570a 100644 --- a/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts @@ -53,7 +53,7 @@ suite('ClaudeDebugArtifacts', () => { // (exact suffix, not substring) — debug logs can hold sensitive content. await write(fileService, joinPath(logDir, `claude-2026-01-01T00-00-00-000Z-${SESSION_ID}-extra.log`)); await write(fileService, joinPath(logDir, `${SESSION_ID}.txt`)); - assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_DEBUG_LOG_LABEL, path: mine.fsPath }]); + assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_DEBUG_LOG_LABEL, uri: mine.toString() }]); }); test('refresh numbers multiple runs in chronological (lexical) order', async () => { @@ -63,8 +63,8 @@ suite('ClaudeDebugArtifacts', () => { await write(fileService, newer); await write(fileService, older); assert.deepStrictEqual(await artifacts.refresh(CWD), [ - { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, path: older.fsPath }, - { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, path: newer.fsPath }, + { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, uri: older.toString() }, + { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, uri: newer.toString() }, ]); }); @@ -75,8 +75,8 @@ suite('ClaudeDebugArtifacts', () => { await write(fileService, log); await write(fileService, transcript); assert.deepStrictEqual(await artifacts.refresh(CWD), [ - { label: CLAUDE_DEBUG_LOG_LABEL, path: log.fsPath }, - { label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }, + { label: CLAUDE_DEBUG_LOG_LABEL, uri: log.toString() }, + { label: CLAUDE_TRANSCRIPT_LABEL, uri: transcript.toString() }, ]); }); @@ -87,14 +87,14 @@ suite('ClaudeDebugArtifacts', () => { // the scan fallback still finds it by session id. const transcript = joinPath(USER_HOME, '.claude', 'projects', '-some-other-proj', `${SESSION_ID}.jsonl`); await write(fileService, transcript); - assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }]); + assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_TRANSCRIPT_LABEL, uri: transcript.toString() }]); }); test('refresh surfaces the transcript alone when no debug log has been written yet', async () => { const { fileService, artifacts } = setup(); const transcript = joinPath(transcriptDir, `${SESSION_ID}.jsonl`); await write(fileService, transcript); - assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_TRANSCRIPT_LABEL, path: transcript.fsPath }]); + assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_TRANSCRIPT_LABEL, uri: transcript.toString() }]); }); test('prepareDebugFile creates the log directory and returns this session\'s per-run path', async () => { diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index 7175a5aac8f847..d2f20afed670b6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer, streamToBuffer } from '../../../../../base/common/buffer.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../base/common/network.js'; import { extname, joinPath } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -57,10 +57,10 @@ export interface IActiveAgentHostSessionForExport { readonly isLocal: boolean; /** * Debug artifacts the host advertised for this session via session `_meta` - * (`agentHost.debugArtifacts`) — host-local absolute paths to provider log / - * transcript files. The export resolves each through the resource proxy - * instead of re-deriving provider-specific on-disk layouts. Omitted when the - * caller can't read the session's `_meta` (e.g. the workbench-side action). + * (`agentHost.debugArtifacts`) — host-encoded `file://` URIs of provider log / + * transcript files. The export parses each and resolves it through the resource + * proxy instead of re-deriving provider-specific on-disk layouts. Omitted when + * the caller can't read the session's `_meta` (e.g. the workbench-side action). */ readonly debugArtifacts?: readonly IDebugArtifact[]; } @@ -234,13 +234,14 @@ export async function collectAgentHostDebugLogs( } const artifactAuthority = remoteConnection ? agentHostAuthority(remoteConnection.address) : undefined; for (const artifact of debugArtifacts ?? []) { - // `artifact.path` is an absolute path on the agent-host machine. Locally - // that's this platform (`URI.file`); remotely it's the remote platform, so - // preserve it verbatim via `URI.from` rather than let `URI.file` apply the - // client's separator/drive rules to a foreign path. + // `artifact.uri` is a `file://` URI encoded on the agent-host machine, so it + // already carries the host's drive/UNC/separator form — parse it as-is (a + // Windows host's `C:\…` would break a client-side `URI.file`). Locally it is + // the resource directly; remotely we wrap it for the connection's authority. + const hostUri = URI.parse(artifact.uri); const resource = activeSession?.isLocal - ? URI.file(artifact.path) - : artifactAuthority ? toAgentHostUri(URI.from({ scheme: Schemas.file, path: artifact.path }), artifactAuthority) : undefined; + ? hostUri + : artifactAuthority ? toAgentHostUri(hostUri, artifactAuthority) : undefined; if (!resource) { continue; } @@ -425,17 +426,27 @@ async function exportFilesToLocalFolder( return true; } +/** + * Cap for a single remote artifact read. The AHP filesystem provider has no + * ranged read, so a remote file is fetched whole into the renderer (and buffered + * again as a string); a runaway `--debug` log could exhaust memory. Above this we + * export a short note instead of the file. + */ +const MAX_REMOTE_ARTIFACT_BYTES = 50 * 1024 * 1024; + async function createDebugLogFile(path: string, resource: URI, fileService: IFileService, size?: number): Promise { if (resource.scheme === Schemas.file) { const observedSize = size ?? (await fileService.resolve(resource, { resolveMetadata: true })).size; return { path, resource, size: observedSize }; } - // Non-local resources (e.g. remote agent-host logs) can't be streamed from - // disk, so read them inline, bounded to the captured size when known. - if (size !== undefined) { - const stream = await fileService.readFileStream(resource, { length: size }); - const content = await streamToBuffer(stream.value); - return { path, contents: content.toString() }; + // Non-local resources (remote agent-host logs) can't be streamed from disk, and + // the AHP proxy has no ranged read — `readFile` fetches the whole file into the + // renderer — so cap by the stat size: above the limit, export a note rather than + // slurp (and re-buffer) a huge log. `length` on `readFileStream` would not help; + // the provider fetches everything regardless. + const observedSize = size ?? (await fileService.resolve(resource, { resolveMetadata: true })).size; + if (observedSize > MAX_REMOTE_ARTIFACT_BYTES) { + return { path, contents: `[skipped] ${path} is ${observedSize} bytes, over the ${MAX_REMOTE_ARTIFACT_BYTES}-byte remote export cap. Retrieve it directly from the agent host.` }; } const content = await fileService.readFile(resource); return { path, contents: content.value.toString() };