Path: blob/main/src/vs/platform/agentHost/test/common/agentSubscription.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 { ActionType, type ActionEnvelope } from '../../common/state/sessionActions.js';10import { SessionLifecycle, SessionStatus, TerminalClaimKind, type RootState, type SessionState, type TerminalState } from '../../common/state/protocol/state.js';11import { StateComponents } from '../../common/state/sessionState.js';12import { AgentSubscriptionManager, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js';1314// Helpers1516function makeRootState(overrides?: Partial<RootState>): RootState {17return {18agents: [],19activeSessions: 0,20terminals: [],21...overrides,22};23}2425function makeSessionState(sessionUri: string, overrides?: Partial<SessionState>): SessionState {26return {27summary: {28resource: sessionUri,29provider: 'copilot',30title: 'Test',31status: SessionStatus.Idle,32createdAt: 1,33modifiedAt: 1,34project: { uri: 'file:///test-project', displayName: 'Test Project' },35},36lifecycle: SessionLifecycle.Ready,37turns: [],38...overrides,39};40}4142function makeTerminalState(overrides?: Partial<TerminalState>): TerminalState {43return {44title: 'bash',45content: [],46claim: { kind: TerminalClaimKind.Client, clientId: 'c1' },47...overrides,48};49}5051function makeEnvelope(action: ActionEnvelope['action'], serverSeq: number, origin?: ActionEnvelope['origin'], rejectionReason?: string): ActionEnvelope {52return { action, serverSeq, origin, rejectionReason };53}5455const noop = () => { };56const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();57const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: '/term1' }).toString();5859// RootStateSubscription6061suite('RootStateSubscription', () => {6263let disposables: DisposableStore;6465setup(() => {66disposables = new DisposableStore();67});6869teardown(() => {70disposables.dispose();71});7273ensureNoDisposablesAreLeakedInTestSuite();7475test('value is undefined before snapshot', () => {76const sub = disposables.add(new RootStateSubscription('c1', noop));77assert.strictEqual(sub.value, undefined);78assert.strictEqual(sub.verifiedValue, undefined);79});8081test('handleSnapshot sets value and verifiedValue', () => {82const sub = disposables.add(new RootStateSubscription('c1', noop));83const state = makeRootState({ activeSessions: 3 });84sub.handleSnapshot(state, 0);85assert.deepStrictEqual(sub.value, state);86assert.deepStrictEqual(sub.verifiedValue, state);87});8889test('handleSnapshot fires onDidChange', () => {90const sub = disposables.add(new RootStateSubscription('c1', noop));91const fired: RootState[] = [];92disposables.add(sub.onDidChange(s => fired.push(s)));93sub.handleSnapshot(makeRootState(), 0);94assert.strictEqual(fired.length, 1);95});9697test('receiveEnvelope updates state for root actions', () => {98const sub = disposables.add(new RootStateSubscription('c1', noop));99sub.handleSnapshot(makeRootState(), 0);100sub.receiveEnvelope(makeEnvelope(101{ type: ActionType.RootActiveSessionsChanged, activeSessions: 5 },1021,103));104assert.strictEqual((sub.value as RootState).activeSessions, 5);105});106107test('ignores non-root actions', () => {108const sub = disposables.add(new RootStateSubscription('c1', noop));109const state = makeRootState();110sub.handleSnapshot(state, 0);111sub.receiveEnvelope(makeEnvelope(112{ type: ActionType.SessionReady, session: sessionUri },1131,114));115assert.deepStrictEqual(sub.value, state);116});117118test('fires onWillApplyAction and onDidApplyAction around envelope', () => {119const sub = disposables.add(new RootStateSubscription('c1', noop));120sub.handleSnapshot(makeRootState(), 0);121const events: string[] = [];122disposables.add(sub.onWillApplyAction(() => events.push('will')));123disposables.add(sub.onDidApplyAction(() => events.push('did')));124sub.receiveEnvelope(makeEnvelope(125{ type: ActionType.RootActiveSessionsChanged, activeSessions: 1 },1261,127));128assert.deepStrictEqual(events, ['will', 'did']);129});130131test('buffers envelopes before snapshot and replays after', () => {132const sub = disposables.add(new RootStateSubscription('c1', noop));133// Send envelope before snapshot134sub.receiveEnvelope(makeEnvelope(135{ type: ActionType.RootActiveSessionsChanged, activeSessions: 7 },1362,137));138assert.strictEqual(sub.value, undefined);139140// Now apply snapshot with fromSeq=1; envelope at seq 2 should replay141sub.handleSnapshot(makeRootState(), 1);142assert.strictEqual((sub.value! as RootState).activeSessions, 7);143});144145test('buffered envelopes with serverSeq <= fromSeq are discarded', () => {146const sub = disposables.add(new RootStateSubscription('c1', noop));147sub.receiveEnvelope(makeEnvelope(148{ type: ActionType.RootActiveSessionsChanged, activeSessions: 99 },1491,150));151sub.handleSnapshot(makeRootState({ activeSessions: 0 }), 1);152// Envelope at seq 1 should not replay since fromSeq === 1153assert.strictEqual((sub.value as RootState).activeSessions, 0);154});155156test('setError makes value return the error', () => {157const sub = disposables.add(new RootStateSubscription('c1', noop));158sub.handleSnapshot(makeRootState(), 0);159const err = new Error('failed');160sub.setError(err);161assert.strictEqual(sub.value, err);162// verifiedValue should still be the state163assert.ok(sub.verifiedValue);164});165});166167// SessionStateSubscription168169suite('SessionStateSubscription', () => {170171let disposables: DisposableStore;172let seq: number;173174setup(() => {175disposables = new DisposableStore();176seq = 0;177});178179teardown(() => {180disposables.dispose();181});182183ensureNoDisposablesAreLeakedInTestSuite();184185function createSub(uri: string = sessionUri, clientId: string = 'c1'): SessionStateSubscription {186return disposables.add(new SessionStateSubscription(uri, clientId, () => ++seq, noop));187}188189test('value is undefined before snapshot', () => {190const sub = createSub();191assert.strictEqual(sub.value, undefined);192});193194test('handleSnapshot sets value and verifiedValue', () => {195const sub = createSub();196const state = makeSessionState(sessionUri);197sub.handleSnapshot(state, 0);198assert.deepStrictEqual(sub.value, state);199assert.deepStrictEqual(sub.verifiedValue, state);200});201202test('applyOptimistic returns clientSeq and updates value but not verifiedValue', () => {203const sub = createSub();204const state = makeSessionState(sessionUri);205sub.handleSnapshot(state, 0);206207const clientSeq = sub.applyOptimistic({208type: ActionType.SessionTitleChanged,209session: sessionUri,210title: 'Optimistic',211});212213assert.strictEqual(clientSeq, 1);214assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic');215// verifiedValue should remain unchanged216assert.strictEqual(sub.verifiedValue!.summary.title, 'Test');217});218219test('confirmed own action removes pending and updates confirmed', () => {220const sub = createSub();221sub.handleSnapshot(makeSessionState(sessionUri), 0);222223const clientSeq = sub.applyOptimistic({224type: ActionType.SessionTitleChanged,225session: sessionUri,226title: 'Optimistic',227});228229// Server confirms the action230sub.receiveEnvelope(makeEnvelope(231{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Optimistic' },2321,233{ clientId: 'c1', clientSeq },234));235236// After confirmation, verifiedValue should match237assert.strictEqual(sub.verifiedValue!.summary.title, 'Optimistic');238// No pending, value falls through to confirmed239assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic');240});241242test('rejected own action removes pending without updating confirmed', () => {243const sub = createSub();244sub.handleSnapshot(makeSessionState(sessionUri), 0);245246const clientSeq = sub.applyOptimistic({247type: ActionType.SessionTitleChanged,248session: sessionUri,249title: 'Optimistic',250});251252// Server rejects the action253sub.receiveEnvelope(makeEnvelope(254{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Optimistic' },2551,256{ clientId: 'c1', clientSeq },257'denied',258));259260// Confirmed state unchanged261assert.strictEqual(sub.verifiedValue!.summary.title, 'Test');262// No more pending, value = confirmed263assert.strictEqual((sub.value as SessionState).summary.title, 'Test');264});265266test('foreign action updates confirmed and recomputes optimistic', () => {267const sub = createSub();268sub.handleSnapshot(makeSessionState(sessionUri), 0);269270// Local optimistic action271sub.applyOptimistic({272type: ActionType.SessionTitleChanged,273session: sessionUri,274title: 'Local',275});276277// Foreign action arrives278sub.receiveEnvelope(makeEnvelope(279{ type: ActionType.SessionReady, session: sessionUri },2801,281{ clientId: 'other-client', clientSeq: 1 },282));283284// Confirmed state should have SessionReady applied285assert.strictEqual(sub.verifiedValue!.lifecycle, SessionLifecycle.Ready);286// Optimistic should still have 'Local' title on top287assert.strictEqual((sub.value as SessionState).summary.title, 'Local');288});289290test('after all pending cleared, value falls through to verifiedValue', () => {291const sub = createSub();292sub.handleSnapshot(makeSessionState(sessionUri), 0);293294const clientSeq = sub.applyOptimistic({295type: ActionType.SessionTitleChanged,296session: sessionUri,297title: 'Temp',298});299300// Confirm the pending action301sub.receiveEnvelope(makeEnvelope(302{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Temp' },3031,304{ clientId: 'c1', clientSeq },305));306307// value and verifiedValue should be the same object reference308assert.strictEqual(sub.value, sub.verifiedValue);309});310311test('clearPending resets optimistic state', () => {312const sub = createSub();313sub.handleSnapshot(makeSessionState(sessionUri), 0);314315sub.applyOptimistic({316type: ActionType.SessionTitleChanged,317session: sessionUri,318title: 'Pending',319});320321assert.strictEqual((sub.value as SessionState).summary.title, 'Pending');322323sub.clearPending();324325// Should fall back to confirmed326assert.strictEqual((sub.value as SessionState).summary.title, 'Test');327});328329test('ignores actions for different session', () => {330const sub = createSub();331sub.handleSnapshot(makeSessionState(sessionUri), 0);332333sub.receiveEnvelope(makeEnvelope(334{ type: ActionType.SessionTitleChanged, session: 'copilot:///other', title: 'Other' },3351,336));337338assert.strictEqual((sub.value as SessionState).summary.title, 'Test');339});340341test('buffers envelopes before snapshot and replays after', () => {342const sub = createSub();343344sub.receiveEnvelope(makeEnvelope(345{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Buffered' },3462,347));348349assert.strictEqual(sub.value, undefined);350351sub.handleSnapshot(makeSessionState(sessionUri), 1);352353assert.strictEqual((sub.value! as SessionState).summary.title, 'Buffered');354});355356test('fires onDidChange on optimistic apply', () => {357const sub = createSub();358sub.handleSnapshot(makeSessionState(sessionUri), 0);359360const fired: SessionState[] = [];361disposables.add(sub.onDidChange(s => fired.push(s)));362363sub.applyOptimistic({364type: ActionType.SessionTitleChanged,365session: sessionUri,366title: 'Changed',367});368369assert.strictEqual(fired.length, 1);370assert.strictEqual(fired[0].summary.title, 'Changed');371});372});373374// TerminalStateSubscription375376suite('TerminalStateSubscription', () => {377378let disposables: DisposableStore;379380setup(() => {381disposables = new DisposableStore();382});383384teardown(() => {385disposables.dispose();386});387388ensureNoDisposablesAreLeakedInTestSuite();389390test('accepts terminal actions matching its URI', () => {391const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));392sub.handleSnapshot(makeTerminalState(), 0);393394sub.receiveEnvelope(makeEnvelope(395{ type: ActionType.TerminalData, terminal: terminalUri, data: 'hello' },3961,397));398399assert.deepStrictEqual((sub.value as TerminalState).content, [400{ type: 'unclassified', value: 'hello' },401]);402});403404test('ignores terminal actions for other URIs', () => {405const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));406sub.handleSnapshot(makeTerminalState(), 0);407408sub.receiveEnvelope(makeEnvelope(409{ type: ActionType.TerminalData, terminal: 'agenthost-terminal:///other', data: 'nope' },4101,411));412413assert.deepStrictEqual((sub.value as TerminalState).content, []);414});415416test('ignores non-terminal actions', () => {417const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));418sub.handleSnapshot(makeTerminalState(), 0);419420sub.receiveEnvelope(makeEnvelope(421{ type: ActionType.RootActiveSessionsChanged, activeSessions: 5 },4221,423));424425assert.deepStrictEqual((sub.value as TerminalState).content, []);426});427428test('handleSnapshot sets value', () => {429const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));430const state = makeTerminalState({ title: 'zsh' });431sub.handleSnapshot(state, 0);432assert.deepStrictEqual(sub.value, state);433});434});435436// AgentSubscriptionManager437438suite('AgentSubscriptionManager', () => {439440let disposables: DisposableStore;441let seq: number;442let subscribedResources: string[];443let unsubscribedResources: string[];444445setup(() => {446disposables = new DisposableStore();447seq = 0;448subscribedResources = [];449unsubscribedResources = [];450});451452teardown(() => {453disposables.dispose();454});455456ensureNoDisposablesAreLeakedInTestSuite();457458function createManager(): AgentSubscriptionManager {459return disposables.add(new AgentSubscriptionManager(460'c1',461() => ++seq,462noop,463async (resource) => {464subscribedResources.push(resource.toString());465const key = resource.toString();466if (key.startsWith('copilot:')) {467return { resource: key, state: makeSessionState(key), fromSeq: 0 };468}469return { resource: key, state: makeTerminalState(), fromSeq: 0 };470},471(resource) => {472unsubscribedResources.push(resource.toString());473},474));475}476477test('rootState is available immediately', () => {478const mgr = createManager();479assert.ok(mgr.rootState);480assert.strictEqual(mgr.rootState.value, undefined);481});482483test('handleRootSnapshot initializes root state', () => {484const mgr = createManager();485const state = makeRootState({ activeSessions: 2 });486mgr.handleRootSnapshot(state, 0);487assert.deepStrictEqual(mgr.rootState.value, state);488});489490test('getSubscription returns IReference with subscription', async () => {491const mgr = createManager();492const uri = URI.parse(sessionUri);493const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);494495assert.ok(ref.object);496assert.strictEqual(ref.object.value, undefined); // not yet initialized (async)497498// Wait for async subscribe499await new Promise(r => setTimeout(r, 0));500501assert.ok(ref.object.value);502ref.dispose();503});504505test('second call for same resource increments refcount', async () => {506const mgr = createManager();507const uri = URI.parse(sessionUri);508const ref1 = mgr.getSubscription<SessionState>(StateComponents.Session, uri);509const ref2 = mgr.getSubscription<SessionState>(StateComponents.Session, uri);510511await new Promise(r => setTimeout(r, 0));512513// Should be the same subscription object514assert.strictEqual(ref1.object, ref2.object);515516// Disposing one ref should not trigger unsubscribe517ref1.dispose();518assert.strictEqual(unsubscribedResources.length, 0);519520// Disposing the last ref should trigger unsubscribe521ref2.dispose();522assert.strictEqual(unsubscribedResources.length, 1);523});524525test('disposing last ref calls unsubscribe callback', async () => {526const mgr = createManager();527const uri = URI.parse(sessionUri);528const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);529530await new Promise(r => setTimeout(r, 0));531532ref.dispose();533assert.ok(unsubscribedResources.includes(sessionUri));534});535536test('receiveEnvelope routes to root and all active subscriptions', async () => {537const mgr = createManager();538mgr.handleRootSnapshot(makeRootState(), 0);539540const uri = URI.parse(sessionUri);541const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);542await new Promise(r => setTimeout(r, 0));543544// Send a root action545mgr.receiveEnvelope(makeEnvelope(546{ type: ActionType.RootActiveSessionsChanged, activeSessions: 10 },5471,548));549assert.strictEqual((mgr.rootState.value as RootState).activeSessions, 10);550551// Send a session action552mgr.receiveEnvelope(makeEnvelope(553{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Routed' },5542,555));556assert.strictEqual((ref.object.value as SessionState).summary.title, 'Routed');557558ref.dispose();559});560561test('creating session subscription for copilot: URI', async () => {562const mgr = createManager();563const mySessionUri = URI.from({ scheme: 'copilot', path: '/my-session' });564const ref = mgr.getSubscription<SessionState>(StateComponents.Session, mySessionUri);565await new Promise(r => setTimeout(r, 0));566567assert.ok(ref.object.value);568assert.ok(subscribedResources.includes(mySessionUri.toString()));569570ref.dispose();571});572573test('creating terminal subscription for terminal URI', async () => {574const mgr = createManager();575const uri = URI.parse(terminalUri);576const ref = mgr.getSubscription<TerminalState>(StateComponents.Terminal, uri);577await new Promise(r => setTimeout(r, 0));578579assert.ok(ref.object.value);580assert.ok(subscribedResources.includes(terminalUri));581582ref.dispose();583});584585test('dispatchOptimistic applies to matching session subscription', async () => {586const mgr = createManager();587const uri = URI.parse(sessionUri);588const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);589await new Promise(r => setTimeout(r, 0));590591const clientSeq = mgr.dispatchOptimistic({592type: ActionType.SessionTitleChanged,593session: sessionUri,594title: 'Dispatched',595});596597assert.ok(clientSeq > 0);598assert.strictEqual((ref.object.value as SessionState).summary.title, 'Dispatched');599// verifiedValue unchanged600assert.strictEqual(ref.object.verifiedValue!.summary.title, 'Test');601602ref.dispose();603});604605test('dispose clears all subscriptions and calls unsubscribe for each', async () => {606const mgr = createManager();607608const ref1 = mgr.getSubscription<SessionState>(StateComponents.Session, URI.parse(sessionUri));609const ref2 = mgr.getSubscription<TerminalState>(StateComponents.Terminal, URI.parse(terminalUri));610await new Promise(r => setTimeout(r, 0));611612// Remove the manager from disposables so we can dispose it manually613// without double-dispose614disposables.delete(mgr);615mgr.dispose();616617assert.ok(unsubscribedResources.includes(sessionUri));618assert.ok(unsubscribedResources.includes(terminalUri));619620// Clean up refs (already disposed with manager, but safe to call)621ref1.dispose();622ref2.dispose();623});624625test('getSubscriptionUnmanaged returns undefined when no subscription exists', () => {626const mgr = createManager();627const result = mgr.getSubscriptionUnmanaged<SessionState>(URI.parse('copilot:/nonexistent'));628assert.strictEqual(result, undefined);629});630631test('getSubscriptionUnmanaged returns existing subscription without affecting refcount', async () => {632const mgr = createManager();633const uri = URI.parse(sessionUri);634635// Create a subscription via getSubscription636const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);637await new Promise(r => setTimeout(r, 0));638639// Get it unmanaged640const unmanaged = mgr.getSubscriptionUnmanaged<SessionState>(uri);641assert.ok(unmanaged);642assert.strictEqual(unmanaged, ref.object);643644// Dispose the ref. Subscription should be released (refcount was 1)645ref.dispose();646647// Now unmanaged should return undefined since it was released648const after = mgr.getSubscriptionUnmanaged<SessionState>(uri);649assert.strictEqual(after, undefined);650});651});652653654