Path: blob/main/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.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 { URI } from '../../../../../base/common/uri.js';8import { SubscribeResult } from '../../../common/state/protocol/commands.js';9import type { SessionAddedNotification, SessionRemovedNotification } from '../../../common/state/sessionActions.js';10import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';11import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js';12import { ResponsePartKind, SessionStatus, type MarkdownResponsePart, type SessionState, type ToolCallResponsePart } from '../../../common/state/sessionState.js';13import { PRE_EXISTING_SESSION_URI } from '../mockAgent.js';14import {15createAndSubscribeSession,16isActionNotification,17IServerHandle,18nextSessionUri,19startServer,20TestProtocolClient21} from './testHelpers.js';2223suite('Protocol WebSocket — Session Lifecycle', function () {2425let server: IServerHandle;26let client: TestProtocolClient;2728suiteSetup(async function () {29this.timeout(15_000);30server = await startServer();31});3233suiteTeardown(function () {34server.process.kill();35});3637setup(async function () {38this.timeout(10_000);39client = new TestProtocolClient(server.port);40await client.connect();41});4243teardown(function () {44client.close();45});4647test('create session triggers sessionAdded notification', async function () {48this.timeout(10_000);4950await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-create-session' });5152await client.call('createSession', { session: nextSessionUri(), provider: 'mock' });5354const notif = await client.waitForNotification(n =>55n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'56);57const notification = (notif.params as INotificationBroadcastParams).notification as SessionAddedNotification;58assert.strictEqual(URI.parse(notification.summary.resource).scheme, 'mock');59assert.strictEqual(notification.summary.provider, 'mock');60});6162test('listSessions returns sessions', async function () {63this.timeout(10_000);6465await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-list-sessions' });6667await client.call('createSession', { session: nextSessionUri(), provider: 'mock' });68await client.waitForNotification(n =>69n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'70);7172const result = await client.call<ListSessionsResult>('listSessions');73assert.ok(Array.isArray(result.items));74assert.ok(result.items.length >= 1, 'should have at least one session');75});7677test('dispose session sends sessionRemoved notification', async function () {78this.timeout(10_000);7980const sessionUri = await createAndSubscribeSession(client, 'test-dispose');81await client.call('disposeSession', { session: sessionUri });8283const notif = await client.waitForNotification(n =>84n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved'85);86const removed = (notif.params as INotificationBroadcastParams).notification as SessionRemovedNotification;87assert.strictEqual(removed.session.toString(), sessionUri.toString());88});8990test('subscribe to a pre-existing session restores turns from agent history', async function () {91this.timeout(10_000);9293await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-restore' });9495// The mock agent seeds a pre-existing session that was never created96// through the server's handleCreateSession -- simulating a session97// from a previous server lifetime.98const preExistingUri = PRE_EXISTING_SESSION_URI.toString();99const list = await client.call<ListSessionsResult>('listSessions');100const preExisting = list.items.find(s => s.resource === preExistingUri);101assert.ok(preExisting, 'listSessions should include the pre-existing session');102103// Clear notifications so we can verify no duplicate sessionAdded fires.104client.clearReceived();105106// Subscribing to this session should trigger the restore path: the107// server fetches message history from the agent and reconstructs turns.108const result = await client.call<SubscribeResult>('subscribe', { resource: preExistingUri });109const state = result.snapshot.state as SessionState;110111assert.strictEqual(state.lifecycle, 'ready', 'restored session should be in ready state');112assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`);113114const turn = state.turns[0];115assert.strictEqual(turn.userMessage.text, 'What files are here?');116assert.strictEqual(turn.state, 'complete');117const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);118assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts');119assert.strictEqual(toolCallParts[0].toolCall.toolName, 'list_files');120const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);121assert.ok(mdParts.some(p => p.content.includes('file1.ts')), 'turn should have markdown part mentioning file1.ts');122123// Restoring should NOT emit a duplicate sessionAdded notification124// (the session is already known to clients via listSessions).125await new Promise(resolve => setTimeout(resolve, 200));126const sessionAddedNotifs = client.receivedNotifications(n =>127n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'128);129assert.strictEqual(sessionAddedNotifs.length, 0, 'restore should not emit sessionAdded');130});131132test('isRead and isArchived flags survive in listSessions after dispatch', async function () {133this.timeout(15_000);134135const sessionUri = await createAndSubscribeSession(client, 'test-read-archived-flags');136137// Dispatch isArchived=true138client.notify('dispatchAction', {139clientSeq: 1,140action: {141type: 'session/isArchivedChanged',142session: sessionUri,143isArchived: true,144},145});146147await client.waitForNotification(n => isActionNotification(n, 'session/isArchivedChanged'));148149// Dispatch isRead=true150client.notify('dispatchAction', {151clientSeq: 2,152action: {153type: 'session/isReadChanged',154session: sessionUri,155isRead: true,156},157});158159await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged'));160161// Verify the flags are reflected in the subscribed session state162const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });163const state = snapshot.snapshot.state as SessionState;164assert.ok(state.summary.status & SessionStatus.IsArchived, 'IsArchived flag should be set in snapshot');165assert.ok(state.summary.status & SessionStatus.IsRead, 'IsRead flag should be set in snapshot');166167// Poll listSessions until the persisted flags appear (async DB write)168client.close();169const client2 = new TestProtocolClient(server.port);170await client2.connect();171await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-read-archived-flags-2' });172173let session: ListSessionsResult['items'][0] | undefined;174for (let i = 0; i < 20; i++) {175const result = await client2.call<ListSessionsResult>('listSessions');176session = result.items.find(s => s.resource === sessionUri);177if (session && (session.status & SessionStatus.IsArchived) && (session.status & SessionStatus.IsRead)) {178break;179}180await timeout(100);181}182assert.ok(session, 'session should appear in listSessions');183assert.ok(session.status & SessionStatus.IsArchived, 'IsArchived should be persisted in listSessions');184assert.ok(session.status & SessionStatus.IsRead, 'IsRead should be persisted in listSessions');185186client2.close();187});188189test('dispatching isRead=false explicitly persists as false', async function () {190this.timeout(15_000);191192const sessionUri = await createAndSubscribeSession(client, 'test-isread-false');193194// On a fresh session, isRead is undefined in the DB. Dispatching195// isRead=false should persist the value so that listSessions196// returns an explicit `false` rather than omitting the field.197client.notify('dispatchAction', {198clientSeq: 1,199action: {200type: 'session/isReadChanged',201session: sessionUri,202isRead: false,203},204});205206await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged'));207208client.close();209const client2 = new TestProtocolClient(server.port);210await client2.connect();211await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-isread-false-2' });212213let session: ListSessionsResult['items'][0] | undefined;214for (let i = 0; i < 20; i++) {215const result = await client2.call<ListSessionsResult>('listSessions');216session = result.items.find(s => s.resource === sessionUri);217if (session && !(session.status & SessionStatus.IsRead)) {218break;219}220await timeout(100);221}222assert.ok(session, 'session should appear in listSessions');223assert.strictEqual(session.status & SessionStatus.IsRead, 0, 'IsRead flag should not be set');224225client2.close();226});227});228229230