microsoft/vscode · #334534

agentHost: Preserve workspace transition boundaries

sandy081 · merged Sep 4, 202627 files · 1908 + / 229
src/vs/platform/agentHost/common/agentHostChatContributionsService.ts2 + / 0
@@ -108,6 +108,8 @@ export type IncomingRequestDisposition = export interface IHydrationContext { 	readonly session: ProtocolURI; 	readonly chat: ProtocolURI;+	/** Authoritative value from already-loaded host session metadata, when available. */+	readonly hasWorkspaceTransitions?: boolean; }  /**
src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts49 + / 0
@@ -5,6 +5,8 @@  export const enum AgentSystemNotificationKind { 	WorktreeCreationFailure = 'worktreeCreationFailure',+	/** The session successfully changed to a requested workspace. */+	WorkspaceTransition = 'workspaceTransition', 	/** An automatic approval review did not finish before its deadline. */ 	AutomaticApprovalReviewTimedOut = 'automaticApprovalReviewTimedOut', 	/** An automatic approval review stopped before reaching a decision. */@@ -21,12 +23,18 @@ export const enum AgentSystemNotificationKind { 	AgentMergePullRequestMerged = 'agentMergePullRequestMerged', } +export const enum AgentSystemNotificationWorkspaceKind {+	Folder = 'folder',+	Worktree = 'worktree',+}+ export const enum AgentSystemNotificationSeverity { 	Warning = 'warning', }  const knownKinds: ReadonlySet<string> = new Set<string>([ 	AgentSystemNotificationKind.WorktreeCreationFailure,+	AgentSystemNotificationKind.WorkspaceTransition, 	AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut, 	AgentSystemNotificationKind.AutomaticApprovalReviewAborted, 	AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted,@@ -43,6 +51,14 @@ interface IHasSystemNotificationMeta { export interface IAgentSystemNotificationMeta { 	readonly kind?: AgentSystemNotificationKind; 	readonly severity?: AgentSystemNotificationSeverity;+	readonly workspaceKind?: AgentSystemNotificationWorkspaceKind;+	readonly workspaceName?: string;+}++export interface IAgentWorkspaceTransitionRecord {+	readonly content: string;+	readonly workspaceKind: AgentSystemNotificationWorkspaceKind;+	readonly workspaceName: string; }  /** Reads recognized Agent Host system-notification metadata. */@@ -52,13 +68,46 @@ export function readAgentSystemNotificationMeta(source: IHasSystemNotificationMe 		return {}; 	} 	const kind = meta['kind'];+	const workspaceKind = meta['workspaceKind']; 	return { 		kind: typeof kind === 'string' && knownKinds.has(kind) ? kind as AgentSystemNotificationKind : undefined, 		severity: meta['severity'] === AgentSystemNotificationSeverity.Warning ? meta['severity'] : undefined,+		workspaceKind: workspaceKind === AgentSystemNotificationWorkspaceKind.Folder || workspaceKind === AgentSystemNotificationWorkspaceKind.Worktree ? workspaceKind : undefined,+		workspaceName: typeof meta['workspaceName'] === 'string' ? meta['workspaceName'] : undefined, 	}; }  /** Serializes Agent Host system-notification metadata for the open protocol bag. */ export function toAgentSystemNotificationMeta(meta: IAgentSystemNotificationMeta): Record<string, unknown> { 	return { ...meta }; }++/** Serializes a durable workspace-transition boundary. */+export function serializeAgentWorkspaceTransition(record: IAgentWorkspaceTransitionRecord): string {+	return JSON.stringify(record);+}++/** Parses and validates a durable workspace-transition boundary. */+export function parseAgentWorkspaceTransition(value: string): IAgentWorkspaceTransitionRecord | undefined {+	let parsed: unknown;+	try {+		parsed = JSON.parse(value);+	} catch {+		return undefined;+	}+	if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {+		return undefined;+	}+	const candidate = parsed as Partial<IAgentWorkspaceTransitionRecord>;+	if (typeof candidate.content !== 'string'+		|| typeof candidate.workspaceName !== 'string'+		|| (candidate.workspaceKind !== AgentSystemNotificationWorkspaceKind.Folder && candidate.workspaceKind !== AgentSystemNotificationWorkspaceKind.Worktree)+	) {+		return undefined;+	}+	return {+		content: candidate.content,+		workspaceKind: candidate.workspaceKind,+		workspaceName: candidate.workspaceName,+	};+}
src/vs/platform/agentHost/common/meta/agentWorkspaceContinuationMeta.tsadded21 + / 0
@@ -0,0 +1,21 @@+/*---------------------------------------------------------------------------------------------+ *  Copyright (c) Microsoft Corporation. All rights reserved.+ *  Licensed under the MIT License. See License.txt in the project root for license information.+ *--------------------------------------------------------------------------------------------*/++const AGENT_WORKSPACE_CONTINUATION_META_KEY = 'vscode.chat.workspaceContinuation';++interface IHasAgentWorkspaceContinuationMeta {+	readonly _meta?: Record<string, unknown>;+}++/** Whether the message is the internal request that resumes a turn after workspace conversion. */+export function isAgentWorkspaceContinuationMessage(source: IHasAgentWorkspaceContinuationMeta): boolean {+	// eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced workspace-continuation slot; validated here.+	return source._meta?.[AGENT_WORKSPACE_CONTINUATION_META_KEY] === true;+}++/** Serializes the workspace-continuation marker for the open protocol bag. */+export function toAgentWorkspaceContinuationMessageMeta(): Record<string, unknown> {+	return { [AGENT_WORKSPACE_CONTINUATION_META_KEY]: true };+}
src/vs/platform/agentHost/common/sessionDataService.ts21 + / 0
@@ -180,6 +180,27 @@ export interface ISessionDatabase extends IDisposable { 	 */ 	getTurnDelegations(): Promise<Map<string, string>>; +	/**+	 * Persists the JSON-serialized successful workspace transition for a turn.+	 * Idempotent — last writer wins per turn.+	 */+	setTurnWorkspaceTransition(turnId: string, transition: string): Promise<void>;++	/**+	 * Atomically persists converted session metadata and the workspace+	 * transition associated with its deferred continuation turn.+	 */+	setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly<Record<string, string>>): Promise<void>;++	/** Deletes a persisted workspace transition without deleting its owning turn. */+	deleteTurnWorkspaceTransition(turnId: string): Promise<void>;++	/**+	 * Returns every persisted workspace transition, keyed by both the turn's+	 * own id and its provider event id when one has been recorded.+	 */+	getTurnWorkspaceTransitions(): Promise<Map<string, string>>;+ 	/** 	 * Associates a git checkpoint ref (e.g. `refs/agents/<sid>/checkpoints/turn/N`) 	 * with a turn. Idempotent — last writer wins per turn.
src/vs/platform/agentHost/common/state/sessionState.ts26 + / 1
@@ -15,6 +15,7 @@ import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/bu import { hasKey, type Mutable } from '../../../../base/common/types.js'; import { URI as ResourceURI } from '../../../../base/common/uri.js'; import type { IProductService } from '../../../product/common/productService.js';+import { isAgentWorkspaceContinuationMessage } from '../meta/agentWorkspaceContinuationMeta.js'; import { readToolCallMeta } from '../meta/agentToolCallMeta.js'; import { readLegacyTurnError } from './legacyProtocolCompatibility.js'; import {@@ -357,10 +358,12 @@ export function withMessageSystemInitiatedLabel(message: Message, label: string)  *  * A *visible* system notification (a background-agent completion, an Agent  * Merge repair prompt) is a real turn and is deliberately not matched.+ * A hidden workspace-continuation request is also a real provider turn.  */ export function isHostNoticeTurn(turn: { readonly message: Message }): boolean { 	return turn.message.origin.kind === MessageKind.SystemNotification-		&& (isMessageHiddenFromTranscript(turn.message) || isMessageRequestHiddenFromTranscript(turn.message));+		&& (isMessageHiddenFromTranscript(turn.message) || isMessageRequestHiddenFromTranscript(turn.message))+		&& !isAgentWorkspaceContinuationMessage(turn.message); }  /** Returns the last turn id that can own file changes, or `undefined` if there is none. */@@ -2012,6 +2015,12 @@ export const SESSION_META_WORKSPACELESS_KEY = 'workspaceless';  */ export const AH_META_WORKSPACELESS_DB_KEY = 'agentHost.workspaceless'; +/** Session-database marker indicating that retained turns include workspace-transition boundaries. */+export const AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY = 'agentHost.hasWorkspaceTransitions';++/** Summary metadata mirror of {@link AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY}. */+export const SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY = 'hasWorkspaceTransitions';+ /** Blocks turns for a session whose provider could not be detached from an untrusted working directory. */ export const AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY = 'agentHost.workspaceConversionQuarantined'; @@ -2073,6 +2082,22 @@ export function withSessionWorkspaceless(meta: SessionSummaryMeta | undefined, w 	return Object.keys(next).length > 0 ? next : undefined; } +/** Whether retained turns in this session include host-owned workspace transitions. */+export function readSessionHasWorkspaceTransitions(meta: SessionSummaryMeta | undefined): boolean {+	return meta?.[SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY] === true;+}++/** Returns summary metadata with the workspace-transition history marker updated. */+export function withSessionHasWorkspaceTransitions(meta: SessionSummaryMeta | undefined, hasTransitions: boolean): SessionSummaryMeta | undefined {+	const next: { [key: string]: unknown } = { ...meta };+	if (hasTransitions) {+		next[SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY] = true;+	} else {+		delete next[SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY];+	}+	return Object.keys(next).length > 0 ? next : undefined;+}+ /** Whether the session was first discovered in a provider-native catalog. */ export function readSessionExternal(meta: SessionSummaryMeta | undefined): boolean { 	return meta?.[SESSION_META_EXTERNAL_KEY] === true;
src/vs/platform/agentHost/node/agentService.ts27 + / 16
@@ -37,7 +37,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 Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js';-import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_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, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';+import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_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, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionHasWorkspaceTransitions, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js';@@ -2215,8 +2215,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_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [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_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [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_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [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_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [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@@ -2242,6 +2242,9 @@ export class AgentService extends Disposable implements IAgentService { 					if (creationReference) { 						updated = { ...updated, _meta: withSessionCreationReference(updated._meta, creationReference) }; 					}+					if (m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] !== undefined) {+						updated = { ...updated, _meta: withSessionHasWorkspaceTransitions(updated._meta, m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] === 'true') };+					} 					if (m[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]) { 						try { 							const metadata = readAgentDevContainerWorktreeMetadata({@@ -3489,12 +3492,12 @@ export class AgentService extends Disposable implements IAgentService { 	 * exactly that window; every other caller relies on the exhaustive origin 	 * {@link _chatContext} stamps. 	 */-	private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin): Promise<readonly Turn[]> {+	private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin, hasWorkspaceTransitions?: boolean): Promise<readonly Turn[]> { 		const context = { ...this._chatContext(session, chat), ...(origin ? { origin } : {}) }; 		this._logService.trace(`[AgentService] getChatMessages start: chat=${chat.toString()}`); 		const providerTurns = await provider.chats.getMessages(chat, context); 		this._logService.trace(`[AgentService] getChatMessages: provider returned ${providerTurns.length} turn(s) for chat=${chat.toString()}`);-		return this._chatContributions.hydrateTurns({ session: session.toString(), chat: chat.toString() }, providerTurns);+		return this._chatContributions.hydrateTurns({ session: session.toString(), chat: chat.toString(), hasWorkspaceTransitions }, providerTurns); 	}  	/**@@ -5497,17 +5500,6 @@ export class AgentService extends Disposable implements IAgentService { 		if (providerData === undefined && materializedDefaultChat?.providerData === undefined) { 			this._logService.warn(`[AgentService] Restoring default chat ${defaultChatUri.toString()} with no persisted or recovered provider backing (agent=${agent.id})`); 		}-		let turns: readonly Turn[];-		try {-			turns = await this._getChatMessages(agent, defaultChatUri, session);-		} catch (err) {-			if (err instanceof ProtocolError) {-				throw err;-			}-			const message = err instanceof Error ? err.message : String(err);-			throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`);-		}- 		// Check for persisted metadata in the session database 		let title = meta.summary ?? 'Session'; 		let isRead: boolean | undefined;@@ -5517,6 +5509,7 @@ export class AgentService extends Disposable implements IAgentService { 		let gitMetadata: Record<string, string | undefined> | undefined; 		let changesetMetadata: Record<string, string | undefined> | undefined; 		let sessionMetadata: Record<string, unknown> | undefined;+		let sessionMetadataRead = false; 		const ref = this._sessionDataService.tryOpenDatabase?.(session); 		if (ref) { 			try {@@ -5529,6 +5522,7 @@ export class AgentService extends Disposable implements IAgentService { 							[AH_META_IS_ARCHIVED_DB_KEY]: true, 							[AH_META_IS_DONE_DB_KEY]: true, 							configValues: true,+							[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: true, 							[AH_META_WORKSPACELESS_DB_KEY]: true, 							[AH_META_EHCLI_ADOPTED_DB_KEY]: true, 							[AH_META_EHCLI_LAST_TURN_DB_KEY]: true,@@ -5540,6 +5534,7 @@ export class AgentService extends Disposable implements IAgentService { 							...GIT_DB_METADATA_KEYS, 							...CHANGESET_DB_METADATA_KEYS, 						});+						sessionMetadataRead = true; 						if (m.customTitle) { 							title = m.customTitle; 						}@@ -5594,6 +5589,9 @@ 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'); 						}+						if (m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] !== undefined) {+							sessionMetadata = withSessionHasWorkspaceTransitions(sessionMetadata, m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] === 'true');+						} 						if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) { 							sessionMetadata = withSessionEhcliAdopted(sessionMetadata, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true'); 						}@@ -5630,13 +5628,26 @@ export class AgentService extends Disposable implements IAgentService { 					} finally { 						db.dispose(); 					}+				} else {+					sessionMetadataRead = true; 				} 			} catch { 				// Best-effort: fall back to agent-provided metadata 			} 		} 		this._logService.trace(`[AgentService] restore: persisted session metadata read for ${sessionStr}`); +		let turns: readonly Turn[];+		try {+			turns = await this._getChatMessages(agent, defaultChatUri, session, undefined, sessionMetadataRead ? readSessionHasWorkspaceTransitions(sessionMetadata) : undefined);+		} catch (err) {+			if (err instanceof ProtocolError) {+				throw err;+			}+			const message = err instanceof Error ? err.message : String(err);+			throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`);+		}+ 		// Encode isRead/isArchived as status bitmask flags 		let status: SessionStatus = SessionStatus.Idle; 		if (isRead) {
src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts85 + / 1
@@ -4,8 +4,15 @@  *--------------------------------------------------------------------------------------------*/  import { Disposable } from '../../../../../base/common/lifecycle.js';+import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js';-import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js';+import { ILogService } from '../../../../log/common/log.js';+import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js';+import { parseAgentWorkspaceTransition, AgentSystemNotificationKind, readAgentSystemNotificationMeta, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js';+import { toAgentWorkspaceContinuationMessageMeta } from '../../../common/meta/agentWorkspaceContinuationMeta.js';+import { ISessionDataService } from '../../../common/sessionDataService.js';+import { chatStorageUri, readSessionHasWorkspaceTransitions, ResponsePartKind, withMessageRequestHiddenFromTranscript, type Turn } from '../../../common/state/sessionState.js';+import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { ISessionWorkspaceConversionService } from './sessionWorkspaceConversionService.js';  /** Finalizes requested workspace conversions after a turn and blocks new turns while conversion is pending. */@@ -17,6 +24,9 @@ export class SessionWorkspaceConversionContribution extends Disposable implement 	constructor( 		protected readonly _context: IAgentHostChatContributionContext, 		@ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService,+		@IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,+		@ISessionDataService private readonly _sessionDataService: ISessionDataService,+		@ILogService private readonly _logService: ILogService, 	) { 		super(); 	}@@ -42,4 +52,78 @@ export class SessionWorkspaceConversionContribution extends Disposable implement 			stage: 'validation', 		}; 	}++	async onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise<readonly Turn[]> {+		if (turns.length === 0) {+			return turns;+		}+		if (context.hasWorkspaceTransitions === false) {+			return turns;+		}+		if (context.hasWorkspaceTransitions !== true) {+			const summary = this._stateManager.getSessionSummary(context.session);+			if (summary && !readSessionHasWorkspaceTransitions(summary._meta)) {+				return turns;+			}+		}+		const storage = chatStorageUri(URI.parse(context.chat));+		if (!storage) {+			return turns;+		}+		const database = await this._sessionDataService.tryOpenDatabase(storage);+		if (!database) {+			return turns;+		}+		let transitions: Map<string, string>;+		try {+			transitions = await database.object.getTurnWorkspaceTransitions();+		} catch (error) {+			this._logService.warn(`[SessionWorkspaceConversionContribution] Failed to restore workspace transitions for ${storage.toString()}`, error);+			return turns;+		} finally {+			database.dispose();+		}+		if (transitions.size === 0) {+			return turns;+		}+		return turns.map(turn => {+			const rawTransition = transitions.get(turn.id);+			if (!rawTransition) {+				return turn;+			}+			const transition = parseAgentWorkspaceTransition(rawTransition);+			if (!transition) {+				return turn;+			}+			const transitionPart = {+				kind: ResponsePartKind.SystemNotification,+				content: transition.content,+				_meta: toAgentSystemNotificationMeta({+					kind: AgentSystemNotificationKind.WorkspaceTransition,+					workspaceKind: transition.workspaceKind,+					workspaceName: transition.workspaceName,+				}),+			} as const;+			const existingIndex = turn.responseParts.findIndex(part =>+				part.kind === ResponsePartKind.SystemNotification+				&& readAgentSystemNotificationMeta(part).kind === AgentSystemNotificationKind.WorkspaceTransition+			);+			const responseParts = existingIndex < 0+				? [transitionPart, ...turn.responseParts]+				: existingIndex === 0+					? turn.responseParts+					: [turn.responseParts[existingIndex], ...turn.responseParts.slice(0, existingIndex), ...turn.responseParts.slice(existingIndex + 1)];+			return {+				...turn,+				message: withMessageRequestHiddenFromTranscript({+					...turn.message,+					_meta: {+						...turn.message._meta,+						...toAgentWorkspaceContinuationMessageMeta(),+					},+				}, true),+				responseParts,+			};+		});+	} }
src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts118 + / 24
@@ -7,18 +7,22 @@ import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js';-import { isEqual } from '../../../../../base/common/resources.js';+import { basename, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { createDecorator } from '../../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../../log/common/log.js'; import { AgentSession, AgentWorkingDirectoryChangedError, type IAgent, type IAgentSessionProjectInfo } from '../../../common/agent.js';+import { AgentHostGlobalAutoApproveEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../../common/agentHostSchema.js';+import { toAgentWorkspaceContinuationMessageMeta } from '../../../common/meta/agentWorkspaceContinuationMeta.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js';+import { AgentSystemNotificationKind, AgentSystemNotificationWorkspaceKind, serializeAgentWorkspaceTransition, type IAgentSystemNotificationMeta, type IAgentWorkspaceTransitionRecord, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js'; import { ActionType } from '../../../common/state/sessionActions.js';-import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, isDefaultChatUri, MessageKind, parseChatUri, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withMessageSystemInitiatedLabel, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } from '../../../common/state/sessionState.js';+import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, chatStorageUri, isDefaultChatUri, MessageKind, parseChatUri, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withMessageRequestHiddenFromTranscript, withMessageSystemInitiatedLabel, withSessionHasWorkspaceTransitions, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { IAgentHostClientConnectionService } from '../../agentHostClientConnectionService.js';+import { IAgentConfigurationService } from '../../agentConfigurationService.js'; import { IAgentHostProviderService } from '../../agentHostProviderService.js'; import { IAgentHostTurnService, type IDeferredAgentHostTurn } from '../../agentHostTurnService.js'; import { IAgentHostServerToolService } from '../../shared/agentServerToolHost.js';@@ -33,6 +37,8 @@ interface IPendingSessionWorkspaceConversion { 	readonly prompt: string | undefined; 	phase: 'requested' | 'converting'; 	resolvedWorkingDirectory?: URI;+	transition?: IAgentWorkspaceTransitionRecord;+	transitionPersisted?: boolean; }  interface IResolvedWorkspace {@@ -70,6 +76,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 		@IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, 		@ISessionDataService private readonly _sessionDataService: ISessionDataService, 		@IAgentHostWorktreeIsolation private readonly _worktreeIsolation: IAgentHostWorktreeIsolation,+		@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, 		@IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, 		@IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, 		@IAgentHostServerToolService private readonly _serverToolHost: IAgentHostServerToolService,@@ -121,30 +128,46 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 		let continuation: IDeferredAgentHostTurn | undefined; 		try { 			continuation = this._beginContinuation(pending);-			pending.resolvedWorkingDirectory = await this._convert(pending.chat, pending.workspaceFolder, pending.isolation, pending.initiatingClientId, pending.prompt);+			pending.transition = this._createWorkspaceTransition(pending);+			pending.resolvedWorkingDirectory = await this._convert(pending, continuation); 			this._pending.delete(chat);-			this._continueConversion(continuation, pending, true);+			await this._continueConversion(continuation, pending, true); 		} catch (error) {-			this._logService.error(`[SessionWorkspaceConversionService] Failed to convert ${pending.chat.toString()}: ${toErrorMessage(error)}`);-			if (error instanceof UnsafeProviderWorkingDirectoryError) {+			let conversionError = error;+			if (pending.transitionPersisted && continuation) {+				try {+					await this._discardPersistedWorkspaceTransition(pending, continuation);+				} catch (discardError) {+					conversionError = new UnsafeProviderWorkingDirectoryError(`${toErrorMessage(error)}; failed to discard the workspace transition: ${toErrorMessage(discardError)}`);+				}+			}+			this._logService.error(`[SessionWorkspaceConversionService] Failed to convert ${pending.chat.toString()}: ${toErrorMessage(conversionError)}`);+			if (conversionError instanceof UnsafeProviderWorkingDirectoryError) { 				this._pending.delete(chat); 				this._quarantined.add(chat);-				this._failConversion(continuation, pending, error);+				this._failConversion(continuation, pending, conversionError); 			} else { 				this._pending.delete(chat);-				this._continueConversion(continuation, pending, false, error);+				await this._continueConversion(continuation, pending, false, conversionError); 			} 		} 	} -	private async _convert(chat: URI, workspaceFolder: URI, isolation: boolean, initiatingClientId: string, prompt?: string): Promise<URI> {+	private async _convert(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise<URI> {+		const { chat, workspaceFolder, isolation, initiatingClientId, prompt, transition } = pending;+		if (!transition) {+			throw new Error('Cannot convert a session without a workspace transition.');+		} 		const { session, state, previousWorkingDirectory } = this._validateConversion(chat, workspaceFolder); 		const provider = this._providerService.getProviderForSession(session); 		if (!provider?.agentHostCapabilities.workspaceConversion) { 			throw new Error(`Provider does not support changing the working directory: ${AgentSession.provider(session) ?? '(unknown)'}`); 		}-		await this._requireWorkspaceTrust(initiatingClientId, workspaceFolder);-		const resolvedWorkspace = await this._resolveWorkspace(session, chat, workspaceFolder, isolation, initiatingClientId, prompt, state.config?.values);+		const sessionKey = session.toString();+		const workspaceTrustRequired = this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) !== true+			&& (this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default') !== 'autoApprove';+		await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, workspaceFolder);+		const resolvedWorkspace = await this._resolveWorkspace(session, chat, workspaceFolder, isolation, workspaceTrustRequired, initiatingClientId, prompt, state.config?.values); 		let authoritativeWorkingDirectory = resolvedWorkspace.workingDirectory; 		let providerAlignmentError: AgentWorkingDirectoryChangedError | undefined; 		try {@@ -162,7 +185,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 		} 		if (!isEqual(authoritativeWorkingDirectory, resolvedWorkspace.workingDirectory)) { 			try {-				await this._requireWorkspaceTrust(initiatingClientId, authoritativeWorkingDirectory);+				await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, authoritativeWorkingDirectory); 			} catch (error) { 				const finalizationErrors = [error]; 				const disposal = await this._disposeUnsafeProviderChat(provider, chat, session);@@ -201,13 +224,19 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 			? { ...convertedState.config?.values, ...configPatch } 			: undefined; 		let persistenceError: unknown;+		const persistTransition = this._stateManager.getActiveTurnId(chat.toString()) === continuation.turnId; 		const database = this._sessionDataService.openDatabase(session); 		try { 			const metadata = { [AH_META_WORKSPACELESS_DB_KEY]: 'false' }; 			if (configValues) { 				Object.assign(metadata, { configValues: JSON.stringify(configValues) }); 			}-			await database.object.setMetadataValues(metadata);+			if (persistTransition) {+				await database.object.setWorkspaceConversion(continuation.turnId, serializeAgentWorkspaceTransition(transition), metadata);+				pending.transitionPersisted = true;+			} else {+				await database.object.setMetadataValues(metadata);+			} 		} catch (error) { 			persistenceError = error; 		} finally {@@ -241,7 +270,10 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 				displayName: resolvedWorkspace.project.displayName, 			}); 		}-		this._stateManager.setSessionMeta(session.toString(), withSessionWorkspaceless(finalState._meta, false));+		this._stateManager.setSessionMeta(+			session.toString(),+			withSessionHasWorkspaceTransitions(withSessionWorkspaceless(finalState._meta, false), persistTransition),+		); 		this._stateManager.dispatchServerAction(session.toString(), { 			type: ActionType.SessionWorkingDirectoryReplaced, 			directory: previousWorkingDirectory,@@ -271,7 +303,10 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 		return authoritativeWorkingDirectory; 	} -	private async _requireWorkspaceTrust(clientId: string, workspace: URI, trustedParent?: URI): Promise<void> {+	private async _requireWorkspaceTrust(required: boolean, clientId: string, workspace: URI, trustedParent?: URI): Promise<void> {+		if (!required) {+			return;+		} 		const trusted = await this._clientConnections.requestWorkspaceTrust(clientId, { 			workspace: workspace.toString(), 			...(trustedParent ? { trustedParent: trustedParent.toString() } : {}),@@ -317,6 +352,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 		chat: URI, 		workspaceFolder: URI, 		isolation: boolean,+		workspaceTrustRequired: boolean, 		initiatingClientId: string, 		prompt: string | undefined, 		currentConfig: Record<string, unknown> | undefined,@@ -355,9 +391,9 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 			prompt, 			onWillCreate: async metadata => { 				if (!isEqual(metadata.repositoryRoot, workspaceFolder)) {-					await this._requireWorkspaceTrust(initiatingClientId, metadata.repositoryRoot);+					await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, metadata.repositoryRoot); 				}-				await this._requireWorkspaceTrust(initiatingClientId, metadata.worktreePath, metadata.repositoryRoot);+				await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, metadata.worktreePath, metadata.repositoryRoot); 			}, 		}); 		if (!workingDirectory || isEqual(workingDirectory, workspaceFolder)) {@@ -460,14 +496,24 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 	}  	private _beginContinuation(pending: IPendingSessionWorkspaceConversion): IDeferredAgentHostTurn {-		const continuation = this._turnService.beginDeferredTurnMessage(pending.chat, withMessageSystemInitiatedLabel({+		const continuation = this._turnService.beginDeferredTurnMessage(pending.chat, withMessageRequestHiddenFromTranscript(withMessageSystemInitiatedLabel({ 			text: localize('agentHost.continueInWorkspaceMessage', "Continue in the requested workspace."), 			origin: { kind: MessageKind.SystemNotification },-		}, localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace")));+			_meta: toAgentWorkspaceContinuationMessageMeta(),+		}, localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace")), true)); 		return continuation; 	} -	private _continueConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, converted: boolean, error?: unknown): void {+	private _createWorkspaceTransition(pending: IPendingSessionWorkspaceConversion): IAgentWorkspaceTransitionRecord {+		const workspaceName = basename(pending.workspaceFolder) || pending.workspaceFolder.path;+		return {+			content: localize('agentHost.workspaceTransitionLabel', "Now working in {0}", workspaceName),+			workspaceKind: pending.isolation ? AgentSystemNotificationWorkspaceKind.Worktree : AgentSystemNotificationWorkspaceKind.Folder,+			workspaceName,+		};+	}++	private async _continueConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, converted: boolean, error?: unknown): Promise<void> { 		if (!continuation) { 			this._logService.error(`[SessionWorkspaceConversionService] Cannot continue workspace conversion for ${pending.chat.toString()} because its deferred turn did not start.`); 			return;@@ -479,17 +525,64 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 		const label = converted 			? localize('agentHost.workspaceSetLabel', "Workspace Set") 			: localize('agentHost.workspaceSetupFailedLabel', "Workspace Setup Failed");-		this._publishConversionOutcome(pending.chat, continuation, label);+		if (converted && pending.transitionPersisted && pending.transition) {+			this._publishConversionOutcome(+				pending.chat,+				continuation,+				pending.transition.content,+				{+					kind: AgentSystemNotificationKind.WorkspaceTransition,+					workspaceKind: pending.transition.workspaceKind,+					workspaceName: pending.transition.workspaceName,+				},+			);+		} else {+			this._publishConversionOutcome(pending.chat, continuation, label);+		} 		try { 			if (!this._turnService.continueDeferredTurnMessage(pending.chat, continuation, withMessageSystemInitiatedLabel({ 				text, 				origin: { kind: MessageKind.SystemNotification }, 			}, label))) {+				await this._discardPersistedWorkspaceTransition(pending, continuation); 				this._logService.info(`[SessionWorkspaceConversionService] The deferred workspace conversion turn for ${pending.chat.toString()} ended before it could continue.`); 			} 		} catch (continuationError) {-			this._logService.error(`[SessionWorkspaceConversionService] Failed to start the conversion continuation for ${pending.chat.toString()}: ${toErrorMessage(continuationError)}`);-			this._failConversion(continuation, pending, continuationError instanceof Error ? continuationError : new Error(toErrorMessage(continuationError)), false);+			let failure = continuationError;+			try {+				await this._discardPersistedWorkspaceTransition(pending, continuation);+			} catch (discardError) {+				failure = new Error(`${toErrorMessage(continuationError)}; failed to discard the workspace transition: ${toErrorMessage(discardError)}`);+			}+			this._logService.error(`[SessionWorkspaceConversionService] Failed to start the conversion continuation for ${pending.chat.toString()}: ${toErrorMessage(failure)}`);+			this._failConversion(continuation, pending, failure instanceof Error ? failure : new Error(toErrorMessage(failure)), false);+		}+	}++	private async _discardPersistedWorkspaceTransition(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise<void> {+		if (!pending.transitionPersisted) {+			return;+		}+		await this._discardWorkspaceTransition(pending.chat, continuation.turnId);+		pending.transitionPersisted = false;+	}++	private async _discardWorkspaceTransition(chat: URI, turnId: string): Promise<void> {+		const storage = chatStorageUri(chat);+		if (!storage) {+			throw new Error(`Cannot discard workspace transition for invalid chat resource: ${chat.toString()}`);+		}+		const database = this._sessionDataService.openDatabase(storage);+		try {+			await database.object.deleteTurnWorkspaceTransition(turnId);+			const hasTransitions = await database.object.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY) === 'true';+			const session = parseChatUri(chat)?.session;+			if (session) {+				const state = this._stateManager.getSessionState(session);+				this._stateManager.setSessionMeta(session, withSessionHasWorkspaceTransitions(state?._meta, hasTransitions));+			}+		} finally {+			database.dispose(); 		} 	} @@ -509,7 +602,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 		} 	} -	private _publishConversionOutcome(chat: URI, continuation: IDeferredAgentHostTurn, label: string): void {+	private _publishConversionOutcome(chat: URI, continuation: IDeferredAgentHostTurn, label: string, meta?: IAgentSystemNotificationMeta): void { 		if (this._stateManager.getActiveTurnId(chat.toString()) !== continuation.turnId) { 			return; 		}@@ -519,6 +612,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 			part: { 				kind: ResponsePartKind.SystemNotification, 				content: label,+				...(meta ? { _meta: toAgentSystemNotificationMeta(meta) } : {}), 			}, 		}); 	}
src/vs/platform/agentHost/node/sessionDatabase.ts225 + / 146
@@ -9,7 +9,7 @@ import type { Database, RunResult } from '@vscode/sqlite3'; import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase } from '../common/sessionDataService.js'; import { dirname } from '../../../base/common/path.js'; import { URI } from '../../../base/common/uri.js';-import type { Message } from '../common/state/sessionState.js';+import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, type Message } from '../common/state/sessionState.js';  /**  * A single numbered migration. Migrations are applied in order of@@ -142,6 +142,22 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ 			delegation TEXT NOT NULL 		)`, 	},+	{+		version: 11,+		// Workspace-transition boundaries are host-owned turn data. Keeping+		// them as children of `turns` makes every truncate/delete path bound+		// their lifetime to the provider history they enrich.+		sql: `CREATE TABLE IF NOT EXISTS turn_workspace_transition (+			turn_id    TEXT PRIMARY KEY NOT NULL REFERENCES turns(id) ON DELETE CASCADE,+			transition TEXT NOT NULL+		)`,+	},+	{+		version: 12,+		sql: `INSERT OR REPLACE INTO session_metadata (key, value)+			SELECT '${AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY}', 'true'+			WHERE EXISTS (SELECT 1 FROM turn_workspace_transition)`,+	}, ];  // ---- Promise wrappers around callback-based @vscode/sqlite3 API -----------@@ -257,24 +273,73 @@ export class SessionDatabase implements ISessionDatabase { 	private readonly _fileEditSequencer = new SequencerByKey<string>();  	private readonly _metadataSequencer = new Sequencer();-	private readonly _transactionSequencer = new Sequencer();+	private readonly _mutationSequencer = new Sequencer();  	/**-	 * Serializes every `turn_usage` access — writes, prunes, the fork remap, and the restore read-	 * alike. `@vscode/sqlite3` runs in parallelized mode (see {@link _metadataSequencer}), so a-	 * fire-and-forget `setTurnUsage` submitted before a truncation can otherwise complete *after*+	 * Serializes turn-child writes, prunes, fork remaps, and restore reads.+	 * `@vscode/sqlite3` runs in parallelized mode (see {@link _metadataSequencer}), so a+	 * fire-and-forget child write submitted before a truncation can otherwise complete *after* 	 * it and resurrect a row the truncation was meant to remove, and a read can otherwise overtake 	 * a write it was submitted after. Mutations must go through {@link _mutateTurnUsage} rather 	 * than queueing on this directly, so they are tracked for {@link whenIdle}. 	 */ 	private readonly _turnUsageSequencer = new Sequencer();  	/**-	 * Runs a mutation that touches `turn_usage`, tracked for {@link whenIdle}-	 * and serialized against every other such mutation.+	 * Queues a mutation on the shared connection. Transaction bodies call+	 * their statements directly after acquiring this sequencer to avoid+	 * reentrant acquisition.+	 */+	protected _queueMutation<T>(operation: (db: Database) => Promise<T>): Promise<T> {+		return this._mutationSequencer.queue(async () => operation(await this._ensureDb()));+	}++	private _mutate<T>(operation: (db: Database) => Promise<T>): Promise<T> {+		return this._track(() => this._queueMutation(operation));+	}++	private _queueTransaction<T>(operation: (db: Database) => Promise<T>): Promise<T> {+		return this._queueMutation(async db => {+			await dbExec(db, 'BEGIN TRANSACTION');+			try {+				const result = await operation(db);+				await dbExec(db, 'COMMIT');+				return result;+			} catch (err) {+				await dbExec(db, 'ROLLBACK');+				throw err;+			}+		});+	}++	/**+	 * Runs a mutation that touches sequenced turn data, tracked for+	 * {@link whenIdle} and serialized against every other such mutation. 	 */ 	private _mutateTurnUsage(operation: (db: Database) => Promise<void>): Promise<void> {-		return this._track(() => this._turnUsageSequencer.queue(async () => operation(await this._ensureDb())));+		return this._track(() => this._turnUsageSequencer.queue(() => this._queueMutation(operation)));+	}++	/**+	 * Runs an atomic mutation touching metadata and sequenced turn data.+	 * Always acquire sequencers in metadata, turn-data, mutation order.+	 */+	protected _mutateMetadataAndTurnUsage(operation: (db: Database) => Promise<void>): Promise<void> {+		return this._track(() => this._metadataSequencer.queue(() =>+			this._turnUsageSequencer.queue(() =>+				this._queueTransaction(operation)+			)+		));+	}++	private async _deleteWorkspaceTransitionMarkerIfEmpty(db: Database): Promise<void> {+		await dbRun(+			db,+			`DELETE FROM session_metadata+				WHERE key = ?+				AND NOT EXISTS (SELECT 1 FROM turn_workspace_transition)`,+			[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY],+		); 	}  	/**@@ -348,22 +413,21 @@ export class SessionDatabase implements ISessionDatabase { 	// ---- Turns ----------------------------------------------------------  	createTurn(turnId: string): Promise<void> {-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); 		}); 	}  	deleteTurn(turnId: string): Promise<void> {-		return this._mutateTurnUsage(async db => {-			// File edits and turn usage cascade-delete via their foreign keys.+		return this._mutateMetadataAndTurnUsage(async db => {+			// Turn-owned file edits, usage, and workspace transitions cascade-delete. 			await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]);+			await this._deleteWorkspaceTransitionMarkerIfEmpty(db); 		}); 	}  	setTurnEventId(turnId: string, eventId: string): Promise<void> {-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); 			// Only set the event ID if not already set — steering messages 			// trigger additional user.message events within the same turn,@@ -444,8 +508,7 @@ export class SessionDatabase implements ISessionDatabase { 	}  	setTurnDelegation(turnId: string, delegation: string): Promise<void> {-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); 			await dbRun(db, 'INSERT OR REPLACE INTO turn_delegation (turn_id, delegation) VALUES (?, ?)', [turnId, delegation]); 		});@@ -472,9 +535,50 @@ export class SessionDatabase implements ISessionDatabase { 		return result; 	} +	setTurnWorkspaceTransition(turnId: string, transition: string): Promise<void> {+		return this.setWorkspaceConversion(turnId, transition, {});+	}++	setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly<Record<string, string>>): Promise<void> {+		return this._mutateMetadataAndTurnUsage(async db => {+			await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]);+			await dbRun(db, 'INSERT OR REPLACE INTO turn_workspace_transition (turn_id, transition) VALUES (?, ?)', [turnId, transition]);+			for (const [key, value] of Object.entries({ ...metadata, [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: 'true' })) {+				await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]);+			}+		});+	}++	deleteTurnWorkspaceTransition(turnId: string): Promise<void> {+		return this._mutateMetadataAndTurnUsage(async db => {+			await dbRun(db, 'DELETE FROM turn_workspace_transition WHERE turn_id = ?', [turnId]);+			await this._deleteWorkspaceTransitionMarkerIfEmpty(db);+		});+	}++	async getTurnWorkspaceTransitions(): Promise<Map<string, string>> {+		return this._turnUsageSequencer.queue(() => this._queueMutation(async db => {+			const rows = await dbAll(+				db,+				`SELECT w.turn_id AS turn_id, t.event_id AS event_id, w.transition AS transition+				FROM turn_workspace_transition w LEFT JOIN turns t ON t.id = w.turn_id`,+				[],+			);+			const result = new Map<string, string>();+			for (const row of rows) {+				const transition = row.transition as string;+				result.set(row.turn_id as string, transition);+				const eventId = row.event_id as string | null;+				if (eventId) {+					result.set(eventId, transition);+				}+			}+			return result;+		}));+	}+ 	setTurnCheckpointRef(turnId: string, ref: string): Promise<void> {-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); 			await dbRun(db, 'UPDATE turns SET checkpoint_ref = ? WHERE id = ?', [ref, turnId]); 		});@@ -506,40 +610,41 @@ export class SessionDatabase implements ISessionDatabase { 	}  	truncateFromTurn(turnId: string): Promise<void> {-		return this._mutateTurnUsage(async db => {+		return this._mutateMetadataAndTurnUsage(async db => { 			// Delete the target turn and all turns inserted after it (by rowid order).-			// File edits and turn usage cascade-delete via their foreign keys.+			// Turn-owned child records cascade-delete via their foreign keys. 			await dbRun(db, 				`DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)`, 				[turnId], 			);+			await this._deleteWorkspaceTransitionMarkerIfEmpty(db); 		}); 	}  	deleteTurnsAfter(turnId: string): Promise<void> {-		return this._mutateTurnUsage(async db => {+		return this._mutateMetadataAndTurnUsage(async db => { 			// Delete all turns inserted after the given turn (by rowid order),-			// keeping the given turn itself. File edits and turn usage-			// cascade-delete via their foreign keys.+			// keeping the given turn itself. Turn-owned child records cascade-delete. 			await dbRun(db, 				`DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)`, 				[turnId], 			);+			await this._deleteWorkspaceTransitionMarkerIfEmpty(db); 		}); 	}  	deleteAllTurns(): Promise<void> {-		return this._mutateTurnUsage(async db => {-			// File edits and turn usage cascade-delete via their foreign keys.+		return this._mutateMetadataAndTurnUsage(async db => {+			// Turn-owned child records cascade-delete via their foreign keys. 			await dbExec(db, 'DELETE FROM turns');+			await this._deleteWorkspaceTransitionMarkerIfEmpty(db); 		}); 	}  	// ---- Local (host-injected) turns ------------------------------------  	insertLocalTurn(record: ILocalTurnRecord): Promise<void> {-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			await dbRun(db, 				'INSERT OR REPLACE INTO local_turns (turn_id, chat_uri, anchor_turn_id, seq, payload) VALUES (?, ?, ?, ?, ?)', 				[record.turnId, record.chatUri, record.anchorTurnId ?? null, record.seq, record.payload],@@ -560,21 +665,21 @@ export class SessionDatabase implements ISessionDatabase { 	}  	deleteLocalTurns(turnIds: readonly string[]): Promise<void> {-		return this._track(async () => {+		return this._track(() => { 			if (turnIds.length === 0) {-				return;+				return Promise.resolve(); 			}-			const db = await this._ensureDb();-			const placeholders = turnIds.map(() => '?').join(',');-			await dbRun(db, `DELETE FROM local_turns WHERE turn_id IN (${placeholders})`, [...turnIds]);+			return this._queueMutation(async db => {+				const placeholders = turnIds.map(() => '?').join(',');+				await dbRun(db, `DELETE FROM local_turns WHERE turn_id IN (${placeholders})`, [...turnIds]);+			}); 		}); 	}  	// ---- File edits -----------------------------------------------------  	storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void> {-		return this._track(() => this._fileEditSequencer.queue(edit.filePath, async () => {-			const db = await this._ensureDb();+		return this._track(() => this._fileEditSequencer.queue(edit.filePath, () => this._queueMutation(async db => { 			// Ensure the turn exists — lazily insert since the turn record 			// may not have been created by an explicit createTurn() call. 			await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [edit.turnId]);@@ -595,7 +700,7 @@ export class SessionDatabase implements ISessionDatabase { 					edit.removedLines ?? null, 				], 			);-		}));+		}))); 	}  	async getFileEdits(toolCallIds: string[]): Promise<IFileEditRecord[]> {@@ -712,72 +817,54 @@ export class SessionDatabase implements ISessionDatabase { 	}  	setMetadata(key: string, value: string): Promise<void> {-		return this._track(() => this._metadataSequencer.queue(async () => {-			const db = await this._ensureDb();+		return this._track(() => this._metadataSequencer.queue(() => this._queueMutation(async db => { 			await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]);-		}));+		}))); 	}  	setMetadataValues(values: Readonly<Record<string, string>>): Promise<void> {-		return this._track(() => this._metadataSequencer.queue(async () => {-			const db = await this._ensureDb();-			await this._transactionSequencer.queue(async () => {-				await dbExec(db, 'BEGIN TRANSACTION');-				try {-					for (const [key, value] of Object.entries(values)) {-						await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]);-					}-					await dbExec(db, 'COMMIT');-				} catch (err) {-					await dbExec(db, 'ROLLBACK');-					throw err;+		return this._track(() => this._metadataSequencer.queue(() =>+			this._queueTransaction(async db => {+				for (const [key, value] of Object.entries(values)) {+					await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); 				}-			});-		}));+			})+		)); 	}  	deleteMetadata(keys: readonly string[]): Promise<void> {-		return this._track(() => this._metadataSequencer.queue(async () => {+		return this._track(() => this._metadataSequencer.queue(() => { 			if (keys.length === 0) {-				return;+				return Promise.resolve(); 			}-			const db = await this._ensureDb();-			const placeholders = keys.map(() => '?').join(',');-			await dbRun(db, `DELETE FROM session_metadata WHERE key IN (${placeholders})`, [...keys]);+			return this._queueMutation(async db => {+				const placeholders = keys.map(() => '?').join(',');+				await dbRun(db, `DELETE FROM session_metadata WHERE key IN (${placeholders})`, [...keys]);+			}); 		})); 	}  	setMetadataValuesIfAbsent(key: string, values: Readonly<Record<string, string>>, copies: Readonly<Record<string, string>> = {}): Promise<boolean> {-		return this._track(() => this._metadataSequencer.queue(async () => {-			const db = await this._ensureDb();-			return this._transactionSequencer.queue(async () => {-				await dbExec(db, 'BEGIN TRANSACTION');-				try {-					const existing = await dbGet(db, 'SELECT 1 FROM session_metadata WHERE key = ?', [key]);-					if (existing) {-						await dbExec(db, 'COMMIT');-						return false;-					}-					for (const [targetKey, value] of Object.entries(values)) {-						await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [targetKey, value]);-					}-					for (const [targetKey, sourceKey] of Object.entries(copies)) {-						await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) SELECT ?, value FROM session_metadata WHERE key = ?', [targetKey, sourceKey]);-					}-					await dbExec(db, 'COMMIT');-					return true;-				} catch (err) {-					await dbExec(db, 'ROLLBACK');-					throw err;+		return this._track(() => this._metadataSequencer.queue(() =>+			this._queueTransaction(async db => {+				const existing = await dbGet(db, 'SELECT 1 FROM session_metadata WHERE key = ?', [key]);+				if (existing) {+					return false; 				}-			});-		}));+				for (const [targetKey, value] of Object.entries(values)) {+					await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [targetKey, value]);+				}+				for (const [targetKey, sourceKey] of Object.entries(copies)) {+					await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) SELECT ?, value FROM session_metadata WHERE key = ?', [targetKey, sourceKey]);+				}+				return true;+			})+		)); 	}  	setChatDraft(chat: URI, draft: Message | undefined): Promise<void> { 		const chatUri = chat.toString();-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			if (!draft) { 				await dbRun(db, 'DELETE FROM chat_drafts WHERE chat_uri = ?', [chatUri]); 				return;@@ -802,15 +889,13 @@ export class SessionDatabase implements ISessionDatabase { 	// ---- Reviewed files -------------------------------------------------  	markFileReviewed(uri: URI, nonce: string): Promise<void> {-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			await dbRun(db, 'INSERT OR IGNORE INTO reviewed_files (uri, nonce) VALUES (?, ?)', [uri.toString(), nonce]); 		}); 	}  	unmarkFileReviewed(uri: URI, nonce: string): Promise<void> {-		return this._track(async () => {-			const db = await this._ensureDb();+		return this._mutate(async db => { 			await dbRun(db, 'DELETE FROM reviewed_files WHERE uri = ? AND nonce = ?', [uri.toString(), nonce]); 		}); 	}@@ -834,63 +919,56 @@ export class SessionDatabase implements ISessionDatabase { 	}  	remapTurnIds(mapping: ReadonlyMap<string, string>, eventIds?: ReadonlyMap<string, string>): Promise<void> {-		// Mutates `turn_usage`, so it must serialize with every other such-		// mutation — a usage write racing the fork transaction would otherwise-		// land against either the old or the new turn id unpredictably.-		return this._mutateTurnUsage(async db => {-			await this._transactionSequencer.queue(async () => {-				// Defer FK checks to commit time so we can update turns.id and-				// file_edits.turn_id in any order without mid-statement violations.-				// This pragma auto-resets after the transaction ends.-				await dbExec(db, 'PRAGMA defer_foreign_keys = ON');-				await dbExec(db, 'BEGIN TRANSACTION');-				try {-					// Delete turns not present in the mapping (e.g. turns beyond-					// the fork point). File edits cascade-delete via FK.-					const oldIds = [...mapping.keys()];-					if (oldIds.length > 0) {-						const placeholders = oldIds.map(() => '?').join(',');-						await dbRun(db,-							`DELETE FROM turns WHERE id NOT IN (${placeholders})`,-							oldIds,-						);-					}--					// Remap the remaining turn IDs to their new values-					for (const [oldId, newId] of mapping) {-						await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]);-						await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);-					}-					for (const [turnId, eventId] of eventIds ?? []) {-						await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ?', [eventId, turnId]);-					}--					if (oldIds.length > 0) {-						const placeholders = oldIds.map(() => '?').join(',');-						await dbRun(db,-							`DELETE FROM local_turns WHERE turn_id NOT IN (${placeholders})`,-							oldIds,-						);-					}-					for (const [oldId, newId] of mapping) {-						await dbRun(db, 'UPDATE local_turns SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);-						await dbRun(db, 'UPDATE local_turns SET anchor_turn_id = ? WHERE anchor_turn_id = ?', [newId, oldId]);-					}--					// Rows past the fork point were already removed by the `turns`-					// delete above, via the same cascade as file edits. The surviving-					// ids still need remapping (the FK cascades deletes, not updates),-					// or the forked session would restore with no gauge and zero cost.-					for (const [oldId, newId] of mapping) {-						await dbRun(db, 'UPDATE turn_usage SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);-						await dbRun(db, 'UPDATE turn_delegation SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);-					}-					await dbExec(db, 'COMMIT');-				} catch (err) {-					await dbExec(db, 'ROLLBACK');-					throw err;-				}-			});+		// Mutates turn-owned child data, so it must serialize with every other+		// such mutation or a write racing the fork transaction could land+		// against either the old or the new turn id unpredictably.+		return this._mutateMetadataAndTurnUsage(async db => {+			// Defer FK checks to commit time so we can update turns.id and+			// file_edits.turn_id in any order without mid-statement violations.+			// This pragma auto-resets after the transaction ends.+			await dbExec(db, 'PRAGMA defer_foreign_keys = ON');+			// Delete turns not present in the mapping (e.g. turns beyond+			// the fork point). File edits cascade-delete via FK.+			const oldIds = [...mapping.keys()];+			if (oldIds.length > 0) {+				const placeholders = oldIds.map(() => '?').join(',');+				await dbRun(db,+					`DELETE FROM turns WHERE id NOT IN (${placeholders})`,+					oldIds,+				);+			}++			// Remap the remaining turn IDs to their new values+			for (const [oldId, newId] of mapping) {+				await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]);+				await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);+			}+			for (const [turnId, eventId] of eventIds ?? []) {+				await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ?', [eventId, turnId]);+			}++			if (oldIds.length > 0) {+				const placeholders = oldIds.map(() => '?').join(',');+				await dbRun(db,+					`DELETE FROM local_turns WHERE turn_id NOT IN (${placeholders})`,+					oldIds,+				);+			}+			for (const [oldId, newId] of mapping) {+				await dbRun(db, 'UPDATE local_turns SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);+				await dbRun(db, 'UPDATE local_turns SET anchor_turn_id = ? WHERE anchor_turn_id = ?', [newId, oldId]);+			}++			// Rows past the fork point were already removed by the `turns`+			// delete above, via the same cascade as file edits. The surviving+			// ids still need remapping (the FK cascades deletes, not updates),+			// or the forked session would restore with no gauge and zero cost.+			for (const [oldId, newId] of mapping) {+				await dbRun(db, 'UPDATE turn_usage SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);+				await dbRun(db, 'UPDATE turn_delegation SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);+				await dbRun(db, 'UPDATE turn_workspace_transition SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);+			}+			await this._deleteWorkspaceTransitionMarkerIfEmpty(db); 		}); 	} @@ -907,9 +985,10 @@ export class SessionDatabase implements ISessionDatabase { 		} 	} -	async vacuumInto(targetPath: string) {-		const db = await this._ensureDb();-		await dbRun(db, 'VACUUM INTO ?', [targetPath]);+	vacuumInto(targetPath: string): Promise<void> {+		return this._mutate(async db => {+			await dbRun(db, 'VACUUM INTO ?', [targetPath]);+		}); 	}  	/**
src/vs/platform/agentHost/test/common/sessionTestHelpers.ts54 + / 1
@@ -11,7 +11,7 @@ import type { IDetailedDiffResult, IDiffComputeService, IDiffCountResult } from import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js';-import type { ISessionGitHubState, Message } from '../../common/state/sessionState.js';+import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, type ISessionGitHubState, type Message } from '../../common/state/sessionState.js';  export class TestSessionDatabase implements ISessionDatabase { 	private readonly _edits: (IFileEditRecord & IFileEditContent)[] = [];@@ -21,10 +21,12 @@ export class TestSessionDatabase implements ISessionDatabase { 	private readonly _localTurns = new Map<string, ILocalTurnRecord>(); 	private readonly _turnUsages = new Map<string, string>(); 	private readonly _turnDelegations = new Map<string, string>();+	private readonly _turnWorkspaceTransitions = new Map<string, string>(); 	private readonly _turnEventIds = new Map<string, string>();  	getAllFileEditsCalls = 0; 	getFileEditsByTurnCalls = 0;+	getTurnWorkspaceTransitionsCalls = 0; 	deleteTurnsAfterCalls: string[] = []; 	deleteAllTurnsCalls = 0; 	setTurnEventIdCalls: Array<{ turnId: string; eventId: string }> = [];@@ -38,12 +40,14 @@ export class TestSessionDatabase implements ISessionDatabase {  	async deleteTurn(turnId: string): Promise<void> { 		this._turnDelegations.delete(turnId);+		this._turnWorkspaceTransitions.delete(turnId); 		this._turnEventIds.delete(turnId); 		for (let i = this._edits.length - 1; i >= 0; i--) { 			if (this._edits[i].turnId === turnId) { 				this._edits.splice(i, 1); 			} 		}+		this._deleteWorkspaceTransitionMarkerIfEmpty(); 	}  	async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void> {@@ -171,6 +175,36 @@ export class TestSessionDatabase implements ISessionDatabase { 		return result; 	} +	async setTurnWorkspaceTransition(turnId: string, transition: string): Promise<void> {+		this._turnWorkspaceTransitions.set(turnId, transition);+		this._metadata.set(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, 'true');+	}++	async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly<Record<string, string>>): Promise<void> {+		for (const [key, value] of Object.entries(metadata)) {+			this._metadata.set(key, value);+		}+		this._turnWorkspaceTransitions.set(turnId, transition);+		this._metadata.set(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, 'true');+	}++	async deleteTurnWorkspaceTransition(turnId: string): Promise<void> {+		this._turnWorkspaceTransitions.delete(turnId);+		this._deleteWorkspaceTransitionMarkerIfEmpty();+	}++	async getTurnWorkspaceTransitions(): Promise<Map<string, string>> {+		this.getTurnWorkspaceTransitionsCalls++;+		const result = new Map(this._turnWorkspaceTransitions);+		for (const [turnId, eventId] of this._turnEventIds) {+			const transition = this._turnWorkspaceTransitions.get(turnId);+			if (transition) {+				result.set(eventId, transition);+			}+		}+		return result;+	}+ 	async truncateFromTurn(_turnId: string): Promise<void> { }  	async deleteTurnsAfter(turnId: string): Promise<void> {@@ -181,6 +215,8 @@ export class TestSessionDatabase implements ISessionDatabase { 		this.deleteAllTurnsCalls++; 		this._edits.length = 0; 		this._turnDelegations.clear();+		this._turnWorkspaceTransitions.clear();+		this._metadata.delete(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY); 		this._turnEventIds.clear(); 	} @@ -203,18 +239,35 @@ export class TestSessionDatabase implements ISessionDatabase { 				this._turnDelegations.delete(turnId); 			} 		}+		for (const turnId of [...this._turnWorkspaceTransitions.keys()]) {+			if (!mapping.has(turnId)) {+				this._turnWorkspaceTransitions.delete(turnId);+			}+		} 		for (const [oldId, newId] of mapping) { 			const delegation = this._turnDelegations.get(oldId); 			if (delegation) { 				this._turnDelegations.delete(oldId); 				this._turnDelegations.set(newId, delegation); 			}+			const transition = this._turnWorkspaceTransitions.get(oldId);+			if (transition) {+				this._turnWorkspaceTransitions.delete(oldId);+				this._turnWorkspaceTransitions.set(newId, transition);+			} 			const eventId = eventIds?.get(newId) ?? this._turnEventIds.get(oldId); 			this._turnEventIds.delete(oldId); 			if (eventId) { 				this._turnEventIds.set(newId, eventId); 			} 		}+		this._deleteWorkspaceTransitionMarkerIfEmpty();+	}++	private _deleteWorkspaceTransitionMarkerIfEmpty(): void {+		if (this._turnWorkspaceTransitions.size === 0) {+			this._metadata.delete(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY);+		} 	}  	async markFileReviewed(uri: URI, nonce: string): Promise<void> {
src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts15 + / 7
@@ -15,6 +15,7 @@ import { AgentSession } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js';+import { toAgentWorkspaceContinuationMessageMeta } from '../../common/meta/agentWorkspaceContinuationMeta.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, buildDefaultChatUri, withMessageRequestHiddenFromTranscript, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js';@@ -1826,11 +1827,11 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { 	});  	/**-	 * A host notice turn captures no checkpoint, so picking it as the session's-	 * latest turn drops the session changeset onto the edit tracker, which-	 * cannot see terminal-tool edits.+	 * A workspace continuation is a real provider turn even though its request+	 * row is hidden; a later pure host notice must not replace it as the latest+	 * attributable turn. 	 */-	test('session changeset keeps its git fast path when the chat ends on a host notice turn', async () => {+	test('session changeset attributes a hidden workspace continuation before a host notice', async () => { 		const git = createNoopGitService(); 		git.getRepositoryRoot = async wd => URI.parse(wd.toString()); 		const diffCalls: Array<{ fromRef: string; toRef: string }> = [];@@ -1841,15 +1842,22 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { 		const checkpoint: IAgentHostCheckpointService = { 			...NULL_CHECKPOINT_SERVICE, 			getBaselineCheckpoint: async () => 'baseline',-			// Mirrors production: only the agent's turn has a checkpoint. 			getTurnCheckpointPair: async (_session: URI, turnId: string) =>-				turnId === 'agent-turn' ? { parent: 'agent~p', current: 'agent~c' } : undefined,+				turnId === 'continuation-turn' ? { parent: 'continuation~p', current: 'continuation~c' } : undefined, 		}; 		const { svc, stateManager } = build({ workingDirectories: ['file:///wd'], git, checkpoint });  		const chat = buildDefaultChatUri(sessionStr); 		for (const turn of [ 			{ id: 'agent-turn', message: { text: 'Edit edited.ts', origin: { kind: MessageKind.User } } },+			{+				id: 'continuation-turn',+				message: withMessageRequestHiddenFromTranscript({+					text: 'Continue in the requested workspace.',+					origin: { kind: MessageKind.SystemNotification },+					_meta: toAgentWorkspaceContinuationMessageMeta(),+				}, true),+			}, 			{ 				id: 'notice-turn', 				message: withMessageRequestHiddenFromTranscript(@@ -1869,7 +1877,7 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { 			diffCalls, 			files: stateManager.getChangesetState(buildSessionChangesetUri(sessionStr))?.files.map(file => file.id), 		}, {-			diffCalls: [{ fromRef: 'baseline', toRef: 'agent~c' }],+			diffCalls: [{ fromRef: 'baseline', toRef: 'continuation~c' }], 			files: [URI.file('/wd/edited.ts').toString()], 		}); 	});
src/vs/platform/agentHost/test/node/sessionDatabase.test.ts264 + / 1
@@ -6,10 +6,11 @@ import assert from 'assert'; import { tmpdir } from 'os'; import * as fs from 'fs/promises';+import { DeferredPromise } from '../../../../base/common/async.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js';-import { FileEditKind, MessageKind } from '../../common/state/sessionState.js';+import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, FileEditKind, MessageKind } from '../../common/state/sessionState.js'; import type { IReviewedFileRecord } from '../../common/sessionDataService.js'; import type { Database } from '@vscode/sqlite3'; import { generateUuid } from '../../../../base/common/uuid.js';@@ -58,6 +59,9 @@ suite('SessionDatabase', () => { 	 * Database instance, enabling reopen tests with :memory: databases. 	 */ 	class TestableSessionDatabase extends SessionDatabase {+		private nextMutationGate: { readonly started: DeferredPromise<void>; readonly release: DeferredPromise<void> } | undefined;+		private nextMetadataAndTurnUsageMutationGate: { readonly started: DeferredPromise<void>; readonly release: DeferredPromise<void> } | undefined;+ 		static override async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise<TestableSessionDatabase> { 			const inst = new TestableSessionDatabase(path, migrations); 			await inst._ensureDb();@@ -78,6 +82,62 @@ suite('SessionDatabase', () => { 			}); 		} +		async waitForRaw(): Promise<void> {+			const rawDb = await this._ensureDb();+			await new Promise<void>(resolve => rawDb.wait(() => resolve()));+		}++		async hasRawTurn(turnId: string): Promise<boolean> {+			const rawDb = await this._ensureDb();+			return new Promise<boolean>((resolve, reject) => {+				rawDb.get('SELECT 1 AS present FROM turns WHERE id = ?', [turnId], (err: Error | null, row: Record<string, unknown> | undefined) => {+					if (err) {+						reject(err);+						return;+					}+					resolve(row?.present === 1);+				});+			});+		}++		blockNextMutation(): { readonly started: DeferredPromise<void>; readonly release: DeferredPromise<void> } {+			const gate = {+				started: new DeferredPromise<void>(),+				release: new DeferredPromise<void>(),+			};+			this.nextMutationGate = gate;+			return gate;+		}++		protected override _queueMutation<T>(operation: (db: Database) => Promise<T>): Promise<T> {+			const gate = this.nextMutationGate;+			this.nextMutationGate = undefined;+			return super._queueMutation(gate ? async db => {+				gate.started.complete();+				await gate.release.p;+				return operation(db);+			} : operation);+		}++		blockNextMetadataAndTurnUsageMutation(): { readonly started: DeferredPromise<void>; readonly release: DeferredPromise<void> } {+			const gate = {+				started: new DeferredPromise<void>(),+				release: new DeferredPromise<void>(),+			};+			this.nextMetadataAndTurnUsageMutationGate = gate;+			return gate;+		}++		protected override _mutateMetadataAndTurnUsage(operation: (db: Database) => Promise<void>): Promise<void> {+			const gate = this.nextMetadataAndTurnUsageMutationGate;+			this.nextMetadataAndTurnUsageMutationGate = undefined;+			return super._mutateMetadataAndTurnUsage(gate ? async db => {+				gate.started.complete();+				await gate.release.p;+				await operation(db);+			} : operation);+		}+ 		/** Extract the raw db connection; this instance becomes inert. */ 		async ejectDb(): Promise<Database> { 			const rawDb = await this._ensureDb();@@ -660,6 +720,209 @@ suite('SessionDatabase', () => { 		}); 	}); +	// ---- Workspace transitions -----------------------------------------++	suite('workspace transitions', () => {++		test('restores transitions by host or provider turn id', async () => {+			db = disposables.add(await SessionDatabase.open(':memory:'));+			await db.setTurnWorkspaceTransition('host-turn', '{"content":"Now working in vscode"}');+			await db.setTurnEventId('host-turn', 'provider-turn');++			assert.deepStrictEqual({+				transitions: [...(await db.getTurnWorkspaceTransitions()).entries()],+				hasTransitions: await db.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY),+			}, {+				transitions: [+					['host-turn', '{"content":"Now working in vscode"}'],+					['provider-turn', '{"content":"Now working in vscode"}'],+				],+				hasTransitions: 'true',+			});+		});++		test('transition reads wait for earlier turn event ID mappings', async () => {+			const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:'));+			await testDatabase.setTurnWorkspaceTransition('host-turn', '{"content":"Now working in vscode"}');+			const gate = testDatabase.blockNextMutation();+			const eventIdWrite = testDatabase.setTurnEventId('host-turn', 'provider-turn');+			await gate.started.p;++			const transitionsRead = testDatabase.getTurnWorkspaceTransitions();+			let readSettled = false;+			void transitionsRead.then(+				() => readSettled = true,+				() => readSettled = true,+			);+			await new Promise<void>(resolve => setImmediate(resolve));+			await testDatabase.waitForRaw();+			const readSettledBeforeWrite = readSettled;+			gate.release.complete();+			const transitions = await transitionsRead;+			await eventIdWrite;++			assert.deepStrictEqual({+				readSettledBeforeWrite,+				transitions: [...transitions.entries()],+			}, {+				readSettledBeforeWrite: false,+				transitions: [+					['host-turn', '{"content":"Now working in vscode"}'],+					['provider-turn', '{"content":"Now working in vscode"}'],+				],+			});+		});++		test('truncation and remapping bound transitions to their owning turns', async () => {+			db = disposables.add(await SessionDatabase.open(':memory:'));+			await db.setTurnWorkspaceTransition('old-1', '{"content":"Now working in one"}');+			await db.setTurnWorkspaceTransition('old-2', '{"content":"Now working in two"}');++			await db.remapTurnIds(new Map([['old-1', 'new-1']]));+			await db.deleteTurnsAfter('new-1');++			assert.deepStrictEqual([...(await db.getTurnWorkspaceTransitions()).entries()], [+				['new-1', '{"content":"Now working in one"}'],+			]);+		});++		test('migration v11 creates the workspace transition table', async () => {+			db = disposables.add(await SessionDatabase.open(':memory:'));+			const tables = await db.getAllTables();+			assert.ok(tables.includes('turn_workspace_transition'));+		});++		test('migration v12 backfills the transition history marker', async () => {+			const v11Migrations = sessionDatabaseMigrations.filter(migration => migration.version <= 11);+			const v11Database = await TestableSessionDatabase.open(':memory:', v11Migrations);+			await v11Database.setTurnWorkspaceTransition('turn-1', '{"content":"Now working in vscode"}');+			await v11Database.deleteMetadata([AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]);+			const rawDb = await v11Database.ejectDb();++			db = disposables.add(await TestableSessionDatabase.fromDb(rawDb));++			assert.strictEqual(await db.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), 'true');+		});++		test('deleting the last transition clears the history marker', async () => {+			db = disposables.add(await SessionDatabase.open(':memory:'));+			await db.setTurnWorkspaceTransition('turn-1', '{"content":"Now working in vscode"}');++			await db.deleteTurn('turn-1');++			assert.deepStrictEqual({+				transitions: [...(await db.getTurnWorkspaceTransitions()).entries()],+				hasTransitions: await db.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY),+			}, {+				transitions: [],+				hasTransitions: undefined,+			});+		});++		test('workspace conversion metadata rolls back when transition persistence fails', async () => {+			const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:'));+			await testDatabase.runRaw(`CREATE TRIGGER fail_workspace_transition_insert+				BEFORE INSERT ON turn_workspace_transition+				BEGIN+					SELECT RAISE(ABORT, 'transition write failed');+				END`);++			await assert.rejects(() => testDatabase.setWorkspaceConversion(+				'turn-1',+				'{"content":"Now working in vscode"}',+				{ [AH_META_WORKSPACELESS_DB_KEY]: 'false' },+			), /transition write failed/);++			assert.deepStrictEqual({+				transitions: [...(await testDatabase.getTurnWorkspaceTransitions()).entries()],+				hasTransitions: await testDatabase.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY),+				workspaceless: await testDatabase.getMetadata(AH_META_WORKSPACELESS_DB_KEY),+			}, {+				transitions: [],+				hasTransitions: undefined,+				workspaceless: undefined,+			});+		});++		test('failed workspace conversion excludes concurrent unrelated mutations', async () => {+			const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:'));+			await testDatabase.runRaw(`CREATE TRIGGER fail_workspace_transition_insert+				BEFORE INSERT ON turn_workspace_transition+				BEGIN+					SELECT RAISE(ABORT, 'transition write failed');+				END`);+			const gate = testDatabase.blockNextMetadataAndTurnUsageMutation();+			const conversionFailure = assert.rejects(() => testDatabase.setWorkspaceConversion(+				'turn-1',+				'{"content":"Now working in vscode"}',+				{ [AH_META_WORKSPACELESS_DB_KEY]: 'false' },+			), /transition write failed/);+			await gate.started.p;++			const unrelatedTurnWrite = testDatabase.createTurn('unrelated-turn');+			const unrelatedMetadataWrite = testDatabase.setMetadata('unrelated', 'persists');+			let unrelatedTurnSettled = false;+			let unrelatedMetadataSettled = false;+			void unrelatedTurnWrite.then(+				() => unrelatedTurnSettled = true,+				() => unrelatedTurnSettled = true,+			);+			void unrelatedMetadataWrite.then(+				() => unrelatedMetadataSettled = true,+				() => unrelatedMetadataSettled = true,+			);+			await new Promise<void>(resolve => setImmediate(resolve));+			await testDatabase.waitForRaw();+			const unrelatedTurnInsideConversion = await testDatabase.hasRawTurn('unrelated-turn');+			const unrelatedInsideConversion = await testDatabase.getMetadata('unrelated');+			const unrelatedTurnSettledInsideConversion = unrelatedTurnSettled;+			const unrelatedMetadataSettledInsideConversion = unrelatedMetadataSettled;+			gate.release.complete();+			await Promise.all([conversionFailure, unrelatedTurnWrite, unrelatedMetadataWrite]);++			assert.deepStrictEqual({+				unrelatedTurnSettledInsideConversion,+				unrelatedMetadataSettledInsideConversion,+				unrelatedTurnInsideConversion,+				unrelatedInsideConversion,+				unrelatedTurn: await testDatabase.hasRawTurn('unrelated-turn'),+				unrelated: await testDatabase.getMetadata('unrelated'),+				workspaceless: await testDatabase.getMetadata(AH_META_WORKSPACELESS_DB_KEY),+				hasTransitions: await testDatabase.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY),+			}, {+				unrelatedTurnSettledInsideConversion: false,+				unrelatedMetadataSettledInsideConversion: false,+				unrelatedTurnInsideConversion: false,+				unrelatedInsideConversion: undefined,+				unrelatedTurn: true,+				unrelated: 'persists',+				workspaceless: undefined,+				hasTransitions: undefined,+			});+		});++		test('marker cleanup failure rolls back turn deletion', async () => {+			const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:'));+			await testDatabase.setTurnWorkspaceTransition('turn-1', '{"content":"Now working in vscode"}');+			await testDatabase.runRaw(`CREATE TRIGGER fail_workspace_transition_marker_delete+				BEFORE DELETE ON session_metadata+				WHEN OLD.key = '${AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY}'+				BEGIN+					SELECT RAISE(ABORT, 'marker cleanup failed');+				END`);++			await assert.rejects(() => testDatabase.deleteTurn('turn-1'), /marker cleanup failed/);++			assert.deepStrictEqual({+				transitions: [...(await testDatabase.getTurnWorkspaceTransitions()).entries()],+				hasTransitions: await testDatabase.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY),+			}, {+				transitions: [['turn-1', '{"content":"Now working in vscode"}']],+				hasTransitions: 'true',+			});+		});+	});+ 	// ---- Turn checkpoint refs -------------------------------------------  	suite('turn checkpoint refs', () => {
src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts430 + / 19
@@ -4,20 +4,31 @@  *--------------------------------------------------------------------------------------------*/  import assert from 'assert';+import * as fs from 'fs';+import { tmpdir } from 'os';+import { join } from '../../../../base/common/path.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js';+import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentWorkingDirectoryChangedError, type IAgent } from '../../common/agent.js';-import { schemaProperty } from '../../common/agentHostSchema.js';+import type { IAgentHostChatContributionContext } from '../../common/agentHostChatContributionsService.js';+import { AgentHostGlobalAutoApproveEnabledConfigKey, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js';+import { AgentSystemNotificationKind, AgentSystemNotificationWorkspaceKind, readAgentSystemNotificationMeta, serializeAgentWorkspaceTransition } from '../../common/meta/agentSystemNotificationMeta.js';+import { isAgentWorkspaceContinuationMessage } from '../../common/meta/agentWorkspaceContinuationMeta.js';+import type { ISessionDatabase } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.js';-import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isMessageHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withSessionWorkspaceless, type ErrorInfo, type Message } from '../../common/state/sessionState.js';+import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildChatUri, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isHostNoticeTurn, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionHasWorkspaceTransitions, readSessionWorkspaceless, ResponsePartKind, SessionStatus, TurnState, withSessionHasWorkspaceTransitions, withSessionWorkspaceless, type ErrorInfo, type Message, type Turn } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import type { IAgentHostTurnService, IDeferredAgentHostTurn } from '../../node/agentHostTurnService.js';-import { SessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js';+import { AgentConfigurationService } from '../../node/agentConfigurationService.js';+import { SessionDatabase } from '../../node/sessionDatabase.js';+import { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js';+import { SessionWorkspaceConversionService, type ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import type { IAgentHostServerToolService } from '../../node/shared/agentServerToolHost.js'; import { NullAgentHostWorktreeIsolation, type IIsolationConfigContribution, type IResolveIsolationConfigRequest, type IResolveWorkingDirectoryRequest, type ISessionWorktree } from '../../node/shared/worktreeIsolation.js'; import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js';@@ -95,9 +106,18 @@ class GatedConversionDatabase extends TestSessionDatabase { 	readonly releaseWrite = new DeferredPromise<void>();  	override async setMetadataValues(values: Readonly<Record<string, string>>): Promise<void> {+		await this._waitForRelease();+		await super.setMetadataValues(values);+	}++	override async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly<Record<string, string>>): Promise<void> {+		await this._waitForRelease();+		await super.setWorkspaceConversion(turnId, transition, metadata);+	}++	private async _waitForRelease(): Promise<void> { 		this.writeStarted.complete(); 		await this.releaseWrite.p;-		await super.setMetadataValues(values); 	} } @@ -107,10 +127,11 @@ suite('SessionWorkspaceConversionService', () => { 	function createHarness( 		worktreeIsolation = new NullAgentHostWorktreeIsolation(), 		requestWorkspaceTrust: IAgentHostClientConnectionService['requestWorkspaceTrust'] = async () => true,-		database = new TestSessionDatabase(),+		database: ISessionDatabase = new TestSessionDatabase(), 	) { 		const logService = new NullLogService(); 		const stateManager = disposables.add(new AgentHostStateManager(logService));+		const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); 		const sessionDataService = createSessionDataService(database); 		const agent = new MockAgent('copilot', { multipleChats: { fork: true } }, { workspaceConversion: true }); 		disposables.add({ dispose: () => agent.dispose() });@@ -123,6 +144,7 @@ suite('SessionWorkspaceConversionService', () => { 			} 		}(); 		const continuations: { chat: string; message: Message }[] = [];+		const outcomeKindsAtContinuation: Array<Array<AgentSystemNotificationKind | undefined>> = []; 		const deferredContinuations: { chat: string; message: Message; turnId: string }[] = []; 		const failedContinuations: { chat: string; error: ErrorInfo; turnId: string }[] = []; 		let deferredTurnCounter = 0;@@ -143,6 +165,10 @@ suite('SessionWorkspaceConversionService', () => { 				if (stateManager.getActiveTurnId(targetChat.toString()) !== turn.turnId) { 					return false; 				}+				const activeTurn = stateManager.getChatState(targetChat.toString())?.activeTurn;+				outcomeKindsAtContinuation.push(activeTurn?.responseParts.flatMap(part =>+					part.kind === ResponsePartKind.SystemNotification ? [readAgentSystemNotificationMeta(part).kind] : []+				) ?? []); 				continuations.push({ chat: targetChat.toString(), message }); 				return true; 			}@@ -167,7 +193,7 @@ suite('SessionWorkspaceConversionService', () => { 				refreshedServerTools.push(targetSession); 			} 		}();-		const service = disposables.add(new SessionWorkspaceConversionService(stateManager, providerService, sessionDataService, worktreeIsolation, clientConnections, turnService, serverToolHost, logService));+		const service = disposables.add(new SessionWorkspaceConversionService(stateManager, providerService, sessionDataService, worktreeIsolation, configurationService, clientConnections, turnService, serverToolHost, logService)); 		const session = URI.parse('copilot:/workspace-less'); 		const chat = URI.parse(buildDefaultChatUri(session)); 		const scratch = URI.file('/tmp/copilot-scratch/workspace-less');@@ -181,7 +207,14 @@ suite('SessionWorkspaceConversionService', () => { 			workingDirectories: [scratch.toString()], 			_meta: withSessionWorkspaceless(undefined, true), 		});-		return { service, stateManager, database, agent, session, chat, scratch, continuations, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools };+		return { service, stateManager, configurationService, sessionDataService, database, agent, session, chat, scratch, continuations, outcomeKindsAtContinuation, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools };+	}++	function setSessionConfig(harness: ReturnType<typeof createHarness>, values: Record<string, unknown>): void {+		harness.stateManager.setSessionConfig(harness.session.toString(), {+			schema: platformSessionSchema.toProtocol(),+			values,+		}); 	}  	function startTurn(stateManager: AgentHostStateManager, chat: URI, turnId = 'turn-1'): void {@@ -205,6 +238,20 @@ suite('SessionWorkspaceConversionService', () => { 		return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); 	} +	function createHydrationStateManager(session: URI, hasWorkspaceTransitions: boolean): AgentHostStateManager {+		const stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));+		stateManager.createSession({+			resource: session.toString(),+			provider: 'copilot',+			title: hasWorkspaceTransitions ? 'Converted Session' : 'Normal Session',+			status: SessionStatus.Idle,+			createdAt: new Date(0).toISOString(),+			modifiedAt: new Date(0).toISOString(),+			_meta: withSessionHasWorkspaceTransitions(undefined, hasWorkspaceTransitions),+		});+		return stateManager;+	}+ 	test('keeps a visible continuation in progress while converting after the invoking turn', async () => { 		const trustDecision = new DeferredPromise<boolean>(); 		const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p);@@ -256,6 +303,9 @@ suite('SessionWorkspaceConversionService', () => { 			deferredContinuations: harness.deferredContinuations.map(entry => ({ 				chat: entry.chat, 				hidden: isMessageHiddenFromTranscript(entry.message),+				requestHidden: isMessageRequestHiddenFromTranscript(entry.message),+				workspaceContinuation: isAgentWorkspaceContinuationMessage(entry.message),+				hostNotice: isHostNoticeTurn({ message: entry.message }), 				label: readMessageSystemInitiatedLabel(entry.message), 				origin: entry.message.origin.kind, 				text: entry.message.text,@@ -273,9 +323,12 @@ suite('SessionWorkspaceConversionService', () => { 			deferredContinuations: [{ 				chat: harness.chat.toString(), 				hidden: false,+				requestHidden: true,+				workspaceContinuation: true,+				hostNotice: false, 				label: 'Continue in Requested Workspace', 				origin: MessageKind.SystemNotification,-				text: 'Continue in the requested workspace.',+				text: '<!-- vscode-request-hidden-from-transcript -->\nContinue in the requested workspace.', 				turnId: 'continuation-1', 			}], 			continuations: [],@@ -293,13 +346,19 @@ suite('SessionWorkspaceConversionService', () => { 			pending: harness.service.isPending(harness.chat.toString()), 			workingDirectories: state?.workingDirectories, 			workspaceless: readSessionWorkspaceless(state?._meta),+			hasWorkspaceTransitions: readSessionHasWorkspaceTransitions(state?._meta), 			persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY),+			persistedHasWorkspaceTransitions: await harness.database.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), 			refreshedServerTools: harness.refreshedServerTools, 			stateWhenCustomizationsRefreshed, 			customizations: state?.customizations, 			activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, 			activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()),-			outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []),+			outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [{+				content: part.content,+				meta: readAgentSystemNotificationMeta(part),+			}] : []),+			outcomeKindsAtContinuation: harness.outcomeKindsAtContinuation, 			continuations: harness.continuations.map(entry => ({ 				chat: entry.chat, 				hidden: isMessageHiddenFromTranscript(entry.message),@@ -320,7 +379,9 @@ suite('SessionWorkspaceConversionService', () => { 			pending: false, 			workingDirectories: ['file:///workspace/project'], 			workspaceless: false,+			hasWorkspaceTransitions: true, 			persistedWorkspaceless: 'false',+			persistedHasWorkspaceTransitions: 'true', 			refreshedServerTools: [harness.session.toString()], 			stateWhenCustomizationsRefreshed: { 				workingDirectories: ['file:///workspace/project'],@@ -329,7 +390,16 @@ suite('SessionWorkspaceConversionService', () => { 			customizations: [customization], 			activity: undefined, 			activeTurnId: 'continuation-1',-			outcomeNotifications: ['Workspace Set'],+			outcomeNotifications: [{+				content: 'Now working in project',+				meta: {+					kind: AgentSystemNotificationKind.WorkspaceTransition,+					severity: undefined,+					workspaceKind: AgentSystemNotificationWorkspaceKind.Folder,+					workspaceName: 'project',+				},+			}],+			outcomeKindsAtContinuation: [[AgentSystemNotificationKind.WorkspaceTransition]], 			continuations: [{ 				chat: harness.chat.toString(), 				hidden: false,@@ -340,6 +410,233 @@ suite('SessionWorkspaceConversionService', () => { 		}); 	}); +	test('skips transition storage for a normal loaded session', async () => {+		const session = URI.parse('copilot:/normal-session');+		const database = new TestSessionDatabase();+		const baseSessionDataService = createSessionDataService(database);+		let databaseOpenCalls = 0;+		const sessionDataService = {+			...baseSessionDataService,+			tryOpenDatabase: async (resource: URI) => {+				databaseOpenCalls++;+				return baseSessionDataService.tryOpenDatabase(resource);+			},+		};+		const contribution = disposables.add(new SessionWorkspaceConversionContribution(+			new class extends mock<IAgentHostChatContributionContext>() { }(),+			new class extends mock<ISessionWorkspaceConversionService>() { }(),+			createHydrationStateManager(session, false),+			sessionDataService,+			new NullLogService(),+		));+		const turns: Turn[] = [{+			id: 'turn-1',+			message: { text: 'Implement the feature', origin: { kind: MessageKind.User } },+			responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response-1', content: 'Done' }],+			usage: undefined,+			state: TurnState.Complete,+		}];++		const hydrated = await contribution.onHydrateTurns({+			session: session.toString(),+			chat: buildDefaultChatUri(session),+		}, turns);++		assert.deepStrictEqual({+			sameTurns: hydrated === turns,+			databaseOpenCalls,+			transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls,+		}, {+			sameTurns: true,+			databaseOpenCalls: 0,+			transitionQueryCalls: 0,+		});+	});++	test('restores one durable transition before provider output after service restart', async () => {+		const temporaryDirectory = await fs.promises.mkdtemp(join(tmpdir(), `workspace-transition-${generateUuid()}-`));+		const databasePath = join(temporaryDirectory, 'session.db');+		let conversionDatabase: SessionDatabase | undefined;+		let restoredDatabase: SessionDatabase | undefined;+		try {+			conversionDatabase = await SessionDatabase.open(databasePath);+			const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, conversionDatabase);+			const workspaceFolder = URI.file('/workspace/project');+			harness.agent.setWorkingDirectory = async () => { };+			startTurn(harness.stateManager, harness.chat);+			await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true');+			harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1');+			completeTurn(harness.stateManager, harness.chat);++			await updateSessionWorkspace(harness);+			await harness.database.setTurnEventId('continuation-1', 'provider-continuation');+			harness.service.dispose();+			harness.stateManager.dispose();+			await conversionDatabase.close();+			conversionDatabase = undefined;++			restoredDatabase = await SessionDatabase.open(databasePath);+			const restoredStateManager = createHydrationStateManager(harness.session, true);+			const restoredContribution = disposables.add(new SessionWorkspaceConversionContribution(+				new class extends mock<IAgentHostChatContributionContext>() { }(),+				new class extends mock<ISessionWorkspaceConversionService>() { }(),+				restoredStateManager,+				createSessionDataService(restoredDatabase),+				new NullLogService(),+			));+			const providerTurns: Turn[] = [{+				id: 'provider-continuation',+				message: {+					text: 'Continue the original task in the converted workspace.',+					origin: { kind: MessageKind.SystemNotification },+				},+				responseParts: [{+					kind: ResponsePartKind.Markdown,+					id: 'provider-response',+					content: 'Provider continued work',+				}],+				usage: undefined,+				state: TurnState.Complete,+			}];+			const restoredOnce = await restoredContribution.onHydrateTurns({+				session: harness.session.toString(),+				chat: harness.chat.toString(),+			}, providerTurns);+			const restoredTwice = await restoredContribution.onHydrateTurns({+				session: harness.session.toString(),+				chat: harness.chat.toString(),+			}, restoredOnce);+			const restoredTurn = restoredTwice[0];++			assert.deepStrictEqual({+				requestHidden: isMessageRequestHiddenFromTranscript(restoredTurn.message),+				workspaceContinuation: isAgentWorkspaceContinuationMessage(restoredTurn.message),+				responseParts: restoredTurn.responseParts.map(part => part.kind === ResponsePartKind.SystemNotification ? {+					kind: part.kind,+					content: part.content,+					meta: readAgentSystemNotificationMeta(part),+				} : {+					kind: part.kind,+					content: part.kind === ResponsePartKind.Markdown ? part.content : undefined,+				}),+				persistedTransitions: [...(await restoredDatabase.getTurnWorkspaceTransitions()).keys()],+			}, {+				requestHidden: true,+				workspaceContinuation: true,+				responseParts: [{+					kind: ResponsePartKind.SystemNotification,+					content: 'Now working in project',+					meta: {+						kind: AgentSystemNotificationKind.WorkspaceTransition,+						severity: undefined,+						workspaceKind: AgentSystemNotificationWorkspaceKind.Folder,+						workspaceName: 'project',+					},+				}, {+					kind: ResponsePartKind.Markdown,+					content: 'Provider continued work',+				}],+				persistedTransitions: ['continuation-1', 'provider-continuation'],+			});+		} finally {+			await Promise.all([conversionDatabase?.close(), restoredDatabase?.close()]);+			await fs.promises.rm(temporaryDirectory, { recursive: true, force: true });+		}+	});++	test('restores every persisted workspace conversion at its own turn boundary', async () => {+		const database = new TestSessionDatabase();+		const baseSessionDataService = createSessionDataService(database);+		let databaseOpenCalls = 0;+		const sessionDataService = {+			...baseSessionDataService,+			tryOpenDatabase: async (resource: URI) => {+				databaseOpenCalls++;+				return baseSessionDataService.tryOpenDatabase(resource);+			},+		};+		await database.setTurnWorkspaceTransition('turn-1', serializeAgentWorkspaceTransition({+			content: 'Now working in first',+			workspaceKind: AgentSystemNotificationWorkspaceKind.Folder,+			workspaceName: 'first',+		}));+		await database.setTurnWorkspaceTransition('turn-2', serializeAgentWorkspaceTransition({+			content: 'Now working in second',+			workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree,+			workspaceName: 'second',+		}));+		const contribution = disposables.add(new SessionWorkspaceConversionContribution(+			new class extends mock<IAgentHostChatContributionContext>() { }(),+			new class extends mock<ISessionWorkspaceConversionService>() { }(),+			createHydrationStateManager(URI.parse('copilot:/workspace-less'), true),+			sessionDataService,+			new NullLogService(),+		));+		const turns = ['turn-1', 'turn-2'].map((id): Turn => ({+			id,+			message: { text: 'Continue work', origin: { kind: MessageKind.SystemNotification } },+			responseParts: [{ kind: ResponsePartKind.Markdown, id: `${id}-response`, content: `${id} output` }],+			usage: undefined,+			state: TurnState.Complete,+		}));++		const restored = await contribution.onHydrateTurns({+			session: 'copilot:/workspace-less',+			chat: buildDefaultChatUri('copilot:/workspace-less'),+		}, turns);++		assert.deepStrictEqual({+			responseParts: restored.map(turn => turn.responseParts.map(part =>+				part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind+			)),+			databaseOpenCalls,+			transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls,+		}, {+			responseParts: [+				['Now working in first', ResponsePartKind.Markdown],+				['Now working in second', ResponsePartKind.Markdown],+			],+			databaseOpenCalls: 1,+			transitionQueryCalls: 1,+		});+	});++	test('hydrates a persisted workspace transition for a non-default chat', async () => {+		const session = URI.parse('copilot:/workspace-less');+		const database = new TestSessionDatabase();+		await database.setTurnWorkspaceTransition('turn-1', serializeAgentWorkspaceTransition({+			content: 'Now working in project',+			workspaceKind: AgentSystemNotificationWorkspaceKind.Folder,+			workspaceName: 'project',+		}));+		const contribution = disposables.add(new SessionWorkspaceConversionContribution(+			new class extends mock<IAgentHostChatContributionContext>() { }(),+			new class extends mock<ISessionWorkspaceConversionService>() { }(),+			createHydrationStateManager(session, true),+			createSessionDataService(database),+			new NullLogService(),+		));+		const turns: Turn[] = [{+			id: 'turn-1',+			message: { text: 'Continue work', origin: { kind: MessageKind.SystemNotification } },+			responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response-1', content: 'Provider output' }],+			usage: undefined,+			state: TurnState.Complete,+		}];++		const restored = await contribution.onHydrateTurns({+			session: session.toString(),+			chat: buildChatUri(session, 'peer-chat'),+		}, turns);++		assert.deepStrictEqual(restored[0].responseParts.map(part =>+			part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind+		), [+			'Now working in project',+			ResponsePartKind.Markdown,+		]);+	});+ 	test('creates an isolated worktree and sets it as the workspace', async () => { 		const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); 		const harness = createHarness(worktreeIsolation);@@ -365,6 +662,7 @@ suite('SessionWorkspaceConversionService', () => {  		const state = harness.stateManager.getSessionState(harness.session.toString()); 		const summary = harness.stateManager.getSessionSummary(harness.session.toString());+		const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; 		assert.deepStrictEqual({ 			worktreeRequests: worktreeIsolation.requests.map(request => ({ 				session: request.sessionUri.toString(),@@ -382,6 +680,10 @@ suite('SessionWorkspaceConversionService', () => { 			isolation: state?.config?.values[SessionConfigKey.Isolation], 			branch: state?.config?.values[SessionConfigKey.Branch], 			persistedConfig: JSON.parse((await harness.database.getMetadata('configValues')) ?? '{}'),+			outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [{+				content: part.content,+				meta: readAgentSystemNotificationMeta(part),+			}] : []), 			continuationText: harness.continuations[0]?.message.text, 		}, { 			worktreeRequests: [{@@ -416,10 +718,87 @@ suite('SessionWorkspaceConversionService', () => { 				[SessionConfigKey.Isolation]: 'worktree', 				[SessionConfigKey.Branch]: 'main', 			},+			outcomeNotifications: [{+				content: 'Now working in project',+				meta: {+					kind: AgentSystemNotificationKind.WorkspaceTransition,+					severity: undefined,+					workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree,+					workspaceName: 'project',+				},+			}], 			continuationText: `The current session is now attached to ${worktreeIsolation.worktree.fsPath} in an isolated worktree. Continue the user's original task in this workspace. Do not request another session or workspace conversion.`, 		}); 	}); +	test('does not request workspace trust in Allow All mode', async () => {+		const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false);+		const workspaceFolder = URI.file('/workspace/project');+		harness.agent.setWorkingDirectory = async () => { };+		setSessionConfig(harness, { [SessionConfigKey.AutoApprove]: 'autoApprove' });+		startTurn(harness.stateManager, harness.chat);+		harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1');+		completeTurn(harness.stateManager, harness.chat);++		await updateSessionWorkspace(harness);++		assert.deepStrictEqual({+			trustRequests: harness.trustRequests,+			workingDirectories: harness.stateManager.getSessionState(harness.session.toString())?.workingDirectories,+		}, {+			trustRequests: [],+			workingDirectories: [workspaceFolder.toString()],+		});+	});++	test('does not request workspace, repository, or worktree trust when global auto-approve is enabled', async () => {+		const workspaceFolder = URI.file('/workspace/project/packages/app');+		const repository = URI.file('/workspace/project');+		const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature'), repository);+		const harness = createHarness(worktreeIsolation, async () => false);+		harness.agent.setWorkingDirectory = async () => { };+		harness.configurationService.updateRootConfig({ [AgentHostGlobalAutoApproveEnabledConfigKey]: true });+		startTurn(harness.stateManager, harness.chat);+		harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1');+		completeTurn(harness.stateManager, harness.chat);++		await updateSessionWorkspace(harness);++		assert.deepStrictEqual({+			trustRequests: harness.trustRequests,+			createdWorktrees: worktreeIsolation.createdWorktrees,+			workingDirectories: harness.stateManager.getSessionState(harness.session.toString())?.workingDirectories,+		}, {+			trustRequests: [],+			createdWorktrees: [worktreeIsolation.worktree],+			workingDirectories: [worktreeIsolation.worktree.toString()],+		});+	});++	test('still requests workspace trust outside Allow All mode', async () => {+		const trustRequests: ReturnType<typeof createHarness>['trustRequests'][] = [];+		for (const values of [+			{ [SessionConfigKey.AutoApprove]: 'default' },+			{ [SessionConfigKey.AutoApprove]: 'assisted' },+			{ [SessionConfigKey.AutoApprove]: 'default', [SessionConfigKey.Mode]: 'autopilot' },+		]) {+			const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false);+			setSessionConfig(harness, values);+			startTurn(harness.stateManager, harness.chat);+			harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1');+			completeTurn(harness.stateManager, harness.chat);++			await updateSessionWorkspace(harness);+			trustRequests.push(harness.trustRequests);+		}++		assert.deepStrictEqual(trustRequests, [+			[{ clientId: 'client-1', workspace: 'file:///workspace/project' }],+			[{ clientId: 'client-1', workspace: 'file:///workspace/project' }],+			[{ clientId: 'client-1', workspace: 'file:///workspace/project' }],+		]);+	});+ 	test('keeps the session workspace-less when workspace trust is declined', async () => { 		const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false); 		const workspaceFolder = URI.file('/workspace/project');@@ -442,6 +821,7 @@ suite('SessionWorkspaceConversionService', () => { 			workingDirectories: state?.workingDirectories, 			workspaceless: readSessionWorkspaceless(state?._meta), 			persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY),+			persistedTransitions: [...(await harness.database.getTurnWorkspaceTransitions()).values()], 			continuation: harness.continuations.map(entry => ({ 				label: readMessageSystemInitiatedLabel(entry.message), 				text: entry.message.text,@@ -455,6 +835,7 @@ suite('SessionWorkspaceConversionService', () => { 			workingDirectories: [harness.scratch.toString()], 			workspaceless: true, 			persistedWorkspaceless: 'true',+			persistedTransitions: [], 			continuation: [{ 				label: 'Workspace Setup Failed', 				text: `The requested workspace setup did not complete successfully: Workspace trust was not granted for '${workspaceFolder.fsPath}'. Do not run the user's task. Tell the user that workspace setup failed and include this error.`,@@ -585,19 +966,33 @@ suite('SessionWorkspaceConversionService', () => { 			workingDirectories: state?.workingDirectories, 			workspaceless: readSessionWorkspaceless(state?._meta), 			continuationHidden: harness.continuations[0] ? isMessageHiddenFromTranscript(harness.continuations[0].message) : undefined,+			continuationRequestHidden: harness.continuations[0] ? isMessageRequestHiddenFromTranscript(harness.continuations[0].message) : undefined, 			continuationLabel: harness.continuations[0] ? readMessageSystemInitiatedLabel(harness.continuations[0].message) : undefined, 			continuationOrigin: harness.continuations[0]?.message.origin.kind, 			continuationText: harness.continuations[0]?.message.text,-			outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []),+			deferredRequestHidden: activeTurn ? isMessageRequestHiddenFromTranscript(activeTurn.message) : undefined,+			deferredRequestIsWorkspaceContinuation: activeTurn ? isAgentWorkspaceContinuationMessage(activeTurn.message) : undefined,+			resumedTurnIsHostNotice: activeTurn ? isHostNoticeTurn(activeTurn) : undefined,+			outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [{+				content: part.content,+				kind: readAgentSystemNotificationMeta(part).kind,+			}] : []), 		}, { 			pending: false, 			workingDirectories: [harness.scratch.toString()], 			workspaceless: true, 			continuationHidden: false,+			continuationRequestHidden: false, 			continuationLabel: 'Workspace Setup Failed', 			continuationOrigin: MessageKind.SystemNotification, 			continuationText: 'The requested workspace setup did not complete successfully: provider failed. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.',-			outcomeNotifications: ['Workspace Setup Failed'],+			deferredRequestHidden: true,+			deferredRequestIsWorkspaceContinuation: true,+			resumedTurnIsHostNotice: false,+			outcomeNotifications: [{+				content: 'Workspace Setup Failed',+				kind: undefined,+			}], 		}); 	}); @@ -620,11 +1015,13 @@ suite('SessionWorkspaceConversionService', () => { 			workspaceless: readSessionWorkspaceless(state?._meta), 			persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), 			continuationText: harness.continuations[0]?.message.text,+			failedContinuations: harness.failedContinuations, 		}, { 			workingDirectories: ['file:///workspace/authoritative'], 			workspaceless: false, 			persistedWorkspaceless: 'false', 			continuationText: `The requested workspace setup did not complete successfully: The workspace changed to '${authoritative.fsPath}', but conversion did not complete cleanly: SDK returned a different directory. Do not run the user's task. Tell the user that workspace setup failed and include this error.`,+			failedContinuations: [], 		}); 	}); @@ -692,35 +1089,41 @@ suite('SessionWorkspaceConversionService', () => { 	});  	test('does not continue a setup turn that the user cancelled during conversion', async () => {-		const trustDecision = new DeferredPromise<boolean>();-		const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p);+		const database = new GatedConversionDatabase();+		const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); 		const provider: IAgent = harness.agent; 		provider.setWorkingDirectory = async () => { }; 		startTurn(harness.stateManager, harness.chat); 		harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); 		completeTurn(harness.stateManager, harness.chat);  		const conversion = updateSessionWorkspace(harness);-		await Promise.resolve();+		await database.writeStarted.p; 		harness.stateManager.dispatchServerAction(harness.chat.toString(), { 			type: ActionType.ChatTurnCancelled, 			turnId: 'continuation-1', 			duration: 1, 		});-		trustDecision.complete(true);+		database.releaseWrite.complete(); 		await conversion;  		const state = harness.stateManager.getSessionState(harness.session.toString()); 		assert.deepStrictEqual({ 			workingDirectories: state?.workingDirectories, 			workspaceless: readSessionWorkspaceless(state?._meta),+			hasWorkspaceTransitions: readSessionHasWorkspaceTransitions(state?._meta),+			persistedTransitions: [...(await harness.database.getTurnWorkspaceTransitions()).entries()],+			persistedHasWorkspaceTransitions: await harness.database.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), 			continuations: harness.continuations, 			failedContinuations: harness.failedContinuations, 			activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, 			activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), 		}, { 			workingDirectories: ['file:///workspace/project'], 			workspaceless: false,+			hasWorkspaceTransitions: false,+			persistedTransitions: [],+			persistedHasWorkspaceTransitions: undefined, 			continuations: [], 			failedContinuations: [], 			activity: undefined,@@ -757,8 +1160,8 @@ suite('SessionWorkspaceConversionService', () => {  	test('atomically persists conversion metadata or quarantines before publishing state', async () => { 		class FailingConversionDatabase extends TestSessionDatabase {-			override async setMetadataValues(): Promise<void> {-				throw new Error('atomic commit failed');+			override async setWorkspaceConversion(): Promise<void> {+				throw new Error('transition write failed'); 			} 		} 		const database = new FailingConversionDatabase();@@ -775,21 +1178,27 @@ suite('SessionWorkspaceConversionService', () => { 		assert.deepStrictEqual({ 			pending: harness.service.isPending(harness.chat.toString()), 			persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY),+			persistedWorkspaceless: await database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), 			workingDirectories: state?.workingDirectories, 			workspaceless: readSessionWorkspaceless(state?._meta),+			persistedTransitions: [...(await database.getTurnWorkspaceTransitions()).entries()],+			persistedHasWorkspaceTransitions: await database.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), 			continuations: harness.continuations, 		}, { 			pending: true, 			persistedQuarantine: 'true',+			persistedWorkspaceless: undefined, 			workingDirectories: [harness.scratch.toString()], 			workspaceless: true,+			persistedTransitions: [],+			persistedHasWorkspaceTransitions: undefined, 			continuations: [], 		}); 	});  	test('keeps the session quarantined in memory when durable quarantine persistence fails', async () => { 		class FailingQuarantineDatabase extends TestSessionDatabase {-			override async setMetadataValues(): Promise<void> {+			override async setWorkspaceConversion(): Promise<void> { 				throw new Error('atomic commit failed'); 			} @@ -966,9 +1375,11 @@ suite('SessionWorkspaceConversionService', () => { 		assert.deepStrictEqual({ 			pending: harness.service.isPending(harness.chat.toString()), 			continuations: harness.continuations,+			deferredContinuations: harness.deferredContinuations, 		}, { 			pending: false, 			continuations: [],+			deferredContinuations: [], 		}); 	}); });
src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts7 + / 0
@@ -384,6 +384,13 @@ export function getChatResponsePlaintextParts(item: IChatResponseViewModel, incl 				} 				break; 			}+			case 'systemNotification': {+				const text = part.accessibilityLabel ?? renderChatMessageAsPlaintext(part.content);+				if (text.trim()) {+					contentParts.push({ partIndex, text });+				}+				break;+			} 			case 'inlineReference': { 				const ref = part.inlineReference; 				let text: string;
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts4 + / 0
@@ -3,6 +3,7 @@  *  Licensed under the MIT License. See License.txt in the project root for license information.  *--------------------------------------------------------------------------------------------*/ +import { status } from '../../../../../../base/browser/ui/aria/aria.js'; import { Delayer, disposableTimeout, raceCancellation } from '../../../../../../base/common/async.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js';@@ -3371,6 +3372,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC 						if (responseParts$.get().indexOf(initial) >= (opts.initialResponsePartCount ?? 0) && opts.subAgentInvocationId === undefined) { 							const progress = systemNotificationToChatPart(initial.content, this._config.connectionAuthority, initial._meta); 							if (progress) {+								if (progress.kind === 'systemNotification' && progress.presentation === 'workspaceTransition' && progress.accessibilityLabel) {+									status(progress.accessibilityLabel);+								} 								opts.sink([progress]); 							} 						}
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts15 + / 1
@@ -26,7 +26,7 @@ import { SessionServerToolName } from '../../../../../../platform/agentHost/comm import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAnnotationsAttachment, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { getBrowserViewAttachmentMetadata, isBrowserViewAttachment } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; import { readAgentMessageDelegationMeta } from '../../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js';-import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js';+import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, AgentSystemNotificationWorkspaceKind, readAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { AGENT_HOST_SESSION_LINK_SCHEME, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { parsePartialToolInputForDisplay } from '../../../../../../platform/agentHost/common/partialToolInput.js';@@ -488,6 +488,20 @@ export function systemNotificationToChatPart(content: StringOrMarkdown | undefin 			return meta.severity === AgentSystemNotificationSeverity.Warning 				? { kind: 'warning', content: markdown } 				: { kind: 'systemNotification', content: markdown };+		case AgentSystemNotificationKind.WorkspaceTransition:+			if (!meta.workspaceName || !meta.workspaceKind) {+				return { kind: 'systemNotification', content: markdown };+			}+			return {+				kind: 'systemNotification',+				content: markdown,+				icon: meta.workspaceKind === AgentSystemNotificationWorkspaceKind.Worktree ? Codicon.worktreeCompact : Codicon.folderCompact,+				presentation: 'workspaceTransition',+				workspaceName: meta.workspaceName,+				accessibilityLabel: meta.workspaceKind === AgentSystemNotificationWorkspaceKind.Worktree+					? localize('agentHost.workspaceTransition.worktree', "Workspace changed. This session is now working in {0} using an isolated worktree.", meta.workspaceName)+					: localize('agentHost.workspaceTransition.folder', "Workspace changed. This session is now working directly in {0}.", meta.workspaceName),+			}; 		case AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut: 			return { kind: 'systemNotification', content: markdown, icon: Codicon.clock, collapsible: true }; 		case AgentSystemNotificationKind.AutomaticApprovalReviewAborted:
src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts28 + / 2
@@ -11,6 +11,7 @@ import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js';+import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IChatSystemNotificationPart } from '../../../common/chatService/chatService.js';@@ -41,11 +42,14 @@ export class ChatSystemNotificationContentPart extends Disposable implements ICh 		private readonly notification: IChatSystemNotificationPart, 		renderer: IMarkdownRenderer, 		@IInstantiationService instantiationService: IInstantiationService,+		@IHoverService private readonly hoverService: IHoverService, 	) { 		super();  		let notificationNode: HTMLElement;-		if (notification.collapsible) {+		if (notification.presentation === 'workspaceTransition') {+			notificationNode = this._renderWorkspaceTransition(notification);+		} else if (notification.collapsible) { 			const firstLineBreak = notification.content.value.indexOf('\n'); 			const detailsValue = firstLineBreak === -1 ? '' : notification.content.value.slice(firstLineBreak).trim(); 			if (detailsValue) {@@ -67,6 +71,25 @@ export class ChatSystemNotificationContentPart extends Disposable implements ICh 		} 	} +	private _renderWorkspaceTransition(notification: IChatSystemNotificationPart): HTMLElement {+		const owner = dom.$('.chat-workspace-transition');+		owner.setAttribute('role', 'separator');+		owner.setAttribute('aria-orientation', 'horizontal');+		owner.setAttribute('aria-label', notification.accessibilityLabel ?? renderAsPlaintext(notification.content));+		dom.append(owner, dom.$('span.chat-workspace-transition-line')).setAttribute('aria-hidden', 'true');+		const label = dom.append(owner, dom.$('span.chat-workspace-transition-label'));+		this._register(this.hoverService.setupDelayedHover(label, { content: renderAsPlaintext(notification.content) }));+		const workspaceNameIndex = notification.workspaceName ? notification.content.value.lastIndexOf(notification.workspaceName) : -1;+		const iconIndex = workspaceNameIndex >= 0 ? workspaceNameIndex : 0;+		label.append(notification.content.value.slice(0, iconIndex));+		const icon = dom.append(label, dom.$('span.chat-workspace-transition-icon'));+		icon.classList.add(...ThemeIcon.asClassNameArray(notification.icon ?? Codicon.folderCompact));+		icon.setAttribute('aria-hidden', 'true');+		label.append(notification.content.value.slice(iconIndex));+		dom.append(owner, dom.$('span.chat-workspace-transition-line')).setAttribute('aria-hidden', 'true');+		return owner;+	}+ 	private _renderNotification(notification: IChatSystemNotificationPart, renderer: IMarkdownRenderer, instantiationService: IInstantiationService): HTMLElement { 		const rendered = this._register(renderer.render(notification.content)); 		return this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, notification.icon ?? Codicon.check, undefined)).domNode;@@ -119,6 +142,9 @@ export class ChatSystemNotificationContentPart extends Disposable implements ICh 			&& other.content.value === this.notification.content.value 			&& ThemeIcon.isEqual(other.icon ?? Codicon.check, this.notification.icon ?? Codicon.check) 			&& !!other.collapsible === !!this.notification.collapsible-			&& !!other.renderInlineTiming === !!this.notification.renderInlineTiming;+			&& !!other.renderInlineTiming === !!this.notification.renderInlineTiming+			&& other.presentation === this.notification.presentation+			&& other.workspaceName === this.notification.workspaceName+			&& other.accessibilityLabel === this.notification.accessibilityLabel; 	} }
src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSystemNotificationContentPart.css39 + / 0
@@ -61,6 +61,45 @@ 	display: none; } +.chat-workspace-transition {+	display: flex;+	align-items: center;+	gap: var(--vscode-spacing-size80);+	width: 100%;+	margin: var(--vscode-spacing-size160) 0;+	color: var(--vscode-descriptionForeground);+	font-size: var(--vscode-fontSize-body2);+}++.chat-workspace-transition-line {+	flex: 1;+	height: var(--vscode-strokeThickness);+	background-color: var(--vscode-chat-requestBorder, var(--vscode-input-background));+}++.chat-workspace-transition-line:first-child {+	mask-image: linear-gradient(to right, transparent, black);+	-webkit-mask-image: linear-gradient(to right, transparent, black);+}++.chat-workspace-transition-line:last-child {+	mask-image: linear-gradient(to left, transparent, black);+	-webkit-mask-image: linear-gradient(to left, transparent, black);+}++.chat-workspace-transition-label {+	min-width: 0;+	overflow: hidden;+	text-overflow: ellipsis;+	white-space: nowrap;+}++.chat-workspace-transition .chat-workspace-transition-label .chat-workspace-transition-icon.codicon {+	margin-inline: var(--vscode-spacing-size40);+	font-size: var(--vscode-codiconFontSize-compact);+	vertical-align: text-bottom;+}+ .chat-system-notification-layout { 	display: flex; 	align-items: last baseline;
src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts23 + / 2
@@ -161,6 +161,7 @@ export interface IChatListItemTemplate { 	renderedPartsMounted?: boolean; 	renderedContent?: ReadonlyArray<IChatRendererContent>; 	completedResponseDisclosure?: HTMLDetailsElement;+	completedResponseCollapseStartIndex?: number; 	completedResponseCollapseEndIndex?: number; 	completedResponseDisclosureOpen?: boolean; 	wasResponseComplete?: boolean;@@ -2899,6 +2900,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch 			this.removeCompletedResponseDisclosure(templateData); 			return; 		}+		const workspaceTransitionIndex = content.findIndex(part => part.kind === 'systemNotification' && part.presentation === 'workspaceTransition');+		const collapseStartIndex = workspaceTransitionIndex >= 0 && workspaceTransitionIndex < collapseEndIndex+			? workspaceTransitionIndex + 1+			: 0;  		const collapseEndNode = templateData.renderedParts?.[collapseEndIndex]?.domNode; 		if (!collapseEndNode) {@@ -2922,16 +2927,30 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch 		}  		if (existingDisclosure+			&& templateData.completedResponseCollapseStartIndex === collapseStartIndex 			&& templateData.completedResponseCollapseEndIndex === collapseEndIndex 			&& existingDisclosure.nextSibling === collapseEndRoot-			&& templateData.renderedParts?.slice(0, collapseEndIndex).every(part => !part?.domNode || existingDisclosure.contains(part.domNode))+			&& templateData.renderedParts?.slice(collapseStartIndex, collapseEndIndex).every(part => !part?.domNode || existingDisclosure.contains(part.domNode)) 		) { 			return; 		}  		this.removeCompletedResponseDisclosure(templateData); 		const valueChildren = Array.from(templateData.value.childNodes);-		const nodesToCollapse = valueChildren.slice(0, valueChildren.indexOf(collapseEndRoot));+		const collapseEndChildIndex = valueChildren.indexOf(collapseEndRoot);+		let collapseStartChildIndex = 0;+		const workspaceTransitionNode = workspaceTransitionIndex >= 0 ? templateData.renderedParts?.[workspaceTransitionIndex]?.domNode : undefined;+		if (workspaceTransitionNode) {+			let workspaceTransitionRoot = workspaceTransitionNode;+			while (workspaceTransitionRoot.parentElement && workspaceTransitionRoot.parentElement !== templateData.value) {+				workspaceTransitionRoot = workspaceTransitionRoot.parentElement;+			}+			const workspaceTransitionChildIndex = valueChildren.indexOf(workspaceTransitionRoot);+			if (workspaceTransitionChildIndex >= 0 && workspaceTransitionChildIndex < collapseEndChildIndex) {+				collapseStartChildIndex = workspaceTransitionChildIndex + 1;+			}+		}+		const nodesToCollapse = valueChildren.slice(collapseStartChildIndex, collapseEndChildIndex); 		const stepCount = getVisibleCompletedResponseItemCount(nodesToCollapse); 		if (stepCount < 2) { 			return;@@ -2967,6 +2986,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch 		templateData.value.insertBefore(details, collapseEndRoot); 		details.append(...nodesToCollapse); 		templateData.completedResponseDisclosure = details;+		templateData.completedResponseCollapseStartIndex = collapseStartIndex; 		templateData.completedResponseCollapseEndIndex = collapseEndIndex; 		templateData.completedResponseDisclosureDisposables.add(dom.addDisposableListener(details, 'toggle', () => { 			templateData.completedResponseDisclosureOpen = details.open;@@ -3005,6 +3025,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch 		} 		details.remove(); 		templateData.completedResponseDisclosure = undefined;+		templateData.completedResponseCollapseStartIndex = undefined; 		templateData.completedResponseCollapseEndIndex = undefined; 	} 
src/vs/workbench/contrib/chat/common/chatService/chatService.ts6 + / 0
@@ -326,6 +326,12 @@ export interface IChatSystemNotificationPart { 	collapsible?: boolean; 	/** Render response timing beside the notification instead of using the response footer. */ 	renderInlineTiming?: boolean;+	/** Use a quiet transcript boundary treatment instead of a progress row. */+	presentation?: 'workspaceTransition';+	/** Workspace folder name emphasized by the transition presentation. */+	workspaceName?: string;+	/** Complete accessible description for non-visual presentation and announcements. */+	accessibilityLabel?: string; }  export interface IChatTask extends IChatTaskDto {
src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts31 + / 1
@@ -7,19 +7,49 @@ import assert from 'assert'; import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js';+import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { Location } from '../../../../../../editor/common/languages.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';-import { ChatResponseAccessibleView, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js';+import { ChatResponseAccessibleView, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, getChatResponsePlaintextParts, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { IChatExtensionsContent, IChatPullRequestContent, IChatSessionCreatedData, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js';+import type { IResponse } from '../../../common/model/chatModel.js';+import type { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js';  suite('ChatResponseAccessibleView', () => { 	const store = ensureNoDisposablesAreLeakedInTestSuite(); +	test('omits the hidden workspace-continuation request text while preserving transition and provider response text', () => {+		const item = upcastPartial<IChatResponseViewModel>({+			response: upcastPartial<IResponse>({+				value: [+					{+						kind: 'systemNotification',+						content: new MarkdownString('Now working in vscode'),+						presentation: 'workspaceTransition',+						accessibilityLabel: 'Workspace changed. This session is now working in vscode using an isolated worktree.',+					},+					{ kind: 'markdownContent', content: new MarkdownString('Provider continued work') },+				],+			}),+		});++		assert.deepStrictEqual(getChatResponsePlaintextParts(item, true), [+			{+				partIndex: 0,+				text: 'Workspace changed. This session is now working in vscode using an isolated worktree.',+			},+			{+				partIndex: 1,+				text: 'Provider continued work',+			},+		]);+	});+ 	suite('getToolSpecificDataDescription', () => { 		test('returns empty string for undefined', () => { 			assert.strictEqual(getToolSpecificDataDescription(undefined), '');
src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts10 + / 0
@@ -15,6 +15,7 @@ import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { buildBranchChangesetUri, buildTurnChangesetUri } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { toAgentMergeMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js';+import { toAgentWorkspaceContinuationMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentWorkspaceContinuationMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { 	buildDefaultChatUri,@@ -184,17 +185,26 @@ suite('AgentHostResponseFileChangesProvider', () => { 		const { latest } = observe(provider, ds); 		const visibleRepairFiles = latest().map(diff => fromAgentHostUri(diff.modifiedURI).path); +		conn.setState(defaultChatUri.toString(), chatState(withMessageRequestHiddenFromTranscript({+			text: 'Continue in the requested workspace.',+			origin: { kind: MessageKind.SystemNotification },+			_meta: toAgentWorkspaceContinuationMessageMeta(),+		}, true)));+		const workspaceContinuationFiles = latest().map(diff => fromAgentHostUri(diff.modifiedURI).path);+ 		conn.setState(defaultChatUri.toString(), chatState(withMessageRequestHiddenFromTranscript({ 			text: 'Agent Merge is enabled.', 			origin: { kind: MessageKind.SystemNotification }, 		}, true)));  		assert.deepStrictEqual({ 			visibleRepairFiles,+			workspaceContinuationFiles, 			noticeFiles: latest(), 			noticeIsAuthoritativeEmpty: latest() === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, 		}, { 			visibleRepairFiles: ['/repo/changed.ts'],+			workspaceContinuationFiles: ['/repo/changed.ts'], 			noticeFiles: [], 			noticeIsAuthoritativeEmpty: true, 		});
src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts95 + / 5
@@ -4,6 +4,8 @@  *--------------------------------------------------------------------------------------------*/  import assert from 'assert';+import * as dom from '../../../../../../base/browser/dom.js';+import { setARIAContainer } from '../../../../../../base/browser/ui/aria/aria.js'; import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js';@@ -32,7 +34,8 @@ import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; import { BrowserViewAttachmentDisplayKind, BrowserViewAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js';-import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js';+import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, AgentSystemNotificationWorkspaceKind, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js';+import { toAgentWorkspaceContinuationMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentWorkspaceContinuationMeta.js'; import { ActionType, AuthRequiredReason, isSessionAction, isChatAction, NotificationType, 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 { AHP_NOT_FOUND, ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ChatInteractivity, ConfirmationOptionKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type AgentCustomization, type ClientPluginCustomization, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js';@@ -4911,6 +4914,37 @@ suite('AgentHostChatContribution', () => { 			assert.deepStrictEqual(notifications.map(part => part.content.value), ['Background command completed']); 		})); +		test('workspace transition response part is announced once as a polite status', () => runWithFakedTimers({ useFakeTimers: true }, async () => {+			const ariaHost = dom.append(document.body, dom.$('div'));+			disposables.add(toDisposable(() => ariaHost.remove()));+			setARIAContainer(ariaHost);+			const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);+			const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables);++			fire({+				type: 'chat/responsePart',+				session,+				turnId,+				part: {+					kind: ResponsePartKind.SystemNotification,+					content: 'Now working in vscode',+					_meta: toAgentSystemNotificationMeta({+						kind: AgentSystemNotificationKind.WorkspaceTransition,+						workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree,+						workspaceName: 'vscode',+					}),+				},+			} as ChatAction);+			fire({ type: 'chat/responsePart', session, turnId, part: { kind: ResponsePartKind.Markdown, id: 'md-1', content: 'Continuing work' } } as ChatAction);+			fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction);+			await turnPromise;++			assert.deepStrictEqual(+				[...ariaHost.querySelectorAll('.monaco-status')].map(element => element.textContent).filter(Boolean),+				['Workspace changed. This session is now working in vscode using an isolated worktree.'],+			);+		}));+ 		test('worktree failure response parts become live warnings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { 			const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); 			const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables);@@ -7437,6 +7471,58 @@ suite('AgentHostChatContribution', () => { 			} 		}); +		test('restored workspace transition does not emit a live status announcement', async () => {+			const ariaHost = dom.append(document.body, dom.$('div'));+			disposables.add(toDisposable(() => ariaHost.remove()));+			setARIAContainer(ariaHost);+			const { sessionHandler, agentHostService } = createContribution(disposables);+			const sessionUri = AgentSession.uri('copilot', 'restored-workspace-transition');+			agentHostService.sessionStates.set(sessionUri.toString(), {+				...createSessionState({ resource: sessionUri.toString(), provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }),+				lifecycle: SessionLifecycle.Ready,+				turns: [{+					id: 'provider-continuation',+					message: withMessageRequestHiddenFromTranscript({+						text: 'Continue the original task in the converted workspace.',+						origin: { kind: MessageKind.SystemNotification },+						_meta: toAgentWorkspaceContinuationMessageMeta(),+					}, true),+					responseParts: [{+						kind: ResponsePartKind.SystemNotification,+						content: 'Now working in vscode',+						_meta: toAgentSystemNotificationMeta({+							kind: AgentSystemNotificationKind.WorkspaceTransition,+							workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree,+							workspaceName: 'vscode',+						}),+					}, {+						kind: ResponsePartKind.Markdown,+						id: 'provider-response',+						content: 'Provider continued work',+					}],+					usage: undefined,+					state: TurnState.Complete,+				}],+			});++			const session = await sessionHandler.provideChatSessionContent(+				URI.from({ scheme: 'agent-host-copilot', path: '/restored-workspace-transition' }),+				CancellationToken.None,+			);+			disposables.add(toDisposable(() => session.dispose()));+			const response = session.history.find(item => item.type === 'response');++			assert.deepStrictEqual({+				history: session.history.map(item => item.type),+				responseParts: response?.type === 'response' ? response.parts.map(part => part.kind) : undefined,+				statusAnnouncements: [...ariaHost.querySelectorAll('.monaco-status')].map(element => element.textContent).filter(Boolean),+			}, {+				history: ['request', 'response'],+				responseParts: ['systemNotification', 'markdownContent'],+				statusAnnouncements: [],+			});+		});+ 		test('restores agent feedback attachments into request history variable data', async () => { 			const { sessionHandler, agentHostService } = createContribution(disposables); 			const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/feedback-history' });@@ -11968,7 +12054,7 @@ suite('AgentHostChatContribution', () => { 			}); 		}); -		test('detects server-initiated turn and fires onDidStartServerRequest', () => runWithFakedTimers({ useFakeTimers: true }, async () => {+		test('forwards a hidden workspace-continuation request to the live transcript adapter', () => runWithFakedTimers({ useFakeTimers: true }, async () => { 			const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);  			// Create and subscribe a session@@ -11995,7 +12081,7 @@ suite('AgentHostChatContribution', () => { 			agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); 			await turn1Promise; -			// Now simulate a server-initiated turn (e.g. from a consumed queued message)+			// Now simulate the server-initiated turn that resumes after workspace conversion. 			const serverTurnId = 'server-turn-1'; 			const serverRequestEvents: IChatSessionServerRequest[] = []; 			disposables.add(chatSession.onDidStartServerRequest!(e => serverRequestEvents.push(e)));@@ -12005,7 +12091,11 @@ suite('AgentHostChatContribution', () => { 				action: { 					type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', 					turnId: serverTurnId,-					message: withMessageRequestHiddenFromTranscript({ text: 'queued message text', origin: { kind: MessageKind.User } }, true),+					message: withMessageRequestHiddenFromTranscript({+						text: 'Continue in the requested workspace.',+						origin: { kind: MessageKind.SystemNotification },+						_meta: toAgentWorkspaceContinuationMessageMeta(),+					}, true), 				} as ChatAction, 				serverSeq: 3, 				origin: undefined, // Server-originated — no client origin@@ -12016,7 +12106,7 @@ suite('AgentHostChatContribution', () => { 			// onDidStartServerRequest should have fired, carrying the provider turn id 			assert.deepStrictEqual( 				serverRequestEvents.map(e => ({ id: e.id, prompt: e.prompt, isHidden: e.isHidden, isRequestHidden: e.isRequestHidden })),-				[{ id: serverTurnId, prompt: '<!-- vscode-request-hidden-from-transcript -->\nqueued message text', isHidden: false, isRequestHidden: true }],+				[{ id: serverTurnId, prompt: '<!-- vscode-request-hidden-from-transcript -->\nContinue in the requested workspace.', isHidden: false, isRequestHidden: true }], 			);  			// isCompleteObs should be false (turn in progress)
src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts87 + / 1
@@ -12,7 +12,8 @@ import { MarkdownString, type IMarkdownString } from '../../../../../../base/com import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentHostAutoReplyAnswer } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { toAgentMessageDelegationMeta } from '../../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js';-import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js';+import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, AgentSystemNotificationWorkspaceKind, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js';+import { toAgentWorkspaceContinuationMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentWorkspaceContinuationMeta.js'; import { McpAuthRequiredReason } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { createAgentHostResourceUriMapper, fromAgentHostUri, toAgentHostContentUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, createErrorResponsePart, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, withMessageRequestHiddenFromTranscript, withMessageSystemInitiatedLabel, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js';@@ -531,6 +532,57 @@ suite('stateToProgressAdapter', () => { 			assert.strictEqual(progress.content.value, 'Shell command completed'); 		}); +		test('workspace continuation restores with a hidden request and visible transition before provider output', () => {+			const turn = createTurn({+				message: withMessageRequestHiddenFromTranscript({+					text: 'Continue in the requested workspace.',+					origin: { kind: MessageKind.SystemNotification },+					_meta: toAgentWorkspaceContinuationMessageMeta(),+				}, true),+				responseParts: [+					{+						kind: ResponsePartKind.SystemNotification,+						content: 'Now working in vscode',+						_meta: toAgentSystemNotificationMeta({+							kind: AgentSystemNotificationKind.WorkspaceTransition,+							workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree,+							workspaceName: 'vscode',+						}),+					},+					{ kind: ResponsePartKind.Markdown, id: 'provider-response', content: 'Provider continued work' },+				],+			});++			const history = turnsToHistory(URI.file('/'), [turn], 'participant-1');+			const request = history[0];+			const response = history[1];+			assert.deepStrictEqual({+				request: request.type === 'request' ? {+					prompt: request.prompt,+					isRequestHidden: request.isRequestHidden,+					isHidden: request.isHidden,+				} : undefined,+				responseParts: response.type === 'response' ? response.parts : undefined,+			}, {+				request: {+					prompt: '<!-- vscode-request-hidden-from-transcript -->\nContinue in the requested workspace.',+					isRequestHidden: true,+					isHidden: undefined,+				},+				responseParts: [{+					kind: 'systemNotification',+					content: new MarkdownString('Now working in vscode'),+					icon: Codicon.worktreeCompact,+					presentation: 'workspaceTransition',+					workspaceName: 'vscode',+					accessibilityLabel: 'Workspace changed. This session is now working in vscode using an isolated worktree.',+				}, {+					kind: 'markdownContent',+					content: new MarkdownString('Provider continued work'),+				}],+			});+		});+ 		test('worktree failure notification restores as warning', () => { 			const turn = createTurn({ 				responseParts: [{@@ -2597,6 +2649,40 @@ suite('stateToProgressAdapter', () => { 			}); 		}); +		test('styles workspace transitions as accessible transcript boundaries', () => {+			const notice = (workspaceKind: AgentSystemNotificationWorkspaceKind) => activeTurnToProgress(URI.file('/'), createActiveTurnState([{+				kind: ResponsePartKind.SystemNotification,+				content: 'Now working in vscode',+				_meta: toAgentSystemNotificationMeta({+					kind: AgentSystemNotificationKind.WorkspaceTransition,+					workspaceKind,+					workspaceName: 'vscode',+				}),+			}]), undefined)[0];++			assert.deepStrictEqual({+				folder: notice(AgentSystemNotificationWorkspaceKind.Folder),+				worktree: notice(AgentSystemNotificationWorkspaceKind.Worktree),+			}, {+				folder: {+					kind: 'systemNotification',+					content: new MarkdownString('Now working in vscode'),+					icon: Codicon.folderCompact,+					presentation: 'workspaceTransition',+					workspaceName: 'vscode',+					accessibilityLabel: 'Workspace changed. This session is now working directly in vscode.',+				},+				worktree: {+					kind: 'systemNotification',+					content: new MarkdownString('Now working in vscode'),+					icon: Codicon.worktreeCompact,+					presentation: 'workspaceTransition',+					workspaceName: 'vscode',+					accessibilityLabel: 'Workspace changed. This session is now working in vscode using an isolated worktree.',+				},+			});+		});+ 		test('styles automatic approval review terminal states', () => { 			const notice = (kind: AgentSystemNotificationKind) => activeTurnToProgress(URI.file('/'), createActiveTurnState([{ 				kind: ResponsePartKind.SystemNotification,
src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts80 + / 1
@@ -6,11 +6,14 @@ import assert from 'assert'; import { addDisposableListener } from '../../../../../../../base/browser/dom.js'; import { IRenderedMarkdown, renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js';+import { IDelayedHoverOptions, IHoverLifecycleOptions } from '../../../../../../../base/browser/ui/hover/hover.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js';-import { DisposableStore } from '../../../../../../../base/common/lifecycle.js';+import { Disposable, DisposableStore, IDisposable } from '../../../../../../../base/common/lifecycle.js';+import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';+import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js';@@ -19,6 +22,21 @@ import { ChatSystemNotificationContentPart } from '../../../../browser/widget/ch suite('ChatSystemNotificationContentPart', () => { 	const store = ensureNoDisposablesAreLeakedInTestSuite(); +	class TestHoverService extends mock<IHoverService>() {+		target: HTMLElement | undefined;+		content: IDelayedHoverOptions['content'] | undefined;++		override setupDelayedHover(+			target: HTMLElement,+			hoverOptions: (() => IDelayedHoverOptions) | IDelayedHoverOptions,+			_lifecycleOptions?: IHoverLifecycleOptions,+		): IDisposable {+			this.target = target;+			this.content = (typeof hoverOptions === 'function' ? hoverOptions() : hoverOptions).content;+			return Disposable.None;+		}+	}+ 	test('renders persistent checked notification content', () => { 		const disposables = store.add(new DisposableStore()); 		const instantiationService = workbenchInstantiationService(undefined, disposables);@@ -184,4 +202,65 @@ suite('ChatSystemNotificationContentPart', () => { 			withoutDetailsHasOrdinaryProgress: true, 		}); 	});++	test('renders a workspace transition as a named separator', () => {+		const disposables = store.add(new DisposableStore());+		const instantiationService = workbenchInstantiationService(undefined, disposables);+		const hoverService = new TestHoverService();+		instantiationService.stub(IHoverService, hoverService);+		const renderer: IMarkdownRenderer = {+			render: (markdown: IMarkdownString): IRenderedMarkdown => {+				const element = mainWindow.document.createElement('div');+				element.textContent = renderAsPlaintext(markdown);+				return { element, dispose: () => { } };+			},+		};+		const notification = {+			kind: 'systemNotification' as const,+			content: new MarkdownString('Now working in working'),+			icon: Codicon.worktreeCompact,+			presentation: 'workspaceTransition' as const,+			workspaceName: 'working',+			accessibilityLabel: 'Workspace changed. This session is now working in working using an isolated worktree.',+		};+		const part = disposables.add(instantiationService.createInstance(ChatSystemNotificationContentPart, notification, renderer));+		part.domNode.style.setProperty('--vscode-codiconFontSize-compact', '12px');+		mainWindow.document.body.appendChild(part.domNode);+		disposables.add({ dispose: () => part.domNode.remove() });+		const icon = part.domNode.querySelector<HTMLElement>('.chat-workspace-transition-icon');++		assert.deepStrictEqual({+			text: part.domNode.textContent,+			role: part.domNode.getAttribute('role'),+			orientation: part.domNode.getAttribute('aria-orientation'),+			label: part.domNode.getAttribute('aria-label'),+			lines: part.domNode.querySelectorAll('.chat-workspace-transition-line').length,+			icon: icon?.classList.contains('codicon-worktree-compact'),+			iconHidden: icon?.getAttribute('aria-hidden'),+			iconFontSize: icon && mainWindow.getComputedStyle(icon).fontSize,+			iconVerticalAlign: icon && mainWindow.getComputedStyle(icon).verticalAlign,+			labelParts: [...part.domNode.querySelector('.chat-workspace-transition-label')?.childNodes ?? []].map(node =>+				node.nodeType === Node.TEXT_NODE ? node.textContent : (node as HTMLElement).className+			),+			hoverTargetsLabel: hoverService.target === part.domNode.querySelector('.chat-workspace-transition-label'),+			hoverContent: hoverService.content,+			sameContent: part.hasSameContent(notification),+			differentPresentation: part.hasSameContent({ kind: 'systemNotification', content: notification.content, icon: notification.icon }),+		}, {+			text: 'Now working in working',+			role: 'separator',+			orientation: 'horizontal',+			label: 'Workspace changed. This session is now working in working using an isolated worktree.',+			lines: 2,+			icon: true,+			iconHidden: 'true',+			iconFontSize: '12px',+			iconVerticalAlign: 'text-bottom',+			labelParts: ['Now working in ', 'chat-workspace-transition-icon codicon codicon-worktree-compact', 'working'],+			hoverTargetsLabel: true,+			hoverContent: 'Now working in working',+			sameContent: true,+			differentPresentation: false,+		});+	}); });
src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts99 + / 0
@@ -1609,6 +1609,105 @@ suite('ChatListRenderer', () => { 		disposables.dispose(); 	}); +	test('keeps a workspace transition outside collapsed completed steps', async () => {+		const disposables = store.add(new DisposableStore());+		const instantiationService = workbenchInstantiationService(undefined, disposables);+		const configurationService = new TestConfigurationService();+		configurationService.setUserConfiguration(ChatConfiguration.IncrementalRendering, false);+		configurationService.setUserConfiguration(ChatConfiguration.CollapseCompletedResponses, true);+		configurationService.setUserConfiguration('chat.checkpoints.enabled', false);+		configurationService.setUserConfiguration('chat.checkpoints.showFileChanges', false);+		configurationService.setUserConfiguration(ChatConfiguration.TurnStatusPills, false);+		configurationService.setUserConfiguration(ChatConfiguration.Verbose, false);+		instantiationService.stub(IConfigurationService, configurationService);+		instantiationService.stub(IChatService, new MockChatService());+		instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService());+		instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService)));++		const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true }));+		const viewModel = disposables.add(instantiationService.createInstance(ChatViewModel, model, undefined));+		const text = 'Continue in the requested workspace.';+		const request = model.addRequest({+			text,+			parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)]+		}, { variables: [] }, 0, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'workspace-continuation', true, 'Continue in Requested Workspace', undefined, false, undefined, false, undefined, true);+		const response = viewModel.getItems().find(isResponseVM);+		assert.ok(response);++		const container = mainWindow.document.createElement('div');+		mainWindow.document.body.appendChild(container);+		disposables.add(toDisposable(() => container.remove()));+		const renderer = disposables.add(instantiationService.createInstance(+			ChatListItemRenderer,+			{} as ChatEditorOptions,+			{},+			{+				getListLength: () => 1,+				onDidScroll: () => toDisposable(() => { }),+				container,+				currentChatMode: () => ChatModeKind.Agent,+				isStickyScrollEnabled: () => false,+				refreshStickyScroll: () => { },+				stickyScrollTopPadding: 0,+			},+			undefined,+			viewModel,+		));+		const template = renderer.renderTemplate(container);+		disposables.add(toDisposable(() => renderer.disposeTemplate(template)));+		const node = { element: response, children: [], depth: 0, visibleChildrenCount: 0, visibleChildIndex: 0, collapsible: false, collapsed: false, visible: true, filterData: undefined };++		model.acceptResponseProgress(request, {+			kind: 'systemNotification',+			content: new MarkdownString('Now working in vscode'),+			presentation: 'workspaceTransition',+			workspaceName: 'vscode',+			accessibilityLabel: 'Workspace changed. This session is now working directly in vscode.',+		});+		for (const callId of ['call-1', 'call-2']) {+			const toolInvocation = new ChatToolInvocation({+				invocationMessage: 'Running tool...',+				pastTenseMessage: 'Tool completed',+			}, {+				id: 'my-tool',+				displayName: 'My Tool',+				modelDescription: 'Test tool',+				source: ToolDataSource.Internal,+			}, callId, undefined, {}, {}, request.id);+			model.acceptResponseProgress(request, toolInvocation);+			await toolInvocation.didExecuteTool(undefined);+		}+		model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Provider continued work') });+		request.response?.complete();+		renderer.renderElement(node, 0, template);++		const transition = container.querySelector<HTMLElement>('.chat-workspace-transition');+		const disclosure = container.querySelector<HTMLDetailsElement>('.completed-response-disclosure');+		const providerOutput = [...container.querySelectorAll<HTMLElement>('.rendered-markdown')]+			.find(element => element.textContent === 'Provider continued work');+		assert.deepStrictEqual({+			requestVisible: viewModel.getItems().some(isRequestVM),+			transitionVisible: !!transition,+			transitionParentIsResponse: transition?.parentElement === template.value,+			transitionInsideDisclosure: !!transition && !!disclosure?.contains(transition),+			disclosureLabel: disclosure?.querySelector('.completed-response-summary')?.textContent,+			disclosureOpen: disclosure?.open,+			transitionBeforeDisclosure: !!transition && !!disclosure && !!(transition.compareDocumentPosition(disclosure) & Node.DOCUMENT_POSITION_FOLLOWING),+			disclosureBeforeProviderOutput: !!disclosure && !!providerOutput && !!(disclosure.compareDocumentPosition(providerOutput) & Node.DOCUMENT_POSITION_FOLLOWING),+		}, {+			requestVisible: false,+			transitionVisible: true,+			transitionParentIsResponse: true,+			transitionInsideDisclosure: false,+			disclosureLabel: 'Completed 2 steps',+			disclosureOpen: false,+			transitionBeforeDisclosure: true,+			disclosureBeforeProviderOutput: true,+		});++		disposables.dispose();+	});+ 	test('reconstructs a large collapsed subagent history through one renderer batch', async () => { 		const disposables = store.add(new DisposableStore()); 		const instantiationService = workbenchInstantiationService(undefined, disposables);
src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts47 + / 0
@@ -3177,6 +3177,53 @@ suite('ChatService', () => { 			]); 		}); +		test('hides a live workspace-continuation request while keeping its response boundary and provider output', async () => {+			const onDidStartServerRequest = testDisposables.add(new Emitter<IChatSessionServerRequest>());+			const progressObs = observableValue<IChatProgress[]>('progress', []);+			const { resource } = setupRemoteProvider({+				history: [],+				progressObs,+				isCompleteObs: observableValue<boolean>('complete', false),+				interruptActiveResponseCallback: async () => true,+				onDidStartServerRequest: onDidStartServerRequest.event,+			});++			const testService = createChatService();+			const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None);+			assert.ok(ref);+			testDisposables.add(ref);+			onDidStartServerRequest.fire({+				id: 'workspace-continuation',+				prompt: '<!-- vscode-request-hidden-from-transcript -->\nContinue in the requested workspace.',+				isSystemInitiated: true,+				isRequestHidden: true,+			});+			progressObs.set([{+				kind: 'systemNotification',+				content: new MarkdownString('Now working in vscode'),+				presentation: 'workspaceTransition',+				workspaceName: 'vscode',+				accessibilityLabel: 'Workspace changed. This session is now working directly in vscode.',+			}, {+				kind: 'markdownContent',+				content: new MarkdownString('Provider continued work'),+			}], undefined);++			const request = ref.object.getRequests()[0];+			const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, ref.object, undefined));+			assert.deepStrictEqual({+				requestHidden: request.isRequestHiddenFromTranscript,+				responseHidden: request.response?.isHiddenFromTranscript,+				visibleItems: viewModel.getItems().map(item => isResponseVM(item) ? 'response' : 'request'),+				responseParts: request.response?.response.value.map(part => part.kind),+			}, {+				requestHidden: true,+				responseHidden: false,+				visibleItems: ['response'],+				responseParts: ['systemNotification', 'markdownContent'],+			});+		});+ 		test('remote resume reopens the existing request without duplicating it', async () => { 			const onDidStartServerRequest = testDisposables.add(new Emitter<IChatSessionServerRequest>()); 			const progressObs = observableValue<IChatProgress[]>('progress', []);