From fe6c3988862bfcaab459ee2772786f6277307262 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 4 Aug 2026 08:10:59 +1000 Subject: [PATCH 1/2] Support multi-root Agent Host session metadata Persist Editor workspace provenance on Agent Host sessions and use it for authoritative multi-root session filtering. Remove the temporary workspace membership memento. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/sessions/SKILL.md | 4 +- .../browser/remoteAgentHostProtocolClient.ts | 1 + .../platform/agentHost/common/agentService.ts | 1 + .../common/state/protocol/common/commands.ts | 5 + .../agentHost/common/state/sessionState.ts | 61 ++++ .../platform/agentHost/node/agentService.ts | 58 ++- .../agentHost/node/protocolServerHandler.ts | 1 + .../common/sessionWorkspacelessMeta.test.ts | 31 +- .../remoteAgentHostProtocolClient.test.ts | 2 + .../agentHost/test/node/agentService.test.ts | 156 +++++++- .../test/node/protocolServerHandler.test.ts | 7 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 1 + .../browser/baseAgentHostSessionsProvider.ts | 8 +- .../localAgentHostSessionsProvider.test.ts | 33 +- .../agentHost/agentHostSessionHandler.ts | 2 + .../agentHost/agentHostSessionListStore.ts | 29 +- ...ntHostUntitledProvisionalSessionService.ts | 22 +- ...gentHostWorkspaceSessionMembershipStore.ts | 210 ----------- .../agentHostChatContribution.test.ts | 141 +++----- ...tUntitledProvisionalSessionService.test.ts | 73 +++- ...ostWorkspaceSessionMembershipStore.test.ts | 342 ------------------ 21 files changed, 500 insertions(+), 688 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceSessionMembershipStore.ts delete mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostWorkspaceSessionMembershipStore.test.ts diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 43955c9b2393e..640fa6b670dc5 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -120,9 +120,7 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru - **Centralize session workspace filtering behind a semantic predicate**: refresh, add-notification, and summary-update paths should call one `_isSessionInWorkspace(entry)`-style helper. Keep key construction, working-directory parsing, pending-local lookup, and provenance checks out of each caller so the high-level list flow stays readable and all paths apply identical rules. -- **Feature-specific workspace storage must be gated at the storage service boundary**: Editor multi-root provenance is enabled only for a non-Sessions window with `WorkbenchState.WORKSPACE` and more than one open folder. Lazy-load it only after that gate passes; folder/empty/Agents windows must use ordinary path filtering and never read, write, refresh, or delete the persisted state. - -- **Keep legacy single-folder filtering explicit when adding multi-root provenance**: zero-folder windows still include all sessions, and one-folder windows still use direct any-directory path containment. Call the provenance service only when the window has multiple folders; keep its internal scope gate as defense-in-depth. +- **Multi-root Editor filtering belongs to durable session metadata, not a workspace memento**: sessions with `_meta.multiRoot.workspaceFile` match a multi-root Editor window by URI identity against `IWorkspace.configuration`. Metadata-less sessions use containment against any current folder; do not retain a parallel workspace-scoped membership store whose lifecycle can drift from the host-owned session metadata. - **Name semantic layout operations after the user-facing surface**: a shared operation must use the stable UI concept (`toggleSecondarySideBar()`), not the implementation term (`AuxiliaryBar`) that happens to back it in classic layouts. This keeps single-pane mappings clear and avoids leaking layout internals through the API. diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 9cb21e3da04cd..7b55a2f24fbf7 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -913,6 +913,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC // awaiting via `getInflightSessionCreate` resume on the same microtask queue as direct `createSession()` awaiters. const promise = this._sendRequest('createSession', { channel: session.toString(), + _meta: config?._meta, provider, workingDirectories: config?.workingDirectories?.map(d => fromAgentHostUri(d).toString()), fork: config?.fork ? { session: fromAgentHostUri(config.fork.session).toString(), turnId: config.fork.turnId } : undefined, diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 2a1a0936af83c..33dc9de05a9a8 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -947,6 +947,7 @@ export const GITHUB_REPO_PROTECTED_RESOURCE: ProtectedResourceMetadata = { export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: ModelSelection; + readonly _meta?: Record; /** * Initial custom agent selection for the new session. Omit to start with * no custom agent selected (provider default behavior). diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index 2041f2a23a1ba..b4e17d4e094e3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -34,6 +34,11 @@ import type { TelemetryCapabilities } from '../channels-otlp/state.js'; export interface BaseParams { /** Channel URI this command targets. */ channel: URI; + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + _meta?: Record; } // ─── Pagination ────────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 0fa630e27c87e..8ee679ec2e4e0 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1090,6 +1090,67 @@ export const SESSION_META_GITHUB_KEY = 'github'; export const SESSION_META_PROMPT_CACHE_KEY = 'vscode.promptCache'; +export const SESSION_META_MULTI_ROOT_KEY = 'multiRoot'; + +const MAX_WORKSPACE_FILE_LENGTH = 4096; +const MAX_WORKSPACE_NAME_LENGTH = 512; + +/** Multi-root workspace provenance attached by the creating client. */ +export interface ISessionMultiRootMetadata { + readonly workspaceFile: string; + readonly name?: string; +} + +/** Reads validated multi-root workspace provenance from session metadata. */ +export function readSessionMultiRootMetadata(meta: SessionMeta | undefined): ISessionMultiRootMetadata | undefined { + return validateSessionMultiRootMetadata(meta?.[SESSION_META_MULTI_ROOT_KEY]); +} + +/** Parses validated multi-root workspace provenance from its persisted JSON representation. */ +export function parseSessionMultiRootMetadata(value: string | undefined): ISessionMultiRootMetadata | undefined { + if (!value) { + return undefined; + } + try { + return validateSessionMultiRootMetadata(JSON.parse(value)); + } catch { + return undefined; + } +} + +/** Returns session metadata with the multi-root workspace provenance updated or removed. */ +export function withSessionMultiRootMetadata(meta: SessionMeta | undefined, multiRoot: ISessionMultiRootMetadata | undefined): SessionMeta | undefined { + const next: SessionMeta = { ...meta }; + if (multiRoot) { + next[SESSION_META_MULTI_ROOT_KEY] = multiRoot; + } else { + delete next[SESSION_META_MULTI_ROOT_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +function validateSessionMultiRootMetadata(value: unknown): ISessionMultiRootMetadata | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + if (typeof raw.workspaceFile !== 'string' || raw.workspaceFile.length === 0 || raw.workspaceFile.length > MAX_WORKSPACE_FILE_LENGTH) { + return undefined; + } + const name = raw.name; + if (name !== undefined && (typeof name !== 'string' || name.length > MAX_WORKSPACE_NAME_LENGTH)) { + return undefined; + } + try { + if (!ResourceURI.parse(raw.workspaceFile, true).scheme) { + return undefined; + } + } catch { + return undefined; + } + return name === undefined ? { workspaceFile: raw.workspaceFile } : { workspaceFile: raw.workspaceFile, name }; +} + /** Latest known prompt-cache state for the model active in an agent session. */ export interface ISessionPromptCacheState { readonly modelId: string; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 604371e072aed..42b4f49327471 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -34,7 +34,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, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type ChatOrigin, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionStatusFlag, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionGitState, readSessionMultiRootMetadata, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { IProductService } from '../../product/common/productService.js'; import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js'; @@ -904,8 +904,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); // This session is an internal peer-chat backing (e.g. a // Claude peer chat's SDK session, enumerated by the agent's @@ -915,7 +915,7 @@ export class AgentService extends Disposable implements IAgentService { if (m[PEER_CHAT_BACKING_METADATA_KEY]) { return undefined; } - let updated = s; + let updated = { ...s, _meta: withSessionMultiRootMetadata(s._meta, undefined) }; if (m.customTitle) { updated = { ...updated, summary: m.customTitle }; } @@ -947,6 +947,10 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; } + const multiRoot = parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY]); + if (multiRoot) { + updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; + } let repositoryRootRaw = m[WORKTREE_META_REPOSITORY_ROOT]; if (repositoryRootRaw) { @@ -987,9 +991,10 @@ export class AgentService extends Disposable implements IAgentService { // session that has not yet persisted its state to its session // database still reports it here. Keep the DB value as the base so // any keys absent from the live `_meta` are preserved. - const _meta = liveSummary._meta !== undefined || s._meta !== undefined + let _meta = liveSummary._meta !== undefined || s._meta !== undefined ? { ...s._meta, ...liveSummary._meta } : undefined; + _meta = withSessionMultiRootMetadata(_meta, readSessionMultiRootMetadata(liveSummary._meta) ?? readSessionMultiRootMetadata(s._meta)); const liveWorkingDirs = liveSummary.workingDirectories; return { ...s, @@ -1282,6 +1287,7 @@ export class AgentService extends Disposable implements IAgentService { // exists, from the value `_buildInitialSummary` inferred. Provisional // sessions defer this to `_onDidMaterializeSession`. this._persistWorkspaceless(session, readSessionWorkspaceless(this._stateManager.getSessionSummary(session.toString())?._meta)); + this._persistMultiRoot(session, readSessionMultiRootMetadata(this._stateManager.getSessionSummary(session.toString())?._meta)); // `SessionReady` transitions the session lifecycle from // `Creating` to `Ready`. For provisional sessions we defer @@ -1510,7 +1516,7 @@ export class AgentService extends Disposable implements IAgentService { let created: IAgentCreateSessionResult | undefined; try { - created = await provider.createSession(config ? this._toProviderConfig(config) : undefined); + created = await provider.createSession(config ? this._toProviderConfig({ ...config, _meta: undefined }) : undefined); if (deferWorktreeCreation && created.provisional) { this._worktree?.notePending(AgentSession.id(created.session)); } @@ -1730,6 +1736,14 @@ export class AgentService extends Disposable implements IAgentService { private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; resolvedWorkingDirectory?: URI }, title: string): SessionSummary { const now = new Date().toISOString(); + const explicitMultiRoot = readSessionMultiRootMetadata(config?._meta); + const inheritedMultiRoot = config?.fork + ? readSessionMultiRootMetadata(this._stateManager.getSessionSummary(config.fork.session.toString())?._meta) + : undefined; + let _meta = withSessionMultiRootMetadata(undefined, explicitMultiRoot ?? inheritedMultiRoot); + _meta = !config?.fork && !config?.workingDirectories + ? withSessionWorkspaceless(_meta, true) + : _meta; return { resource: session.toString(), provider: provider.id, @@ -1749,7 +1763,7 @@ export class AgentService extends Disposable implements IAgentService { // re-inferred later) and tagged on the generic `_meta` bag. Use // `=== undefined` so an explicit empty set (`[]`) is NOT treated as // workspace-less. - ...(!config?.fork && !config?.workingDirectories ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), + ...(_meta ? { _meta } : {}), }; } @@ -1802,6 +1816,7 @@ export class AgentService extends Disposable implements IAgentService { // Persist the AH-owned workspace-less marker now that the session has a // real on-disk database (deferred from create for provisional sessions). this._persistWorkspaceless(e.session, readSessionWorkspaceless(summary._meta)); + this._persistMultiRoot(e.session, readSessionMultiRootMetadata(summary._meta)); // `markSessionPersisted` writes the summary into state and fires // the deferred `SessionAdded` notification atomically so subscribers // see consistent state through both paths. @@ -1880,6 +1895,24 @@ export class AgentService extends Disposable implements IAgentService { }); } + private _persistMultiRoot(session: URI, multiRoot: ReturnType): void { + if (!multiRoot) { + return; + } + let ref; + try { + ref = this._sessionDataService.openDatabase(session); + } catch (err) { + this._logService.warn(`[AgentService] Failed to open session database to persist multi-root metadata for ${session.toString()}: ${toErrorMessage(err)}`); + return; + } + ref.object.setMetadata(SESSION_META_MULTI_ROOT_KEY, JSON.stringify(multiRoot)).catch(err => { + this._logService.warn(`[AgentService] Failed to persist multi-root metadata for ${session.toString()}: ${toErrorMessage(err)}`); + }).finally(() => { + ref.dispose(); + }); + } + private _persistConfigValues(session: URI, values: Record): void { let ref; try { @@ -2708,6 +2741,7 @@ export class AgentService extends Disposable implements IAgentService { [AH_META_IS_DONE_DB_KEY]: true, configValues: true, [AH_META_WORKSPACELESS_DB_KEY]: true, + [SESSION_META_MULTI_ROOT_KEY]: true, ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, }); @@ -2757,6 +2791,7 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true'); } + sessionMetadata = withSessionMultiRootMetadata(sessionMetadata, parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY])); if (m.configValues) { try { @@ -2783,6 +2818,9 @@ export class AgentService extends Disposable implements IAgentService { status |= SessionStatus.IsArchived; } + const providerMeta = withSessionMultiRootMetadata(meta._meta, undefined); + let restoredMeta = (sessionMetadata || providerMeta) ? { ...(providerMeta ?? {}), ...(sessionMetadata ?? {}) } : undefined; + restoredMeta = withSessionMultiRootMetadata(restoredMeta, readSessionMultiRootMetadata(sessionMetadata)); const summary: SessionSummary = { resource: sessionStr, provider: agent.id, @@ -2793,7 +2831,7 @@ export class AgentService extends Disposable implements IAgentService { ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), changes: meta.changes ?? changes, workingDirectories: meta.workingDirectories?.map(d => d.toString()), - _meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined, + _meta: restoredMeta, }; const [defaultDraft, defaultChatTitle] = await Promise.all([ @@ -2842,8 +2880,8 @@ export class AgentService extends Disposable implements IAgentService { // Restore persisted `_meta` (e.g. git state) onto the new session // state. This dispatches a SessionMetaChanged action. - if (meta._meta) { - this._stateManager.setSessionMeta(sessionStr, meta._meta); + if (summary._meta) { + this._stateManager.setSessionMeta(sessionStr, summary._meta); } // Resolve the session config so clients (e.g. the running-session diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 4081463998ac1..79a75a3548516 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1190,6 +1190,7 @@ export class ProtocolServerHandler extends Disposable { try { createdSession = await this._agentService.createSession({ provider: params.provider, + _meta: params._meta, workingDirectories: params.workingDirectories?.map(d => URI.parse(d)), session: URI.parse(params.channel), fork, diff --git a/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts b/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts index 155fbe30147e3..1e4722bb21c59 100644 --- a/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts +++ b/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { readSessionWorkspaceless, SESSION_META_WORKSPACELESS_KEY, withSessionGitHubState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { parseSessionMultiRootMetadata, readSessionMultiRootMetadata, readSessionWorkspaceless, SESSION_META_MULTI_ROOT_KEY, SESSION_META_WORKSPACELESS_KEY, withSessionGitHubState, withSessionMultiRootMetadata, withSessionWorkspaceless } from '../../common/state/sessionState.js'; suite('Session workspace-less meta', () => { @@ -18,6 +18,35 @@ suite('Session workspace-less meta', () => { assert.strictEqual(readSessionWorkspaceless({ [SESSION_META_WORKSPACELESS_KEY]: 'true' }), false); }); + suite('Session multi-root meta', () => { + + test('round-trips workspace provenance and preserves other slots', () => { + const multiRoot = { + workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace', + name: 'Demo Workspace', + }; + const tagged = withSessionMultiRootMetadata({ other: true }, multiRoot); + + assert.deepStrictEqual({ + multiRoot: readSessionMultiRootMetadata(tagged), + persisted: parseSessionMultiRootMetadata(JSON.stringify(multiRoot)), + other: tagged?.other, + }, { + multiRoot, + persisted: multiRoot, + other: true, + }); + }); + + test('rejects malformed workspace provenance', () => { + assert.deepStrictEqual([ + readSessionMultiRootMetadata({ [SESSION_META_MULTI_ROOT_KEY]: { workspaceFile: 'relative.code-workspace' } }), + readSessionMultiRootMetadata({ [SESSION_META_MULTI_ROOT_KEY]: { workspaceFile: 'file:///demo.code-workspace', name: 42 } }), + parseSessionMultiRootMetadata('{'), + ], [undefined, undefined, undefined]); + }); + }); + test('withSessionWorkspaceless round-trips the marker and preserves other slots', () => { const withOther = withSessionGitHubState(undefined, { owner: 'octo' }); const tagged = withSessionWorkspaceless(withOther, true); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 6ae23e4db13a9..be887e3c33624 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -429,6 +429,7 @@ suite('RemoteAgentHostProtocolClient', () => { const creation = client.createSession({ provider: 'copilot', session, + _meta: { multiRoot: { workspaceFile: 'file:///demo.code-workspace', name: 'Demo' } }, fork: { session: source, turnIndex: 2, turnId: 'turn-2' }, progressToken: 'progress-token', }); @@ -437,6 +438,7 @@ suite('RemoteAgentHostProtocolClient', () => { hasKey(message, { method: true }) && message.method === 'createSession'); assert.deepStrictEqual(request?.params, { channel: session.toString(), + _meta: { multiRoot: { workspaceFile: 'file:///demo.code-workspace', name: 'Demo' } }, provider: 'copilot', workingDirectories: undefined, fork: { session: source.toString(), turnId: 'turn-2' }, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index f4e904ce36a06..7030f88a48232 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -27,7 +27,7 @@ import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataS import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseChatUri, parseSubagentSessionUri, ChatOriginKind, type ChangesetState, type ISessionWithDefaultChat, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionWithDefaultChat, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -474,6 +474,7 @@ suite('AgentService (node dispatcher)', () => { workingDirectories: workingDirectory ? [workingDirectory] : undefined, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, }); + const failedSession = AgentSession.uri('codex', 'failed-before-create'); failCreate = true; await assert.rejects(localService.createSession({ @@ -496,6 +497,112 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('createSession validates, exposes, persists, and inherits multi-root metadata', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + const multiRoot = { + workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace', + name: 'Demo Workspace', + }; + const session = await localService.createSession({ + provider: agent.id, + workingDirectories: [URI.file('/workspace/one'), URI.file('/workspace/two')], + _meta: { multiRoot, ignored: 'client value' }, + }); + const sourceChat = buildDefaultChatUri(session.toString()); + localService.dispatchAction(sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: new Date().toISOString(), + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }, 'test-client', 1); + localService.dispatchAction(sourceChat, { + type: ActionType.ChatTurnComplete, + turnId: 'source-turn', + duration: 0, + }, 'test-client', 2); + const inherited = await localService.createSession({ + provider: agent.id, + _meta: { multiRoot: { workspaceFile: 'relative.code-workspace' } }, + fork: { session, turnIndex: 0, turnId: 'source-turn' }, + }); + const override = { + workspaceFile: 'file:///work/override.code-workspace', + name: 'Override', + }; + const overridden = await localService.createSession({ + provider: agent.id, + _meta: { multiRoot: override }, + fork: { session, turnIndex: 0, turnId: 'source-turn' }, + }); + + assert.deepStrictEqual({ + state: localService.stateManager.getSessionState(session.toString())?._meta, + persisted: await db.getMetadata(SESSION_META_MULTI_ROOT_KEY), + inherited: readSessionMultiRootMetadata(localService.stateManager.getSessionState(inherited.toString())?._meta), + overridden: readSessionMultiRootMetadata(localService.stateManager.getSessionState(overridden.toString())?._meta), + }, { + state: { multiRoot }, + persisted: JSON.stringify(override), + inherited: multiRoot, + overridden: override, + }); + }); + + test('provisional materialization preserves and persists multi-root metadata', async () => { + class ProvisionalAgent extends MockAgent { + private readonly _onDidMaterialize = new Emitter<{ session: URI; workingDirectories: readonly URI[] | undefined; project: undefined }>(); + readonly onDidMaterializeSession = this._onDidMaterialize.event; + + override async createSession(config?: IAgentCreateSessionConfig): Promise { + return { ...await super.createSession(config), provisional: true }; + } + + materialize(session: URI, workingDirectories: readonly URI[]): void { + this._onDidMaterialize.fire({ session, workingDirectories, project: undefined }); + } + + override dispose(): void { + this._onDidMaterialize.dispose(); + super.dispose(); + } + } + + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new ProvisionalAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + const multiRoot = { + workspaceFile: 'file:///work/demo.code-workspace', + name: 'Demo Workspace', + }; + const session = await localService.createSession({ + provider: agent.id, + workingDirectories: [URI.file('/work/one'), URI.file('/work/two')], + _meta: { multiRoot }, + }); + const before = readSessionMultiRootMetadata(localService.stateManager.getSessionState(session.toString())?._meta); + const persistedBefore = await db.getMetadata(SESSION_META_MULTI_ROOT_KEY); + + agent.materialize(session, [URI.file('/work/materialized'), URI.file('/work/two')]); + + assert.deepStrictEqual({ + before, + persistedBefore, + after: readSessionMultiRootMetadata(localService.stateManager.getSessionState(session.toString())?._meta), + persistedAfter: await db.getMetadata(SESSION_META_MULTI_ROOT_KEY), + }, { + before: multiRoot, + persistedBefore: undefined, + after: multiRoot, + persistedAfter: JSON.stringify(multiRoot), + }); + }); + test('reconciles pending worktree isolation when creating session config changes', async () => { const gitService = createNoopGitService(); const sessionDataService = createSessionDataService(new TestSessionDatabase()); @@ -1309,6 +1416,9 @@ suite('AgentService (node dispatcher)', () => { // The agent returns the session with NO `_meta.workspaceless` of its own. const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); + agent.sessionMetadataOverrides = { + _meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } }, + }; (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -1319,6 +1429,29 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(sessions[0]._meta, { workspaceless: true }); }); + test('listSessions restores persisted multi-root metadata', async () => { + const db = new TestSessionDatabase(); + const multiRoot = { + workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace', + name: 'Demo Workspace', + }; + await db.setMetadata(SESSION_META_MULTI_ROOT_KEY, JSON.stringify(multiRoot)); + const sessionId = 'test-session-multi-root'; + const sessionUri = AgentSession.uri('copilot', sessionId); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.sessionMetadataOverrides = { + _meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } }, + }; + (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + + const sessions = await svc.listSessions(); + + assert.deepStrictEqual(readSessionMultiRootMetadata(sessions[0]._meta), multiRoot); + }); + test('listSessions normalizes a persisted linked-worktree project without probing a missing session worktree', async () => { const db = disposables.add(new TestSessionDatabase()); const primaryRoot = URI.file('/workspace/vscode'); @@ -2448,6 +2581,27 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?._meta, { workspaceless: true }); }); + test('restores persisted multi-root metadata', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + copilotAgent.sessionMessages = []; + copilotAgent.sessionMetadataOverrides = { + _meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } }, + }; + const multiRoot = { + workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace', + name: 'Demo Workspace', + }; + await db.setMetadata(SESSION_META_MULTI_ROOT_KEY, JSON.stringify(multiRoot)); + + await localService.restoreSession(sessionResource); + + assert.deepStrictEqual(readSessionMultiRootMetadata(localService.stateManager.getSessionState(sessionResource.toString())?._meta), multiRoot); + }); + test('restores a session with message history', async () => { service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 308b9cff4579c..564bcef804fe4 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -788,22 +788,25 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual(result.items.map(item => item._meta), [undefined]); }); - test('createSession returns null and broadcasts project in sessionAdded summary', async () => { + test('createSession forwards request metadata and broadcasts project in sessionAdded summary', async () => { const transport = connectClient('client-create'); transport.sent.length = 0; const responsePromise = waitForResponse(transport, 2); const newSession = URI.parse('copilot:///created-session').toString(); - transport.simulateMessage(request(2, 'createSession', { channel: newSession })); + const _meta = { multiRoot: { workspaceFile: 'file:///demo.code-workspace', name: 'Demo' } }; + transport.simulateMessage(request(2, 'createSession', { channel: newSession, _meta })); const resp = await responsePromise; const added = findNotifications(transport.sent, 'root/sessionAdded')[0]; assert.deepStrictEqual({ result: (resp as { result: null }).result, project: (added!.params as SessionAddedParams).summary.project, + _meta: agentService.createSessionConfigs.at(-1)?._meta, }, { result: null, project: { uri: 'file:///created-project', displayName: 'Created Project' }, + _meta, }); }); 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 b3437ba25757f..f83a0fe435a0a 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 @@ -89,6 +89,7 @@ To avoid an empty list on window startup — before the agent host has started, - 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`. +- Multi-root Editor sessions carry their originating workspace provenance in `_meta.multiRoot` as `{ workspaceFile, name? }`. `workspaceFile` is the complete workspace configuration URI string and `name` is `IWorkspace.name`; 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. - Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned. - `_shouldTrackSessionCacheChanges()` is a hook (default `true`) the remote provider overrides to suspend dirty-tracking while its sessions are unpublished (offline), so the on-disk snapshot survives an unreachable host. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 8f19e424974f6..5d125c8e7e694 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -27,7 +27,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, 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 { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitHubState, readSessionGitState, readSessionWorkspaceless, ROOT_STATE_URI, SessionMeta, StateComponents, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, StateComponents, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, 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'; @@ -106,6 +106,7 @@ interface ISerializedSessionMetadata { * host's scratch dir as a workspace folder until the next listing arrives. */ readonly workspaceless?: boolean; + readonly multiRoot?: ISessionMultiRootMetadata; } /** @@ -125,11 +126,14 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad status: meta.status !== undefined ? meta.status & SESSION_STATUS_FLAG_MASK : undefined, project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, workspaceless: readSessionWorkspaceless(meta._meta) || undefined, + multiRoot: readSessionMultiRootMetadata(meta._meta), }; } function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { try { + let _meta = withSessionWorkspaceless(undefined, raw.workspaceless === true); + _meta = withSessionMultiRootMetadata(_meta, readSessionMultiRootMetadata({ [SESSION_META_MULTI_ROOT_KEY]: raw.multiRoot })); return { session: URI.parse(raw.session), startTime: raw.startTime, @@ -138,7 +142,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, - ...(raw.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), + ...(_meta ? { _meta } : {}), }; } catch { return undefined; 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 4c9e3d8f5d89e..51aa8f69d37e6 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 @@ -17,7 +17,7 @@ import { AgentHostCodexAgentEnabledSettingId, AgentSession, ClaudePreferAgentHos import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentInfo, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, SessionStatus as ProtocolSessionStatus, StateComponents, withSessionGitState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, SessionStatus as ProtocolSessionStatus, StateComponents, withSessionGitState, withSessionMultiRootMetadata, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -314,7 +314,9 @@ class MockAgentHostService extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string; name?: string } }): IAgentSessionMetadata { + let _meta = opts?.quickChat ? withSessionWorkspaceless(undefined, true) : undefined; + _meta = withSessionMultiRootMetadata(_meta, opts?.multiRoot); return { session: AgentSession.uri(opts?.provider ?? 'copilotcli', id), startTime: opts?.startTime ?? 1000, @@ -322,7 +324,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; summary: opts?.summary, project: opts?.project, workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, - _meta: opts?.quickChat ? withSessionWorkspaceless(undefined, true) : undefined, + _meta, }; } @@ -1333,6 +1335,31 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('hydrated session preserves multi-root metadata after reload', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const multiRoot = { + workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace', + name: 'Demo Workspace', + }; + await persistCachedSessions(disposables, storageService, [ + createSession('multi-root-cached', { summary: 'Multi Root', multiRoot }), + ]); + const snapshot = JSON.parse(storageService.get('localAgentHost.cachedSessions.v2', StorageScope.APPLICATION)!) as Array<{ multiRoot?: typeof multiRoot }>; + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + + const session = createProvider(disposables, nextHost, undefined, { storageService }).getSessions()[0]; + + assert.deepStrictEqual({ + persisted: snapshot[0].multiRoot, + hydratedTitle: session.title.get(), + }, { + persisted: multiRoot, + hydratedTitle: 'Multi Root', + }); + })); + test('a refresh publishes _meta and summary fields as one atomic update', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // `AgentHostSessionAdapter.update` applies `_meta` through `setMeta`, // which must join the caller's transaction. A plain `transaction()` diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 02de720ff7539..a3d31ccf5e653 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -4251,6 +4251,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC try { session = await this._config.connection.createSession({ session: requestedSession, + _meta: this._provisionalService.getInitialSessionMetadata(), model, provider: this._config.provider, workingDirectories, @@ -4270,6 +4271,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC onFailureStage?.('createSession'); session = await this._config.connection.createSession({ session: requestedSession, + _meta: this._provisionalService.getInitialSessionMetadata(), model, provider: this._config.provider, workingDirectories, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index b2621d1c68e77..609c216db9769 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -10,9 +10,8 @@ import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resour import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { IAgentHostWorkspaceSessionMembershipStore } from './agentHostWorkspaceSessionMembershipStore.js'; /** * Minimal agent-host connection surface needed by the session list store. @@ -92,7 +91,6 @@ export class AgentHostSessionListStore extends Disposable { constructor( private readonly _connection: IAgentHostSessionListConnection, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @IAgentHostWorkspaceSessionMembershipStore private readonly _workspaceMembership: IAgentHostWorkspaceSessionMembershipStore, ) { super(); @@ -179,8 +177,6 @@ export class AgentHostSessionListStore extends Disposable { // removal; invalidating that snapshot here prevents `_doRefresh` from // resurrecting the just-removed session. this._mutationGeneration++; - const key = this._key(provider, rawId); - this._workspaceMembership.remove(key); this._removeSessionFromList(provider, rawId); } @@ -242,18 +238,14 @@ export class AgentHostSessionListStore extends Disposable { } const nextEntries: IAgentHostSessionListEntry[] = []; - const backendSessionKeys: string[] = []; for (const session of sessions) { const entry = this._makeEntryFromMetadata(session); if (entry) { - const key = this._key(entry.provider, entry.rawId); - backendSessionKeys.push(key); if (this._isSessionInWorkspace(entry)) { nextEntries.push(entry); } } } - this._workspaceMembership.reconcileBackendSessions(backendSessionKeys); this._entries.clear(); for (const entry of nextEntries) { @@ -292,7 +284,6 @@ export class AgentHostSessionListStore extends Disposable { if (!this._isSessionInWorkspace(entry)) { return; } - this._workspaceMembership.markSeen(key); this._mutationGeneration++; this._entries.set(key, entry); // The backend has now announced this session, so it is no longer a @@ -329,7 +320,6 @@ export class AgentHostSessionListStore extends Disposable { return; } - this._workspaceMembership.markSeen(key); this._mutationGeneration++; this._entries.set(key, updated); this._onDidChangeSessions.fire({ addedOrUpdated: [updated] }); @@ -358,6 +348,7 @@ export class AgentHostSessionListStore extends Disposable { modifiedAt: new Date(session.modifiedTime).toISOString(), changes: session.changes, workingDirectories: session.workingDirectories?.map(d => d.toString()), + ...(session._meta !== undefined ? { _meta: session._meta } : {}), }, }; } @@ -375,18 +366,22 @@ export class AgentHostSessionListStore extends Disposable { }; } - /** Uses legacy path containment for zero/single-folder windows and durable provenance only for multi-root workspaces. */ + /** Uses workspace-file provenance for multi-root workspaces and path containment otherwise. */ private _isSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { const workingDirectories = entry.summary.workingDirectories?.map(directory => URI.parse(directory)) ?? []; - const folders = this._workspaceContextService.getWorkspace().folders; + const workspace = this._workspaceContextService.getWorkspace(); + const folders = workspace.folders; if (folders.length === 0) { return true; } - if (folders.length === 1) { - return workingDirectories.some(directory => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folders[0].uri)); + const multiRoot = readSessionMultiRootMetadata(entry.summary._meta); + if (folders.length > 1 && multiRoot) { + return URI.isUri(workspace.configuration) + && extUriBiasedIgnorePathCase.isEqual(URI.parse(multiRoot.workspaceFile), workspace.configuration); } - const key = this._key(entry.provider, entry.rawId); - return this._workspaceMembership.shouldInclude(key, workingDirectories, this._pendingNewSessions.has(key)); + return workingDirectories.some(directory => + folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) + ); } private _toRemoval(entry: IAgentHostSessionListEntry): IAgentHostSessionListRemoval { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index be0852d19e7b4..ead69f9cb3d84 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -61,11 +61,12 @@ import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../../platform/ import { migrateLegacyAutopilotConfig } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { withSessionMultiRootMetadata } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { ChatConfiguration, getChatPermissionLevelFromDefaultConfiguration, type IChatDefaultConfiguration } from '../../../common/constants.js'; @@ -106,6 +107,9 @@ export interface IAgentHostUntitledProvisionalSessionService { */ getInitialSessionConfig(): Record | undefined; + /** Initial session metadata contributed by the current Editor workspace. */ + getInitialSessionMetadata(): Record | undefined; + /** * Ensure a backend provisional exists for an untitled chat UI resource. * Multiple picker chips may call this concurrently; implementation must keep @@ -294,6 +298,20 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple return computeWorkingDirectories(primary, this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri), this._agentHostService.rootState.value, provider); } + getInitialSessionMetadata(): Record | undefined { + const workspace = this._workspaceContextService.getWorkspace(); + if (this._environmentService.isSessionsWindow + || this._workspaceContextService.getWorkbenchState() !== WorkbenchState.WORKSPACE + || workspace.folders.length < 2 + || !URI.isUri(workspace.configuration)) { + return undefined; + } + return withSessionMultiRootMetadata(undefined, { + workspaceFile: workspace.configuration.toString(), + name: workspace.name, + }); + } + getInitialSessionConfig(): Record | undefined { return this._getInitialConfig(); } @@ -421,6 +439,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple created = await this._agentHostService.createSession({ provider: entry.provider, session: candidate, + _meta: this.getInitialSessionMetadata(), workingDirectories: this._computeWorkingDirectories(workingDirectory, entry.provider), config, progressToken: generateUuid(), @@ -524,6 +543,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple created = await this._agentHostService.createSession({ provider, session: newBackendSession, + _meta: this.getInitialSessionMetadata(), workingDirectories: this._computeWorkingDirectories(targetWorkingDirectory, provider), config, ...(imported ? { model: imported.model, importConversation: { turns: imported.turns, model: imported.model } } : {}), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceSessionMembershipStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceSessionMembershipStore.ts deleted file mode 100644 index d5dd36bbb4654..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorkspaceSessionMembershipStore.ts +++ /dev/null @@ -1,210 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; -import { isObject } from '../../../../../../base/common/types.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; - -const STORAGE_KEY = 'agentHost.workspaceSessionMembership.v1'; -const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; -const SEEN_WRITE_INTERVAL_MS = 24 * 60 * 60 * 1000; - -interface IStoredMembership { - readonly key: string; - readonly lastSeenAt: number; -} - -interface ISerializedMembership { - readonly version: 1; - readonly sessions: readonly IStoredMembership[]; -} - -export const IAgentHostWorkspaceSessionMembershipStore = createDecorator('agentHostWorkspaceSessionMembershipStore'); - -export interface IAgentHostWorkspaceSessionMembershipStore { - readonly _serviceBrand: undefined; - /** Sets exact last-seen timestamps from a complete backend snapshot and prunes memberships absent for over 30 days. */ - reconcileBackendSessions(sessionKeys: readonly string[]): void; - /** Refreshes one membership after an isolated backend notification. */ - markSeen(key: string): void; - /** Returns whether a session belongs to this workspace, recording new multi-root provenance when appropriate. */ - shouldInclude(key: string, workingDirectories: readonly URI[], isPendingLocalSession: boolean): boolean; - /** Removes durable provenance after the backend session is definitively deleted. */ - remove(key: string): void; - /** Returns whether this eligible Editor multi-root workspace has recorded the session. */ - has(key: string): boolean; -} - -/** - * Keeps multi-root sessions associated with the Editor Window workspace where they were first seen, even when that workspace's folders later change. - * Backend snapshots batch last-seen updates and stale pruning; outside Editor multi-root workspaces the storage remains dormant. - */ -export class AgentHostWorkspaceSessionMembershipStore implements IAgentHostWorkspaceSessionMembershipStore { - - declare readonly _serviceBrand: undefined; - private readonly _entries = new Map(); - private _loaded = false; - private _dirty = false; - - constructor( - @IStorageService private readonly _storageService: IStorageService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @ILogService private readonly _logService: ILogService, - @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, - ) { } - - protected now(): number { - return Date.now(); - } - - reconcileBackendSessions(sessionKeys: readonly string[]): void { - if (!this._isEnabled()) { - return; - } - this._ensureLoaded(); - - const now = this.now(); - const present = new Set(sessionKeys); - for (const key of present) { - if (this._entries.has(key) && this._entries.get(key) !== now) { - this._entries.set(key, now); - this._dirty = true; - } - } - for (const [key, lastSeenAt] of this._entries) { - if (!present.has(key) && now - lastSeenAt > RETENTION_MS) { - this._entries.delete(key); - this._dirty = true; - } - } - this._flushIfDirty(); - } - - shouldInclude(key: string, workingDirectories: readonly URI[], isPendingLocalSession: boolean): boolean { - const folders = this._workspaceContextService.getWorkspace().folders; - const pathMatches = workingDirectories.some(directory => - folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) - ); - const isMultiRootSession = workingDirectories.length > 1; - - if (folders.length === 0) { - return true; - } - if (!this._isEnabled() || !isMultiRootSession) { - return pathMatches; - } - this._ensureLoaded(); - if (!this._entries.has(key) && (isPendingLocalSession || pathMatches)) { - this._entries.set(key, this.now()); - this._dirty = true; - } - return this._entries.has(key); - } - - markSeen(key: string): void { - if (!this._isEnabled()) { - return; - } - this._ensureLoaded(); - this._markSeen(key, this.now()); - this._flushIfDirty(); - } - - remove(key: string): void { - if (!this._isEnabled()) { - return; - } - this._ensureLoaded(); - if (this._entries.delete(key)) { - this._dirty = true; - this._flushIfDirty(); - } - } - - has(key: string): boolean { - if (!this._isEnabled()) { - return false; - } - this._ensureLoaded(); - return this._entries.has(key); - } - - /** Restricts durable provenance to Editor Windows with an actual multi-root workspace. */ - private _isEnabled(): boolean { - return !this._environmentService.isSessionsWindow - && this._workspaceContextService.getWorkbenchState() === WorkbenchState.WORKSPACE - && this._workspaceContextService.getWorkspace().folders.length > 1; - } - - /** Loads workspace membership lazily so ineligible windows never read this storage. */ - private _ensureLoaded(): void { - if (!this._loaded) { - this._loaded = true; - this._load(); - } - } - - /** Advances notification-driven freshness only after the daily write-throttle interval. */ - private _markSeen(key: string, now: number): void { - const lastSeenAt = this._entries.get(key); - if (lastSeenAt === undefined || now - lastSeenAt < SEEN_WRITE_INTERVAL_MS) { - return; - } - this._entries.set(key, now); - this._dirty = true; - } - - private _load(): void { - const raw = this._storageService.get(STORAGE_KEY, StorageScope.WORKSPACE); - if (!raw) { - return; - } - try { - const value: unknown = JSON.parse(raw); - if (!isObject(value)) { - return; - } - const serialized = value as Record; - if (serialized.version !== 1 || !Array.isArray(serialized.sessions)) { - return; - } - for (const entry of serialized.sessions) { - if (isObject(entry)) { - const membership = entry as Record; - if (typeof membership.key === 'string' && typeof membership.lastSeenAt === 'number' && Number.isFinite(membership.lastSeenAt)) { - this._entries.set(membership.key, membership.lastSeenAt); - } - } - } - } catch (error) { - this._logService.warn('[AgentHostWorkspaceSessionMembershipStore] Failed to parse persisted membership', error); - } - } - - /** Persists all accumulated membership changes in one whole-map write. */ - private _flushIfDirty(): void { - if (!this._dirty) { - return; - } - if (this._entries.size === 0) { - this._storageService.remove(STORAGE_KEY, StorageScope.WORKSPACE); - } else { - const value: ISerializedMembership = { - version: 1, - sessions: [...this._entries].map(([key, lastSeenAt]) => ({ key, lastSeenAt })), - }; - this._storageService.store(STORAGE_KEY, JSON.stringify(value), StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - this._dirty = false; - } -} - -registerSingleton(IAgentHostWorkspaceSessionMembershipStore, AgentHostWorkspaceSessionMembershipStore, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 7eb4242de8074..a4818860c33c7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -31,7 +31,7 @@ import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSy import { ActionType, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ChatInteractivity, ConfirmationOptionKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type ClientPluginCustomization, type ProtectedResourceMetadata, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withSessionMultiRootMetadata, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer, chatReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -65,7 +65,6 @@ import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/a import { AgentHostSessionListContribution } from '../../../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; import { AgentHostSessionListController } from '../../../browser/agentSessions/agentHost/agentHostSessionListController.js'; import { AgentHostSessionListStore, type IAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; -import { AgentHostWorkspaceSessionMembershipStore, IAgentHostWorkspaceSessionMembershipStore } from '../../../browser/agentSessions/agentHost/agentHostWorkspaceSessionMembershipStore.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -846,7 +845,6 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv isNewSession: sessionResource => workingDirectoryResolver?.isNewSession?.(sessionResource) ?? sessionResource.path.substring(1).startsWith('new-'), }); instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow } as Partial); - instantiationService.stub(IAgentHostWorkspaceSessionMembershipStore, instantiationService.createInstance(AgentHostWorkspaceSessionMembershipStore)); instantiationService.stub(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); instantiationService.stub(IChatInputNotificationService, { _serviceBrand: undefined, @@ -864,6 +862,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv onDidChange: Event.None, get: () => undefined, getInitialSessionConfig: () => undefined, + getInitialSessionMetadata: () => undefined, waitForPending: async () => undefined, getOrCreate: async () => undefined, tryRebind: async () => undefined, @@ -921,13 +920,12 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController, modelService, workingCopyService, commandService }; } -function createSessionListStore(disposables: DisposableStore, instantiationService: TestInstantiationService, connection: IAgentHostSessionListConnection, workspaceMembership?: IAgentHostWorkspaceSessionMembershipStore): AgentHostSessionListStore { - instantiationService.stub(IAgentHostWorkspaceSessionMembershipStore, workspaceMembership ?? instantiationService.createInstance(AgentHostWorkspaceSessionMembershipStore)); +function createSessionListStore(disposables: DisposableStore, instantiationService: TestInstantiationService, connection: IAgentHostSessionListConnection): AgentHostSessionListStore { return disposables.add(instantiationService.createInstance(AgentHostSessionListStore, connection)); } -function createSessionListController(disposables: DisposableStore, instantiationService: TestInstantiationService, connection: IAgentHostSessionListConnection, sessionType = 'agent-host-copilot', provider = 'copilot', description: string | undefined = undefined, workspaceMembership?: IAgentHostWorkspaceSessionMembershipStore): AgentHostSessionListController { - const sessionListStore = createSessionListStore(disposables, instantiationService, connection, workspaceMembership); +function createSessionListController(disposables: DisposableStore, instantiationService: TestInstantiationService, connection: IAgentHostSessionListConnection, sessionType = 'agent-host-copilot', provider = 'copilot', description: string | undefined = undefined): AgentHostSessionListController { + const sessionListStore = createSessionListStore(disposables, instantiationService, connection); return disposables.add(instantiationService.createInstance(AgentHostSessionListController, sessionType, provider, sessionListStore, description, 'local')); } @@ -2897,54 +2895,6 @@ suite('AgentHostChatContribution', () => { }); }); - test('summary eviction preserves workspace membership but session removal clears it', async () => { - const { instantiationService, agentHostService } = createTestServices(disposables); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - instantiationService.stub(IWorkspaceContextService, { - getWorkbenchState: () => WorkbenchState.WORKSPACE, - getWorkspace: () => ({ id: 'workspace', folders: [a, b].map((uri, index) => ({ uri, name: uri.path, index, toResource: () => uri })) }), - getWorkspaceFolder: () => null, - onDidChangeWorkspaceFolders: Event.None, - }); - let include = true; - const removedMemberships: string[] = []; - const workspaceMembership: IAgentHostWorkspaceSessionMembershipStore = { - _serviceBrand: undefined, - reconcileBackendSessions: () => { }, - markSeen: () => { }, - shouldInclude: () => include, - remove: key => removedMemberships.push(key), - has: () => false, - }; - const session = AgentSession.uri('copilot', 'membership'); - agentHostService.addSession({ session, startTime: 1000, modifiedTime: 2000, summary: 'Membership' }); - const listController = createSessionListController(disposables, instantiationService, agentHostService, 'agent-host-copilot', 'copilot', undefined, workspaceMembership); - await listController.refresh(CancellationToken.None); - - include = false; - agentHostService.fireNotification({ - type: 'root/sessionSummaryChanged', - channel: ROOT_STATE_URI, - session: session.toString(), - changes: { workingDirectories: [URI.file('/other').toString()] }, - } as INotification); - const afterSummaryEviction = listController.items.length; - agentHostService.fireNotification({ - type: 'root/sessionRemoved', - channel: ROOT_STATE_URI, - session: session.toString(), - } as INotification); - - assert.deepStrictEqual({ - afterSummaryEviction, - removedMemberships, - }, { - afterSummaryEviction: 0, - removedMemberships: ['copilot://membership'], - }); - }); - test('sessionRemoved notification removes only the matching item', async () => { const { instantiationService, agentHostService } = createTestServices(disposables); @@ -3113,26 +3063,11 @@ suite('AgentHostChatContribution', () => { agentHostService.addSession({ session: AgentSession.uri('copilot', 'out-ws'), startTime: 1000, modifiedTime: 2000, summary: 'Outside workspace', workingDirectories: [URI.file('/other/place')] }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'no-wd'), startTime: 1000, modifiedTime: 2000, summary: 'No working directory' }); - let membershipChecks = 0; - const workspaceMembership: IAgentHostWorkspaceSessionMembershipStore = { - _serviceBrand: undefined, - reconcileBackendSessions: () => { }, - markSeen: () => { }, - shouldInclude: () => { membershipChecks++; return false; }, - remove: () => { }, - has: () => false, - }; - const listController = createSessionListController(disposables, instantiationService, agentHostService, 'agent-host-copilot', 'copilot', undefined, workspaceMembership); + const listController = createSessionListController(disposables, instantiationService, agentHostService); await listController.refresh(CancellationToken.None); - assert.deepStrictEqual({ - labels: listController.items.map(item => item.label), - membershipChecks, - }, { - labels: ['In workspace', 'Tail in workspace'], - membershipChecks: 0, - }); + assert.deepStrictEqual(listController.items.map(item => item.label), ['In workspace', 'Tail in workspace']); }); test('refresh does not filter when no workspace folders are open', async () => { @@ -3185,55 +3120,68 @@ suite('AgentHostChatContribution', () => { }); }); - test('multi-root workspace membership survives workspace folder changes', async () => { + test('multi-root workspace filtering uses workspace-file metadata', async () => { const { instantiationService, agentHostService } = createTestServices(disposables); const a = URI.file('/workspace/a'); const b = URI.file('/workspace/b'); const c = URI.file('/workspace/c'); const d = URI.file('/workspace/d'); + const firstWorkspaceFile = URI.file('/workspace/first.code-workspace'); + const secondWorkspaceFile = URI.file('/workspace/second.code-workspace'); let folders = [a, b]; + let configuration = firstWorkspaceFile; const onDidChangeWorkspaceFolders = disposables.add(new Emitter<{ readonly added: never[]; readonly removed: never[]; readonly changed: never[] }>()); instantiationService.stub(IWorkspaceContextService, { getWorkbenchState: () => WorkbenchState.WORKSPACE, - getWorkspace: () => ({ id: 'workspace', folders: folders.map((uri, index) => ({ uri, name: uri.path, index, toResource: () => uri })) }), + getWorkspace: () => ({ id: 'workspace', configuration, folders: folders.map((uri, index) => ({ uri, name: uri.path, index, toResource: () => uri })) }), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, }); agentHostService.addSession({ - session: AgentSession.uri('copilot', 'multi-root'), + session: AgentSession.uri('copilot', 'matching-workspace-file'), + startTime: 1000, + modifiedTime: 2000, + summary: 'Matching workspace', + workingDirectories: [c, d], + _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: firstWorkspaceFile.toString(), name: 'First' }), + }); + agentHostService.addSession({ + session: AgentSession.uri('copilot', 'different-workspace-file'), + startTime: 1000, + modifiedTime: 2000, + summary: 'Different workspace', + workingDirectories: [a, b], + _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: secondWorkspaceFile.toString(), name: 'Second' }), + }); + agentHostService.addSession({ + session: AgentSession.uri('copilot', 'metadata-less'), startTime: 1000, modifiedTime: 2000, - summary: 'Multi-root session', - workingDirectories: [b, a], + summary: 'Metadata-less', + workingDirectories: [b], }); const listController = createSessionListController(disposables, instantiationService, agentHostService); await listController.refresh(CancellationToken.None); const initial = listController.items.map(item => item.label); - folders = [a]; - onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); - await timeout(0); - const oneOriginalFolderRemaining = listController.items.map(item => item.label); folders = [c, d]; onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); await timeout(0); - const changedMultiRootWorkspace = listController.items.map(item => item.label); - folders = [c]; + const foldersChanged = listController.items.map(item => item.label); + configuration = secondWorkspaceFile; onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); await timeout(0); assert.deepStrictEqual({ initial, - oneOriginalFolderRemaining, - changedMultiRootWorkspace, - singleFolderWorkspace: listController.items.map(item => item.label), + foldersChanged, + workspaceFileChanged: listController.items.map(item => item.label), }, { - initial: ['Multi-root session'], - oneOriginalFolderRemaining: ['Multi-root session'], - changedMultiRootWorkspace: ['Multi-root session'], - singleFolderWorkspace: [], + initial: ['Matching workspace', 'Metadata-less'], + foldersChanged: ['Matching workspace'], + workspaceFileChanged: ['Different workspace'], }); }); @@ -8613,7 +8561,10 @@ suite('AgentHostChatContribution', () => { undefined, undefined, undefined, - { getInitialSessionConfig: () => ({ isolation: 'folder' }) }, + { + getInitialSessionConfig: () => ({ isolation: 'folder' }), + getInitialSessionMetadata: () => ({ multiRoot: { workspaceFile: 'file:///workspace/demo.code-workspace', name: 'Demo' } }), + }, ); disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -8631,7 +8582,13 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.deepStrictEqual(agentHostService.createSessionCalls[0].config, { isolation: 'folder' }); + assert.deepStrictEqual({ + config: agentHostService.createSessionCalls[0].config, + _meta: agentHostService.createSessionCalls[0]._meta, + }, { + config: { isolation: 'folder' }, + _meta: { multiRoot: { workspaceFile: 'file:///workspace/demo.code-workspace', name: 'Demo' } }, + }); })); test('handler waits for an in-flight provisional before falling back to direct creation', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index c55ae8f35f2a1..32b854efe6cac 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -19,7 +19,7 @@ import { ActionType } from '../../../../../../platform/agentHost/common/state/pr import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; -import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { MessageKind, TurnState, type AgentInfo, type RootState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; @@ -160,22 +160,36 @@ suite('AgentHostUntitledProvisionalSessionService', () => { let workspaceTrusted: boolean; let untrustedFolders: Set; let workspaceFolders: URI[]; + let workspaceConfiguration: URI | null; + let workspaceName: string | undefined; + let workbenchState: WorkbenchState; + let isSessionsWindow: boolean; setup(async () => { agentHost = ds.add(new MockAgentHostService()); workspaceTrusted = true; untrustedFolders = new Set(); workspaceFolders = []; + workspaceConfiguration = null; + workspaceName = undefined; + workbenchState = WorkbenchState.EMPTY; + isSessionsWindow = false; const insta = ds.add(new TestInstantiationService()); insta.stub(IAgentHostService, agentHost); insta.stub(ILogService, new NullLogService()); insta.stub(IChatService, new MockChatService()); insta.stub(IConfigurationService, new TestConfigurationService()); - insta.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false } as Partial); + insta.stub(IWorkbenchEnvironmentService, { get isSessionsWindow() { return isSessionsWindow; } } as Partial); insta.stub(IWorkspaceContextService, new class extends mock() { override getWorkspace(): IWorkspace { - return { folders: workspaceFolders.map(uri => ({ uri } as IWorkspaceFolder)) } as IWorkspace; + return { + id: 'workspace', + folders: workspaceFolders.map(uri => ({ uri } as IWorkspaceFolder)), + configuration: workspaceConfiguration, + name: workspaceName, + }; } + override getWorkbenchState(): WorkbenchState { return workbenchState; } }); insta.stub(IWorkspaceTrustManagementService, new class extends mock() { override isWorkspaceTrusted(): boolean { return workspaceTrusted; } @@ -211,6 +225,42 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); }); + test('getOrCreate includes Editor multi-root workspace metadata', async () => { + workspaceFolders = [URI.file('/workspace/one'), URI.file('/workspace/two')]; + workspaceConfiguration = URI.parse('vscode-remote://ssh-remote+host/work/demo.code-workspace'); + workspaceName = 'Demo Workspace'; + workbenchState = WorkbenchState.WORKSPACE; + + await provisional.getOrCreate(untitledChatUri('multi-root'), 'copilot', workspaceFolders[0]); + + assert.deepStrictEqual(agentHost.createCalls[0]._meta, { + multiRoot: { + workspaceFile: workspaceConfiguration.toString(), + name: workspaceName, + }, + }); + }); + + test('getOrCreate omits multi-root metadata without a workspace configuration', async () => { + workspaceFolders = [URI.file('/workspace/one'), URI.file('/workspace/two')]; + workbenchState = WorkbenchState.WORKSPACE; + + await provisional.getOrCreate(untitledChatUri('multi-root-no-config'), 'copilot', workspaceFolders[0]); + + assert.strictEqual(agentHost.createCalls[0]._meta, undefined); + }); + + test('getOrCreate omits multi-root metadata in the Agents window', async () => { + workspaceFolders = [URI.file('/workspace/one'), URI.file('/workspace/two')]; + workspaceConfiguration = URI.file('/workspace/demo.code-workspace'); + workbenchState = WorkbenchState.WORKSPACE; + isSessionsWindow = true; + + await provisional.getOrCreate(untitledChatUri('agents-window'), 'copilot', workspaceFolders[0]); + + assert.strictEqual(agentHost.createCalls[0]._meta, undefined); + }); + test('getOrCreate does not spawn a backend provisional in an untrusted workspace', async () => { workspaceTrusted = false; const ui = untitledChatUri('untrusted'); @@ -416,6 +466,10 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); test('tryRebind waits for pending config reconciliation', async () => { + workspaceFolders = [URI.file('/workspace/one'), URI.file('/workspace/two')]; + workspaceConfiguration = URI.file('/workspace/demo.code-workspace'); + workspaceName = 'Demo Workspace'; + workbenchState = WorkbenchState.WORKSPACE; const ui = untitledChatUri('g'); // Block the re-resolve so it does NOT run before tryRebind's read. const blocked = new DeferredPromise(); @@ -440,7 +494,18 @@ suite('AgentHostUntitledProvisionalSessionService', () => { const reboundCreate = agentHost.createCalls.find(c => c.session?.path === '/real-g'); assert.ok(reboundCreate, 'rebind triggered a createSession'); - assert.strictEqual(reboundCreate.config?.['isolation'], 'worktree'); + assert.deepStrictEqual({ + isolation: reboundCreate.config?.['isolation'], + _meta: reboundCreate._meta, + }, { + isolation: 'worktree', + _meta: { + multiRoot: { + workspaceFile: workspaceConfiguration.toString(), + name: workspaceName, + }, + }, + }); }); test('tryRebind retries when config changes during final session creation', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostWorkspaceSessionMembershipStore.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostWorkspaceSessionMembershipStore.test.ts deleted file mode 100644 index 0e3a92083d309..0000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostWorkspaceSessionMembershipStore.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Event } from '../../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../../../platform/log/common/log.js'; -import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; -import { AgentHostWorkspaceSessionMembershipStore } from '../../../browser/agentSessions/agentHost/agentHostWorkspaceSessionMembershipStore.js'; - -suite('AgentHostWorkspaceSessionMembershipStore', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - class TestMembershipStore extends AgentHostWorkspaceSessionMembershipStore { - constructor( - private readonly _now: () => number, - storageService: InMemoryStorageService, - workspaceContextService: IWorkspaceContextService, - isSessionsWindow = false, - ) { - super(storageService, workspaceContextService, new NullLogService(), { isSessionsWindow } as Partial as IWorkbenchEnvironmentService); - } - - protected override now(): number { - return this._now(); - } - } - - class TestWorkspaceContextService extends mock() { - state = WorkbenchState.WORKSPACE; - folders: URI[] = []; - override readonly onDidChangeWorkspaceFolders = Event.None; - override getWorkbenchState(): WorkbenchState { return this.state; } - override getWorkspace(): IWorkspace { - return upcastPartial({ - id: 'workspace', - folders: this.folders.map((uri, index) => ({ uri, index, name: uri.path, toResource: path => URI.joinPath(uri, path) })), - }); - } - } - - test('workspace membership survives folder and directory-set transitions', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - const c = URI.file('/workspace/c'); - const d = URI.file('/workspace/d'); - workspaceService.folders = [a, b]; - const store = new TestMembershipStore(() => 1000, storageService, workspaceService); - - const key = 'copilot://session'; - const initial = store.shouldInclude(key, [a, b], false); - store.reconcileBackendSessions([key]); - const restoredStore = new TestMembershipStore(() => 1000, storageService, workspaceService); - workspaceService.folders = [c, d]; - const changedWorkspace = restoredStore.shouldInclude(key, [a, b], false); - workspaceService.folders = [c]; - const singleFolder = restoredStore.shouldInclude(key, [a, b], false); - workspaceService.folders = [c, d]; - const shrunkSession = restoredStore.shouldInclude(key, [a], false); - const expandedAgain = restoredStore.shouldInclude(key, [a, b], false); - restoredStore.remove(key); - const afterDelete = restoredStore.shouldInclude(key, [a, b], false); - - assert.deepStrictEqual({ - initial, - changedWorkspace, - singleFolder, - shrunkSession, - expandedAgain, - afterDelete, - }, { - initial: true, - changedWorkspace: true, - singleFolder: false, - shrunkSession: false, - expandedAgain: true, - afterDelete: false, - }); - }); - - test('records only eligible multi-root session provenance', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - const c = URI.file('/workspace/c'); - const d = URI.file('/workspace/d'); - workspaceService.folders = [a, b]; - const store = new TestMembershipStore(() => 1000, storageService, workspaceService); - - const pathMatchKey = 'copilot://path-match'; - const pendingKey = 'copilot://pending'; - const noMatchKey = 'copilot://no-match'; - const singleRootKey = 'copilot://single-root'; - assert.deepStrictEqual({ - pathMatch: store.shouldInclude(pathMatchKey, [c, b], false), - pathMatchStored: store.has(pathMatchKey), - pendingNoMatch: store.shouldInclude(pendingKey, [c, d], true), - pendingStored: store.has(pendingKey), - nonPendingNoMatch: store.shouldInclude(noMatchKey, [c, d], false), - nonPendingStored: store.has(noMatchKey), - singleRootPathMatch: store.shouldInclude(singleRootKey, [a], false), - singleRootStored: store.has(singleRootKey), - }, { - pathMatch: true, - pathMatchStored: true, - pendingNoMatch: true, - pendingStored: true, - nonPendingNoMatch: false, - nonPendingStored: false, - singleRootPathMatch: true, - singleRootStored: false, - }); - }); - - test('last-seen reconciliation retains active sessions and prunes after thirty unseen days', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - workspaceService.folders = [a, b]; - let now = 0; - const store = new TestMembershipStore(() => now, storageService, workspaceService); - const key = 'copilot://session'; - - store.shouldInclude(key, [a, b], false); - now = 20 * 24 * 60 * 60 * 1000; - store.reconcileBackendSessions([key]); - now += 29 * 24 * 60 * 60 * 1000; - store.reconcileBackendSessions([]); - const retained = store.has(key); - now += 2 * 24 * 60 * 60 * 1000; - store.reconcileBackendSessions([]); - - assert.deepStrictEqual({ retained, pruned: store.has(key) }, { retained: true, pruned: false }); - }); - - test('snapshot reconciliation batches new and retained membership writes', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - workspaceService.folders = [a, b]; - let now = 0; - const store = new TestMembershipStore(() => now, storageService, workspaceService); - const first = 'copilot://first'; - const second = 'copilot://second'; - const listenerStore = disposables.add(new DisposableStore()); - let writes = 0; - listenerStore.add(storageService.onDidChangeValue(StorageScope.WORKSPACE, undefined, listenerStore)(() => writes++)); - store.shouldInclude(first, [a, b], false); - store.shouldInclude(second, [a, b], false); - const beforeSnapshot = writes; - store.reconcileBackendSessions([first, second]); - const afterNewMembershipSnapshot = writes; - now = 2 * 24 * 60 * 60 * 1000; - store.reconcileBackendSessions([first, second]); - const afterRetainedMembershipSnapshot = writes; - now += 12 * 60 * 60 * 1000; - store.markSeen(first); - const afterThrottledNotification = writes; - now += 24 * 60 * 60 * 1000; - store.markSeen(first); - - assert.deepStrictEqual({ - beforeSnapshot, - afterNewMembershipSnapshot, - afterRetainedMembershipSnapshot, - afterThrottledNotification, - afterEligibleNotification: writes, - }, { - beforeSnapshot: 0, - afterNewMembershipSnapshot: 1, - afterRetainedMembershipSnapshot: 2, - afterThrottledNotification: 2, - afterEligibleNotification: 3, - }); - }); - - test('snapshot freshness prevents pruning before thirty full days of absence', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - workspaceService.folders = [a, b]; - let now = 0; - const store = new TestMembershipStore(() => now, storageService, workspaceService); - const key = 'copilot://session'; - store.shouldInclude(key, [a, b], false); - store.reconcileBackendSessions([key]); - now = 12 * 60 * 60 * 1000; - store.reconcileBackendSessions([key]); - now += 29 * 24 * 60 * 60 * 1000 + 23 * 60 * 60 * 1000; - store.reconcileBackendSessions([]); - const retainedBeforeThirtyDays = store.has(key); - now += 2 * 60 * 60 * 1000; - store.reconcileBackendSessions([]); - - assert.deepStrictEqual({ retainedBeforeThirtyDays, prunedAfterThirtyDays: store.has(key) }, { - retainedBeforeThirtyDays: true, - prunedAfterThirtyDays: false, - }); - }); - - test('notification path flushes a newly discovered membership', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - workspaceService.folders = [a, b]; - const key = 'copilot://session'; - const store = new TestMembershipStore(() => 1000, storageService, workspaceService); - const listenerStore = disposables.add(new DisposableStore()); - let writes = 0; - listenerStore.add(storageService.onDidChangeValue(StorageScope.WORKSPACE, undefined, listenerStore)(() => writes++)); - - store.shouldInclude(key, [a, b], false); - const beforeMarkSeen = writes; - store.markSeen(key); - const restoredStore = new TestMembershipStore(() => 1000, storageService, workspaceService); - - assert.deepStrictEqual({ beforeMarkSeen, afterMarkSeen: writes, restored: restoredStore.has(key) }, { - beforeMarkSeen: 0, - afterMarkSeen: 1, - restored: true, - }); - }); - - test('ignores malformed persisted membership', () => { - const storageService = disposables.add(new InMemoryStorageService()); - storageService.store('agentHost.workspaceSessionMembership.v1', '{not-json', StorageScope.WORKSPACE, StorageTarget.MACHINE); - const workspaceService = new TestWorkspaceContextService(); - workspaceService.folders = [URI.file('/workspace/a'), URI.file('/workspace/b')]; - const store = new TestMembershipStore(() => 1000, storageService, workspaceService); - - assert.deepStrictEqual({ - hasCorruptEntry: store.has('copilot://corrupt'), - unmatchedSession: store.shouldInclude('copilot://unmatched', [URI.file('/other/a'), URI.file('/other/b')], false), - }, { - hasCorruptEntry: false, - unmatchedSession: false, - }); - }); - - test('storage is dormant in the Agents window', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - const c = URI.file('/workspace/c'); - const d = URI.file('/workspace/d'); - workspaceService.folders = [a, b]; - const key = 'copilot://session'; - const seedStore = new TestMembershipStore(() => 0, storageService, workspaceService); - seedStore.shouldInclude(key, [a, b], false); - seedStore.reconcileBackendSessions([key]); - - const listenerStore = disposables.add(new DisposableStore()); - let writes = 0; - listenerStore.add(storageService.onDidChangeValue(StorageScope.WORKSPACE, undefined, listenerStore)(() => writes++)); - workspaceService.folders = [c, d]; - const sessionsWindowStore = new TestMembershipStore(() => 2 * 24 * 60 * 60 * 1000, storageService, workspaceService, true); - const sessionsWindowIncluded = sessionsWindowStore.shouldInclude(key, [a, b], false); - sessionsWindowStore.reconcileBackendSessions([key]); - sessionsWindowStore.markSeen(key); - sessionsWindowStore.remove(key); - workspaceService.folders = [a, b]; - const restoredEditorStore = new TestMembershipStore(() => 2 * 24 * 60 * 60 * 1000, storageService, workspaceService); - - assert.deepStrictEqual({ - sessionsWindowIncluded, - writes, - membershipPreserved: restoredEditorStore.has(key), - }, { - sessionsWindowIncluded: false, - writes: 0, - membershipPreserved: true, - }); - }); - - test('storage is dormant in Editor windows without a multi-root workspace', () => { - const storageService = disposables.add(new InMemoryStorageService()); - const workspaceService = new TestWorkspaceContextService(); - const a = URI.file('/workspace/a'); - const b = URI.file('/workspace/b'); - const c = URI.file('/workspace/c'); - workspaceService.folders = [a, b]; - const key = 'copilot://session'; - const seedStore = new TestMembershipStore(() => 0, storageService, workspaceService); - seedStore.shouldInclude(key, [a, b], false); - seedStore.reconcileBackendSessions([key]); - - const listenerStore = disposables.add(new DisposableStore()); - let writes = 0; - listenerStore.add(storageService.onDidChangeValue(StorageScope.WORKSPACE, undefined, listenerStore)(() => writes++)); - workspaceService.folders = [c]; - const singleFolderWorkspaceStore = new TestMembershipStore(() => 2 * 24 * 60 * 60 * 1000, storageService, workspaceService); - const singleFolderWorkspaceIncluded = singleFolderWorkspaceStore.shouldInclude(key, [a, b], false); - singleFolderWorkspaceStore.reconcileBackendSessions([key]); - singleFolderWorkspaceStore.markSeen(key); - singleFolderWorkspaceStore.remove(key); - workspaceService.state = WorkbenchState.FOLDER; - const folderWindowStore = new TestMembershipStore(() => 2 * 24 * 60 * 60 * 1000, storageService, workspaceService); - const folderWindowIncluded = folderWindowStore.shouldInclude(key, [a, b], false); - folderWindowStore.reconcileBackendSessions([key]); - folderWindowStore.markSeen(key); - folderWindowStore.remove(key); - workspaceService.state = WorkbenchState.EMPTY; - workspaceService.folders = []; - const emptyWindowStore = new TestMembershipStore(() => 2 * 24 * 60 * 60 * 1000, storageService, workspaceService); - const emptyWindowIncluded = emptyWindowStore.shouldInclude(key, [a, b], false); - emptyWindowStore.reconcileBackendSessions([key]); - emptyWindowStore.markSeen(key); - emptyWindowStore.remove(key); - workspaceService.state = WorkbenchState.WORKSPACE; - workspaceService.folders = [a, b]; - const restoredEditorStore = new TestMembershipStore(() => 2 * 24 * 60 * 60 * 1000, storageService, workspaceService); - - assert.deepStrictEqual({ - singleFolderWorkspaceIncluded, - folderWindowIncluded, - emptyWindowIncluded, - writes, - membershipPreserved: restoredEditorStore.has(key), - }, { - singleFolderWorkspaceIncluded: false, - folderWindowIncluded: false, - emptyWindowIncluded: true, - writes: 0, - membershipPreserved: true, - }); - }); -}); From 54b761bcdf64bebbbf207c9a027f6f2752e65b0f Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 4 Aug 2026 08:23:49 +1000 Subject: [PATCH 2/2] Address multi-root session review feedback Apply workspace-file filtering across folder-count transitions, sanitize provider metadata before database fallbacks, and exercise cache deserialization in tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/node/agentService.ts | 7 ++++--- .../agentHost/test/node/agentService.test.ts | 17 +++++++++++++++++ .../localAgentHostSessionsProvider.test.ts | 15 +++++++++++---- .../agentHost/agentHostSessionListStore.ts | 8 ++++---- .../agentHostChatContribution.test.ts | 14 +++++++++++++- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 42b4f49327471..5ae824930cb67 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -889,10 +889,11 @@ export class AgentService extends Disposable implements IAgentService { // Overlay persisted custom titles from per-session databases. const overlaid = await Promise.all(flat.map(async (s): Promise => { + const sanitized = { ...s, _meta: withSessionMultiRootMetadata(s._meta, undefined) }; try { const ref = await this._sessionDataService.tryOpenDatabase(s.session); if (!ref) { - return s; + return sanitized; } try { // Batch the always-required keys (title / read / archive @@ -915,7 +916,7 @@ export class AgentService extends Disposable implements IAgentService { if (m[PEER_CHAT_BACKING_METADATA_KEY]) { return undefined; } - let updated = { ...s, _meta: withSessionMultiRootMetadata(s._meta, undefined) }; + let updated = sanitized; if (m.customTitle) { updated = { ...updated, summary: m.customTitle }; } @@ -968,7 +969,7 @@ export class AgentService extends Disposable implements IAgentService { } catch (e) { this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e); } - return s; + return sanitized; })); const result = overlaid.filter((s): s is IAgentSessionMetadata => s !== undefined); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 7030f88a48232..ad62ccbe3b96e 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -1452,6 +1452,23 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(readSessionMultiRootMetadata(sessions[0]._meta), multiRoot); }); + test('listSessions strips provider multi-root metadata when no session database exists', async () => { + const sessionId = 'test-session-provider-multi-root'; + const sessionUri = AgentSession.uri('copilot', sessionId); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.sessionMetadataOverrides = { + _meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } }, + }; + (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + + const sessions = await svc.listSessions(); + + assert.strictEqual(readSessionMultiRootMetadata(sessions[0]._meta), undefined); + }); + test('listSessions normalizes a persisted linked-worktree project without probing a missing session worktree', async () => { const db = disposables.add(new TestSessionDatabase()); const primaryRoot = URI.file('/workspace/vscode'); 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 51aa8f69d37e6..d35523b00e48a 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 @@ -1344,19 +1344,26 @@ suite('LocalAgentHostSessionsProvider', () => { await persistCachedSessions(disposables, storageService, [ createSession('multi-root-cached', { summary: 'Multi Root', multiRoot }), ]); - const snapshot = JSON.parse(storageService.get('localAgentHost.cachedSessions.v2', StorageScope.APPLICATION)!) as Array<{ multiRoot?: typeof multiRoot }>; const nextHost = new MockAgentHostService(); disposables.add(toDisposable(() => nextHost.dispose())); nextHost.setAuthenticationPending(true); const session = createProvider(disposables, nextHost, undefined, { storageService }).getSessions()[0]; + nextHost.fireAction({ + channel: AgentSession.uri('copilotcli', 'multi-root-cached').toString(), + action: { type: ActionType.SessionTitleChanged, title: 'Updated after hydration' }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + await storageService.flush(); + const repersisted = JSON.parse(storageService.get('localAgentHost.cachedSessions.v2', StorageScope.APPLICATION)!) as Array<{ multiRoot?: typeof multiRoot }>; assert.deepStrictEqual({ - persisted: snapshot[0].multiRoot, + repersisted: repersisted[0].multiRoot, hydratedTitle: session.title.get(), }, { - persisted: multiRoot, - hydratedTitle: 'Multi Root', + repersisted: multiRoot, + hydratedTitle: 'Updated after hydration', }); })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 609c216db9769..d2554ea5928e0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -371,14 +371,14 @@ export class AgentHostSessionListStore extends Disposable { const workingDirectories = entry.summary.workingDirectories?.map(directory => URI.parse(directory)) ?? []; const workspace = this._workspaceContextService.getWorkspace(); const folders = workspace.folders; - if (folders.length === 0) { - return true; - } const multiRoot = readSessionMultiRootMetadata(entry.summary._meta); - if (folders.length > 1 && multiRoot) { + if (multiRoot) { return URI.isUri(workspace.configuration) && extUriBiasedIgnorePathCase.isEqual(URI.parse(multiRoot.workspaceFile), workspace.configuration); } + if (folders.length === 0) { + return true; + } return workingDirectories.some(directory => folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) ); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index a4818860c33c7..edbac0520479e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -3170,6 +3170,14 @@ suite('AgentHostChatContribution', () => { onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); await timeout(0); const foldersChanged = listController.items.map(item => item.label); + folders = [c]; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + await timeout(0); + const oneFolderRemaining = listController.items.map(item => item.label); + folders = []; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + await timeout(0); + const noFoldersRemaining = listController.items.map(item => item.label); configuration = secondWorkspaceFile; onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); await timeout(0); @@ -3177,11 +3185,15 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual({ initial, foldersChanged, + oneFolderRemaining, + noFoldersRemaining, workspaceFileChanged: listController.items.map(item => item.label), }, { initial: ['Matching workspace', 'Metadata-less'], foldersChanged: ['Matching workspace'], - workspaceFileChanged: ['Different workspace'], + oneFolderRemaining: ['Matching workspace'], + noFoldersRemaining: ['Matching workspace', 'Metadata-less'], + workspaceFileChanged: ['Different workspace', 'Metadata-less'], }); });