diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 425fa2ff313..03c2e996285 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -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. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 480b0381952..2b0f81ad6ea 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -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) { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index ac95e75117f..87d17d5cfeb 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -1289,6 +1289,79 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('hydrates persisted change stats before the live list is available', () => runWithFakedTimers({ 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({ 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() { + 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({ 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.