Skip to content
Merged
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
14 changes: 11 additions & 3 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Comment thread
roblourens marked this conversation as resolved.
}
return s;
});
Expand Down Expand Up @@ -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,
Comment thread
roblourens marked this conversation as resolved.
createdAt: Date.now(),
modifiedAt: Date.now(),
Expand Down
16 changes: 16 additions & 0 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Comment thread
roblourens marked this conversation as resolved.
}
Comment thread
roblourens marked this conversation as resolved.

const agent = this._options.getAgent(action.session);
if (!agent) {
this._stateManager.dispatchServerAction({
Expand Down
17 changes: 17 additions & 0 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
131 changes: 130 additions & 1 deletion src/vs/platform/agentHost/test/node/agentSideEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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<ISubscribeResult>('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<IListSessionsResult>('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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>('modelId', metadata.model ? `${logicalSessionType}:${metadata.model}` : undefined);
this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>('modelId', metadata.model ? `${resourceScheme}:${metadata.model}` : undefined);
this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined);
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading