Skip to content

Commit 9a19d73

Browse files
roblourensCopilot
andauthored
sessions: set session title to first message text immediately (#310345)
* sessions: set session title to first message text immediately When a new agent host session starts, the title shows "New Session" for a long time until the AI-generated title arrives. Fix this by: 1. Dispatching SessionTitleChanged with the user's message text on the first turn in agentSideEffects 2. Overlaying the live title from the state manager in listSessions so refreshSessions picks up the updated title instead of the stale SDK value 3. Handling notify/sessionSummaryChanged in both local and remote session providers to propagate title changes via the notification channel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (Written by Copilot) * Simplify: use empty initial title, add unit + integration tests - Use empty string as initial session title instead of 'New Session' sentinel. The UI already falls back to the localized 'New Session' label when the title is falsy, so no display change. - Simplify listSessions overlay: liveTitle || s.summary (natural falsy) - Simplify side effects check: just turns.length === 0, no string compare - Remove redundant sessionSummaryChanged notification handlers - Add 3 unit tests for immediate title dispatch (first turn, whitespace, second turn) - Add 1 unit test for listSessions title overlay - Add 1 integration test for end-to-end immediate title via WebSocket - Update existing agent-generated title integration test to expect immediate title first (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot review: guard against clobbering existing title - Only dispatch immediate title when state.summary.title is empty, preventing overwrite of user-renamed or provider-set titles - Add test for the non-empty title guard - Fix misleading test comment (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use DEFAULT_SESSION_TITLE constant instead of empty string Reverts from empty string to 'New Session' placeholder with a named constant (DEFAULT_SESSION_TITLE) to avoid blank titles in consumers using nullish coalescing (`??`) instead of logical OR (`||`). The side effects check now compares against the constant, and listSessions overlay skips the default title to avoid overwriting SDK-reported titles. Updated PR description to match final implementation (removed reference to provider-side sessionSummaryChanged handlers). (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use empty string initial title, fix ?? consumers to use || Reverts to empty string as the initial session title for simplicity. Fixes downstream consumers that used nullish coalescing (??) to use logical OR (||) so empty strings correctly fall through to the fallback label. Also normalizes whitespace and truncates the fallback title to 200 characters. Affected consumers: - agentHostSessionListController.ts - localAgentHostSessionsProvider.ts - remoteAgentHostSessionsProvider.ts (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7049f52 commit 9a19d73

File tree

9 files changed

+214
-11
lines changed

9 files changed

+214
-11
lines changed

src/vs/platform/agentHost/node/agentService.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,19 @@ export class AgentService extends Disposable implements IAgentService {
178178
return s;
179179
}));
180180

