diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index b33d6cff624ff..759065aa1e79c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -48,6 +48,24 @@ function mapSessionStatus(status: SessionStatus | undefined): ChatSessionStatus return ChatSessionStatus.Completed; } +function keepPendingNewSessionStatusInProgress(status: SessionStatus | undefined): SessionStatus { + const resolvedStatus = status ?? SessionStatus.Idle; + if ((resolvedStatus & SessionStatus.InProgress) !== 0 || (resolvedStatus & SessionStatus.Error) !== 0) { + return resolvedStatus; + } + + return (resolvedStatus & ~SessionStatus.Idle) | SessionStatus.InProgress; +} + +function keepPendingNewSessionInProgress(summary: SessionSummary): SessionSummary { + const status = keepPendingNewSessionStatusInProgress(summary.status); + if (status === summary.status) { + return summary; + } + + return { ...summary, status }; +} + /** * Provides session list items for the chat sessions sidebar by querying * active sessions from an agent host connection. Listens to protocol @@ -64,7 +82,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS private _items: IChatSessionItem[] = []; /** Cached full summaries per session so partial updates can be applied. */ private readonly _cachedSummaries = new Map(); - /** Final-looking resources created locally before the backend session exists. */ + /** Final-looking resources created locally before the backend session is listed. */ private readonly _pendingNewSessions = new Set(); /** * Once `listSessions()` has succeeded, the in-memory list is kept in @@ -94,15 +112,11 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._register(this._connection.onDidNotification(n => { if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) { const rawId = AgentSession.id(n.summary.resource); - this._pendingNewSessions.delete(rawId); - this._cachedSummaries.set(rawId, n.summary); - const item = this._makeItemFromSummary(rawId, n.summary, n.summary.diffs); - const existingIndex = this._items.findIndex(item => item.resource.path === `/${rawId}`); - if (existingIndex >= 0) { - this._items[existingIndex] = item; - } else { - this._items.push(item); - } + const wasPendingNewSession = this._pendingNewSessions.delete(rawId); + const summary = wasPendingNewSession ? keepPendingNewSessionInProgress(n.summary) : n.summary; + this._cachedSummaries.set(rawId, summary); + const item = this._makeItemFromSummary(rawId, summary, summary.diffs); + this._upsertItem(item); this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] }); } else if (n.type === 'notify/sessionRemoved' && AgentSession.provider(n.session) === this._provider) { const removedId = AgentSession.id(n.session); @@ -152,14 +166,16 @@ export class AgentHostSessionListController extends Disposable implements IChatS return undefined; } const rawId = generateUuid(); - this._pendingNewSessions.add(rawId); const now = Date.now(); + this._pendingNewSessions.add(rawId); const item = this._makeItem(rawId, { title: request.prompt.trim(), status: SessionStatus.InProgress, createdAt: now, modifiedAt: now, }); + this._upsertItem(item); + this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] }); // Bridge any pre-creation provisional session the user built up // against the untitled chat-input URI to the freshly-minted real @@ -189,9 +205,12 @@ export class AgentHostSessionListController extends Disposable implements IChatS const sessions = await this._connection.listSessions(); const filtered = sessions.filter(s => AgentSession.provider(s.session) === this._provider); this._cachedSummaries.clear(); + const previousItems = this._items; + const listedSessionIds = new Set(); this._items = filtered.map(s => { const rawId = AgentSession.id(s.session); - this._pendingNewSessions.delete(rawId); + listedSessionIds.add(rawId); + const wasPendingNewSession = this._pendingNewSessions.delete(rawId); let status = s.status ?? SessionStatus.Idle; if (s.isRead) { status |= SessionStatus.IsRead; @@ -199,6 +218,9 @@ export class AgentHostSessionListController extends Disposable implements IChatS if (s.isArchived) { status |= SessionStatus.IsArchived; } + if (wasPendingNewSession) { + status = keepPendingNewSessionStatusInProgress(status); + } this._cachedSummaries.set(rawId, { resource: s.session.toString(), provider: this._provider, @@ -219,6 +241,12 @@ export class AgentHostSessionListController extends Disposable implements IChatS diffs: s.diffs, }); }); + for (const item of previousItems) { + const rawId = item.resource.path.substring(1); + if (this._pendingNewSessions.has(rawId) && !listedSessionIds.has(rawId)) { + this._items.push(item); + } + } this._cacheValid = true; } catch { this._cachedSummaries.clear(); @@ -268,6 +296,15 @@ export class AgentHostSessionListController extends Disposable implements IChatS }; } + private _upsertItem(item: IChatSessionItem): void { + const existingIndex = this._items.findIndex(existing => existing.resource.toString() === item.resource.toString()); + if (existingIndex >= 0) { + this._items[existingIndex] = item; + } else { + this._items.push(item); + } + } + private _buildMetadata(workingDirectory: URI | undefined): { readonly [key: string]: unknown } | undefined { if (!this._description && !workingDirectory) { return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index b8686c5014611..0731ac0e045ff 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -514,13 +514,27 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); this._register(this.chatSessionsService.onDidChangeSessionItems((delta) => { const changedChatSessionTypes = new Set(); - - for (const resource of delta.addedOrUpdated ?? []) { - changedChatSessionTypes.add(getChatSessionType(resource.resource)); + const mapSessionContributionToType = this.getSessionContributionMap(); + let didChangeSessions = false; + + for (const item of delta.addedOrUpdated ?? []) { + const chatSessionType = getChatSessionType(item.resource); + changedChatSessionTypes.add(chatSessionType); + this._sessions.set(item.resource, this.toAgentSessionFromItem(chatSessionType, item, mapSessionContributionToType)); + didChangeSessions = true; } for (const resource of delta.removed ?? []) { changedChatSessionTypes.add(getChatSessionType(resource)); + if (this._sessions.has(resource)) { + this._sessions.delete(resource); + didChangeSessions = true; + } + } + + if (didChangeSessions) { + this._resolved = true; + this._onDidChangeSessions.fire(); } for (const chatSessionType of changedChatSessionTypes) { @@ -629,10 +643,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode } } - const mapSessionContributionToType = new Map(); - for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { - mapSessionContributionToType.set(contribution.type, contribution); - } + const mapSessionContributionToType = this.getSessionContributionMap(); // Phase 1: Fetch new items for this provider (async, may interleave with other providers) const sessions = new ResourceMap(); @@ -642,37 +653,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode } for (const session of providerSessions) { - let icon: ThemeIcon; - let providerLabel: string; - const agentSessionProvider = getAgentSessionProvider(chatSessionType); - if (agentSessionProvider !== undefined) { - providerLabel = getAgentSessionProviderName(agentSessionProvider); - icon = getAgentSessionProviderIcon(agentSessionProvider); - } else { - providerLabel = mapSessionContributionToType.get(chatSessionType)?.name ?? chatSessionType; - icon = session.iconPath ?? Codicon.terminal; - } - - const changes = session.changes; - const normalizedChanges = changes && !(changes instanceof Array) - ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } - : changes; - - sessions.set(session.resource, this.toAgentSession({ - providerType: chatSessionType, - providerLabel, - resource: session.resource, - label: session.label.split('\n')[0], // protect against weird multi-line labels that break our layout - description: session.description, - icon, - badge: session.badge, - tooltip: session.tooltip, - status: session.status ?? AgentSessionStatus.Completed, - archived: session.archived, - timing: session.timing, - changes: normalizedChanges, - metadata: session.metadata, - })); + sessions.set(session.resource, this.toAgentSessionFromItem(chatSessionType, session, mapSessionContributionToType)); } } @@ -697,6 +678,48 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode this._onDidChangeSessions.fire(); } + private getSessionContributionMap(): Map { + const mapSessionContributionToType = new Map(); + for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { + mapSessionContributionToType.set(contribution.type, contribution); + } + return mapSessionContributionToType; + } + + private toAgentSessionFromItem(chatSessionType: string, session: IChatSessionItem, mapSessionContributionToType: Map): IInternalAgentSession { + let icon: ThemeIcon; + let providerLabel: string; + const agentSessionProvider = getAgentSessionProvider(chatSessionType); + if (agentSessionProvider !== undefined) { + providerLabel = getAgentSessionProviderName(agentSessionProvider); + icon = getAgentSessionProviderIcon(agentSessionProvider); + } else { + providerLabel = mapSessionContributionToType.get(chatSessionType)?.name ?? chatSessionType; + icon = session.iconPath ?? Codicon.terminal; + } + + const changes = session.changes; + const normalizedChanges = changes && !(changes instanceof Array) + ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } + : changes; + + return this.toAgentSession({ + providerType: chatSessionType, + providerLabel, + resource: session.resource, + label: session.label.split('\n')[0], // protect against weird multi-line labels that break our layout + description: session.description, + icon, + badge: session.badge, + tooltip: session.tooltip, + status: session.status ?? AgentSessionStatus.Completed, + archived: session.archived, + timing: session.timing, + changes: normalizedChanges, + metadata: session.metadata, + }); + } + private toAgentSession(data: IInternalAgentSessionData): IInternalAgentSession { return { ...data, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 3eadbdcc63847..bf426ec49e37b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -17,7 +17,7 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; -import { ActionType, isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isSessionAction, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, MessageAttachmentKind, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -30,7 +30,7 @@ import { ChatAgentLocation } from '../../../common/constants.js'; import { ChatRequestQueueKind, ElicitationState, IChatService, IChatMarkdownContent, IChatProgress, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService, type IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionsService, type IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; @@ -326,6 +326,10 @@ class MockAgentHostService extends mock() { } } + fireNotification(notification: INotification): void { + this._onDidNotification.fire(notification); + } + addSession(meta: IAgentSessionMetadata): void { this._sessions.set(AgentSession.id(meta.session), meta); } @@ -849,12 +853,20 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(item.resource.scheme, 'agent-host-copilot'); assert.ok(!item.resource.path.substring(1).startsWith('untitled-')); assert.strictEqual(listController.isNewSession(item.resource), true); - assert.strictEqual(listController.items.some(existing => existing.resource.toString() === item.resource.toString()), false); + assert.deepStrictEqual(listController.items.map(item => ({ resource: item.resource.toString(), status: item.status })), [{ + resource: item.resource.toString(), + status: ChatSessionStatus.InProgress, + }]); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hello from controller', sessionResource: item.resource, }); + + const visibleItem = listController.items.find(existing => existing.resource.toString() === item.resource.toString()); + assert.ok(visibleItem); + assert.strictEqual(visibleItem.status, ChatSessionStatus.InProgress); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -866,6 +878,77 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(listController.items.some(existing => existing.resource.toString() === item.resource.toString()), true); })); + test('pending new session stays visible across refresh before backend listing', async () => { + const { listController } = createContribution(disposables); + + const item = await listController.newChatSessionItem({ prompt: 'Hello from controller' }, CancellationToken.None); + assert.ok(item); + + assert.deepStrictEqual(listController.items.map(item => ({ resource: item.resource.toString(), status: item.status })), [{ + resource: item.resource.toString(), + status: ChatSessionStatus.InProgress, + }]); + + await listController.refresh(CancellationToken.None); + + assert.deepStrictEqual(listController.items.map(item => ({ resource: item.resource.toString(), status: item.status })), [{ + resource: item.resource.toString(), + status: ChatSessionStatus.InProgress, + }]); + }); + + test('pending new session stays in progress when refresh sees an idle backend session', async () => { + const { listController, agentHostService } = createContribution(disposables); + + const item = await listController.newChatSessionItem({ prompt: 'Hello from controller' }, CancellationToken.None); + assert.ok(item); + const rawId = item.resource.path.substring(1); + + agentHostService.addSession({ + session: AgentSession.uri('copilot', rawId), + startTime: 1, + modifiedTime: 2, + summary: 'Backend session', + status: SessionStatus.Idle, + }); + + await listController.refresh(CancellationToken.None); + + assert.deepStrictEqual(listController.items.map(item => ({ resource: item.resource.toString(), label: item.label, status: item.status })), [{ + resource: item.resource.toString(), + label: 'Backend session', + status: ChatSessionStatus.InProgress, + }]); + assert.strictEqual(listController.isNewSession(item.resource), false); + }); + + test('pending new session stays in progress when first backend summary is idle', async () => { + const { listController, agentHostService } = createContribution(disposables); + + const item = await listController.newChatSessionItem({ prompt: 'Hello from controller' }, CancellationToken.None); + assert.ok(item); + const rawId = item.resource.path.substring(1); + + agentHostService.fireNotification({ + type: NotificationType.SessionAdded, + summary: { + resource: AgentSession.uri('copilot', rawId).toString(), + provider: 'copilot', + title: 'Backend session', + status: SessionStatus.Idle, + createdAt: 1, + modifiedAt: 2, + } + }); + + assert.deepStrictEqual(listController.items.map(item => ({ resource: item.resource.toString(), label: item.label, status: item.status })), [{ + resource: item.resource.toString(), + label: 'Backend session', + status: ChatSessionStatus.InProgress, + }]); + assert.strictEqual(listController.isNewSession(item.resource), false); + }); + test('newChatSessionItem rebinds untitled provisional to real resource so chip-selected config survives first send', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService } = createTestServices(disposables); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsModel.test.ts new file mode 100644 index 0000000000000..0a232af4ad051 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsModel.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { AgentSessionsModel } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { ChatSessionStatus, IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; + +suite('AgentSessionsModel', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let chatSessionsService: MockChatSessionsService; + let instantiationService: TestInstantiationService; + + setup(() => { + chatSessionsService = new MockChatSessionsService(); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatSessionsService, chatSessionsService); + }); + + test('applies changed session items immediately', () => { + const model = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + const resource = URI.parse('test-provider:/session/1'); + + chatSessionsService.fireDidChangeSessionItems({ + addedOrUpdated: [{ + resource, + label: 'Running Session\nwith details', + status: ChatSessionStatus.InProgress, + timing: { + created: 1, + lastRequestStarted: 2, + lastRequestEnded: undefined + } + }] + }); + + assert.deepStrictEqual(model.sessions.map(session => ({ + resource: session.resource.toString(), + label: session.label, + status: session.status, + timing: session.timing, + })), [{ + resource: resource.toString(), + label: 'Running Session', + status: ChatSessionStatus.InProgress, + timing: { + created: 1, + lastRequestStarted: 2, + lastRequestEnded: undefined + } + }]); + }); +});