Skip to content
8 changes: 8 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,14 @@ export interface IAgent {
*/
disposeChat?(session: URI, chat: URI): Promise<void>;

/**
* Returns the persisted catalog of additional (non-default) peer chats for a
* session as their channel URIs. Used to re-register peer chats (and seed
* their history) when a session is restored after a process restart.
* Optional: harnesses without multi-chat persistence omit it.
*/
getChats?(session: URI): Promise<readonly URI[]>;

/**
* Called when the session's pending (steering) message changes.
* The agent harness decides how to react — e.g. inject steering
Expand Down
28 changes: 28 additions & 0 deletions src/vs/platform/agentHost/node/agentHostStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,34 @@ export class AgentHostStateManager extends Disposable {
return chatSummary;
}

/**
* Re-registers an additional (non-default) peer chat when a session is
* restored from persistent storage, seeding its {@link ChatState} with the
* supplied turns. Unlike {@link addChat} this does not snapshot the session
* title onto the default chat (the default chat's persisted title is
* restored independently) and it seeds history. The catalog entry is added
* in place so the object identity returned by {@link restoreSession} stays
* live; no {@link ActionType.SessionChatAdded} is dispatched because restore
* runs before clients subscribe.
*/
restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[] }): void {
const sessionState = this._sessionStates.get(session);
if (!sessionState) {
this._logService.warn(`[AgentHostStateManager] restoreChat for unknown session: ${session}`);
return;
}
if (sessionState.chats.some(c => c.resource === chatUri)) {
return;
}
const chatSummary: ChatSummary = {
...createDefaultChatSummary(sessionState.summary, chatUri),
title: options.title ?? '',
status: SessionStatus.Idle,
};
this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options.turns });
sessionState.chats = [...sessionState.chats, chatSummary];
}

/**
* Removes an additional chat from a session. Deletes its
* {@link ChatState}, dispatches {@link ActionType.SessionChatRemoved}, and
Expand Down
97 changes: 97 additions & 0 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export class AgentService extends Disposable implements IAgentService {
* for it.
*/
private readonly _resourceSubscribers = new ResourceMap<Set<string>>();
private readonly _restoreSessionInFlight = new Map<string, Promise<void>>();
private readonly _restoreSubagentInFlight = new Map<string, Promise<void>>();

/**
* Pending {@link _runSessionGc} timers, keyed by session URI. A timer is
Expand Down Expand Up @@ -1395,6 +1397,27 @@ export class AgentService extends Disposable implements IAgentService {
return;
}

const inFlight = this._restoreSessionInFlight.get(sessionStr);
if (inFlight) {
return inFlight;
}

const restore = this._doRestoreSession(session, sessionStr);
this._restoreSessionInFlight.set(sessionStr, restore);
try {
await restore;
} finally {
if (this._restoreSessionInFlight.get(sessionStr) === restore) {
this._restoreSessionInFlight.delete(sessionStr);
}
}
}

private async _doRestoreSession(session: URI, sessionStr: string): Promise<void> {
if (this._stateManager.getSessionState(sessionStr)) {
return;
}

const agent = this._findProviderForSession(session);
if (!agent) {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${sessionStr}`);
Expand Down Expand Up @@ -1499,6 +1522,11 @@ export class AgentService extends Disposable implements IAgentService {

this._stateManager.restoreSession(summary, [...turns]);

// Restore any additional (non-default) peer chats the provider has
// persisted for this session, seeding each with its own history and
// persisted title so they reappear after a process restart.
await this._restorePeerChats(agent, session);

const changesets = buildDefaultChangesetCatalogue(sessionStr);
this._stateManager.setSessionChangesets(sessionStr, changesets);

Expand Down Expand Up @@ -1540,6 +1568,54 @@ export class AgentService extends Disposable implements IAgentService {
this._attachGitState(session, meta.workingDirectory);
}

/**
* Restores the additional (non-default) peer chats persisted for a session.
* For each chat returned by the provider, loads its history and persisted
* title and re-registers it in the state manager so it reappears in the
* session's chat catalog after a process restart. Best-effort: a chat whose
* history fails to load is restored with no turns rather than dropped.
*/
private async _restorePeerChats(agent: IAgent, session: URI): Promise<void> {
if (!agent.getChats) {
return;
}
let chats: readonly URI[];
try {
chats = await agent.getChats(session);
} catch (err) {
this._logService.warn(`[AgentService] Failed to enumerate peer chats for ${session.toString()}: ${toErrorMessage(err)}`);
return;
}
if (chats.length === 0) {
return;
}
for (const chatUri of chats) {
let turns: readonly Turn[] = [];
try {
turns = await agent.getSessionMessages(chatUri);
} catch (err) {
this._logService.warn(`[AgentService] Failed to load history for peer chat ${chatUri.toString()}: ${toErrorMessage(err)}`);
}
const title = await this._readPersistedChatTitle(session, chatUri);
this._stateManager.restoreChat(session.toString(), chatUri.toString(), { title, turns: [...turns] });
}
}

/** Reads a peer chat's persisted custom title, if any. */
private async _readPersistedChatTitle(session: URI, chatUri: URI): Promise<string | undefined> {
const ref = await this._sessionDataService.tryOpenDatabase?.(session);
if (!ref) {
return undefined;
}
try {
return (await ref.object.getMetadata(`customChatTitle:${chatUri.toString()}`)) ?? undefined;
} catch {
return undefined;
} finally {
ref.dispose();
}
}

private async _getSessionMetadataForRestore(agent: IAgent, session: URI): Promise<IAgentSessionMetadata | undefined> {
const sessionStr = session.toString();
if (agent.getSessionMetadata) {
Expand Down Expand Up @@ -2011,6 +2087,27 @@ export class AgentService extends Disposable implements IAgentService {
* turns from those events.
*/
private async _restoreSubagentSession(subagentUri: string, parentSession: URI): Promise<void> {
if (this._stateManager.getSessionState(subagentUri)) {
return;
}

const inFlight = this._restoreSubagentInFlight.get(subagentUri);
if (inFlight) {
return inFlight;
}

const restore = this._doRestoreSubagentSession(subagentUri, parentSession);
this._restoreSubagentInFlight.set(subagentUri, restore);
try {
await restore;
} finally {
if (this._restoreSubagentInFlight.get(subagentUri) === restore) {
this._restoreSubagentInFlight.delete(subagentUri);
}
}
}

private async _doRestoreSubagentSession(subagentUri: string, parentSession: URI): Promise<void> {
// Ensure the parent session is loaded first
const parentSessionKey = parentSession.toString();
if (!this._stateManager.getSessionState(parentSessionKey)) {
Expand Down
Loading
Loading