diff --git a/src/vs/platform/agentHost/common/agentHostLogNaming.ts b/src/vs/platform/agentHost/common/agentHostLogNaming.ts new file mode 100644 index 00000000000000..8f209faf48b228 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostLogNaming.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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(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, + uri, + })); + if (transcriptUri) { + artifacts.push({ label: CLAUDE_TRANSCRIPT_LABEL, uri: transcriptUri }); + } + 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` 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-Z]):/, (_, drive: string) => `${drive.toUpperCase()}:`).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/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..5d6d51a68208d1 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,58 @@ 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 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; + /** 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`/`uri` 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['uri'] === 'string') { + artifacts.push({ label: raw['label'], uri: raw['uri'] }); + } + } + } + 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..ca18bb61b2cfe3 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -8,6 +8,7 @@ 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 { 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 +25,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 +351,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 +369,35 @@ export class ClaudeAgentSession extends Disposable { this._customizationWatcher.add(watcher.onDidChange(() => this._onDidCustomizationsChange.fire())); } + /** + * 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; + + /** + * 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 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)); + } + /** * One-shot SDK assistant-message uuid that the next materialize / rebuild * resumes *up to and including* (the SDK's `Options.resumeSessionAt`). @@ -446,6 +478,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 +494,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 +530,14 @@ export class ClaudeAgentSession extends Disposable { } this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithMcpContributor(this._enrichSignalWithCredits(s))))); this._pipeline = pipeline; + // 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. @@ -543,6 +585,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 +601,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 +613,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 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 new file mode 100644 index 00000000000000..9c220a25d2ba44 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeDebugArtifacts.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IFileService } from '../../../files/common/files.js'; +import { ILogService } from '../../../log/common/log.js'; +import { buildClaudeDebugArtifacts, buildClaudeDebugFilePath, buildClaudeTranscriptPath, CLAUDE_LOG_DIR, claudeDebugLogSessionToken, toFileTimestamp } from '../../common/agentHostLogNaming.js'; +import { type IDebugArtifact } from '../../common/state/sessionState.js'; + +/** + * 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}. + * + * 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 { + + 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; + } + } + + /** + * 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}. 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 [logUris, transcriptUri] = await Promise.all([this._readDebugLogUris(), this._readTranscriptUri(cwd)]); + return buildClaudeDebugArtifacts(logUris, transcriptUri); + } + + /** 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` + // 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.startsWith('claude-') && c.name.endsWith(suffix)).map(c => c.resource.toString()); + } catch { + return []; // /claude may not exist yet. + } + } + + /** + * 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 _readTranscriptUri(cwd: URI): Promise { + const direct = await this._resolveTranscriptFile(buildClaudeTranscriptPath(this._userHome, cwd.fsPath, this._sessionId)); + if (direct) { + return direct.uri; + } + 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 ?? []) { + 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 { + // ~/.claude/projects may not exist yet. + } + return best?.uri; + } + + /** 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 : { uri: meta.resource.toString(), mtime: meta.mtime ?? 0 }; + } catch { + return undefined; + } + } +} 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..545017c48a4327 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostLogNaming.test.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * 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, buildClaudeTranscriptPath, CLAUDE_DEBUG_LOG_LABEL, CLAUDE_TRANSCRIPT_LABEL, claudeDebugLogSessionToken, claudeProjectSlug, 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, 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`, 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' }, + ], + ); + }); + + test('claudeProjectSlug + buildClaudeTranscriptPath encode the cwd like the CLI', () => { + assert.deepStrictEqual( + { + 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/common/sessionDebugArtifactsMeta.test.ts b/src/vs/platform/agentHost/test/common/sessionDebugArtifactsMeta.test.ts new file mode 100644 index 00000000000000..ab9db31c4f4fa4 --- /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/uri are dropped; well-formed ones survive. + assert.deepStrictEqual( + readSessionDebugArtifacts({ + [SESSION_META_DEBUG_ARTIFACTS_KEY]: [ + { label: 'debug', uri: '/logs/claude/x.log' }, + { label: 'no uri' }, + { uri: '/no/label' }, + 'garbage', + ], + }), + [{ label: 'debug', uri: '/logs/claude/x.log' }], + ); + }); + + test('withSessionDebugArtifacts round-trips artifacts and preserves other slots', () => { + 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); + + 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..2e0ecd6a29570a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/claudeDebugArtifacts.test.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * 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 CWD = URI.file('/work/my-proj'); + const SESSION_ID = 'sess-abc'; + + const logDir = joinPath(LOGS_HOME, 'claude'); + // 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())); + 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 returns nothing when neither location exists on disk', async () => { + const { artifacts } = setup(); + assert.deepStrictEqual(await artifacts.refresh(CWD), []); + }); + + 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')); + // 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`)); + assert.deepStrictEqual(await artifacts.refresh(CWD), [{ label: CLAUDE_DEBUG_LOG_LABEL, uri: mine.toString() }]); + }); + + 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); + assert.deepStrictEqual(await artifacts.refresh(CWD), [ + { label: `${CLAUDE_DEBUG_LOG_LABEL} 1`, uri: older.toString() }, + { label: `${CLAUDE_DEBUG_LOG_LABEL} 2`, uri: newer.toString() }, + ]); + }); + + 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(transcriptDir, `${SESSION_ID}.jsonl`); + await write(fileService, log); + await write(fileService, transcript); + assert.deepStrictEqual(await artifacts.refresh(CWD), [ + { label: CLAUDE_DEBUG_LOG_LABEL, uri: log.toString() }, + { label: CLAUDE_TRANSCRIPT_LABEL, uri: transcript.toString() }, + ]); + }); + + test('refresh falls back to scanning when the transcript is filed under a different slug', async () => { + const { fileService, artifacts } = setup(); + // 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, 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, uri: transcript.toString() }]); + }); + + 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..d2f20afed670b6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -3,15 +3,15 @@ * 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 { 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'; @@ -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-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[]; } export type IAgentHostDebugLogFile = @@ -192,6 +203,59 @@ 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. + // + // 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) { + // The local host and a remote connection both expose `listSessions()`; pick + // 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; + 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; + for (const artifact of debugArtifacts ?? []) { + // `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 + ? hostUri + : artifactAuthority ? toAgentHostUri(hostUri, 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`. @@ -292,21 +356,25 @@ export class ExportAgentHostDebugLogsAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const chatWidgetService = accessor.get(IChatWidgetService); - const widget = chatWidgetService.lastFocusedWidget; - const model = widget?.viewModel?.model; + const model = chatWidgetService.lastFocusedWidget?.viewModel?.model; 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); } } /** - * 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)) { @@ -358,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() }; 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 }; } }