From 7f74aa5574b87ecbff9c827e03d2c6dce947c48e Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 20:44:23 -0700 Subject: [PATCH 1/3] sessions: resolve focused chat for Copilot CLI state file and debug log export The "Open Copilot CLI State File" and "Export Agent Host Debug Logs" actions resolved the session's main chat regardless of which chat tab was focused. Since the events file is per-chat, use the focused chat (IActiveSession.activeChat) when resolving the events.jsonl resource. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/browser/exportDebugLogsAction.ts | 15 ++++++++++----- .../browser/openSessionEventsFileActions.ts | 12 ++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts index 60ddb06ca43120..182ce6defdf47b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts @@ -44,11 +44,16 @@ export class ExportAgentHostDebugLogsAction extends Action2 { const activeAgentHostSession = isAgentHostSession(activeSession, sessionsProvidersService) ? activeSession : undefined; const sessionForEvents = activeAgentHostSession ?? getMostRecentAgentHostSession(sessionsManagementService.getSessions(), sessionsProvidersService); - const activeSessionContext: IActiveAgentHostSessionForExport | undefined = sessionForEvents + // Sessions can contain multiple chats and the events file is per-chat, + // so prefer the focused chat tab's resource for the active session. + const focusedChat = activeAgentHostSession?.activeChat.get(); + const eventsResource = focusedChat?.resource ?? sessionForEvents?.resource; + + const activeSessionContext: IActiveAgentHostSessionForExport | undefined = sessionForEvents && eventsResource ? { - resource: sessionForEvents.resource, - title: activeAgentHostSession?.title.get(), - isLocal: sessionForEvents.resource.scheme.startsWith('agent-host-'), + resource: eventsResource, + title: focusedChat?.title.get() ?? activeAgentHostSession?.title.get(), + isLocal: eventsResource.scheme.startsWith('agent-host-'), } : undefined; @@ -56,7 +61,7 @@ export class ExportAgentHostDebugLogsAction extends Action2 { } } -function isAgentHostSession(session: ISession | undefined, sessionsProvidersService: ISessionsProvidersService): session is ISession { +function isAgentHostSession(session: T | undefined, sessionsProvidersService: ISessionsProvidersService): session is T { return !!session && sessionsProvidersService.getProvider(session.providerId) instanceof BaseAgentHostSessionsProvider; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts index 4b3c7c06ba0177..fda3b25654e45d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts @@ -15,9 +15,12 @@ import { IsAgentHostSession } from './agentHostSkillButtons.js'; /** * Sessions-app variant of "Open Copilot CLI State File". Uses the Agents - * window's `ISessionsService.activeSession` to find the active - * Copilot CLI session, then defers to the shared workbench helper for - * the actual resolution and editor opening. + * window's `ISessionsService.activeSession` to find the focused chat tab + * of the active Copilot CLI session, then defers to the shared workbench + * helper for the actual resolution and editor opening. + * + * Sessions can contain multiple chats; the state file is per-chat, so we + * resolve the focused chat's resource rather than the session's main chat. * * The vscode workbench registers a separate action class * (`OpenCopilotCliStateFileAction` in @@ -40,7 +43,8 @@ export class OpenSessionEventsFileAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const sessionsService = accessor.get(ISessionsService); - const sessionResource = sessionsService.activeSession.get()?.resource; + const activeSession = sessionsService.activeSession.get(); + const sessionResource = activeSession?.activeChat.get()?.resource ?? activeSession?.resource; await openCopilotCliStateFile(accessor, sessionResource); } } From 13cd11a5e5b87c0101027f99b4bf6484e824c9b3 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 23:00:07 -0700 Subject: [PATCH 2/3] sessions: resolve Copilot CLI state file / debug logs per focused chat's sdk session The "Open Copilot CLI State File" and "Export Agent Host Debug Logs" actions resolved events.jsonl from the chat resource's path id. For peer chats that path id is the main session id, not the peer chat's backing Copilot SDK session id, so they opened/exported the wrong file (and export filtered the shared ~/.copilot/logs by the wrong id). The host now reports each peer chat's backing sdk session id to the renderer under SessionState._meta (keyed by AHP chat id), the same carrier already used for git state. The front end maps the focused chat to its sdk id and reuses the existing resolveEventsUri / getCopilotCliSessionRawId pipeline by swapping the resource path to the sdk id. The default chat needs no entry (its path id already names its logs) and falls back to the chat resource. - common/state/sessionState.ts: readChatSessionIds / withChatSessionId accessors - common/agentService.ts: optional IAgent.getChatSessionId - node/copilot/copilotAgent.ts: implement getChatSessionId (in-mem + persisted) - node/agentService.ts: publish sdk id into _meta on create/restore, clean up on dispose - browser/baseAgentHostSessionsProvider.ts: getChatSdkSessionId / getChatSdkSessionResource - copilotCliEventsUri.ts: buildSdkSessionResource helper - sessions actions: resolve focused chat -> sdk resource, fall back to chat resource Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/agentService.ts | 13 ++++ .../agentHost/common/state/sessionState.ts | 59 +++++++++++++++++++ .../platform/agentHost/node/agentService.ts | 43 +++++++++++++- .../agentHost/node/copilot/copilotAgent.ts | 24 ++++++++ .../browser/baseAgentHostSessionsProvider.ts | 39 +++++++++++- .../browser/exportDebugLogsAction.ts | 15 ++++- .../browser/openSessionEventsFileActions.ts | 19 +++++- .../browser/openSessionEventsFile.test.ts | 40 ++++++++++++- .../chat/browser/copilotCliEventsUri.ts | 12 ++++ 9 files changed, 259 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 055e77bf7badd0..a821111b1acac8 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -894,6 +894,19 @@ export interface IAgent { */ getChats?(session: URI): Promise; + /** + * Resolves the backing agent-session id for a chat — the id that names the + * chat's on-disk log / session-state directory. For the default chat this + * is the owning session's id; for a peer chat it is the harness's + * per-chat session id, which differs from the chat's `ahp-chat` URI. + * + * Lets the agent service publish the id under + * {@link SessionState._meta} so clients can locate a specific chat's logs. + * Optional: harnesses that name every chat's logs by the session id (so the + * client can derive it from the chat resource) omit it. + */ + getChatSessionId?(session: URI, chat: URI): Promise; + /** * Called when the session's pending (steering) message changes. * The agent harness decides how to react — e.g. inject steering diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 2470806750f269..41bc621be42efb 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -766,6 +766,65 @@ export function withSessionGitState(meta: SessionMeta | undefined, gitState: ISe return Object.keys(next).length > 0 ? next : undefined; } +/** + * Reserved key under {@link SessionMeta} for the map of each chat's backing + * agent-session id (the id that names the chat's on-disk log directory). + * + * A peer chat is addressed by an `ahp-chat` URI whose path is the *owning + * session's* id and whose fragment is the AHP chat id — neither of which is + * the id the harness uses to name the chat's own log/session-state directory. + * Hosts that back chats by a distinct per-chat session id (e.g. the Copilot + * CLI harness) publish that id here, keyed by AHP chat id, so clients can + * locate a specific chat's logs without reconstructing host-private paths. + * + * The default chat is omitted: its log directory is already named by the + * owning session id, which clients can read straight off the chat resource. + */ +export const SESSION_META_CHAT_SESSION_IDS_KEY = 'chatSessionIds'; + +/** + * Reads the {@link SESSION_META_CHAT_SESSION_IDS_KEY} map from + * {@link SessionMeta}: AHP chat id → backing agent-session id. Returns + * `undefined` when absent or malformed; entries with a non-string value are + * dropped so a partial map still propagates. + */ +export function readChatSessionIds(meta: SessionMeta | undefined): Record | undefined { + const value = meta?.[SESSION_META_CHAT_SESSION_IDS_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const result: Record = {}; + for (const [chatId, sessionId] of Object.entries(value as Record)) { + if (typeof sessionId === 'string') { + result[chatId] = sessionId; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Returns a new {@link SessionMeta} with `chatId` mapped to `sessionId` under + * {@link SESSION_META_CHAT_SESSION_IDS_KEY} (or with the entry removed when + * `sessionId` is `undefined`). Existing entries for other chats are preserved. + * Returns `undefined` if the result would be empty. + */ +export function withChatSessionId(meta: SessionMeta | undefined, chatId: string, sessionId: string | undefined): SessionMeta | undefined { + const existing = readChatSessionIds(meta); + const map: Record = { ...existing }; + if (sessionId !== undefined) { + map[chatId] = sessionId; + } else { + delete map[chatId]; + } + const next: { [key: string]: unknown } = { ...meta }; + if (Object.keys(map).length > 0) { + next[SESSION_META_CHAT_SESSION_IDS_KEY] = map; + } else { + delete next[SESSION_META_CHAT_SESSION_IDS_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + // ---- RootState _meta accessors --------------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e6065a8e801aaf..382a5856f3d86f 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -29,7 +29,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseChatUri, parseDefaultChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readChatSessionIds, readSessionGitState, withChatSessionId, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -681,12 +681,20 @@ export class AgentService extends Disposable implements IAgentService { // subscribers once the chat can actually receive messages. await provider.createChat(session, chat, options); this._stateManager.addChat(sessionKey, chat.toString(), options?.title !== undefined ? { title: options.title } : undefined); + await this._publishChatSessionId(provider, session, chat); } async disposeChat(session: URI, chat: URI): Promise { const sessionKey = session.toString(); const provider = this._findProviderForSession(session); this._stateManager.removeChat(sessionKey, chat.toString()); + const parsed = parseChatUri(chat); + if (parsed) { + const current = this._stateManager.getSessionState(sessionKey)?._meta; + if (readChatSessionIds(current)?.[parsed.chatId] !== undefined) { + this._stateManager.setSessionMeta(sessionKey, withChatSessionId(current, parsed.chatId, undefined)); + } + } await provider?.disposeChat?.(session, chat); } @@ -1612,7 +1620,40 @@ export class AgentService extends Disposable implements IAgentService { } const title = await this._readPersistedChatTitle(session, chatUri); this._stateManager.restoreChat(session.toString(), chatUri.toString(), { title, turns: [...turns] }); + await this._publishChatSessionId(agent, session, chatUri); + } + } + + /** + * Publishes a peer chat's backing agent-session id under the owning + * session's `_meta` (keyed by AHP chat id) so clients can locate the + * chat's on-disk logs. No-op for the default chat (its logs are named by + * the session id, derivable from the chat resource) and for providers that + * do not report a distinct per-chat id. The read-modify-write of `_meta` + * is synchronous after the `getChatSessionId` await so it cannot clobber a + * concurrent git-state write. + */ + private async _publishChatSessionId(agent: IAgent, session: URI, chat: URI): Promise { + if (!agent.getChatSessionId) { + return; + } + const parsed = parseChatUri(chat); + if (!parsed) { + return; } + let sessionId: string | undefined; + try { + sessionId = await agent.getChatSessionId(session, chat); + } catch (err) { + this._logService.warn(`[AgentService] Failed to resolve chat session id for ${chat.toString()}: ${toErrorMessage(err)}`); + return; + } + if (!sessionId) { + return; + } + const sessionStr = session.toString(); + const current = this._stateManager.getSessionState(sessionStr)?._meta; + this._stateManager.setSessionMeta(sessionStr, withChatSessionId(current, parsed.chatId, sessionId)); } /** Reads a peer chat's persisted custom title, if any. */ diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 34b6ea80046105..a98d0ae7034cba 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1703,6 +1703,30 @@ export class CopilotAgent extends Disposable implements IAgent { return result; } + /** + * Resolves the backing SDK session id for a chat — the id that names the + * chat's `~/.copilot/session-state/` directory. The default chat is + * backed by the owning session's id; a peer chat is backed by a distinct + * `chatSdkId` minted in {@link createChat}, looked up from the in-memory + * session if live, otherwise from the persisted catalog. Returns + * `undefined` when a peer chat has no backing conversation. + */ + async getChatSessionId(session: URI, chat: URI): Promise { + if (isDefaultChatUri(chat)) { + return AgentSession.id(session); + } + const inMemory = this._chatSessions.get(chat.toString())?.sessionId; + if (inMemory) { + return inMemory; + } + const parsed = parseChatUri(chat); + if (!parsed) { + return undefined; + } + const persisted = await this._readPersistedChats(session); + return persisted.get(parsed.chatId)?.sdkSessionId; + } + /** * Returns the SDK-backed {@link CopilotAgentSession} for an additional peer * chat, resuming its persisted SDK conversation if it is not already in diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index bfea62dee75908..c16e461139db12 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -28,7 +28,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, AgentSelection, ChangesSummary, type ChangesetFile, 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 { AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentInfo, buildChatUri, buildDefaultChatUri, DEFAULT_CHAT_ID, isDefaultChatUri, parseChatUri, readChatSessionIds, readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ChatSummary, 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'; @@ -36,6 +36,7 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { buildSdkSessionResource } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; import { IChatSendRequestOptions, IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel, type IAgentSessionDefaultConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; @@ -759,6 +760,17 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return workspaceChanged; } + /** + * Resolves the backing agent-session id the host reported for one of this + * session's chats (keyed by AHP chat id under `_meta`). Returns `undefined` + * for the default chat or any chat the host did not report — callers fall + * back to the chat resource's own id, which already names the default + * chat's logs. + */ + getChatSdkSessionId(chatId: string): string | undefined { + return readChatSessionIds(this._meta)?.[chatId]; + } + updateChangesets(changesetsMetadata: readonly Changeset[] | undefined) { if (!changesetsMetadata) { return; @@ -2562,6 +2574,31 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } + /** + * Resolves a session-shaped resource carrying the host-reported backing + * session id for `chatResource`, so a specific chat's `events.jsonl` can be + * resolved through the same pipeline as a top-level session. Returns + * `undefined` when the chat is the default chat or the host did not report a + * distinct id — callers then fall back to `chatResource`, whose own path id + * already names the default chat's logs. + */ + getChatSdkSessionResource(chatResource: URI): URI | undefined { + const rawId = chatResource.path.startsWith('/') ? chatResource.path.substring(1) : chatResource.path; + if (!rawId) { + return undefined; + } + const cached = this._sessionCache.get(rawId); + if (!cached) { + return undefined; + } + const chatId = chatResource.fragment || DEFAULT_CHAT_ID; + const sdkSessionId = cached.getChatSdkSessionId(chatId); + if (!sdkSessionId || sdkSessionId === rawId) { + return undefined; + } + return buildSdkSessionResource(chatResource, sdkSessionId); + } + // -- Lazy session-state subscription seeding ----------------------------- /** diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts index 182ce6defdf47b..1967394c2ddd88 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts @@ -47,7 +47,20 @@ export class ExportAgentHostDebugLogsAction extends Action2 { // Sessions can contain multiple chats and the events file is per-chat, // so prefer the focused chat tab's resource for the active session. const focusedChat = activeAgentHostSession?.activeChat.get(); - const eventsResource = focusedChat?.resource ?? sessionForEvents?.resource; + const chatResource = focusedChat?.resource ?? sessionForEvents?.resource; + + // A peer chat's events.jsonl (and its shared-log lines) live under a + // host-private backing session id that the chat resource does not + // encode. Ask the provider to map the focused chat to a session-shaped + // resource carrying that id so the export collects the right files; + // fall back to the chat resource for the default chat. + let eventsResource = chatResource; + if (activeAgentHostSession && chatResource) { + const provider = sessionsProvidersService.getProvider(activeAgentHostSession.providerId); + if (provider instanceof BaseAgentHostSessionsProvider) { + eventsResource = provider.getChatSdkSessionResource(chatResource) ?? chatResource; + } + } const activeSessionContext: IActiveAgentHostSessionForExport | undefined = sessionForEvents && eventsResource ? { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts index fda3b25654e45d..66e1ab750d4064 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts @@ -11,6 +11,8 @@ import { ServicesAccessor } from '../../../../../platform/instantiation/common/i import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { openCopilotCliStateFile } from '../../../../../workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; import { IsAgentHostSession } from './agentHostSkillButtons.js'; /** @@ -21,6 +23,11 @@ import { IsAgentHostSession } from './agentHostSkillButtons.js'; * * Sessions can contain multiple chats; the state file is per-chat, so we * resolve the focused chat's resource rather than the session's main chat. + * A peer chat's `events.jsonl` lives under a host-private backing session id + * that the chat resource does not encode, so we first ask the provider to map + * the focused chat to a session-shaped resource carrying that id (falling back + * to the chat resource for the default chat, whose path id already names its + * logs). * * The vscode workbench registers a separate action class * (`OpenCopilotCliStateFileAction` in @@ -43,8 +50,18 @@ export class OpenSessionEventsFileAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const sessionsService = accessor.get(ISessionsService); + const sessionsProvidersService = accessor.get(ISessionsProvidersService); const activeSession = sessionsService.activeSession.get(); - const sessionResource = activeSession?.activeChat.get()?.resource ?? activeSession?.resource; + const focusedChatResource = activeSession?.activeChat.get()?.resource ?? activeSession?.resource; + + let sessionResource = focusedChatResource; + if (activeSession && focusedChatResource) { + const provider = sessionsProvidersService.getProvider(activeSession.providerId); + if (provider instanceof BaseAgentHostSessionsProvider) { + sessionResource = provider.getChatSdkSessionResource(focusedChatResource) ?? focusedChatResource; + } + } + await openCopilotCliStateFile(accessor, sessionResource); } } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts index 8bb8f2447a5255..0d9ee5fc135616 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts @@ -11,7 +11,8 @@ import { IRemoteAgentHostConnectionInfo, RemoteAgentHostConnectionStatus } from import { IsSessionsWindowContext } from '../../../../../../workbench/common/contextkeys.js'; import { OpenCopilotCliStateFileAction } from '../../../../../../workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.js'; import { ChatContextKeys } from '../../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { buildLocalCopilotLogsUri, buildRemoteCopilotLogsUri, getCopilotCliSessionRawId, resolveEventsUri } from '../../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; +import { buildLocalCopilotLogsUri, buildRemoteCopilotLogsUri, buildSdkSessionResource, getCopilotCliSessionRawId, resolveEventsUri } from '../../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; +import { readChatSessionIds, withChatSessionId } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IsAgentHostSession } from '../../browser/agentHostSkillButtons.js'; import { OpenSessionEventsFileAction } from '../../browser/openSessionEventsFileActions.js'; @@ -146,4 +147,41 @@ suite('openSessionEventsFile resolveEventsUri', () => { const result = resolveEventsUri(undefined, userHome, () => undefined); assert.deepStrictEqual(result, { kind: 'no-session' }); }); + + test('peer chat resolves via its backing sdk session id, not the main session path id', () => { + // A peer chat resource carries the main session id in its path and the + // AHP chat id in its fragment; neither names the chat's events.jsonl. + const peerChat = URI.from({ scheme: 'agent-host-copilotcli', path: '/main-session', fragment: 'chat-2' }); + const sdkResource = buildSdkSessionResource(peerChat, 'sdk-789'); + const result = resolveEventsUri(sdkResource, userHome, () => undefined); + assert.deepStrictEqual( + { resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { resource: 'file:///home/me/.copilot/session-state/sdk-789/events.jsonl' }, + ); + }); + + test('peer chat on a remote keeps the chat scheme so resolution stays remote', () => { + const conn = makeRemoteConn('localhost:4321', '/home/remote'); + const peerChat = URI.from({ scheme: 'remote-localhost__4321-copilotcli', path: '/main', fragment: 'chat-2' }); + const sdkResource = buildSdkSessionResource(peerChat, 'sdk-789'); + const result = resolveEventsUri(sdkResource, userHome, authority => authority === 'localhost__4321' ? conn : undefined); + assert.deepStrictEqual( + { resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { resource: 'vscode-agent-host://localhost__4321/home/remote/.copilot/session-state/sdk-789/events.jsonl?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0' }, + ); + }); + + test('chat session id _meta accessors round-trip and merge per chat', () => { + const meta1 = withChatSessionId(undefined, 'chat-2', 'sdk-789'); + const meta2 = withChatSessionId(meta1, 'chat-3', 'sdk-abc'); + assert.deepStrictEqual({ + afterFirst: readChatSessionIds(meta1), + afterSecond: readChatSessionIds(meta2), + missing: readChatSessionIds(undefined), + }, { + afterFirst: { 'chat-2': 'sdk-789' }, + afterSecond: { 'chat-2': 'sdk-789', 'chat-3': 'sdk-abc' }, + missing: undefined, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts b/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts index 3ca2fbabfb01e3..6b756f441ae97f 100644 --- a/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts +++ b/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts @@ -101,6 +101,18 @@ export function getCopilotCliSessionRawId(sessionResource: URI | undefined): str return getRawSessionId(sessionResource); } +/** + * Builds a session-shaped resource that carries `rawSessionId` in its path, + * preserving the chat resource's scheme (local/remote authority) and dropping + * the chat fragment. Used to resolve a specific chat's `events.jsonl` through + * the same {@link resolveEventsUri} pipeline as a top-level session, by + * substituting the chat's host-reported backing session id for the chat + * resource's own path id. + */ +export function buildSdkSessionResource(chatResource: URI, rawSessionId: string): URI { + return URI.from({ scheme: chatResource.scheme, path: `/${rawSessionId}` }); +} + export type ResolveEventsUriResult = | { readonly kind: 'ok'; readonly resource: URI } | { readonly kind: 'no-session' } From abb9bc0baa6e5a5d2d4fc8d8ad1c32a2bf31679d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 21 Jun 2026 17:54:18 -0700 Subject: [PATCH 3/3] agentHost: align readChatSessionIds doc with detached-meta reader convention Mirror the note added to readSessionGitState in #322304: the chat-session-id reader takes the raw SessionMeta (a detached snapshot held by the sessions provider) rather than its parent SessionState, unlike the parent-object readers introduced by the centralized typed `_meta` access convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/state/sessionState.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 28f473e97d54c5..601fb66ea51fc5 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -843,6 +843,10 @@ export const SESSION_META_CHAT_SESSION_IDS_KEY = 'chatSessionIds'; * {@link SessionMeta}: AHP chat id → backing agent-session id. Returns * `undefined` when absent or malformed; entries with a non-string value are * dropped so a partial map still propagates. + * + * Like {@link readSessionGitState}, this takes the raw {@link SessionMeta} + * value rather than its parent {@link SessionState}: the sessions provider + * stores and reads a detached meta snapshot without retaining the owning state. */ export function readChatSessionIds(meta: SessionMeta | undefined): Record | undefined { const value = meta?.[SESSION_META_CHAT_SESSION_IDS_KEY];