Path: blob/main/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { timeout } from '../../../../../base/common/async.js';7import { SubscribeResult } from '../../../common/state/protocol/commands.js';8import type { IModelChangedAction, IResponsePartAction, SessionAddedNotification, ITitleChangedAction } from '../../../common/state/sessionActions.js';9import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';10import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js';11import { PendingMessageKind, ResponsePartKind, type SessionState } from '../../../common/state/sessionState.js';12import { MOCK_AUTO_TITLE } from '../mockAgent.js';13import {14createAndSubscribeSession,15dispatchTurnStarted,16getActionEnvelope,17isActionNotification,18IServerHandle,19nextSessionUri,20startServer,21TestProtocolClient,22} from './testHelpers.js';2324suite('Protocol WebSocket — Session Features', function () {2526let server: IServerHandle;27let client: TestProtocolClient;2829suiteSetup(async function () {30this.timeout(15_000);31server = await startServer();32});3334suiteTeardown(function () {35server.process.kill();36});3738setup(async function () {39this.timeout(10_000);40client = new TestProtocolClient(server.port);41await client.connect();42});4344teardown(function () {45client.close();46});4748// ---- Session rename / title ------------------------------------------------4950test('client titleChanged updates session state snapshot', async function () {51this.timeout(10_000);5253const sessionUri = await createAndSubscribeSession(client, 'test-titleChanged');5455client.notify('dispatchAction', {56clientSeq: 1,57action: {58type: 'session/titleChanged',59session: sessionUri,60title: 'My Custom Title',61},62});6364const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));65const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;66assert.strictEqual(titleAction.title, 'My Custom Title');6768const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });69const state = snapshot.snapshot.state as SessionState;70assert.strictEqual(state.summary.title, 'My Custom Title');71});7273test('agent-generated titleChanged is broadcast', async function () {74this.timeout(10_000);7576const sessionUri = await createAndSubscribeSession(client, 'test-agent-title');77dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1);7879// The first titleChanged is the immediate fallback (user message text).80// Wait for the agent-generated title which arrives second.81const titleNotif = await client.waitForNotification(n => {82if (!isActionNotification(n, 'session/titleChanged')) {83return false;84}85const action = getActionEnvelope(n).action as ITitleChangedAction;86return action.title === MOCK_AUTO_TITLE;87});88const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;89assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE);9091await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));9293const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });94const state = snapshot.snapshot.state as SessionState;95assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE);96});9798test('first turn immediately sets title to user message', async function () {99this.timeout(10_000);100101const sessionUri = await createAndSubscribeSession(client, 'test-immediate-title');102103// Verify the session starts with the default placeholder title104const before = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });105assert.strictEqual((before.snapshot.state as SessionState).summary.title, '');106107// Send first turn — side effects should dispatch an immediate titleChanged108// with the user's message text before the agent produces its own title.109dispatchTurnStarted(client, sessionUri, 'turn-immediate', 'Fix the login bug', 1);110111// The first titleChanged should carry the user message text112const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));113const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;114assert.strictEqual(titleAction.title, 'Fix the login bug');115116// listSessions should also reflect the updated title117const result = await client.call<ListSessionsResult>('listSessions');118const session = result.items.find(s => s.resource === sessionUri);119assert.ok(session, 'session should appear in listSessions');120assert.strictEqual(session.title, 'Fix the login bug');121});122123test('renamed session title persists across listSessions', async function () {124this.timeout(10_000);125126const sessionUri = await createAndSubscribeSession(client, 'test-title-list');127128client.notify('dispatchAction', {129clientSeq: 1,130action: {131type: 'session/titleChanged',132session: sessionUri,133title: 'Persisted Title',134},135});136137await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));138139// Poll listSessions until the persisted title appears (async DB write)140let session: { title: string } | undefined;141for (let i = 0; i < 20; i++) {142const result = await client.call<ListSessionsResult>('listSessions');143session = result.items.find(s => s.resource === sessionUri);144if (session?.title === 'Persisted Title') {145break;146}147await timeout(100);148}149assert.ok(session, 'session should appear in listSessions');150assert.strictEqual(session.title, 'Persisted Title');151});152153// ---- Session model --------------------------------------------------------154155test('session model flows through create, subscribe, listSessions, and modelChanged', async function () {156this.timeout(10_000);157158await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-model-summary' });159160const sessionUri = nextSessionUri();161await client.call('createSession', { session: sessionUri, provider: 'mock', model: { id: 'mock-model' } });162163const addedNotif = await client.waitForNotification(n =>164n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'165);166const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification;167assert.deepStrictEqual(addedSession.summary.model, { id: 'mock-model' });168const createdSessionUri = addedSession.summary.resource;169170const initialSnapshot = await client.call<SubscribeResult>('subscribe', { resource: createdSessionUri });171const initialState = initialSnapshot.snapshot.state as SessionState;172assert.deepStrictEqual(initialState.summary.model, { id: 'mock-model' });173174const initialList = await client.call<ListSessionsResult>('listSessions');175assert.deepStrictEqual(initialList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model' });176177client.notify('dispatchAction', {178clientSeq: 1,179action: {180type: 'session/modelChanged',181session: createdSessionUri,182model: { id: 'mock-model-2' },183},184});185186const modelNotif = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged'));187const modelAction = getActionEnvelope(modelNotif).action as IModelChangedAction;188assert.deepStrictEqual(modelAction.model, { id: 'mock-model-2' });189190const updatedSnapshot = await client.call<SubscribeResult>('subscribe', { resource: createdSessionUri });191const updatedState = updatedSnapshot.snapshot.state as SessionState;192assert.deepStrictEqual(updatedState.summary.model, { id: 'mock-model-2' });193194const updatedList = await client.call<ListSessionsResult>('listSessions');195assert.deepStrictEqual(updatedList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model-2' });196});197198// ---- Reasoning events ------------------------------------------------------199200test('reasoning events produce reasoning response parts and append actions', async function () {201this.timeout(10_000);202203const sessionUri = await createAndSubscribeSession(client, 'test-reasoning');204dispatchTurnStarted(client, sessionUri, 'turn-reasoning', 'with-reasoning', 1);205206// The first reasoning event produces a responsePart with kind Reasoning207const reasoningPart = await client.waitForNotification(n => {208if (!isActionNotification(n, 'session/responsePart')) {209return false;210}211const action = getActionEnvelope(n).action as IResponsePartAction;212return action.part.kind === ResponsePartKind.Reasoning;213});214const reasoningAction = getActionEnvelope(reasoningPart).action as IResponsePartAction;215assert.strictEqual(reasoningAction.part.kind, ResponsePartKind.Reasoning);216217// The second reasoning chunk produces a session/reasoning append action218const appendNotif = await client.waitForNotification(n => isActionNotification(n, 'session/reasoning'));219const appendAction = getActionEnvelope(appendNotif).action;220assert.strictEqual(appendAction.type, 'session/reasoning');221if (appendAction.type === 'session/reasoning') {222assert.strictEqual(appendAction.content, ' about this...');223}224225// Then the markdown response part226const mdPart = await client.waitForNotification(n => {227if (!isActionNotification(n, 'session/responsePart')) {228return false;229}230const action = getActionEnvelope(n).action as IResponsePartAction;231return action.part.kind === ResponsePartKind.Markdown;232});233assert.ok(mdPart);234235await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));236});237238// ---- Queued messages -------------------------------------------------------239240test('queued message is auto-consumed when session is idle', async function () {241this.timeout(10_000);242243const sessionUri = await createAndSubscribeSession(client, 'test-queue-idle');244client.clearReceived();245246// Queue a message when the session is idle — server should immediately consume it247client.notify('dispatchAction', {248clientSeq: 1,249action: {250type: 'session/pendingMessageSet',251session: sessionUri,252kind: PendingMessageKind.Queued,253id: 'q-1',254userMessage: { text: 'hello' },255},256});257258// The server should auto-consume the queued message and start a turn259await client.waitForNotification(n => isActionNotification(n, 'session/turnStarted'));260await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));261await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));262263// Verify the turn was created from the queued message264const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });265const state = snapshot.snapshot.state as SessionState;266assert.ok(state.turns.length >= 1);267assert.strictEqual(state.turns[state.turns.length - 1].userMessage.text, 'hello');268// Queue should be empty after consumption269assert.ok(!state.queuedMessages?.length, 'queued messages should be empty after consumption');270});271272test('queued message waits for in-progress turn to complete', async function () {273this.timeout(15_000);274275const sessionUri = await createAndSubscribeSession(client, 'test-queue-wait');276277// Start a turn first278dispatchTurnStarted(client, sessionUri, 'turn-first', 'hello', 1);279280// Wait for the first turn's response to confirm it is in progress281await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));282283// Queue a message while the turn is in progress284client.notify('dispatchAction', {285clientSeq: 2,286action: {287type: 'session/pendingMessageSet',288session: sessionUri,289kind: PendingMessageKind.Queued,290id: 'q-wait-1',291userMessage: { text: 'hello' },292},293});294295// First turn should complete296const firstComplete = await client.waitForNotification(n => {297if (!isActionNotification(n, 'session/turnComplete')) {298return false;299}300return (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-first';301});302const firstSeq = getActionEnvelope(firstComplete).serverSeq;303304// The queued message's turn should complete AFTER the first turn305const secondComplete = await client.waitForNotification(n => {306if (!isActionNotification(n, 'session/turnComplete')) {307return false;308}309const envelope = getActionEnvelope(n);310return (envelope.action as { turnId: string }).turnId !== 'turn-first'311&& envelope.serverSeq > firstSeq;312});313assert.ok(secondComplete, 'should receive a second turnComplete from the queued message');314315const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });316const state = snapshot.snapshot.state as SessionState;317assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`);318});319320// ---- Steering messages ----------------------------------------------------321322test('steering message is set and consumed by agent', async function () {323this.timeout(10_000);324325const sessionUri = await createAndSubscribeSession(client, 'test-steering');326327// Start a turn first328dispatchTurnStarted(client, sessionUri, 'turn-steer', 'hello', 1);329330// Set a steering message while the turn is in progress331client.notify('dispatchAction', {332clientSeq: 2,333action: {334type: 'session/pendingMessageSet',335session: sessionUri,336kind: PendingMessageKind.Steering,337id: 'steer-1',338userMessage: { text: 'Please be concise' },339},340});341342// The steering message should be set in state initially343const setNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageSet'));344assert.ok(setNotif, 'should see pendingMessageSet action');345346// The mock agent consumes steering and fires steering_consumed,347// which causes the server to dispatch pendingMessageRemoved348const removedNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageRemoved'));349assert.ok(removedNotif, 'should see pendingMessageRemoved after agent consumes steering');350351await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));352353// Steering should be cleared from state354const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });355const state = snapshot.snapshot.state as SessionState;356assert.ok(!state.steeringMessage, 'steering message should be cleared after consumption');357});358359// ---- Truncation -----------------------------------------------------------360361test('truncate session removes turns after specified turn', async function () {362this.timeout(15_000);363364const sessionUri = await createAndSubscribeSession(client, 'test-truncate');365366// Create two turns367dispatchTurnStarted(client, sessionUri, 'turn-t1', 'hello', 1);368await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t1');369370client.clearReceived();371dispatchTurnStarted(client, sessionUri, 'turn-t2', 'hello', 2);372await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2');373374// Verify 2 turns exist375let snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });376let state = snapshot.snapshot.state as SessionState;377assert.strictEqual(state.turns.length, 2);378379client.clearReceived();380381// Truncate: keep only turn-t1382client.notify('dispatchAction', {383clientSeq: 3,384action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-t1' },385});386387await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));388389snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });390state = snapshot.snapshot.state as SessionState;391assert.strictEqual(state.turns.length, 1);392assert.strictEqual(state.turns[0].id, 'turn-t1');393});394395test('truncate all turns clears session history', async function () {396this.timeout(15_000);397398const sessionUri = await createAndSubscribeSession(client, 'test-truncate-all');399400dispatchTurnStarted(client, sessionUri, 'turn-ta1', 'hello', 1);401await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));402403client.clearReceived();404405// Truncate all (no turnId)406client.notify('dispatchAction', {407clientSeq: 2,408action: { type: 'session/truncated', session: sessionUri },409});410411await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));412413const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });414const state = snapshot.snapshot.state as SessionState;415assert.strictEqual(state.turns.length, 0);416});417418test('new turn after truncation works correctly', async function () {419this.timeout(15_000);420421const sessionUri = await createAndSubscribeSession(client, 'test-truncate-resume');422423dispatchTurnStarted(client, sessionUri, 'turn-tr1', 'hello', 1);424await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr1');425426client.clearReceived();427dispatchTurnStarted(client, sessionUri, 'turn-tr2', 'hello', 2);428await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr2');429430client.clearReceived();431432// Truncate to turn-tr1433client.notify('dispatchAction', {434clientSeq: 3,435action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-tr1' },436});437438await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));439440// Send a new turn after truncation441dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4);442await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));443444const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });445const state = snapshot.snapshot.state as SessionState;446assert.strictEqual(state.turns.length, 2);447assert.strictEqual(state.turns[0].id, 'turn-tr1');448assert.strictEqual(state.turns[1].id, 'turn-tr3');449});450451// ---- Fork -----------------------------------------------------------------452453test('fork creates a new session with source history', async function () {454this.timeout(15_000);455456const sessionUri = await createAndSubscribeSession(client, 'test-fork');457458// Create two turns459dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1);460await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1');461462client.clearReceived();463dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2);464await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2');465466client.clearReceived();467468// Fork at turn-f1 (keep turns up to and including turn-f1)469const forkedSessionUri = nextSessionUri();470await client.call('createSession', {471session: forkedSessionUri,472provider: 'mock',473fork: { session: sessionUri, turnId: 'turn-f1' },474});475476const addedNotif = await client.waitForNotification(n =>477n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'478);479const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification;480481// Subscribe — forked session should have 1 turn482const snapshot = await client.call<SubscribeResult>('subscribe', { resource: addedSession.summary.resource });483const state = snapshot.snapshot.state as SessionState;484assert.strictEqual(state.lifecycle, 'ready');485assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn');486487// Source session should be unaffected488const sourceSnapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });489const sourceState = sourceSnapshot.snapshot.state as SessionState;490assert.strictEqual(sourceState.turns.length, 2);491});492493test('fork with invalid turn ID returns error', async function () {494this.timeout(10_000);495496const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid');497498let gotError = false;499try {500await client.call('createSession', {501session: nextSessionUri(),502provider: 'mock',503fork: { session: sessionUri, turnId: 'nonexistent-turn' },504});505} catch {506gotError = true;507}508assert.ok(gotError, 'should get error for invalid fork turn ID');509});510511test('fork with invalid source session returns error', async function () {512this.timeout(10_000);513514await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-fork-no-source' });515516let gotError = false;517try {518await client.call('createSession', {519session: nextSessionUri(),520provider: 'mock',521fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' },522});523} catch {524gotError = true;525}526assert.ok(gotError, 'should get error for invalid fork source session');527});528});529530531