Cache Agent Host session list metadata (#331040)

* sessions: Cache Agent Host session metadata

Persist pull request metadata and aggregate change counts so cached Agent Host sessions render complete list rows before the first live refresh.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* sessions: Clear cached change summaries

Handle explicit change-summary clears so stale aggregate counts are not persisted and restored.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: Preserve summary clears over the wire

Represent aggregate change-summary clears with an additive protocol marker and cover both transport serialization and metadata-only cache persistence.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* sessions: Keep cache change protocol-neutral

Remove the locally invented summary-clear wire marker while retaining isolated coverage for change-stat and metadata-only cache persistence.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
roblourens
2026-08-15 21:53:58 +00:00
committed by GitHub
co-authored by Copilot
parent 962cde6575
commit 0fc8be8db5
3 changed files with 96 additions and 11 deletions
@@ -107,7 +107,7 @@ A single agent host session uses several distinct identifiers:
To avoid an empty list on window startup — before the agent host has started, authentication has settled, and the first `listSessions()` round-trip returns — the base provider persists a lightweight snapshot of each session summary to `IStorageService` and re-hydrates it on the next launch. This machinery lives in `BaseAgentHostSessionsProvider` and is **shared by both the local and remote providers**:
- A subclass opts in by calling `_enableSessionCachePersistence(storageKey)` at the end of its constructor (once the identity fields that `createAdapter` depends on are set). This hydrates persisted summaries into `_sessionCache` immediately, so `getSessions()` returns cached sessions before any live list.
- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived` — read from each adapter's observables), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`.
- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived`, aggregate change counts, and the bounded, validated GitHub summary used for pull-request presentation — from each adapter), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`.
- Multi-root Editor sessions carry their originating workspace provenance in `_meta.multiRoot` as `{ workspaceFile }`. `workspaceFile` is the complete workspace configuration URI string; the Agent Host persists the validated object as JSON under the `multiRoot` session-database key, reconstructs it during listing/restoration, and the startup cache preserves it before the first live listing. The Editor session list matches this URI directly against `IWorkspace.configuration`; metadata-less sessions use current-folder containment without a separate workspace membership memento.
- Multi-root new-session **Folder-picker** decisions are provider-owned and carried in `_meta` under the `vscode.folderPicker` key as `{ hidden, primary? }`. The owning agent computes it (`IAgent.computeFolderPickerDecision`) from the ordered working-directory set when a fresh (non-fork, non-import) multi-root session is created; `AgentService` seeds it into the session `_meta`, persists the validated object as JSON under the `vscode.folderPicker` session-database key, and reconstructs it during listing/restoration so the decision is a frozen creation-time fact (hidden stays hidden on reopen, shown stays shown). The client keeps the picker hidden by default and reveals it only when `hidden` is `false`, auto-selecting `primary` (a working-directory URI string, valid only on a hidden, pinned decision) before the session starts. A provider that expresses no opinion returns `undefined`, so nothing is seeded and the picker stays hidden.
- Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned.
@@ -29,7 +29,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, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, 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, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionExternal, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js';
import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } 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';
@@ -89,9 +89,8 @@ const CACHED_SESSIONS_MAX_PER_HOST = 100;
/**
* Serialized shape of an {@link IAgentSessionMetadata} suitable for
* persisting via {@link IStorageService}. URIs are stored as strings
* and diffs are intentionally omitted (they are re-populated when the
* connection refreshes sessions).
* persisting via {@link IStorageService}. URIs are stored as strings and only
* lightweight metadata needed to render the session list is retained.
*/
interface ISerializedSessionMetadata {
readonly session: string;
@@ -108,6 +107,8 @@ interface ISerializedSessionMetadata {
/** @deprecated Legacy name for `isArchived`. */
readonly isDone?: boolean;
readonly project?: { readonly uri: string; readonly displayName: string };
readonly changes?: ChangesSummary;
readonly github?: ISessionGitHubState;
/**
* Whether the session is a workspace-less quick chat. Persisted because the
* adapter seeds its session-kind from this tag at construction (see
@@ -135,6 +136,8 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad
workingDirectory: meta.workingDirectories?.[0]?.toString(),
status: meta.status !== undefined ? meta.status & SESSION_STATUS_FLAG_MASK : undefined,
project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined,
changes: meta.changes,
github: readSessionGitHubState(meta._meta),
workspaceless: readSessionWorkspaceless(meta._meta) || undefined,
external: readSessionExternal(meta._meta) || undefined,
multiRoot: readSessionMultiRootMetadata(meta._meta),
@@ -146,6 +149,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta
let _meta = withSessionWorkspaceless(undefined, raw.workspaceless === true);
_meta = withSessionExternal(_meta, raw.external === true);
_meta = withSessionMultiRootMetadata(_meta, readSessionMultiRootMetadata({ [SESSION_META_MULTI_ROOT_KEY]: raw.multiRoot }));
_meta = withSessionGitHubState(_meta, raw.github);
return {
session: URI.parse(raw.session),
startTime: raw.startTime,
@@ -154,6 +158,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta
workingDirectories: raw.workingDirectory ? [URI.parse(raw.workingDirectory)] : undefined,
status: deserializeStatus(raw),
project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined,
changes: raw.changes,
...(_meta ? { _meta } : {}),
};
} catch {
@@ -675,6 +680,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {
// `reconcileSelectedAgent`).
private _agentBaseDir: URI | undefined;
private _meta: SessionMeta | undefined;
/** The latest session metadata used to build startup-cache presentation state. */
get sessionMeta(): SessionMeta | undefined { return this._meta; }
/**
* Whether this session is a workspace-less quick chat. Seeded from the
* constructor metadata and only ever promoted by
@@ -1363,11 +1370,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {
* yet.
*/
setMeta(meta: SessionMeta | undefined, tx?: ITransaction): boolean {
const metaChanged = !equals(this._meta, meta);
this._meta = meta;
let didChange = false;
let didChange = metaChanged;
subtransaction(tx, tx => {
this._metaObs.set(this._meta, tx);
didChange = this._promoteToQuickChatIfWorkspaceless(tx);
if (this._promoteToQuickChatIfWorkspaceless(tx)) {
didChange = true;
}
const workspace = this._computeWorkspace();
if (this._setWorkspace(workspace, tx)) {
didChange = true;
@@ -4818,20 +4828,22 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
if (!base) {
continue;
}
const sessionMeta = adapter.isQuickChat.get()
? withSessionWorkspaceless(adapter.sessionMeta, true)
: adapter.sessionMeta;
entries.push(serializeMetadata({
...base,
summary: adapter.title.get() || base.summary,
modifiedTime: adapter.updatedAt.get().getTime(),
changes: adapter.changesSummary.get(),
// A project assigned by `backfillProject` lives only on the adapter.
project: adapter.project ?? base.project,
status: withSessionStatusFlag(
withSessionStatusFlag(base.status ?? ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsRead, adapter.isRead.get()),
ProtocolSessionStatus.IsArchived,
adapter.isArchived.get()),
// The adapter's live kind wins over the snapshot: several metadata
// sources omit `_meta`, and persisting a stale one would resurrect
// the session as a workspace rooted at the host's scratch cwd.
...(adapter.isQuickChat.get() ? { _meta: withSessionWorkspaceless(base._meta, true) } : {}),
// Session-state updates can refine presentation metadata without another listing.
_meta: sessionMeta,
}));
}
if (entries.length === 0) {
@@ -1289,6 +1289,79 @@ suite('LocalAgentHostSessionsProvider', () => {
});
}));
test('hydrates persisted change stats before the live list is available', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
const storageService = disposables.add(new InMemoryStorageService());
const previousHost = new MockAgentHostService();
disposables.add(toDisposable(() => previousHost.dispose()));
previousHost.addSession(createSession('cached-metadata', { summary: 'Cached Metadata' }));
createProvider(disposables, previousHost, undefined, { storageService });
await timeout(0);
await storageService.flush();
fireSessionSummaryChanged(previousHost, 'cached-metadata', {
changes: { additions: 12, deletions: 4, files: 3 },
});
await storageService.flush();
const nextHost = new MockAgentHostService();
disposables.add(toDisposable(() => nextHost.dispose()));
nextHost.setAuthenticationPending(true);
const nextProvider = createProvider(disposables, nextHost, undefined, { storageService });
const listSessionsCallsBeforeRead = nextHost.listSessionsCallCount;
const restored = nextProvider.getSessions()[0];
assert.deepStrictEqual({
listSessionsCallsBeforeRead,
changesSummary: restored.changesSummary?.get(),
}, {
listSessionsCallsBeforeRead: 0,
changesSummary: { additions: 12, deletions: 4, files: 3 },
});
}));
test('hydrates a pull request icon persisted by a metadata-only update', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
const storageService = disposables.add(new InMemoryStorageService());
const previousHost = new MockAgentHostService();
disposables.add(toDisposable(() => previousHost.dispose()));
previousHost.addSession(createSession('cached-pr', {
summary: 'Cached PR',
project: { uri: URI.file('/repo'), displayName: 'repo' },
}));
createProvider(disposables, previousHost, undefined, { storageService });
await timeout(0);
await storageService.flush();
fireSessionSummaryChanged(previousHost, 'cached-pr', {
_meta: withSessionGitHubState(undefined, {
owner: 'owner',
repo: 'repo',
pullRequestUrls: ['https://github.com/owner/repo/pull/42'],
pullRequestBranchName: 'feature',
}),
});
await storageService.flush();
const nextHost = new MockAgentHostService();
disposables.add(toDisposable(() => nextHost.dispose()));
nextHost.setAuthenticationPending(true);
const gitHubService = new class extends mock<IGitHubService>() {
private readonly _model = { pullRequest: constObservable(undefined) } as unknown as GitHubPullRequestModel;
override createPullRequestModelReference = () => new ImmortalReference(this._model);
}();
const nextProvider = createProvider(disposables, nextHost, undefined, { storageService, gitHubService });
const restored = nextProvider.getSessions()[0];
const pullRequestIcon = restored.completedStateIcon?.get();
assert.deepStrictEqual({
pullRequestIcon: pullRequestIcon && { id: pullRequestIcon.id, color: pullRequestIcon.color?.id },
}, {
pullRequestIcon: {
id: computePullRequestIcon(GitHubPullRequestState.Open).id,
color: computePullRequestIcon(GitHubPullRequestState.Open).color?.id,
},
});
}));
test('discards a legacy cache entry so read state is rebuilt from the host', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
// Storage-key literals of the pre-`.v2` cache schema, whose entries
// carried a stale `isRead: true` written by the old always-read adapter.