diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9f4eaa01acbab..9a8bf2ef1d21b 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -178,11 +178,19 @@ export class AgentService extends Disposable implements IAgentService { return s; })); - // Overlay live session status from the state manager + // Overlay live session state from the state manager. + // For the title, prefer the state manager's value when it is + // non-empty, so SDK-sourced titles are not overwritten by the + // initial empty placeholder. const withStatus = result.map(s => { const liveState = this._stateManager.getSessionState(s.session.toString()); if (liveState) { - return { ...s, status: liveState.summary.status, model: liveState.summary.model ?? s.model }; + return { + ...s, + summary: liveState.summary.title || s.summary, + status: liveState.summary.status, + model: liveState.summary.model ?? s.model, + }; } return s; }); @@ -262,7 +270,7 @@ export class AgentService extends Disposable implements IAgentService { const summary: ISessionSummary = { resource: session.toString(), provider: provider.id, - title: 'New Session', + title: '', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 959cd9463a0ff..4d5102c187997 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -568,6 +568,22 @@ export class AgentSideEffects extends Disposable { for (const mapper of this._eventMappers.values()) { mapper.reset(action.session); } + + // On the very first turn, immediately set the session title to the + // user's message so the UI shows a meaningful title right away + // while waiting for the AI-generated title. Only apply when the + // title is still the default placeholder to avoid clobbering a + // title set by the user or provider before the first turn. + const state = this._stateManager.getSessionState(action.session); + const fallbackTitle = action.userMessage.text.trim().replace(/\s+/g, ' ').slice(0, 200); + if (state && state.turns.length === 0 && !state.summary.title && fallbackTitle.length > 0) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionTitleChanged, + session: action.session, + title: fallbackTitle, + }); + } + const agent = this._options.getAgent(action.session); if (!agent) { this._stateManager.dispatchServerAction({ diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 33971f489df42..50ab00e032685 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -222,6 +222,23 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions[0].summary, 'Auto-generated Title'); }); + test('listSessions overlays live state manager title over SDK title', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession({ provider: 'copilot' }); + + // Simulate immediate title change via state manager + service.stateManager.dispatchServerAction({ + type: ActionType.SessionTitleChanged, + session: session.toString(), + title: 'User first message', + }); + + const sessions = await service.listSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].summary, 'User first message'); + }); + test('createSession stores live session config', async () => { service.registerProvider(copilotAgent); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index a844bb594c0b2..f66b5605dd2e4 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -130,7 +130,136 @@ suite('AgentSideEffects', () => { }); }); - // ---- handleAction: session/turnCancelled ---------------------------- + // ---- immediate title on first turn ----------------------------------- + + suite('immediate title on first turn', () => { + + function setupDefaultSession(): void { + stateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, + }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); + } + + test('dispatches titleChanged with user message on first turn', () => { + setupDefaultSession(); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + sideEffects.handleAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-1', + userMessage: { text: 'Fix the login bug' }, + }); + + const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); + assert.ok(titleAction, 'should dispatch session/titleChanged'); + if (titleAction?.action.type === ActionType.SessionTitleChanged) { + assert.strictEqual(titleAction.action.title, 'Fix the login bug'); + } + }); + + test('does not dispatch titleChanged when message is whitespace', () => { + setupDefaultSession(); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + sideEffects.handleAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-1', + userMessage: { text: ' ' }, + }); + + const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); + assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged for empty message'); + }); + + test('normalizes whitespace and truncates long messages', () => { + setupDefaultSession(); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250); + sideEffects.handleAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-1', + userMessage: { text: longMessage }, + }); + + const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); + assert.ok(titleAction, 'should dispatch session/titleChanged'); + if (titleAction?.action.type === ActionType.SessionTitleChanged) { + assert.ok(!titleAction.action.title.includes('\n'), 'should not contain newlines'); + assert.ok(!titleAction.action.title.includes('\t'), 'should not contain tabs'); + assert.ok(!titleAction.action.title.includes(' '), 'should not contain double spaces'); + assert.ok(titleAction.action.title.length <= 200, 'should be truncated to 200 chars'); + } + }); + + test('does not dispatch titleChanged on second turn', () => { + setupDefaultSession(); + startTurn('turn-1'); + + // Complete the first turn so turns.length becomes 1. + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnComplete, + session: sessionUri.toString(), + turnId: 'turn-1', + }); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + sideEffects.handleAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-2', + userMessage: { text: 'second message' }, + }); + + const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); + assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged on second turn'); + }); + + test('does not dispatch titleChanged when title is already set', () => { + // Session has a non-empty title (e.g. user renamed before first message) + stateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'User Renamed', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, + }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + sideEffects.handleAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); + assert.strictEqual(titleAction, undefined, 'should not clobber existing title'); + }); + }); suite('handleAction — session/turnCancelled', () => { diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index c8eee7fbae66b..cd6766fcf074a 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -77,7 +77,15 @@ suite('Protocol WebSocket — Session Features', function () { const sessionUri = await createAndSubscribeSession(client, 'test-agent-title'); dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1); - const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + // The first titleChanged is the immediate fallback (user message text). + // Wait for the agent-generated title which arrives second. + const titleNotif = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/titleChanged')) { + return false; + } + const action = getActionEnvelope(n).action as ITitleChangedAction; + return action.title === MOCK_AUTO_TITLE; + }); const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE); @@ -88,6 +96,31 @@ suite('Protocol WebSocket — Session Features', function () { assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE); }); + test('first turn immediately sets title to user message', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-immediate-title'); + + // Verify the session starts with the default placeholder title + const before = await client.call('subscribe', { resource: sessionUri }); + assert.strictEqual((before.snapshot.state as ISessionState).summary.title, ''); + + // Send first turn — side effects should dispatch an immediate titleChanged + // with the user's message text before the agent produces its own title. + dispatchTurnStarted(client, sessionUri, 'turn-immediate', 'Fix the login bug', 1); + + // The first titleChanged should carry the user message text + const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; + assert.strictEqual(titleAction.title, 'Fix the login bug'); + + // listSessions should also reflect the updated title + const result = await client.call('listSessions'); + const session = result.items.find(s => s.resource === sessionUri); + assert.ok(session, 'session should appear in listSessions'); + assert.strictEqual(session.title, 'Fix the login bug'); + }); + test('renamed session title persists across listSessions', async function () { this.timeout(10_000); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index a8f32056a7fa7..0ceb1291a2555 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -9,7 +9,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../common/agentService.js'; +import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAuthenticateParams, type IAuthenticateResult } from '../../common/agentService.js'; import { IListSessionsResult, IResourceReadResult, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; @@ -95,7 +95,7 @@ class MockAgentService implements IAgentService { this._stateManager.createSession({ resource: session.toString(), provider: config?.provider ?? 'copilot', - title: 'New Session', + title: '', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index cc2bb72787648..4c40f4342f08a 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -112,7 +112,7 @@ class LocalSessionAdapter implements ISession { this.providerId = providerId; this.sessionType = logicalSessionType; this.createdAt = new Date(metadata.startTime); - this.title = observableValue('title', metadata.summary ?? `Session ${rawId.substring(0, 8)}`); + this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); this.modelId = observableValue('modelId', metadata.model ? `${logicalSessionType}:${metadata.model}` : undefined); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 73e49827b7485..2983bca3276b9 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -195,7 +195,7 @@ class RemoteSessionAdapter implements IChatData { this.providerId = providerId; this.sessionType = logicalSessionType; this.createdAt = new Date(metadata.startTime); - this.title = observableValue('title', metadata.summary ?? `Session ${rawId.substring(0, 8)}`); + this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); this.modelId = observableValue('modelId', metadata.model ? `${resourceScheme}:${metadata.model}` : undefined); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); @@ -214,7 +214,7 @@ class RemoteSessionAdapter implements IChatData { } update(metadata: IAgentSessionMetadata): void { - this.title.set(metadata.summary ?? this.title.get(), undefined); + this.title.set(metadata.summary || this.title.get(), undefined); this.updatedAt.set(new Date(metadata.modifiedTime), undefined); this.lastTurnEnd.set(metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined, undefined); if (metadata.isRead !== undefined) { 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 e0da46526e8e8..f3411eb837080 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -171,7 +171,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS }): IChatSessionItem { return { resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), - label: opts.title ?? `Session ${rawId.substring(0, 8)}`, + label: opts.title || `Session ${rawId.substring(0, 8)}`, description: this._description, iconPath: getAgentHostIcon(this._productService), status: mapSessionStatus(opts.status),