181-
// Overlay live session status from the state manager
181+
// Overlay live session state from the state manager.
182+
// For the title, prefer the state manager's value when it is
183+
// non-empty, so SDK-sourced titles are not overwritten by the
184+
// initial empty placeholder.
182185
const withStatus = result.map(s => {
183186
const liveState = this._stateManager.getSessionState(s.session.toString());
184187
if (liveState) {
185-
return { ...s, status: liveState.summary.status, model: liveState.summary.model ?? s.model };
188+
return {
189+
...s,
190+
summary: liveState.summary.title || s.summary,
191+
status: liveState.summary.status,
192+
model: liveState.summary.model ?? s.model,
193+
};
186194
}
187195
return s;
188196
});
@@ -262,7 +270,7 @@ export class AgentService extends Disposable implements IAgentService {
262270
const summary: ISessionSummary = {
263271
resource: session.toString(),
264272
provider: provider.id,
265-
title: 'New Session',
273+
title: '',
266274
status: SessionStatus.Idle,
267275
createdAt: Date.now(),
268276
modifiedAt: Date.now(),

src/vs/platform/agentHost/node/agentSideEffects.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,22 @@ export class AgentSideEffects extends Disposable {
568568
for (const mapper of this._eventMappers.values()) {
569569
mapper.reset(action.session);
570570
}
571+
572+
// On the very first turn, immediately set the session title to the
573+
// user's message so the UI shows a meaningful title right away
574+
// while waiting for the AI-generated title. Only apply when the
575+
// title is still the default placeholder to avoid clobbering a
576+
// title set by the user or provider before the first turn.
577+
const state = this._stateManager.getSessionState(action.session);
578+
const fallbackTitle = action.userMessage.text.trim().replace(/\s+/g, ' ').slice(0, 200);
579+
if (state && state.turns.length === 0 && !state.summary.title && fallbackTitle.length > 0) {
580+
this._stateManager.dispatchServerAction({
581+
type: ActionType.SessionTitleChanged,
582+
session: action.session,
583+
title: fallbackTitle,
584+
});
585+
}
586+
571587
const agent = this._options.getAgent(action.session);
572588
if (!agent) {
573589
this._stateManager.dispatchServerAction({

src/vs/platform/agentHost/test/node/agentService.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,23 @@ suite('AgentService (node dispatcher)', () => {
222222
assert.strictEqual(sessions[0].summary, 'Auto-generated Title');
223223
});
224224

225+
test('listSessions overlays live state manager title over SDK title', async () => {
226+
service.registerProvider(copilotAgent);
227+
228+
const session = await service.createSession({ provider: 'copilot' });
229+
230+
// Simulate immediate title change via state manager
231+
service.stateManager.dispatchServerAction({
232+
type: ActionType.SessionTitleChanged,
233+
session: session.toString(),
234+
title: 'User first message',
235+
});
236+
237+
const sessions = await service.listSessions();
238+
assert.strictEqual(sessions.length, 1);
239+
assert.strictEqual(sessions[0].summary, 'User first message');
240+
});
241+
225242
test('createSession stores live session config', async () => {
226243
service.registerProvider(copilotAgent);
227244

src/vs/platform/agentHost/test/node/agentSideEffects.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,136 @@ suite('AgentSideEffects', () => {
130130
});
131131
});
132132

133-
// ---- handleAction: session/turnCancelled ----------------------------
133+
// ---- immediate title on first turn -----------------------------------
134+
135+
suite('immediate title on first turn', () => {
136+
137+
function setupDefaultSession(): void {
138+
stateManager.createSession({
139+
resource: sessionUri.toString(),
140+
provider: 'mock',
141+
title: '',
142+
status: SessionStatus.Idle,
143+
createdAt: Date.now(),
144+
modifiedAt: Date.now(),
145+
project: { uri: 'file:///test-project', displayName: 'Test Project' },
146+
});
147+
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });
148+
}
149+
150+
test('dispatches titleChanged with user message on first turn', () => {
151+
setupDefaultSession();
152+
153+
const envelopes: IActionEnvelope[] = [];
154+
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
155+
156+
sideEffects.handleAction({
157+
type: ActionType.SessionTurnStarted,
158+
session: sessionUri.toString(),
159+
turnId: 'turn-1',
160+
userMessage: { text: 'Fix the login bug' },
161+
});
162+
163+
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
164+
assert.ok(titleAction, 'should dispatch session/titleChanged');
165+
if (titleAction?.action.type === ActionType.SessionTitleChanged) {
166+
assert.strictEqual(titleAction.action.title, 'Fix the login bug');
167+
}
168+
});
169+
170+
test('does not dispatch titleChanged when message is whitespace', () => {
171+
setupDefaultSession();
172+
173+
const envelopes: IActionEnvelope[] = [];
174+
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
175+
176+
sideEffects.handleAction({
177+
type: ActionType.SessionTurnStarted,
178+
session: sessionUri.toString(),
179+
turnId: 'turn-1',
180+
userMessage: { text: ' ' },
181+
});
182+
183+
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
184+
assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged for empty message');
185+
});
186+
187+
test('normalizes whitespace and truncates long messages', () => {
188+
setupDefaultSession();
189+
190+
const envelopes: IActionEnvelope[] = [];
191+
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
192+
193+
const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250);
194+
sideEffects.handleAction({
195+
type: ActionType.SessionTurnStarted,
196+
session: sessionUri.toString(),
197+
turnId: 'turn-1',
198+
userMessage: { text: longMessage },
199+
});
200+
201+
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
202+
assert.ok(titleAction, 'should dispatch session/titleChanged');
203+
if (titleAction?.action.type === ActionType.SessionTitleChanged) {
204+
assert.ok(!titleAction.action.title.includes('\n'), 'should not contain newlines');
205+
assert.ok(!titleAction.action.title.includes('\t'), 'should not contain tabs');
206+
assert.ok(!titleAction.action.title.includes(' '), 'should not contain double spaces');
207+
assert.ok(titleAction.action.title.length <= 200, 'should be truncated to 200 chars');
208+
}
209+
});
210+
211+
test('does not dispatch titleChanged on second turn', () => {
212+
setupDefaultSession();
213+
startTurn('turn-1');
214+
215+
// Complete the first turn so turns.length becomes 1.
216+
stateManager.dispatchServerAction({
217+
type: ActionType.SessionTurnComplete,
218+
session: sessionUri.toString(),
219+
turnId: 'turn-1',
220+
});
221+
222+
const envelopes: IActionEnvelope[] = [];
223+
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
224+
225+
sideEffects.handleAction({
226+
type: ActionType.SessionTurnStarted,
227+
session: sessionUri.toString(),
228+
turnId: 'turn-2',
229+
userMessage: { text: 'second message' },
230+
});
231+
232+
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
233+
assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged on second turn');
234+
});
235+
236+
test('does not dispatch titleChanged when title is already set', () => {
237+
// Session has a non-empty title (e.g. user renamed before first message)
238+
stateManager.createSession({
239+
resource: sessionUri.toString(),
240+
provider: 'mock',
241+
title: 'User Renamed',
242+
status: SessionStatus.Idle,
243+
createdAt: Date.now(),
244+
modifiedAt: Date.now(),
245+
project: { uri: 'file:///test-project', displayName: 'Test Project' },
246+
});
247+
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });
248+
249+
const envelopes: IActionEnvelope[] = [];
250+
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
251+
252+
sideEffects.handleAction({
253+
type: ActionType.SessionTurnStarted,
254+
session: sessionUri.toString(),
255+
turnId: 'turn-1',
256+
userMessage: { text: 'hello' },
257+
});
258+
259+
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
260+
assert.strictEqual(titleAction, undefined, 'should not clobber existing title');
261+
});
262+
});
134263

135264
suite('handleAction — session/turnCancelled', () => {
136265

src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,15 @@ suite('Protocol WebSocket — Session Features', function () {
7777
const sessionUri = await createAndSubscribeSession(client, 'test-agent-title');
7878
dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1);
7979

80-
const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));
80+
// The first titleChanged is the immediate fallback (user message text).
81+
// Wait for the agent-generated title which arrives second.
82+
const titleNotif = await client.waitForNotification(n => {
83+
if (!isActionNotification(n, 'session/titleChanged')) {
84+
return false;
85+
}
86+
const action = getActionEnvelope(n).action as ITitleChangedAction;
87+
return action.title === MOCK_AUTO_TITLE;
88+
});
8189
const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;
8290
assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE);
8391

@@ -88,6 +96,31 @@ suite('Protocol WebSocket — Session Features', function () {
8896
assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE);
8997
});
9098

