diff --git a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts index fc3cad83b1918e..0105a6ebc01189 100644 --- a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts @@ -108,8 +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; + /** Workspace transitions preloaded from this chat's backing storage, when present. */ + readonly workspaceTransitions?: ReadonlyMap; } /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index d0318b8694ac62..36c53366234f12 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -8,7 +8,7 @@ import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffe import { Barrier, DeferredPromise, disposableTimeout, Limiter, ResourceQueue } from '../../../base/common/async.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { getExtensionForMimeType, getMediaMime, getMediaOrTextMime } from '../../../base/common/mime.js'; import { Schemas } from '../../../base/common/network.js'; import { dirname as resourcesDirname, extname as resourcesExtname, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js'; @@ -21,7 +21,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; -import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; +import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; import { omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; @@ -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_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 { 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, chatStorageUri, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, 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'; @@ -3492,12 +3492,44 @@ 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, hasWorkspaceTransitions?: boolean): Promise { + private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin, workspaceTransitionsPromise?: Promise | undefined>): Promise { const context = { ...this._chatContext(session, chat), ...(origin ? { origin } : {}) }; + const transitionsPromise = workspaceTransitionsPromise ?? this._loadWorkspaceTransitions(chat); this._logService.trace(`[AgentService] getChatMessages start: chat=${chat.toString()}`); - const providerTurns = await provider.chats.getMessages(chat, context); + const [providerTurns, workspaceTransitions] = await Promise.all([ + provider.chats.getMessages(chat, context), + transitionsPromise, + ]); 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(), hasWorkspaceTransitions }, providerTurns); + return this._chatContributions.hydrateTurns({ session: session.toString(), chat: chat.toString(), workspaceTransitions }, providerTurns); + } + + private async _loadWorkspaceTransitions(chat: URI): Promise | undefined> { + const storage = chatStorageUri(chat); + if (!storage) { + return undefined; + } + try { + const database = await this._sessionDataService.tryOpenDatabase(storage); + return database ? this._readWorkspaceTransitions(database, storage, true) : undefined; + } catch (error) { + this._logService.warn(`[AgentService] Failed to open workspace transition storage for ${storage.toString()}`, error); + return undefined; + } + } + + private async _readWorkspaceTransitions(database: IReference, storage: URI, requireMarker = false): Promise | undefined> { + try { + if (requireMarker && await database.object.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY) !== 'true') { + return undefined; + } + return await database.object.getTurnWorkspaceTransitions(); + } catch (error) { + this._logService.warn(`[AgentService] Failed to restore workspace transitions for ${storage.toString()}`, error); + return undefined; + } finally { + database.dispose(); + } } /** @@ -5509,12 +5541,13 @@ export class AgentService extends Disposable implements IAgentService { let gitMetadata: Record | undefined; let changesetMetadata: Record | undefined; let sessionMetadata: Record | undefined; - let sessionMetadataRead = false; + let workspaceTransitionsPromise: Promise | undefined> = Promise.resolve(undefined); const ref = this._sessionDataService.tryOpenDatabase?.(session); if (ref) { try { const db = await ref; if (db) { + let retainedForWorkspaceTransitions = false; try { const m = await db.object.getMetadataObject({ customTitle: true, @@ -5534,7 +5567,10 @@ export class AgentService extends Disposable implements IAgentService { ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, }); - sessionMetadataRead = true; + if (m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] === 'true') { + retainedForWorkspaceTransitions = true; + workspaceTransitionsPromise = this._readWorkspaceTransitions(db, session); + } if (m.customTitle) { title = m.customTitle; } @@ -5626,10 +5662,10 @@ export class AgentService extends Disposable implements IAgentService { } } } finally { - db.dispose(); + if (!retainedForWorkspaceTransitions) { + db.dispose(); + } } - } else { - sessionMetadataRead = true; } } catch { // Best-effort: fall back to agent-provided metadata @@ -5639,7 +5675,7 @@ export class AgentService extends Disposable implements IAgentService { let turns: readonly Turn[]; try { - turns = await this._getChatMessages(agent, defaultChatUri, session, undefined, sessionMetadataRead ? readSessionHasWorkspaceTransitions(sessionMetadata) : undefined); + turns = await this._getChatMessages(agent, defaultChatUri, session, undefined, workspaceTransitionsPromise); } catch (err) { if (err instanceof ProtocolError) { throw err; diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts index 70e2c57a3529da..bb43f8443dbab4 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts @@ -4,15 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.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 { ResponsePartKind, withMessageRequestHiddenFromTranscript, type Turn } from '../../../common/state/sessionState.js'; import { ISessionWorkspaceConversionService } from './sessionWorkspaceConversionService.js'; /** Finalizes requested workspace conversions after a turn and blocks new turns while conversion is pending. */ @@ -24,9 +20,6 @@ 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(); } @@ -53,37 +46,9 @@ export class SessionWorkspaceConversionContribution extends Disposable implement }; } - async onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise { - 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; - 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) { + onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): readonly Turn[] { + const transitions = context.workspaceTransitions; + if (turns.length === 0 || !transitions?.size) { return turns; } return turns.map(turn => { diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts index 5502497dbc6b64..1870eba5319080 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -35,6 +35,7 @@ interface IPendingSessionWorkspaceConversion { readonly isolation: boolean; readonly initiatingClientId: string; readonly prompt: string | undefined; + readonly showTransition: boolean; phase: 'requested' | 'converting'; resolvedWorkingDirectory?: URI; transition?: IAgentWorkspaceTransitionRecord; @@ -99,12 +100,13 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe if (activeTurnId !== turnId) { throw new Error('Session workspace conversion must be requested from the active turn.'); } - const prompt = this._stateManager.getChatState(chat.toString())?.activeTurn?.message.text; + const chatState = this._stateManager.getChatState(chat.toString()); + const prompt = chatState?.activeTurn?.message.text; const key = chat.toString(); if (this.isPending(key)) { throw new Error('A workspace conversion is already pending for this session.'); } - this._pending.set(key, { chat, turnId, workspaceFolder, isolation, initiatingClientId, prompt, phase: 'requested' }); + this._pending.set(key, { chat, turnId, workspaceFolder, isolation, initiatingClientId, prompt, showTransition: !!chatState?.turns.length, phase: 'requested' }); } isPending(chat: ProtocolURI): boolean { @@ -128,7 +130,9 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe let continuation: IDeferredAgentHostTurn | undefined; try { continuation = this._beginContinuation(pending); - pending.transition = this._createWorkspaceTransition(pending); + if (pending.showTransition) { + pending.transition = this._createWorkspaceTransition(pending); + } pending.resolvedWorkingDirectory = await this._convert(pending, continuation); this._pending.delete(chat); await this._continueConversion(continuation, pending, true); @@ -155,9 +159,6 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe private async _convert(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise { 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) { @@ -224,14 +225,14 @@ 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 persistTransition = !!transition && 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) }); } - if (persistTransition) { + if (persistTransition && transition) { await database.object.setWorkspaceConversion(continuation.turnId, serializeAgentWorkspaceTransition(transition), metadata); pending.transitionPersisted = true; } else { @@ -536,7 +537,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe workspaceName: pending.transition.workspaceName, }, ); - } else { + } else if (!converted || pending.showTransition) { this._publishConversionOutcome(pending.chat, continuation, label); } try { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a0d1b7b94fe90f..89cb67b5aee93a 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -45,6 +45,7 @@ import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCL import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; +import { AgentSystemNotificationWorkspaceKind, serializeAgentWorkspaceTransition } from '../../common/meta/agentSystemNotificationMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; @@ -7627,6 +7628,61 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(getStateManager(svc).getSessionState(session.toString()), undefined); }); + test('loads workspace transitions alongside provider history from the existing restore database', async () => { + class DelayedTransitionDatabase extends TestSessionDatabase { + private readonly _releaseTransitionRead = new DeferredPromise(); + transitionReadPending = false; + + override async getTurnWorkspaceTransitions(): Promise> { + this.transitionReadPending = true; + await this._releaseTransitionRead.p; + this.transitionReadPending = false; + return super.getTurnWorkspaceTransitions(); + } + + releaseTransitionRead(): void { + this._releaseTransitionRead.complete(); + } + } + + const database = new DelayedTransitionDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: agent.id }); + await database.setTurnWorkspaceTransition('provider-turn', serializeAgentWorkspaceTransition({ + content: 'Now working in project', + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'project', + })); + let providerSawTransitionRead = false; + agent.chats.getMessages = async () => { + providerSawTransitionRead = database.transitionReadPending; + database.releaseTransitionRead(); + return [{ + id: 'provider-turn', + message: { text: 'Continue work', origin: { kind: MessageKind.SystemNotification } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response-1', content: 'Provider output' }], + usage: undefined, + state: TurnState.Complete, + }]; + }; + getStateManager(svc).deleteSession(session.toString()); + + await svc.restoreSession(session); + + const restoredTurn = getStateManager(svc).getChatState(buildDefaultChatUri(session))?.turns[0]; + assert.deepStrictEqual({ + providerSawTransitionRead, + transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls, + responseParts: restoredTurn?.responseParts.map(part => part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind), + }, { + providerSawTransitionRead: true, + transitionQueryCalls: 1, + responseParts: ['Now working in project', ResponsePartKind.Markdown], + }); + }); + test('marks only an explicit restore as an activating metadata read', async () => { class LazyMetadataAgent extends MockAgent { ambientReads = 0; @@ -11570,6 +11626,45 @@ suite('AgentService (node dispatcher)', () => { assert.ok(!registered.includes(AgentSession.uri('copilot', 'restored-peer-backing-sdk-id').toString()), 'the backing session must not leak into the registered session list'); }); + test('restores workspace transitions from a peer chat database', async () => { + const sessionData = createPerSessionDataService(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: agent.id }); + const peer = URI.parse(buildChatUri(session, 'peer-with-transition')); + const sessionDatabase = sessionData.database(session); + const peerDatabase = sessionData.database(peer); + await sessionDatabase.setMetadata('peerChats', JSON.stringify([{ uri: peer.toString(), providerData: 'peer-backing' }])); + await peerDatabase.setTurnWorkspaceTransition('peer-turn', serializeAgentWorkspaceTransition({ + content: 'Now working in peer workspace', + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'peer workspace', + })); + agent.chats.getMessages = async chat => isDefaultChatUri(chat) ? [] : [{ + id: 'peer-turn', + message: { text: 'Continue peer work', origin: { kind: MessageKind.SystemNotification } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'peer-response', content: 'Peer output' }], + usage: undefined, + state: TurnState.Complete, + }]; + getStateManager(localService).deleteSession(session.toString()); + await localService.restoreSession(session); + + await localService.subscribe(peer, 'peer-reader'); + + const restoredTurn = getStateManager(localService).getChatState(peer.toString())?.turns[0]; + assert.deepStrictEqual({ + sessionTransitionQueries: sessionDatabase.getTurnWorkspaceTransitionsCalls, + peerTransitionQueries: peerDatabase.getTurnWorkspaceTransitionsCalls, + responseParts: restoredTurn?.responseParts.map(part => part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind), + }, { + sessionTransitionQueries: 0, + peerTransitionQueries: 1, + responseParts: ['Now working in peer workspace', ResponsePartKind.Markdown], + }); + }); + test('persists a replacement backing reported after peer chat materialization', async () => { class RematerializingPeerAgent extends MockAgent { private readonly _materialized = new Emitter(); diff --git a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts index 803bb7e115e203..2212440c657462 100644 --- a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts @@ -21,7 +21,7 @@ import { isAgentWorkspaceContinuationMessage } from '../../common/meta/agentWork import type { ISessionDatabase } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.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 { 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, 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'; @@ -234,22 +234,13 @@ suite('SessionWorkspaceConversionService', () => { }); } - function updateSessionWorkspace(harness: ReturnType): Promise { - return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); + function completePriorTurn(stateManager: AgentHostStateManager, chat: URI): void { + startTurn(stateManager, chat, 'turn-0'); + completeTurn(stateManager, chat, 'turn-0'); } - 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; + function updateSessionWorkspace(harness: ReturnType): Promise { + return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); } test('keeps a visible continuation in progress while converting after the invoking turn', async () => { @@ -283,6 +274,7 @@ suite('SessionWorkspaceConversionService', () => { }); await providerMutation.p; }; + completePriorTurn(harness.stateManager, harness.chat); 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'); @@ -410,24 +402,45 @@ suite('SessionWorkspaceConversionService', () => { }); }); - test('skips transition storage for a normal loaded session', async () => { + test('does not show or persist a workspace transition during the first turn', async () => { + const harness = createHarness(); + harness.agent.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); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; + assert.deepStrictEqual({ + 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), + persistedTransitions: [...(await harness.database.getTurnWorkspaceTransitions()).entries()], + outcomeNotifications: activeTurn?.responseParts.filter(part => part.kind === ResponsePartKind.SystemNotification), + outcomeKindsAtContinuation: harness.outcomeKindsAtContinuation, + continuations: harness.continuations.length, + }, { + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + hasWorkspaceTransitions: false, + persistedWorkspaceless: 'false', + persistedHasWorkspaceTransitions: undefined, + persistedTransitions: [], + outcomeNotifications: [], + outcomeKindsAtContinuation: [[]], + continuations: 1, + }); + }); + + test('does not hydrate workspace transitions when none were loaded', () => { 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() { }(), new class extends mock() { }(), - createHydrationStateManager(session, false), - sessionDataService, - new NullLogService(), )); const turns: Turn[] = [{ id: 'turn-1', @@ -437,20 +450,12 @@ suite('SessionWorkspaceConversionService', () => { state: TurnState.Complete, }]; - const hydrated = await contribution.onHydrateTurns({ + const hydrated = contribution.onHydrateTurns({ session: session.toString(), chat: buildDefaultChatUri(session), }, turns); - assert.deepStrictEqual({ - sameTurns: hydrated === turns, - databaseOpenCalls, - transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls, - }, { - sameTurns: true, - databaseOpenCalls: 0, - transitionQueryCalls: 0, - }); + assert.strictEqual(hydrated, turns); }); test('restores one durable transition before provider output after service restart', async () => { @@ -463,6 +468,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, conversionDatabase); const workspaceFolder = URI.file('/workspace/project'); harness.agent.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); 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'); @@ -476,13 +482,9 @@ suite('SessionWorkspaceConversionService', () => { conversionDatabase = undefined; restoredDatabase = await SessionDatabase.open(databasePath); - const restoredStateManager = createHydrationStateManager(harness.session, true); const restoredContribution = disposables.add(new SessionWorkspaceConversionContribution( new class extends mock() { }(), new class extends mock() { }(), - restoredStateManager, - createSessionDataService(restoredDatabase), - new NullLogService(), )); const providerTurns: Turn[] = [{ id: 'provider-continuation', @@ -498,13 +500,16 @@ suite('SessionWorkspaceConversionService', () => { usage: undefined, state: TurnState.Complete, }]; + const workspaceTransitions = await restoredDatabase.getTurnWorkspaceTransitions(); const restoredOnce = await restoredContribution.onHydrateTurns({ session: harness.session.toString(), chat: harness.chat.toString(), + workspaceTransitions, }, providerTurns); const restoredTwice = await restoredContribution.onHydrateTurns({ session: harness.session.toString(), chat: harness.chat.toString(), + workspaceTransitions, }, restoredOnce); const restoredTurn = restoredTwice[0]; @@ -519,7 +524,7 @@ suite('SessionWorkspaceConversionService', () => { kind: part.kind, content: part.kind === ResponsePartKind.Markdown ? part.content : undefined, }), - persistedTransitions: [...(await restoredDatabase.getTurnWorkspaceTransitions()).keys()], + persistedTransitions: [...workspaceTransitions.keys()], }, { requestHidden: true, workspaceContinuation: true, @@ -546,15 +551,6 @@ suite('SessionWorkspaceConversionService', () => { 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, @@ -568,9 +564,6 @@ suite('SessionWorkspaceConversionService', () => { const contribution = disposables.add(new SessionWorkspaceConversionContribution( new class extends mock() { }(), new class extends mock() { }(), - createHydrationStateManager(URI.parse('copilot:/workspace-less'), true), - sessionDataService, - new NullLogService(), )); const turns = ['turn-1', 'turn-2'].map((id): Turn => ({ id, @@ -583,20 +576,19 @@ suite('SessionWorkspaceConversionService', () => { const restored = await contribution.onHydrateTurns({ session: 'copilot:/workspace-less', chat: buildDefaultChatUri('copilot:/workspace-less'), + workspaceTransitions: await database.getTurnWorkspaceTransitions(), }, 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, }); }); @@ -612,9 +604,6 @@ suite('SessionWorkspaceConversionService', () => { const contribution = disposables.add(new SessionWorkspaceConversionContribution( new class extends mock() { }(), new class extends mock() { }(), - createHydrationStateManager(session, true), - createSessionDataService(database), - new NullLogService(), )); const turns: Turn[] = [{ id: 'turn-1', @@ -627,6 +616,7 @@ suite('SessionWorkspaceConversionService', () => { const restored = await contribution.onHydrateTurns({ session: session.toString(), chat: buildChatUri(session, 'peer-chat'), + workspaceTransitions: await database.getTurnWorkspaceTransitions(), }, turns); assert.deepStrictEqual(restored[0].responseParts.map(part => @@ -652,6 +642,7 @@ suite('SessionWorkspaceConversionService', () => { provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { providerCalls.push(workingDirectory.toString()); }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1'); @@ -1093,6 +1084,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); const provider: IAgent = harness.agent; provider.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); completeTurn(harness.stateManager, harness.chat); @@ -1168,6 +1160,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); const provider: IAgent = harness.agent; provider.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); completeTurn(harness.stateManager, harness.chat); @@ -1213,6 +1206,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); const provider: IAgent = harness.agent; provider.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); completeTurn(harness.stateManager, harness.chat);