Path: blob/main/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts
13399 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 { DisposableStore } from '../../../../base/common/lifecycle.js';7import { URI } from '../../../../base/common/uri.js';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';9import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';10import { NullLogService } from '../../../log/common/log.js';11import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js';12import { SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, isSubagentSession, parseSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js';13import { type SessionSummaryChangedNotification } from '../../common/state/protocol/notifications.js';14import { AgentHostStateManager } from '../../node/agentHostStateManager.js';1516suite('AgentHostStateManager', () => {1718let disposables: DisposableStore;19let manager: AgentHostStateManager;20const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();2122function makeSessionSummary(resource?: string): SessionSummary {23return {24resource: resource ?? sessionUri,25provider: 'copilot',26title: 'Test',27status: SessionStatus.Idle,28createdAt: Date.now(),29modifiedAt: Date.now(),30project: { uri: 'file:///test-project', displayName: 'Test Project' },31};32}3334setup(() => {35disposables = new DisposableStore();36manager = disposables.add(new AgentHostStateManager(new NullLogService()));37});3839teardown(() => {40disposables.dispose();41});4243ensureNoDisposablesAreLeakedInTestSuite();4445test('createSession creates initial state with lifecycle Creating', () => {46const state = manager.createSession(makeSessionSummary());47assert.strictEqual(state.lifecycle, SessionLifecycle.Creating);48assert.strictEqual(state.turns.length, 0);49assert.strictEqual(state.activeTurn, undefined);50assert.strictEqual(state.summary.resource.toString(), sessionUri.toString());51});5253test('getSnapshot returns undefined for unknown session', () => {54const unknown = URI.from({ scheme: 'copilot', path: '/unknown' }).toString();55const snapshot = manager.getSnapshot(unknown);56assert.strictEqual(snapshot, undefined);57});5859test('getSnapshot returns root snapshot', () => {60const snapshot = manager.getSnapshot(ROOT_STATE_URI);61assert.ok(snapshot);62assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString());63const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record<string, unknown> } };64assert.deepStrictEqual(root.agents, []);65assert.strictEqual(root.activeSessions, 0);66// Host config is seeded with the platform root schema and defaults.67assert.ok(root.config, 'root state should include a seeded config');68});6970test('getSnapshot returns session snapshot after creation', () => {71manager.createSession(makeSessionSummary());72const snapshot = manager.getSnapshot(sessionUri);73assert.ok(snapshot);74assert.strictEqual(snapshot.resource.toString(), sessionUri.toString());75assert.strictEqual((snapshot.state as SessionState).lifecycle, SessionLifecycle.Creating);76});7778test('dispatchServerAction applies action and emits envelope', () => {79manager.createSession(makeSessionSummary());8081const envelopes: ActionEnvelope[] = [];82disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));8384manager.dispatchServerAction({85type: ActionType.SessionReady,86session: sessionUri,87});8889const state = manager.getSessionState(sessionUri);90assert.ok(state);91assert.strictEqual(state.lifecycle, SessionLifecycle.Ready);9293assert.strictEqual(envelopes.length, 1);94assert.strictEqual(envelopes[0].action.type, ActionType.SessionReady);95assert.strictEqual(envelopes[0].serverSeq, 1);96assert.strictEqual(envelopes[0].origin, undefined);97});9899test('serverSeq increments monotonically', () => {100manager.createSession(makeSessionSummary());101102const envelopes: ActionEnvelope[] = [];103disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));104105manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });106manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Updated' });107108assert.strictEqual(envelopes.length, 2);109assert.strictEqual(envelopes[0].serverSeq, 1);110assert.strictEqual(envelopes[1].serverSeq, 2);111assert.ok(envelopes[1].serverSeq > envelopes[0].serverSeq);112});113114test('dispatchClientAction includes origin in envelope', () => {115manager.createSession(makeSessionSummary());116117const envelopes: ActionEnvelope[] = [];118disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));119120const origin = { clientId: 'renderer-1', clientSeq: 42 };121manager.dispatchClientAction(122{ type: ActionType.SessionReady, session: sessionUri },123origin,124);125126assert.strictEqual(envelopes.length, 1);127assert.deepStrictEqual(envelopes[0].origin, origin);128});129130test('removeSession clears state without notification', () => {131manager.createSession(makeSessionSummary());132133const notifications: INotification[] = [];134disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));135136manager.removeSession(sessionUri);137138assert.strictEqual(manager.getSessionState(sessionUri), undefined);139assert.strictEqual(manager.getSnapshot(sessionUri), undefined);140assert.strictEqual(notifications.length, 0);141});142143test('deleteSession clears state and emits notification', () => {144manager.createSession(makeSessionSummary());145146const notifications: INotification[] = [];147disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));148149manager.deleteSession(sessionUri);150151assert.strictEqual(manager.getSessionState(sessionUri), undefined);152assert.strictEqual(manager.getSnapshot(sessionUri), undefined);153assert.strictEqual(notifications.length, 1);154assert.strictEqual(notifications[0].type, NotificationType.SessionRemoved);155});156157test('createSession emits sessionAdded notification', () => {158const notifications: INotification[] = [];159disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));160161manager.createSession(makeSessionSummary());162163assert.strictEqual(notifications.length, 1);164assert.strictEqual(notifications[0].type, NotificationType.SessionAdded);165});166167test('getActiveTurnId returns active turn id after turnStarted', () => {168manager.createSession(makeSessionSummary());169manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });170171assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined);172173manager.dispatchServerAction({174type: ActionType.SessionTurnStarted,175session: sessionUri,176turnId: 'turn-1',177userMessage: { text: 'hello' },178});179180assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1');181});182183test('root state starts with activeSessions: 0', () => {184const snapshot = manager.getSnapshot(ROOT_STATE_URI);185assert.ok(snapshot);186const root = snapshot.state as { agents: unknown[]; activeSessions: number };187assert.deepStrictEqual(root.agents, []);188assert.strictEqual(root.activeSessions, 0);189});190191test('turnStarted dispatches root/activeSessionsChanged with correct count', () => {192manager.createSession(makeSessionSummary());193manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });194195const envelopes: ActionEnvelope[] = [];196disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));197198manager.dispatchServerAction({199type: ActionType.SessionTurnStarted,200session: sessionUri,201turnId: 'turn-1',202userMessage: { text: 'hello' },203});204205const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged);206assert.strictEqual(activeChanged.length, 1);207assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 1);208assert.strictEqual(manager.rootState.activeSessions, 1);209});210211test('turnComplete dispatches root/activeSessionsChanged back to 0', () => {212manager.createSession(makeSessionSummary());213manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });214manager.dispatchServerAction({215type: ActionType.SessionTurnStarted,216session: sessionUri,217turnId: 'turn-1',218userMessage: { text: 'hello' },219});220221const envelopes: ActionEnvelope[] = [];222disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));223224manager.dispatchServerAction({225type: ActionType.SessionTurnComplete,226session: sessionUri,227turnId: 'turn-1',228});229230const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged);231assert.strictEqual(activeChanged.length, 1);232assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0);233assert.strictEqual(manager.rootState.activeSessions, 0);234});235236test('activeSessions reflects concurrent turn count across sessions', () => {237const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString();238manager.createSession(makeSessionSummary(sessionUri));239manager.createSession(makeSessionSummary(session2Uri));240manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });241manager.dispatchServerAction({ type: ActionType.SessionReady, session: session2Uri });242243manager.dispatchServerAction({244type: ActionType.SessionTurnStarted,245session: sessionUri,246turnId: 'turn-1',247userMessage: { text: 'a' },248});249manager.dispatchServerAction({250type: ActionType.SessionTurnStarted,251session: session2Uri,252turnId: 'turn-2',253userMessage: { text: 'b' },254});255assert.strictEqual(manager.rootState.activeSessions, 2);256257manager.dispatchServerAction({258type: ActionType.SessionTurnComplete,259session: sessionUri,260turnId: 'turn-1',261});262assert.strictEqual(manager.rootState.activeSessions, 1);263264manager.dispatchServerAction({265type: ActionType.SessionTurnComplete,266session: session2Uri,267turnId: 'turn-2',268});269assert.strictEqual(manager.rootState.activeSessions, 0);270});271272test('restoreSession creates session in Ready state with pre-populated turns', () => {273const turns = [274{275id: 'turn-1',276userMessage: { text: 'hello' },277responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart],278usage: undefined,279state: TurnState.Complete,280},281];282283const state = manager.restoreSession(makeSessionSummary(), turns);284assert.strictEqual(state.lifecycle, SessionLifecycle.Ready);285assert.strictEqual(state.turns.length, 1);286assert.strictEqual(state.turns[0].userMessage.text, 'hello');287assert.strictEqual((state.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world');288});289290test('restoreSession returns existing state for duplicate session', () => {291manager.createSession(makeSessionSummary());292const existing = manager.getSessionState(sessionUri);293294const state = manager.restoreSession(makeSessionSummary(), []);295assert.strictEqual(state, existing);296});297298test('restoreSession does not emit sessionAdded notification', () => {299const notifications: INotification[] = [];300disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));301302manager.restoreSession(makeSessionSummary(), []);303304assert.strictEqual(notifications.length, 0, 'should not emit notification for restored sessions');305});306307test('emits sessionSummaryChanged when summary changes', () => {308return runWithFakedTimers({ useFakeTimers: true }, async () => {309manager.createSession(makeSessionSummary());310manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });311312const notifications: INotification[] = [];313disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));314315manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title' });316317// Should not fire synchronously (debounced)318assert.strictEqual(notifications.filter(n => n.type === NotificationType.SessionSummaryChanged).length, 0);319320// Advance past debounce321await new Promise(r => setTimeout(r, 150));322323const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);324assert.strictEqual(changed.length, 1);325const notification = changed[0] as SessionSummaryChangedNotification;326assert.strictEqual(notification.session, sessionUri);327assert.strictEqual(notification.changes.title, 'New Title');328assert.strictEqual(notification.changes.status, undefined, 'unchanged fields should be omitted');329});330});331332test('coalesces multiple summary changes into one notification', () => {333return runWithFakedTimers({ useFakeTimers: true }, async () => {334manager.createSession(makeSessionSummary());335manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });336337const notifications: INotification[] = [];338disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));339340manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'First' });341manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Second' });342343await new Promise(r => setTimeout(r, 150));344345const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);346assert.strictEqual(changed.length, 1, 'should coalesce into one notification');347assert.strictEqual((changed[0] as SessionSummaryChangedNotification).changes.title, 'Second');348});349});350351test('does not emit sessionSummaryChanged when summary is unchanged', () => {352return runWithFakedTimers({ useFakeTimers: true }, async () => {353manager.createSession(makeSessionSummary());354manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });355356const notifications: INotification[] = [];357disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));358359// SessionReady changes lifecycle, not summary — so no summary notification360await new Promise(r => setTimeout(r, 150));361362const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);363assert.strictEqual(changed.length, 0);364});365});366367test('does not emit sessionSummaryChanged for deleted session', () => {368return runWithFakedTimers({ useFakeTimers: true }, async () => {369manager.createSession(makeSessionSummary());370manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });371372const notifications: INotification[] = [];373disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));374375manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title' });376manager.deleteSession(sessionUri);377378await new Promise(r => setTimeout(r, 150));379380const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);381assert.strictEqual(changed.length, 0, 'should not emit for deleted sessions');382});383});384});385386suite('Subagent URI helpers', () => {387388ensureNoDisposablesAreLeakedInTestSuite();389390test('buildSubagentSessionUri creates correct URI', () => {391assert.strictEqual(392buildSubagentSessionUri('copilot:/session-1', 'tc-1'),393'copilot:/session-1/subagent/tc-1',394);395});396397test('parseSubagentSessionUri extracts parent and toolCallId', () => {398const parsed = parseSubagentSessionUri('copilot:/session-1/subagent/tc-1');399assert.deepStrictEqual(parsed, {400parentSession: 'copilot:/session-1',401toolCallId: 'tc-1',402});403});404405test('parseSubagentSessionUri returns undefined for non-subagent URIs', () => {406assert.strictEqual(parseSubagentSessionUri('copilot:/session-1'), undefined);407});408409test('isSubagentSession identifies subagent URIs', () => {410assert.strictEqual(isSubagentSession('copilot:/session-1/subagent/tc-1'), true);411assert.strictEqual(isSubagentSession('copilot:/session-1'), false);412});413});414415416