99+
test('first turn immediately sets title to user message', async function () {
100+
this.timeout(10_000);
101+
102+
const sessionUri = await createAndSubscribeSession(client, 'test-immediate-title');
103+
104+
// Verify the session starts with the default placeholder title
105+
const before = await client.call<ISubscribeResult>('subscribe', { resource: sessionUri });
106+
assert.strictEqual((before.snapshot.state as ISessionState).summary.title, '');
107+
108+
// Send first turn — side effects should dispatch an immediate titleChanged
109+
// with the user's message text before the agent produces its own title.
110+
dispatchTurnStarted(client, sessionUri, 'turn-immediate', 'Fix the login bug', 1);
111+
112+
// The first titleChanged should carry the user message text
113+
const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));
114+
const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;
115+
assert.strictEqual(titleAction.title, 'Fix the login bug');
116+
117+
// listSessions should also reflect the updated title
118+
const result = await client.call<IListSessionsResult>('listSessions');
119+
const session = result.items.find(s => s.resource === sessionUri);
120+
assert.ok(session, 'session should appear in listSessions');
121+
assert.strictEqual(session.title, 'Fix the login bug');
122+
});
123+
91124
test('renamed session title persists across listSessions', async function () {
92125
this.timeout(10_000);
93126

src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js';
99
import { URI } from '../../../../base/common/uri.js';
1010
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
1111
import { NullLogService } from '../../../log/common/log.js';
12-
import type { IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../common/agentService.js';
12+
import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAuthenticateParams, type IAuthenticateResult } from '../../common/agentService.js';
1313
import { IListSessionsResult, IResourceReadResult, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
1414
import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js';
1515
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
@@ -95,7 +95,7 @@ class MockAgentService implements IAgentService {
9595
this._stateManager.createSession({
9696
resource: session.toString(),
9797
provider: config?.provider ?? 'copilot',
98-
title: 'New Session',
98+
title: '',
9999
status: SessionStatus.Idle,
100100
createdAt: Date.now(),
101101
modifiedAt: Date.now(),

src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class LocalSessionAdapter implements ISession {
112112
this.providerId = providerId;
113113
this.sessionType = logicalSessionType;
114114
this.createdAt = new Date(metadata.startTime);
115-
this.title = observableValue('title', metadata.summary ?? `Session ${rawId.substring(0, 8)}`);
115+
this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`);
116116
this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime));
117117
this.modelId = observableValue<string | undefined>('modelId', metadata.model ? `${logicalSessionType}:${metadata.model}` : undefined);
118118
this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined);

src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ class RemoteSessionAdapter implements IChatData {
195195
this.providerId = providerId;
196196
this.sessionType = logicalSessionType;
197197
this.createdAt = new Date(metadata.startTime);
198-
this.title = observableValue('title', metadata.summary ?? `Session ${rawId.substring(0, 8)}`);
198+
this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`);
199199
this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime));
200200
this.modelId = observableValue<string | undefined>('modelId', metadata.model ? `${resourceScheme}:${metadata.model}` : undefined);
201201
this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined);
@@ -214,7 +214,7 @@ class RemoteSessionAdapter implements IChatData {
214214
}
215215

216216
update(metadata: IAgentSessionMetadata): void {
217-
this.title.set(metadata.summary ?? this.title.get(), undefined);
217+
this.title.set(metadata.summary || this.title.get(), undefined);
218218
this.updatedAt.set(new Date(metadata.modifiedTime), undefined);
219219
this.lastTurnEnd.set(metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined, undefined);
220220
if (metadata.isRead !== undefined) {

src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS
171171
}): IChatSessionItem {
172172
return {
173173
resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }),
174-
label: opts.title ?? `Session ${rawId.substring(0, 8)}`,
174+
label: opts.title || `Session ${rawId.substring(0, 8)}`,
175175
description: this._description,
176176
iconPath: getAgentHostIcon(this._productService),
177177
status: mapSessionStatus(opts.status),

0 commit comments

Comments
 (0)