Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,19 @@ export interface IAgent {
*/
getChats?(session: URI): Promise<readonly URI[]>;

/**
* 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<string | undefined>;

/**
* Called when the session's pending (steering) message changes.
* The agent harness decides how to react — e.g. inject steering
Expand Down
63 changes: 63 additions & 0 deletions src/vs/platform/agentHost/common/state/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,69 @@ 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.
*
* 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<string, string> | undefined {
const value = meta?.[SESSION_META_CHAT_SESSION_IDS_KEY];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const result: Record<string, string> = {};
for (const [chatId, sessionId] of Object.entries(value as Record<string, unknown>)) {
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<string, string> = { ...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 ---------------------------------------------

/**
Expand Down
43 changes: 42 additions & 1 deletion src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
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);
}

Expand Down Expand Up @@ -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<void> {
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. */
Expand Down
24 changes: 24 additions & 0 deletions src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,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/<id>` 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<string | undefined> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,6 +37,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla
import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.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 IChatDefaultConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js';
Expand Down Expand Up @@ -760,6 +761,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;
Expand Down Expand Up @@ -2593,6 +2605,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 -----------------------------

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,37 @@ 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 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
? {
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;

await exportAgentHostDebugLogs(accessor, activeSessionContext);
}
}

function isAgentHostSession(session: ISession | undefined, sessionsProvidersService: ISessionsProvidersService): session is ISession {
function isAgentHostSession<T extends ISession>(session: T | undefined, sessionsProvidersService: ISessionsProvidersService): session is T {
return !!session && sessionsProvidersService.getProvider(session.providerId) instanceof BaseAgentHostSessionsProvider;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,23 @@ 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';

/**
* 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.
* 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
Expand All @@ -40,7 +50,18 @@ export class OpenSessionEventsFileAction extends Action2 {

override async run(accessor: ServicesAccessor): Promise<void> {
const sessionsService = accessor.get(ISessionsService);
const sessionResource = sessionsService.activeSession.get()?.resource;
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
const activeSession = sessionsService.activeSession.get();
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);
}
}
Loading
Loading