Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -588,6 +588,13 @@ function inputRequestResponsePartKey(part: InputRequestResponsePart): string {
return `ir:${part.request.id}:${JSON.stringify({ ...part.request, answers: undefined })}`;
}

function getChatTitle(state: Pick<SessionState, 'chats' | 'defaultChat' | 'title'>, chatURI: string): string {
if (state.defaultChat === chatURI && state.chats.length === 1) {
return state.title;
}
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
return state.chats.find(chat => chat.resource === chatURI)?.title || state.title;
}

/**
* The live invocation the reconnect snapshot emitted for this tool call, if
* any. A tool call the snapshot rendered as a serialized part has no live
Expand Down Expand Up @@ -1273,7 +1280,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
let initialProgress: IChatProgress[] | undefined;
let initialResponsePartCount = 0;
let activeTurnId: string | undefined;
let sessionTitle: string | undefined;
let chatTitle: string | undefined;
let draftInputState: ISerializableChatModelInputState | undefined;
let sessionSubscription: IAgentSubscription<SessionState> | undefined;
let chatSubscription: IAgentSubscription<ChatState> | undefined;
Expand Down Expand Up @@ -1315,7 +1322,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
await this._whenSubscriptionHydrated(chatSub, token);
const sessionState = this._getSessionState(resolvedSession.toString(), chatURI);
if (sessionState) {
sessionTitle = sessionState.title;
chatTitle = getChatTitle(sessionState, chatURI);
const draft = sessionState.draft ?? emptyDraftFromLastTurn(sessionState);
draftInputState = this._draftToInputState(sessionResource, draft);
if (!sessionState.draft && draft) {
Expand Down Expand Up @@ -1437,7 +1444,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
AgentHostChatSession,
sessionResource,
history,
sessionTitle,
chatTitle,
sessionSubscription,
chatSubscription,
this._config.promptCacheNotification,
Expand Down Expand Up @@ -2095,7 +2102,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
let lastSeenTurnId: string | undefined = currentState?.activeTurn?.id;
let previousQueuedIds: Set<string> | undefined;
let previousSteeringId: string | undefined = currentState?.steeringMessage?.id;
let previousTitle: string | undefined = currentState?.title;
let previousTitle: string | undefined = currentState ? getChatTitle(currentState, chatURI) : undefined;

const disposables = new DisposableStore();

Expand All @@ -2105,9 +2112,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC

const sessionSub = this._ensureSessionSubscription(sessionStr);
const chatSub = this._ensureChatSubscription(sessionStr, chatURI);
// Conversation contents now live on the default chat, while title and
// other session-scoped fields stay on the session. Re-evaluate on a
// change to either channel, reading the merged view.
// Conversation contents live on the chat, while its catalog title and
// other session-scoped fields live on the session. Re-evaluate on either.
const onChange = () => {
const state = this._getSessionState(sessionStr, chatURI);
if (!state) {
Expand All @@ -2125,7 +2131,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
}
previousSteeringId = currentSteeringId;

const currentTitle = e.state.title;
const currentTitle = getChatTitle(e.state, chatURI);
Comment thread
dmitrivMS marked this conversation as resolved.
if (currentTitle && currentTitle !== previousTitle) {
this._chatService.setChatSessionTitle(sessionResource, currentTitle);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js';
import { URI } from '../../../../../../base/common/uri.js';
import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js';
import { ActionType, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { readSessionEhcliAdoptable, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { ActionType, type ActionEnvelope, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { isDefaultChatUri, readSessionEhcliAdoptable, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js';

/**
* Minimal agent-host connection surface needed by the session list store.
*/
export interface IAgentHostSessionListConnection {
readonly onDidAction: Event<ActionEnvelope>;
readonly onDidNotification: Event<INotification>;
listSessions(): Promise<IAgentSessionMetadata[]>;
disposeSession(session: URI): Promise<void>;
Expand Down Expand Up @@ -61,9 +62,9 @@ export interface IAgentHostSessionListDelta {

/**
* Shared provider-agnostic cache of agent-host sessions. It owns the
* provider-wide listSessions refresh, workspace filtering, and root session
* notifications. Per-provider list controllers project this state into chat
* session items.
* provider-wide listSessions refresh, workspace filtering, session
* notifications, and live title actions. Per-provider list controllers project
* this state into chat session items.
*/
export class AgentHostSessionListStore extends Disposable {

Expand Down Expand Up @@ -94,6 +95,7 @@ export class AgentHostSessionListStore extends Disposable {
) {
super();

this._register(this._connection.onDidAction(e => this._onAction(e)));
this._register(this._connection.onDidNotification(n => this._onNotification(n)));

// Re-fetch the session list whenever the set of VS Code workspace
Expand Down Expand Up @@ -326,6 +328,40 @@ export class AgentHostSessionListStore extends Disposable {
}
}

private _onAction(envelope: ActionEnvelope): void {
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
if (envelope.rejectionReason !== undefined) {
return;
}
const action = envelope.action;
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
const title = action.type === ActionType.SessionTitleChanged
? action.title
: action.type === ActionType.SessionChatUpdated && isDefaultChatUri(action.chat) && action.changes.title !== undefined
? action.changes.title
: undefined;
if (!title) {
return;
}

const provider = AgentSession.provider(envelope.channel);
if (!provider) {
return;
}
const rawId = AgentSession.id(envelope.channel);
const key = this._key(provider, rawId);
const cached = this._entries.get(key);
if (!cached || cached.summary.title === title) {
return;
}

const updated: IAgentHostSessionListEntry = {
...cached,
summary: { ...cached.summary, title },
};
this._mutationGeneration++;
this._entries.set(key, updated);
this._onDidChangeSessions.fire({ addedOrUpdated: [updated] });
}

private _makeEntryFromMetadata(session: IAgentSessionMetadata): IAgentHostSessionListEntry | undefined {
const provider = AgentSession.provider(session.session);
if (!provider) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,10 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv
removePendingRequest(sessionResource: URI, requestId: string) {
this.removePendingRequestCalls.push({ sessionResource, requestId });
},
setChatSessionTitleCalls: [] as { sessionResource: URI; title: string }[],
async setChatSessionTitle(sessionResource: URI, title: string) {
this.setChatSessionTitleCalls.push({ sessionResource, title });
},
syncPendingRequestsFromRemoteCalls: [] as { sessionResource: URI; requests: readonly IRemotePendingRequest[] }[],
/** Set by tests that want to mirror remote pending messages into their fake chat model. */
applyRemotePendingRequests: undefined as ((sessionResource: URI, requests: readonly IRemotePendingRequest[]) => void) | undefined,
Expand Down Expand Up @@ -2950,6 +2954,60 @@ suite('AgentHostChatContribution', () => {
});
});

test('session and default chat title actions update the session list item', async () => {
const { instantiationService, agentHostService } = createTestServices(disposables);
const backendSession = AgentSession.uri('copilot', 'chat-title');
agentHostService.addSession({ session: backendSession, startTime: 1000, modifiedTime: 2000, summary: 'Session title' });

const sessionListStore = createSessionListStore(disposables, instantiationService, agentHostService);
const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', sessionListStore, undefined, 'local'));
await listController.refresh(CancellationToken.None);

const events: string[] = [];
disposables.add(listController.onDidChangeChatSessionItems(delta => {
events.push(...(delta.addedOrUpdated ?? []).map(item => item.label));
}));

agentHostService.fireAction({
channel: backendSession.toString(),
action: {
type: ActionType.SessionTitleChanged,
title: 'Renamed session',
},
serverSeq: 1,
origin: undefined,
});
agentHostService.fireAction({
channel: backendSession.toString(),
action: {
type: ActionType.SessionChatUpdated,
chat: buildDefaultChatUri(backendSession.toString()),
changes: { title: 'Renamed default chat' },
},
serverSeq: 2,
origin: undefined,
});
agentHostService.fireAction({
channel: backendSession.toString(),
action: {
type: ActionType.SessionChatUpdated,
chat: buildDefaultChatUri(backendSession.toString()),
changes: { title: 'Rejected title' },
},
serverSeq: 3,
origin: { clientId: agentHostService.clientId, clientSeq: 1 },
rejectionReason: 'Rename rejected',
});

assert.deepStrictEqual({
label: listController.items[0].label,
events,
}, {
label: 'Renamed default chat',
events: ['Renamed session', 'Renamed default chat'],
});
});

test('sessionRemoved notification removes only the matching item', async () => {
const { instantiationService, agentHostService } = createTestServices(disposables);

Expand Down Expand Up @@ -10028,6 +10086,103 @@ suite('AgentHostChatContribution', () => {
};
}

test('uses independent default chat titles for the editor', async () => {
const { sessionHandler, agentHostService, chatService } = createContribution(disposables);
const backendSession = AgentSession.uri('copilot', 'independent-chat-title');
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/independent-chat-title' });
const defaultChat = buildDefaultChatUri(backendSession.toString());
const peerChat = buildChatUri(backendSession.toString(), 'peer');
const summary: SessionSummary = {
resource: backendSession.toString(),
provider: 'copilot',
title: 'Session title',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
};
agentHostService.sessionStates.set(backendSession.toString(), {
...createSessionState(summary),
lifecycle: SessionLifecycle.Ready,
defaultChat,
chats: [
{ ...createDefaultChatSummary(summary, defaultChat), title: 'Default chat title' },
{ ...createDefaultChatSummary(summary, peerChat), title: 'Peer chat title' },
],
});

const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None);
disposables.add(toDisposable(() => chatSession.dispose()));

agentHostService.fireAction({
channel: backendSession.toString(),
action: {
type: ActionType.SessionChatUpdated,
chat: defaultChat,
changes: { title: 'Renamed default chat' },
},
serverSeq: 1,
origin: undefined,
});

assert.deepStrictEqual({
initialTitle: chatSession.title,
titleChanges: chatService.setChatSessionTitleCalls.map(call => ({
sessionResource: call.sessionResource.toString(),
title: call.title,
})),
}, {
initialTitle: 'Default chat title',
titleChanges: [{
sessionResource: sessionResource.toString(),
title: 'Renamed default chat',
}],
});
});

test('uses the session title for a sole default chat', async () => {
const { sessionHandler, agentHostService, chatService } = createContribution(disposables);
const backendSession = AgentSession.uri('copilot', 'sole-chat-title');
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/sole-chat-title' });
agentHostService.sessionStates.set(backendSession.toString(), {
...createSessionState({
resource: backendSession.toString(),
provider: 'copilot',
title: 'Original session title',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
}),
lifecycle: SessionLifecycle.Ready,
});

const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None);
disposables.add(toDisposable(() => chatSession.dispose()));

agentHostService.fireAction({
channel: backendSession.toString(),
action: {
type: ActionType.SessionTitleChanged,
title: 'Renamed session',
},
serverSeq: 1,
origin: undefined,
});

assert.deepStrictEqual({
initialTitle: chatSession.title,
titleChanges: chatService.setChatSessionTitleCalls.map(call => ({
sessionResource: call.sessionResource.toString(),
title: call.title,
})),
}, {
initialTitle: 'Original session title',
titleChanges: [{
sessionResource: sessionResource.toString(),
title: 'Renamed session',
}],
});
});

test('syncs queued messages added to restored active sessions idempotently', async () => {
const { sessionHandler, agentHostService, chatService } = createContribution(disposables);

Expand Down
Loading