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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/skills/sessions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ export const GITHUB_REPO_PROTECTED_RESOURCE: ProtectedResourceMetadata = {
export interface IAgentCreateSessionConfig {
readonly provider?: AgentProvider;
readonly model?: ModelSelection;
readonly _meta?: Record<string, unknown>;
/**
* Initial custom agent selection for the new session. Omit to start with
* no custom agent selected (provider default behavior).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

// ─── Pagination ──────────────────────────────────────────────────────────────
Expand Down
61 changes: 61 additions & 0 deletions src/vs/platform/agentHost/common/state/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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;
Expand Down
63 changes: 51 additions & 12 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<IAgentSessionMetadata | undefined> => {
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
Expand All @@ -904,8 +905,8 @@ export class AgentService extends Disposable implements IAgentService {
const sessionStr = s.session.toString();
const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr);
const metadataKeys: Record<string, true> = 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
Expand All @@ -915,7 +916,7 @@ export class AgentService extends Disposable implements IAgentService {
if (m[PEER_CHAT_BACKING_METADATA_KEY]) {
return undefined;
}
let updated = s;
let updated = sanitized;
if (m.customTitle) {
updated = { ...updated, summary: m.customTitle };
}
Expand Down Expand Up @@ -947,6 +948,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) {
Expand All @@ -964,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);

Expand All @@ -987,9 +992,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,
Expand Down Expand Up @@ -1282,6 +1288,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
Expand Down Expand Up @@ -1510,7 +1517,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));
}
Expand Down Expand Up @@ -1730,6 +1737,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,
Expand All @@ -1749,7 +1764,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 } : {}),
};
}

Expand Down Expand Up @@ -1802,6 +1817,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.
Expand Down Expand Up @@ -1880,6 +1896,24 @@ export class AgentService extends Disposable implements IAgentService {
});
}

private _persistMultiRoot(session: URI, multiRoot: ReturnType<typeof readSessionMultiRootMetadata>): 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<string, unknown>): void {
let ref;
try {
Expand Down Expand Up @@ -2708,6 +2742,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,
});
Expand Down Expand Up @@ -2757,6 +2792,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 {
Expand All @@ -2783,6 +2819,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,
Expand All @@ -2793,7 +2832,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([
Expand Down Expand Up @@ -2842,8 +2881,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
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/agentHost/node/protocolServerHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading