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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, SessionSummary>();
/** 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<string>();
/**
* Once `listSessions()` has succeeded, the in-memory list is kept in
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -189,16 +205,22 @@ 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<string>();
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;
}
if (s.isArchived) {
status |= SessionStatus.IsArchived;
}
if (wasPendingNewSession) {
status = keepPendingNewSessionStatusInProgress(status);
}
this._cachedSummaries.set(rawId, {
resource: s.session.toString(),
provider: this._provider,
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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) {
Expand Down Expand Up @@ -629,10 +643,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
}
}

const mapSessionContributionToType = new Map<string, ResolvedChatSessionsExtensionPoint>();
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<IInternalAgentSession>();
Expand All @@ -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));
}
}

Expand All @@ -697,6 +678,48 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
this._onDidChangeSessions.fire();
}

private getSessionContributionMap(): Map<string, ResolvedChatSessionsExtensionPoint> {
const mapSessionContributionToType = new Map<string, ResolvedChatSessionsExtensionPoint>();
for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) {
mapSessionContributionToType.set(contribution.type, contribution);
}
return mapSessionContributionToType;
}

private toAgentSessionFromItem(chatSessionType: string, session: IChatSessionItem, mapSessionContributionToType: Map<string, ResolvedChatSessionsExtensionPoint>): 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -326,6 +326,10 @@ class MockAgentHostService extends mock<IAgentHostService>() {
}
}

fireNotification(notification: INotification): void {
this._onDidNotification.fire(notification);
}

addSession(meta: IAgentSessionMetadata): void {
this._sessions.set(AgentSession.id(meta.session), meta);
}
Expand Down Expand Up @@ -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;

Expand All @@ -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);

Expand Down
Loading
Loading