/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; import { withAgentCustomizationSettings } from '../../common/agentCustomizationSettings.js'; suite('AgentHostStateManager', () => { let disposables: DisposableStore; let manager: AgentHostStateManager; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); const sessionChatUri = buildDefaultChatUri(sessionUri); function makeSessionSummary(resource?: string): SessionSummary { return { resource: resource ?? sessionUri, provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, }; } setup(() => { disposables = new DisposableStore(); manager = disposables.add(new AgentHostStateManager(new NullLogService())); }); teardown(() => { disposables.dispose(); }); ensureNoDisposablesAreLeakedInTestSuite(); test('createSession creates initial state with lifecycle Creating', () => { const state = manager.createSession(makeSessionSummary()); assert.strictEqual(state.lifecycle, SessionLifecycle.Creating); const chatState = manager.getDefaultChatState(sessionUri); assert.strictEqual(chatState?.turns.length, 0); assert.strictEqual(chatState?.activeTurn, undefined); assert.strictEqual(manager.getSessionSummary(sessionUri)?.resource.toString(), sessionUri.toString()); }); test('onDidChangeSessionWorkingDirectories fires only when the working-directory set changes', () => { manager.createSession(makeSessionSummary()); const fired: string[] = []; disposables.add(manager.onDidChangeSessionWorkingDirectories(({ session }) => fired.push(session))); // Adding a root changes the set -> fires. manager.dispatchServerAction(sessionUri, { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///a' }); // Re-adding the same root is a reducer no-op -> does not fire. manager.dispatchServerAction(sessionUri, { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///a' }); // Adding a second root changes the set -> fires. manager.dispatchServerAction(sessionUri, { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///b' }); // Removing a root changes the set -> fires. manager.dispatchServerAction(sessionUri, { type: ActionType.SessionWorkingDirectoryRemoved, directory: 'file:///b' }); assert.deepStrictEqual(fired, [sessionUri, sessionUri, sessionUri]); }); test('getSnapshot returns undefined for unknown session', () => { const unknown = URI.from({ scheme: 'copilot', path: '/unknown' }).toString(); const snapshot = manager.getSnapshot(unknown); assert.strictEqual(snapshot, undefined); }); test('getSnapshot returns root snapshot', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString()); const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record } }; assert.deepStrictEqual(root.agents, []); assert.strictEqual(root.activeSessions, 0); // Host config is seeded with the platform root schema and defaults. assert.ok(root.config, 'root state should include a seeded config'); }); test('seeds host build info into root state _meta when provided', () => { const buildInfo = { version: '1.96.0', commit: 'abc1234', date: '2024-01-02T03:04:05Z', quality: 'insider' }; const localManager = disposables.add(new AgentHostStateManager(new NullLogService(), { hostBuildInfo: buildInfo })); assert.deepStrictEqual(readHostBuildInfo(localManager.rootState), buildInfo); }); test('omits host build info from root state _meta when not provided', () => { assert.strictEqual(readHostBuildInfo(manager.rootState), undefined); }); test('getSnapshot returns session snapshot after creation', () => { manager.createSession(makeSessionSummary()); const snapshot = manager.getSnapshot(sessionUri); assert.ok(snapshot); assert.strictEqual(snapshot.resource.toString(), sessionUri.toString()); assert.strictEqual((snapshot.state as SessionState).lifecycle, SessionLifecycle.Creating); }); test('dispatchServerAction applies action and emits envelope', () => { manager.createSession(makeSessionSummary()); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const state = manager.getSessionState(sessionUri); assert.ok(state); assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); assert.strictEqual(envelopes.length, 1); assert.strictEqual(envelopes[0].action.type, ActionType.SessionReady); assert.strictEqual(envelopes[0].serverSeq, 1); assert.strictEqual(envelopes[0].origin, undefined); }); test('emits session title changes and suppresses no-op assignments', () => { manager.createSession(makeSessionSummary()); const changes: Array<{ session: string; title: string }> = []; disposables.add(manager.onDidChangeSessionTitle(e => changes.push(e))); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Updated' }); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Updated' }); assert.deepStrictEqual(changes, [{ session: sessionUri, title: 'Updated' }]); }); test('serverSeq increments monotonically', () => { manager.createSession(makeSessionSummary()); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Updated' }); assert.strictEqual(envelopes.length, 2); assert.strictEqual(envelopes[0].serverSeq, 1); assert.strictEqual(envelopes[1].serverSeq, 2); assert.ok(envelopes[1].serverSeq > envelopes[0].serverSeq); }); test('dispatchClientAction includes origin in envelope', () => { manager.createSession(makeSessionSummary()); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const origin = { clientId: 'renderer-1', clientSeq: 42 }; manager.dispatchClientAction(sessionUri, { type: ActionType.SessionReady, }, origin, ); assert.strictEqual(envelopes.length, 1); assert.deepStrictEqual(envelopes[0].origin, origin); }); test('root action that does not change state is not emitted', () => { const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); // First dispatch: introduces a new value, should emit. manager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { 'my.setting': 'value-a' }, }); assert.strictEqual(envelopes.length, 1); assert.strictEqual(manager.serverSeq, 1); // Second dispatch with the same value: should be deduped and not emit. manager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { 'my.setting': 'value-a' }, }); assert.strictEqual(envelopes.length, 1); assert.strictEqual(manager.serverSeq, 1, 'serverSeq must not advance on a no-op'); // Third dispatch with a deeply-equal but newly allocated object value: // should also be deduped. manager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { 'my.nested': { allow: ['x'], deny: [] } }, }); assert.strictEqual(envelopes.length, 2); assert.strictEqual(manager.serverSeq, 2); manager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { 'my.nested': { allow: ['x'], deny: [] } }, }); assert.strictEqual(envelopes.length, 2); assert.strictEqual(manager.serverSeq, 2, 'serverSeq must not advance on a no-op'); // Real change still emits. manager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { 'my.setting': 'value-b' }, }); assert.strictEqual(envelopes.length, 3); assert.strictEqual(manager.serverSeq, 3); }); test('root config replacement preserves provider-backed values', () => { const rootState = manager.rootState; assert.ok(rootState.config); rootState.config.values['codex.personality'] = 'friendly'; rootState._meta = withAgentCustomizationSettings(rootState, [{ provider: 'codex', title: 'Codex', description: 'Codex settings', settings: [{ key: 'codex.personality', group: 'Personalization' }], }]); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchClientAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { someProviderSetting: 'openai' }, replace: true, }, { clientId: 'renderer-1', clientSeq: 1 }); assert.deepStrictEqual(manager.rootState.config?.values, { someProviderSetting: 'openai', 'codex.personality': 'friendly', }); assert.deepStrictEqual(envelopes[0].action, { type: ActionType.RootConfigChanged, config: { someProviderSetting: 'openai', 'codex.personality': 'friendly', }, replace: true, }); }); test('removeSession clears state without notification', () => { manager.createSession(makeSessionSummary()); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.removeSession(sessionUri); assert.strictEqual(manager.getSessionState(sessionUri), undefined); assert.strictEqual(manager.getSnapshot(sessionUri), undefined); assert.strictEqual(notifications.length, 0); }); test('deleteSession clears state and emits notification', () => { manager.createSession(makeSessionSummary()); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.deleteSession(sessionUri); assert.strictEqual(manager.getSessionState(sessionUri), undefined); assert.strictEqual(manager.getSnapshot(sessionUri), undefined); assert.strictEqual(notifications.length, 1); assert.strictEqual(notifications[0].type, NotificationType.SessionRemoved); }); test('createSession emits sessionAdded notification', () => { const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.createSession(makeSessionSummary()); assert.strictEqual(notifications.length, 1); assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); }); test('default chat inherits the session working directory resolved at materialization', () => { // A deferred (provisional) session is created with a pre-materialization // working directory; materialization later resolves it to a different // one (e.g. a git worktree) via markSessionPersisted. The default chat // has no per-chat working-directory override, so getSessionState must // project the RESOLVED session working directory, never the stale // create-time value that was seeded onto the default chat. manager.createSession({ ...makeSessionSummary(), workingDirectories: ['file:///provisional'] }, { emitNotification: false }); manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectories: ['file:///resolved-worktree'] }); assert.deepStrictEqual({ session: manager.getSessionState(sessionUri)?.workingDirectories?.[0], defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectories?.[0], }, { session: 'file:///resolved-worktree', defaultChat: 'file:///resolved-worktree', }); }); test('getActiveTurnId returns active turn id after turnStarted', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1'); }); test('root state starts with activeSessions: 0', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); const root = snapshot.state as { agents: unknown[]; activeSessions: number }; assert.deepStrictEqual(root.agents, []); assert.strictEqual(root.activeSessions, 0); }); test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 1); assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 1); assert.strictEqual(manager.rootState.activeSessions, 1); }); test('turnComplete dispatches root/activeSessionsChanged back to 0', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000, }); const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 1); assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0); assert.strictEqual(manager.rootState.activeSessions, 0); }); test('activeSessions reflects concurrent turn count across sessions', () => { const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString(); manager.createSession(makeSessionSummary(sessionUri)); manager.createSession(makeSessionSummary(session2Uri)); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(session2Uri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 2); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 1); manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnComplete, turnId: 'turn-2', duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 0); }); test('removeSession decrements active sessions when an active turn is stranded', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); // Evict the session while a turn is still active. The active-sessions // count must drop to zero so that the server lifetime tracker (driving // `--enable-remote-auto-shutdown`) releases its hold. manager.removeSession(sessionUri); assert.strictEqual(manager.rootState.activeSessions, 0); const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 1); assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0); }); test('removeSession does not dispatch active-sessions change when no turn is active', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.removeSession(sessionUri); const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 0); }); test('stale ChatTurnComplete (wrong turnId) does not decrement active sessions', () => { // The reducer's `endTurn` no-ops when the action's turnId doesn't match // `state.activeTurn.id`. The active-session count must follow suit so // the lifetime tracker doesn't release its hold while a turn is still // genuinely running. manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 1); assert.strictEqual(manager.hasActiveSessions, true); }); test('concurrent ChatTurnStarted on same session keeps active count at one', () => { // The reducer unconditionally overwrites `activeTurn`, so two starts // without an intervening complete still represent a single active turn // from state's point of view. The count must mirror that. manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-2', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-2', duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 0); assert.strictEqual(manager.hasActiveSessions, false); }); test('active turn event follows reducer-derived active state transitions', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const events: Array<{ session: string; active: boolean }> = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', duration: 1000, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, error: { errorType: 'failed', message: 'boom' }, }); assert.deepStrictEqual(events, [ { session: sessionUri, active: true }, { session: sessionUri, active: false }, ]); }); test('active turn event covers cancellation and removal while active', () => { const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString(); manager.createSession(makeSessionSummary(sessionUri)); manager.createSession(makeSessionSummary(session2Uri)); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); manager.dispatchServerAction(session2Uri, { type: ActionType.SessionReady, }); const events: Array<{ session: string; active: boolean }> = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000, }); manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hi', origin: { kind: MessageKind.User } }, }); manager.removeSession(session2Uri); assert.deepStrictEqual(events, [ { session: sessionUri, active: true }, { session: sessionUri, active: false }, { session: session2Uri, active: true }, { session: session2Uri, active: false }, ]); }); test('restoreSession creates session in Ready state with pre-populated turns', () => { const turns = [ { id: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart], usage: undefined, state: TurnState.Complete, }, ]; const state = manager.restoreSession(makeSessionSummary(), turns); assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); const chatState = manager.getDefaultChatState(sessionUri); assert.strictEqual(chatState?.turns.length, 1); assert.strictEqual(chatState?.turns[0].message.text, 'hello'); assert.strictEqual((chatState?.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world'); }); test('restoreSession returns existing state for duplicate session', () => { const existing = manager.createSession(makeSessionSummary()); const state = manager.restoreSession(makeSessionSummary(), []); assert.strictEqual(state, existing); }); test('restoreSession does not emit sessionAdded notification', () => { const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.restoreSession(makeSessionSummary(), []); assert.strictEqual(notifications.length, 0, 'should not emit notification for restored sessions'); }); test('restoreSession emits sessionSummaryChanged clearing the adoptable marker for a previously surfaced session', () => { // A surfaced adoptable-legacy session is announced with the marker; adopting // it via restoreSession must notify clients the marker was cleared so they // update the entry in place instead of dropping the just-opened session. manager.announceSurfacedSession({ ...makeSessionSummary(), _meta: withSessionEhcliAdoptable(undefined) }); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.restoreSession(makeSessionSummary(), []); const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged) as SessionSummaryChangedParams[]; assert.strictEqual(changed.length, 1); assert.strictEqual(changed[0].session, sessionUri); assert.strictEqual(Object.prototype.hasOwnProperty.call(changed[0].changes, '_meta'), true); assert.strictEqual(readSessionEhcliAdoptable(changed[0].changes._meta), false); }); suite('unused-draft tracking', () => { test('reports draft status by origin, addressable by session or chat URI', () => { const restoredUri = URI.from({ scheme: 'copilot', path: '/restored-session' }).toString(); manager.createSession(makeSessionSummary()); manager.restoreSession(makeSessionSummary(restoredUri), []); assert.deepStrictEqual({ created: manager.isUnusedDraft(sessionUri), createdViaChatUri: manager.isUnusedDraft(sessionChatUri), restored: manager.isUnusedDraft(restoredUri), restoredViaChatUri: manager.isUnusedDraft(buildDefaultChatUri(restoredUri)), unknown: manager.isUnusedDraft(URI.from({ scheme: 'copilot', path: '/nope' }).toString()), }, { created: true, createdViaChatUri: true, restored: false, restoredViaChatUri: false, unknown: undefined, }); }); test('a restored session that was first created is no longer a draft', () => { // `restoreSession` short-circuits when the session is already in state. manager.createSession(makeSessionSummary()); manager.restoreSession(makeSessionSummary(), []); assert.strictEqual(manager.isUnusedDraft(sessionUri), true); }); test('draft status is retired by a turn and does not come back on truncate', () => { manager.createSession(makeSessionSummary()); const observed: (boolean | undefined)[] = [manager.isUnusedDraft(sessionUri)]; manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); observed.push(manager.isUnusedDraft(sessionUri)); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1 }); observed.push(manager.isUnusedDraft(sessionUri)); // Truncate-to-zero empties the chat but must not resurrect the draft. manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTruncated }); observed.push(manager.isUnusedDraft(sessionUri)); assert.deepStrictEqual({ observed, turnsAfterTruncate: manager.getDefaultChatState(sessionUri)?.turns.length, }, { observed: [true, false, false, false], turnsAfterTruncate: 0, }); }); test('seeding turns for a fork retires draft status', () => { manager.createSession(makeSessionSummary()); manager.seedDefaultChatTurns(sessionUri, [{ id: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete, }]); assert.strictEqual(manager.isUnusedDraft(sessionUri), false); }); }); test('emits sessionSummaryChanged when summary changes', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'New Title' }); // Should not fire synchronously (debounced) assert.strictEqual(notifications.filter(n => n.type === NotificationType.SessionSummaryChanged).length, 0); // Advance past debounce await new Promise(r => setTimeout(r, 150)); const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); assert.strictEqual(changed.length, 1); const notification = changed[0] as SessionSummaryChangedParams; assert.strictEqual(notification.session, sessionUri); assert.strictEqual(notification.changes.title, 'New Title'); assert.strictEqual(notification.changes.status, undefined, 'unchanged fields should be omitted'); }); }); test('coalesces multiple summary changes into one notification', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'First' }); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Second' }); await new Promise(r => setTimeout(r, 150)); const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); assert.strictEqual(changed.length, 1, 'should coalesce into one notification'); assert.strictEqual((changed[0] as SessionSummaryChangedParams).changes.title, 'Second'); }); }); test('does not emit sessionSummaryChanged when summary is unchanged', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); // SessionReady changes lifecycle, not summary — so no summary notification await new Promise(r => setTimeout(r, 150)); const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); assert.strictEqual(changed.length, 0); }); }); test('does not emit sessionSummaryChanged for deleted session', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'New Title' }); manager.deleteSession(sessionUri); await new Promise(r => setTimeout(r, 150)); const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); assert.strictEqual(changed.length, 0, 'should not emit for deleted sessions'); }); }); test('removeSession flushes pending status=Idle notification before eviction', () => { // Regression: when _maybeEvictIdleSession calls removeSession within the // 100 ms scheduler window after a turn completes, the client must still // receive a SessionSummaryChanged with status=Idle so the spinner clears. // // The key precondition is that _lastNotifiedSummaries already has // status=InProgress (the scheduler must have fired after TurnStarted so // the client knows the session is busy). Then TurnComplete flips the // summary back to Idle and schedules another flush. If removeSession // races with that 100 ms window the flush must happen synchronously. return runWithFakedTimers({ useFakeTimers: true }, async () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); // Start a turn → status becomes InProgress. manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); // Let the scheduler fire so _lastNotifiedSummaries now has status=InProgress. await new Promise(r => setTimeout(r, 150)); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); // Turn completes — status flips back to Idle. This schedules a summary // flush 100 ms later but we will call removeSession before it fires. manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000, }); // Simulate eviction within the 100 ms debounce window. manager.removeSession(sessionUri); const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged) as SessionSummaryChangedParams[]; assert.strictEqual(changed.length, 1, 'should emit SessionSummaryChanged synchronously in removeSession'); assert.strictEqual(changed[0].changes.status, SessionStatus.Idle, 'status should be Idle so the spinner clears'); }); }); test('disposeChangeset emits ChangesetCleared and removes the state', () => { manager.createSession(makeSessionSummary()); const changeset = manager.registerChangeset(buildSessionChangesetUri(sessionUri)); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.disposeChangeset(changeset); const cleared = envelopes.filter(e => e.action.type === ActionType.ChangesetCleared); assert.strictEqual(cleared.length, 1, 'expected exactly one cleared envelope'); assert.strictEqual(cleared[0].channel, changeset); assert.strictEqual(manager.getChangesetState(changeset), undefined, 'state should be deleted'); }); test('producer-emitted ChangesetCleared keeps the state alive (recompute path)', () => { manager.createSession(makeSessionSummary()); const changeset = manager.registerChangeset(buildSessionChangesetUri(sessionUri)); manager.dispatchServerAction(changeset, { type: ActionType.ChangesetFileSet, file: { id: 'file:///a.ts', edit: { after: { uri: 'file:///a.ts', content: { uri: 'file:///a.ts' } }, diff: { added: 1, removed: 0 } }, }, }); assert.strictEqual(manager.getChangesetState(changeset)?.files.length, 1); manager.dispatchServerAction(changeset, { type: ActionType.ChangesetCleared, }); const after = manager.getChangesetState(changeset); assert.ok(after, 'state should still exist'); assert.strictEqual(after.files.length, 0, 'files should be cleared'); }); test('removeSession does NOT dispose per-session changesets (LRU eviction must not clear list-view chip)', () => { // Regression: _maybeEvictIdleSession calls removeSession to drop an // idle session from the in-memory cache. The Agents Window list view // keeps a per-row changeset subscription open to render the diff // chip, so cascading disposeSessionChangesets here would emit a // ChangesetCleared envelope that empties the chip while the row is // still on screen. The chip then visibly vanishes and only reappears // when the user clicks back into the session and the list re-seeds // the changeset. manager.createSession(makeSessionSummary()); const changeset = manager.registerChangeset(buildSessionChangesetUri(sessionUri)); manager.dispatchServerAction(changeset, { type: ActionType.ChangesetFileSet, file: { id: 'file:///a.ts', edit: { after: { uri: 'file:///a.ts', content: { uri: 'file:///a.ts' } }, diff: { added: 1, removed: 0 } }, }, }); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.removeSession(sessionUri); const cleared = envelopes.filter(e => e.action.type === ActionType.ChangesetCleared); assert.strictEqual(cleared.length, 0, 'removeSession must not emit ChangesetCleared'); assert.strictEqual(manager.getChangesetState(changeset)?.files.length, 1, 'changeset state should survive eviction'); }); test('deleteSession disposes per-session changesets before emitting SessionRemoved', () => { manager.createSession(makeSessionSummary()); const changeset = manager.registerChangeset(buildSessionChangesetUri(sessionUri)); manager.dispatchServerAction(changeset, { type: ActionType.ChangesetFileSet, file: { id: 'file:///a.ts', edit: { after: { uri: 'file:///a.ts', content: { uri: 'file:///a.ts' } }, diff: { added: 1, removed: 0 } }, }, }); const envelopes: ActionEnvelope[] = []; const notifications: INotification[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); manager.deleteSession(sessionUri); const cleared = envelopes.filter(e => e.action.type === ActionType.ChangesetCleared); const removed = notifications.filter(n => n.type === NotificationType.SessionRemoved); assert.strictEqual(cleared.length, 1, 'deleteSession should emit ChangesetCleared'); assert.strictEqual(removed.length, 1, 'deleteSession should emit SessionRemoved'); assert.strictEqual(manager.getChangesetState(changeset), undefined, 'changeset state should be gone after delete'); }); test('unknown changeset action is ignored without emitting an envelope', () => { manager.createSession(makeSessionSummary()); const changesetUri = `${sessionUri}/changeset/missing`; const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const seqBefore = manager.serverSeq; manager.dispatchServerAction(changesetUri, { type: ActionType.ChangesetFileSet, file: { id: 'file:///x.ts', edit: { after: { uri: 'file:///x.ts', content: { uri: 'file:///x.ts' } }, diff: { added: 1, removed: 0 } } }, }); assert.deepStrictEqual( { envelopeCount: envelopes.length, seqAdvanced: manager.serverSeq - seqBefore, changesetState: manager.getChangesetState(changesetUri), }, { envelopeCount: 0, seqAdvanced: 0, changesetState: undefined, }, ); // Sanity: registering the same URI and re-dispatching produces an // envelope and advances the seq, proving the early return doesn't // break valid changesets. const registered = manager.registerChangeset(buildChangesetUri(sessionUri, 'missing')); assert.strictEqual(registered, changesetUri); manager.dispatchServerAction(changesetUri, { type: ActionType.ChangesetFileSet, file: { id: 'file:///x.ts', edit: { after: { uri: 'file:///x.ts', content: { uri: 'file:///x.ts' } }, diff: { added: 1, removed: 0 } } }, }); assert.strictEqual(envelopes.length, 1, 'registered changeset action should emit an envelope'); assert.strictEqual(manager.serverSeq - seqBefore, 1, 'serverSeq should advance for registered changeset action'); }); suite('multi-chat catalog', () => { const peerChat = buildChatUri(sessionUri, 'peer-1'); test('addChat grows the catalog, creates chat state and emits SessionChatAdded', () => { manager.createSession(makeSessionSummary()); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const summary = manager.addChat(sessionUri, peerChat, { title: 'Peer' }); assert.deepStrictEqual( { addedTitle: summary?.title, chatResources: manager.getSessionState(sessionUri)?.chats.map(c => c.resource.toString()).sort(), peerTurns: manager.getChatState(peerChat)?.turns.length, chatAddedEvents: envelopes.filter(e => e.action.type === ActionType.SessionChatAdded).length, }, { addedTitle: 'Peer', chatResources: [buildDefaultChatUri(sessionUri), peerChat].sort(), peerTurns: 0, chatAddedEvents: 1, }, ); }); test('catalog-only SessionChatAdded does not create chat state', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionChatAdded, summary: { resource: peerChat, title: 'Catalog only', status: SessionStatus.Idle, modifiedAt: '2025-01-01T00:00:00.000Z', }, }); assert.deepStrictEqual({ catalogTitle: manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === peerChat)?.title, chatState: manager.getChatState(peerChat), }, { catalogTitle: 'Catalog only', chatState: undefined, }); }); test('removeChat shrinks the catalog and refuses the default chat', () => { manager.createSession(makeSessionSummary()); manager.addChat(sessionUri, peerChat); manager.removeChat(sessionUri, buildDefaultChatUri(sessionUri)); const afterDefaultRemoval = manager.getSessionState(sessionUri)?.chats.length; manager.removeChat(sessionUri, peerChat); assert.deepStrictEqual( { afterDefaultRemoval, afterPeerRemoval: manager.getSessionState(sessionUri)?.chats.map(c => c.resource.toString()), peerState: manager.getChatState(peerChat), }, { afterDefaultRemoval: 2, afterPeerRemoval: [buildDefaultChatUri(sessionUri)], peerState: undefined, }, ); }); test('session title and default chat title stay independent once multi-chat', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); // Becoming multi-chat snapshots the session title onto the default chat // so it stops inheriting the session title. manager.addChat(sessionUri, peerChat); const afterAdd = manager.getSessionState(sessionUri)?.chats.find(c => c.resource === defaultChat)?.title; // Rename each independently. manager.updateChatTitle(sessionUri, defaultChat, 'Chat A'); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Session B' }); const state = manager.getSessionState(sessionUri); assert.deepStrictEqual( { afterAdd, sessionTitle: state?.title, defaultChatTitle: state?.chats.find(c => c.resource === defaultChat)?.title, }, { afterAdd: 'Test', sessionTitle: 'Session B', defaultChatTitle: 'Chat A', }, ); }); test('addChat is idempotent for an existing chat URI', () => { manager.createSession(makeSessionSummary()); const first = manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const second = manager.addChat(sessionUri, peerChat, { title: 'Ignored' }); assert.deepStrictEqual( { sameSummary: first === second, title: second?.title, chatCount: manager.getSessionState(sessionUri)?.chats.length, chatAddedEvents: envelopes.filter(e => e.action.type === ActionType.SessionChatAdded).length, }, { sameSummary: true, title: 'Peer', chatCount: 2, chatAddedEvents: 0, }, ); }); test('addChat for an unknown session is a no-op', () => { const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const summary = manager.addChat('copilot:/missing', peerChat); assert.deepStrictEqual( { summary, events: envelopes.length, }, { summary: undefined, events: 0, }, ); }); test('addChat supports multiple peers and only snapshots the default title once', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); const peerChat2 = buildChatUri(sessionUri, 'peer-2'); manager.addChat(sessionUri, peerChat); // Rename the default chat away from the snapshotted session title. manager.updateChatTitle(sessionUri, defaultChat, 'Renamed Default'); // Adding a second peer must not re-snapshot / clobber the default title. manager.addChat(sessionUri, peerChat2); const state = manager.getSessionState(sessionUri); assert.deepStrictEqual( { chatResources: state?.chats.map(c => c.resource.toString()).sort(), defaultChatTitle: state?.chats.find(c => c.resource === defaultChat)?.title, }, { chatResources: [defaultChat, peerChat, peerChat2].sort(), defaultChatTitle: 'Renamed Default', }, ); }); test('updateChatTitle on a peer leaves the session and default titles untouched', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); manager.updateChatTitle(sessionUri, peerChat, 'Peer Renamed'); const state = manager.getSessionState(sessionUri); assert.deepStrictEqual( { sessionTitle: state?.title, defaultChatTitle: state?.chats.find(c => c.resource === defaultChat)?.title, peerTitle: state?.chats.find(c => c.resource === peerChat)?.title, peerStateTitle: manager.getChatState(peerChat)?.title, }, { sessionTitle: 'Test', defaultChatTitle: 'Test', peerTitle: 'Peer Renamed', peerStateTitle: 'Peer Renamed', }, ); }); test('removeChat of an unknown chat is a no-op', () => { manager.createSession(makeSessionSummary()); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.removeChat(sessionUri, buildChatUri(sessionUri, 'never-added')); assert.deepStrictEqual( { chatCount: manager.getSessionState(sessionUri)?.chats.length, removedEvents: envelopes.filter(e => e.action.type === ActionType.SessionChatRemoved).length, }, { chatCount: 1, removedEvents: 0, }, ); }); test('removeChat emits SessionChatRemoved for a peer', () => { manager.createSession(makeSessionSummary()); manager.addChat(sessionUri, peerChat); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.removeChat(sessionUri, peerChat); assert.deepStrictEqual( { removed: envelopes .filter(e => e.action.type === ActionType.SessionChatRemoved) .map(e => (e.action as { chat: string }).chat), chatState: manager.getChatState(peerChat), }, { removed: [peerChat], chatState: undefined, }, ); }); test('hasActiveTurn reflects a chat turn lifecycle', () => { manager.createSession(makeSessionSummary()); const idle = manager.hasActiveTurn(sessionUri); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); const afterStart = manager.hasActiveTurn(sessionUri); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000, }); const afterComplete = manager.hasActiveTurn(sessionUri); assert.deepStrictEqual( { idle, afterStart, afterComplete }, { idle: false, afterStart: true, afterComplete: false }, ); }); test('active-turn event observers see the updated active-turn state', () => { // Operations are recomputed synchronously from the active-turn event, // so hasActiveTurn must already reflect the lifecycle change when that // event fires — otherwise operations would stay disabled at turn end. manager.createSession(makeSessionSummary()); const observed: { active: boolean; hasActiveTurn: boolean }[] = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => { observed.push({ active: e.active, hasActiveTurn: manager.hasActiveTurn(sessionUri) }); })); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000, }); assert.deepStrictEqual(observed, [ { active: true, hasActiveTurn: true }, { active: false, hasActiveTurn: false }, ]); }); test('hasActiveTurn stays true until all concurrent chat turns finish', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const idle = manager.hasActiveTurn(sessionUri); // Start a turn on the default chat, then a concurrent turn on the peer. manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-default', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); const afterDefaultStart = manager.hasActiveTurn(sessionUri); manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const afterBothStart = manager.hasActiveTurn(sessionUri); // Completing the default chat must NOT clear while the peer streams. manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-default', duration: 1000, }); const afterDefaultComplete = manager.hasActiveTurn(sessionUri); // Only once the peer finishes too does the session go idle. manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', duration: 1000, }); const afterBothComplete = manager.hasActiveTurn(sessionUri); assert.deepStrictEqual( { idle, afterDefaultStart, afterBothStart, afterDefaultComplete, afterBothComplete }, { idle: false, afterDefaultStart: true, afterBothStart: true, afterDefaultComplete: true, afterBothComplete: false }, ); }); test('a running peer chat promotes the session summary to InProgress while the default chat is idle', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const idle = manager.getSessionState(sessionUri)?.status; // Only the peer (sub) chat starts streaming; the default chat stays idle. manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const whilePeerRuns = manager.getSessionState(sessionUri)?.status; // Once the peer finishes the session falls back to idle. manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', duration: 1000, }); const afterPeerComplete = manager.getSessionState(sessionUri)?.status; assert.deepStrictEqual( { idleHasInProgress: ((idle ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, whilePeerRunsHasInProgress: ((whilePeerRuns ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, afterPeerCompleteHasInProgress: ((afterPeerComplete ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, defaultChatStillIdle: ((manager.getChatState(defaultChat)?.status ?? SessionStatus.Idle) & SessionStatus.InProgress) === 0, }, { idleHasInProgress: false, whilePeerRunsHasInProgress: true, afterPeerCompleteHasInProgress: false, defaultChatStillIdle: true, }, ); }); test('a running peer chat forwards its own status to the session catalog so its tab can show progress', () => { manager.createSession(makeSessionSummary()); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const peerCatalogStatus = () => manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.status ?? SessionStatus.Idle; const chatUpdatesForPeer = () => envelopes.filter(e => e.action.type === ActionType.SessionChatUpdated && (e.action as { chat: string }).chat === peerChat).length; const idleCatalog = peerCatalogStatus(); manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const runningCatalog = peerCatalogStatus(); const updatesAfterStart = chatUpdatesForPeer(); manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', duration: 1000, }); assert.deepStrictEqual( { idleCatalogInProgress: (idleCatalog & SessionStatus.InProgress) === SessionStatus.InProgress, runningCatalogInProgress: (runningCatalog & SessionStatus.InProgress) === SessionStatus.InProgress, finalCatalogInProgress: (peerCatalogStatus() & SessionStatus.InProgress) === SessionStatus.InProgress, emittedChatUpdateOnStart: updatesAfterStart >= 1, }, { idleCatalogInProgress: false, runningCatalogInProgress: true, finalCatalogInProgress: false, emittedChatUpdateOnStart: true, }, ); }); test('active-turn event and active-session count flip once per session across concurrent chats', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const turnEvents: boolean[] = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => turnEvents.push(e.active))); manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-default', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const activeWhileBothRun = manager.rootState.activeSessions; manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-default', duration: 1000, }); const activeAfterFirstCompletes = manager.rootState.activeSessions; manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', duration: 1000, }); assert.deepStrictEqual( { turnEvents, activeWhileBothRun, activeAfterFirstCompletes, activeAfterBothComplete: manager.rootState.activeSessions, }, { // Exactly one true (first chat starts) and one false (last chat ends). turnEvents: [true, false], activeWhileBothRun: 1, activeAfterFirstCompletes: 1, activeAfterBothComplete: 0, }, ); }); test('removeChat clears a peer chat that is removed mid-turn', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const turnEvents: boolean[] = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => turnEvents.push(e.active))); // Both the default chat and the peer chat start a concurrent turn. manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-default', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const activeWhileBothRun = manager.hasActiveTurn(sessionUri); // Removing the peer mid-turn must not strand it in the active set: // the session stays active because the default chat still streams. manager.removeChat(sessionUri, peerChat); const activeAfterPeerRemoved = manager.hasActiveTurn(sessionUri); // Completing the default chat is now enough to flip the session idle. manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-default', duration: 1000, }); assert.deepStrictEqual( { turnEvents, activeWhileBothRun, activeAfterPeerRemoved, activeAfterDefaultComplete: manager.hasActiveTurn(sessionUri), activeSessions: manager.rootState.activeSessions, }, { turnEvents: [true, false], activeWhileBothRun: true, activeAfterPeerRemoved: true, activeAfterDefaultComplete: false, activeSessions: 0, }, ); }); test('removeChat flips the session idle when the removed peer held the last active turn', () => { manager.createSession(makeSessionSummary()); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const turnEvents: boolean[] = []; disposables.add(manager.onDidChangeSessionActiveTurn(e => turnEvents.push(e.active))); // Only the peer chat has an active turn. manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const activeWhilePeerRuns = manager.hasActiveTurn(sessionUri); // Removing that peer is the last active chat, so the session must // flip back to idle instead of staying permanently active. manager.removeChat(sessionUri, peerChat); assert.deepStrictEqual( { turnEvents, activeWhilePeerRuns, activeAfterPeerRemoved: manager.hasActiveTurn(sessionUri), activeSessions: manager.rootState.activeSessions, }, { turnEvents: [true, false], activeWhilePeerRuns: true, activeAfterPeerRemoved: false, activeSessions: 0, }, ); }); }); // Characterization tests (task A3): pin down the *current* catalog behavior // — the default-chat pointer set up by `_ensureDefaultChat`, restored peer // resolution, and the rolled-up session summary produced by the // SessionSummaryNotifier — so the upcoming `providerData` change cannot // silently regress them. suite('catalog characterization (A3)', () => { const peerChat = buildChatUri(sessionUri, 'peer-1'); test('_ensureDefaultChat seeds a single inheriting default chat and points defaultChat at it on createSession', () => { manager.createSession(makeSessionSummary()); const state = manager.getSessionState(sessionUri); assert.deepStrictEqual( { defaultChat: state?.defaultChat, defaultChatIsDeterministic: state?.defaultChat === buildDefaultChatUri(sessionUri), chatResources: state?.chats.map(c => c.resource.toString()), // Empty title => the default chat inherits the session title for display. defaultChatTitle: state?.chats[0]?.title, defaultChatStatePresent: manager.getDefaultChatState(sessionUri) !== undefined, }, { defaultChat: buildDefaultChatUri(sessionUri), defaultChatIsDeterministic: true, chatResources: [buildDefaultChatUri(sessionUri)], defaultChatTitle: '', defaultChatStatePresent: true, }, ); }); test('_ensureDefaultChat seeds the default-chat pointer on restoreSession too', () => { const turns = [ { id: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart], usage: undefined, state: TurnState.Complete, }, ]; manager.restoreSession(makeSessionSummary(), turns); const state = manager.getSessionState(sessionUri); assert.deepStrictEqual( { defaultChat: state?.defaultChat, chatResources: state?.chats.map(c => c.resource.toString()), defaultChatTurns: manager.getDefaultChatState(sessionUri)?.turns.length, }, { defaultChat: buildDefaultChatUri(sessionUri), chatResources: [buildDefaultChatUri(sessionUri)], defaultChatTurns: 1, }, ); }); test('registerRestoredChatSummary and resolveChatState hydrate a peer without dispatching SessionChatAdded', async () => { manager.restoreSession(makeSessionSummary(), []); const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const turns = [ { id: 'peer-turn-1', message: { text: 'restored', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'history' } satisfies MarkdownResponsePart], usage: undefined, state: TurnState.Complete, }, ]; const draft = { text: 'work in progress', origin: { kind: MessageKind.User } }; manager.registerRestoredChatSummary(sessionUri, peerChat, { title: 'Restored Peer', draft, resolver: async () => ({ turns }), }); const peerState = await manager.resolveChatState(peerChat); assert.deepStrictEqual( { chatResources: manager.getSessionState(sessionUri)?.chats.map(c => c.resource.toString()).sort(), restoredTitle: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, peerTurns: peerState?.turns.length, peerDraft: peerState?.draft?.text, chatAddedEvents: envelopes.filter(e => e.action.type === ActionType.SessionChatAdded).length, }, { chatResources: [buildDefaultChatUri(sessionUri), peerChat].sort(), restoredTitle: 'Restored Peer', peerTurns: 1, peerDraft: 'work in progress', chatAddedEvents: 0, }, ); }); test('resolveChatState coalesces restored peer resolution and atomically installs its state', async () => { manager.restoreSession(makeSessionSummary(), []); let resolverCalls = 0; manager.registerRestoredChatSummary(sessionUri, peerChat, { title: 'Restored Peer', draft: { text: 'work in progress', origin: { kind: MessageKind.User } }, resolver: async () => { resolverCalls++; return { turns: [{ id: 'peer-turn-1', message: { text: 'restored', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete, }], }; }, }); const beforeHydration = { summary: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, state: manager.getChatState(peerChat), }; const [first, second] = await Promise.all([ manager.resolveChatState(peerChat), manager.resolveChatState(peerChat), ]); const state = manager.getChatState(peerChat); assert.deepStrictEqual({ beforeHydration, sameState: first === second, resolverCalls, afterHydration: state && { title: state.title, turns: state.turns.map(turn => turn.id), draft: state.draft?.text }, }, { beforeHydration: { summary: 'Restored Peer', state: undefined }, sameState: true, resolverCalls: 1, afterHydration: { title: 'Restored Peer', turns: ['peer-turn-1'], draft: 'work in progress' }, }); }); test('resolveChatState retries failed restored peer resolution', async () => { manager.restoreSession(makeSessionSummary(), []); let resolverCalls = 0; manager.registerRestoredChatSummary(sessionUri, peerChat, { resolver: async () => { resolverCalls++; if (resolverCalls === 1) { throw new Error('history unavailable'); } return { turns: [] }; }, }); await assert.rejects(() => manager.resolveChatState(peerChat), /history unavailable/); const state = await manager.resolveChatState(peerChat); assert.deepStrictEqual({ resolverCalls, state: state && { title: state.title, turns: state.turns.length }, }, { resolverCalls: 2, state: { title: '', turns: 0 }, }); }); test('uses the latest unresolved summary when resolving a restored peer chat', async () => { manager.restoreSession(makeSessionSummary(), []); let resolveHistory!: (state: { turns: Turn[] }) => void; manager.registerRestoredChatSummary(sessionUri, peerChat, { title: 'Original title', resolver: () => new Promise(resolve => { resolveHistory = resolve; }), }); const resolving = manager.resolveChatState(peerChat); manager.updateChatTitle(sessionUri, peerChat, 'Updated title'); resolveHistory({ turns: [] }); const state = await resolving; assert.deepStrictEqual({ catalogTitle: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, stateTitle: state?.title, }, { catalogTitle: 'Updated title', stateTitle: 'Updated title', }); }); test('invalidates a pending restored peer resolver before same-URI reuse', async () => { manager.restoreSession(makeSessionSummary(), []); let resolveHistory!: (state: { turns: Turn[] }) => void; manager.registerRestoredChatSummary(sessionUri, peerChat, { resolver: () => new Promise(resolve => { resolveHistory = resolve; }), }); const resolving = manager.resolveChatState(peerChat); manager.removeChat(sessionUri, peerChat); manager.addChat(sessionUri, peerChat, { title: 'Replacement' }); resolveHistory({ turns: [] }); await assert.rejects(() => resolving, /invalidated/); assert.deepStrictEqual({ replacement: manager.getChatState(peerChat) && { title: manager.getChatState(peerChat)?.title, turns: manager.getChatState(peerChat)?.turns.length }, }, { replacement: { title: 'Replacement', turns: 0 }, }); }); test('registerRestoredChatSummary does not replace an already-hydrated chat URI', async () => { manager.createSession(makeSessionSummary()); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); let resolverCalls = 0; manager.registerRestoredChatSummary(sessionUri, peerChat, { title: 'Ignored', resolver: async () => { resolverCalls++; return { turns: [] }; }, }); await manager.resolveChatState(peerChat); assert.deepStrictEqual( { chatCount: manager.getSessionState(sessionUri)?.chats.length, title: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, peerTurns: manager.getChatState(peerChat)?.turns.length, resolverCalls, }, { chatCount: 2, title: 'Peer', peerTurns: 0, resolverCalls: 0, }, ); }); test('registerRestoredChatSummary does not register a peer for an unknown session', async () => { const summary = manager.registerRestoredChatSummary('copilot:/missing', peerChat, { resolver: async () => ({ turns: [] }), }); assert.deepStrictEqual({ summary, state: await manager.resolveChatState(peerChat), }, { summary: undefined, state: undefined, }); }); test('SessionSummaryNotifier rolls a running peer chat up onto the session summary and emits one coalesced SessionSummaryChanged', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); manager.addChat(sessionUri, peerChat, { title: 'Peer' }); const notifications: INotification[] = []; disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); const summaryHasInProgress = () => ((manager.getSessionSummary(sessionUri)?.status ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress; const idleRollup = summaryHasInProgress(); // Only the peer chat streams; the default chat stays idle. manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const runningRollup = summaryHasInProgress(); await new Promise(r => setTimeout(r, 150)); const summaryChanges = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged) as SessionSummaryChangedParams[]; assert.deepStrictEqual( { idleRollup, runningRollup, summaryChangedCount: summaryChanges.length, notifiedStatusHasInProgress: ((summaryChanges[0]?.changes.status ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, notifiedSession: summaryChanges[0]?.session, }, { idleRollup: false, runningRollup: true, summaryChangedCount: 1, notifiedStatusHasInProgress: true, notifiedSession: sessionUri, }, ); }); }); }); // Exercises the opaque, agent-owned `providerData` blob supplied to restored // peer resolvers. The StateManager must pass through the authoritative value // without parsing it, including after updates and retries. suite('providerData (G-B1)', () => { const peerChat = buildChatUri(sessionUri, 'peer-1'); const peerChat2 = buildChatUri(sessionUri, 'peer-2'); test('passes initial providerData verbatim to a restored peer resolver', async () => { manager.restoreSession(makeSessionSummary(), []); const blob = '{"sdkSessionId":"abc-123","model":{"id":"x\\"y"}}'; let received: string | undefined; manager.registerRestoredChatSummary(sessionUri, peerChat, { providerData: blob, resolver: async providerData => { received = providerData; return { turns: [] }; }, }); await manager.resolveChatState(peerChat); assert.strictEqual(received, blob); }); test('passes providerData updated before resolution to the resolver', async () => { manager.restoreSession(makeSessionSummary(), []); let received: string | undefined; manager.registerRestoredChatSummary(sessionUri, peerChat, { providerData: 'v1', resolver: async providerData => { received = providerData; return { turns: [] }; }, }); manager.updateChatProviderData(peerChat, 'v2'); await manager.resolveChatState(peerChat); assert.strictEqual(received, 'v2'); }); test('retries resolution with current providerData', async () => { manager.restoreSession(makeSessionSummary(), []); const received: Array = []; manager.registerRestoredChatSummary(sessionUri, peerChat, { providerData: 'v1', resolver: async providerData => { received.push(providerData); if (received.length === 1) { throw new Error('materialization failed'); } return { turns: [] }; }, }); await assert.rejects(() => manager.resolveChatState(peerChat), /materialization failed/); manager.updateChatProviderData(peerChat, 'v2'); await manager.resolveChatState(peerChat); assert.deepStrictEqual(received, ['v1', 'v2']); }); test('removeChat prevents an unresolved peer resolver from observing stale providerData', async () => { manager.restoreSession(makeSessionSummary(), []); let resolverCalls = 0; manager.registerRestoredChatSummary(sessionUri, peerChat, { providerData: 'blob', resolver: async () => { resolverCalls++; return { turns: [] }; }, }); manager.removeChat(sessionUri, peerChat); assert.deepStrictEqual({ state: await manager.resolveChatState(peerChat), resolverCalls, }, { state: undefined, resolverCalls: 0, }); }); test('removeSession prevents unresolved peer resolvers from observing stale providerData', async () => { manager.restoreSession(makeSessionSummary(), []); const resolverCalls: string[] = []; for (const chat of [peerChat, peerChat2]) { manager.registerRestoredChatSummary(sessionUri, chat, { providerData: `blob-${chat}`, resolver: async () => { resolverCalls.push(chat); return { turns: [] }; }, }); } manager.removeSession(sessionUri); assert.deepStrictEqual( { peer1: await manager.resolveChatState(peerChat), peer2: await manager.resolveChatState(peerChat2), resolverCalls, }, { peer1: undefined, peer2: undefined, resolverCalls: [], }, ); }); }); }); suite('Subagent URI helpers', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('buildSubagentSessionUri creates correct URI', () => { assert.strictEqual( buildSubagentSessionUri('copilot:/session-1', 'tc-1'), 'copilot:/session-1/subagent/tc-1', ); }); test('buildSubagentSessionUri preserves parent URI path shape', () => { assert.strictEqual( buildSubagentSessionUri('copilot:/session-1//nested/../kept', 'tc-1'), 'copilot:/session-1//nested/../kept/subagent/tc-1', ); }); test('parseSubagentSessionUri extracts parent and toolCallId', () => { const parsed = parseSubagentSessionUri('copilot:/session-1/subagent/tc-1'); assert.deepStrictEqual(parsed && { parentSession: parsed.parentSession.toString(), toolCallId: parsed.toolCallId, }, { parentSession: 'copilot:/session-1', toolCallId: 'tc-1', }); }); test('parseSubagentSessionUri handles nested subagent URIs', () => { const parsed = parseSubagentSessionUri('copilot:/session-1/subagent/tc-1/subagent/tc-2'); assert.deepStrictEqual(parsed && { parentSession: parsed.parentSession.toString(), toolCallId: parsed.toolCallId, }, { parentSession: 'copilot:/session-1/subagent/tc-1', toolCallId: 'tc-2', }); }); test('parseSubagentSessionUri returns undefined for non-subagent URIs', () => { assert.strictEqual(parseSubagentSessionUri('copilot:/session-1'), undefined); }); test('isSubagentSession identifies subagent URIs', () => { assert.strictEqual(isSubagentSession('copilot:/session-1/subagent/tc-1'), true); assert.strictEqual(isSubagentSession('copilot:/session-1'), false); }); test('buildSubagentSessionUriPrefix creates state manager prefix', () => { assert.strictEqual( buildSubagentSessionUriPrefix('copilot:/session-1'), 'copilot:/session-1/subagent/', ); }); test('buildSubagentSessionUriPrefix preserves parent URI path shape', () => { assert.strictEqual( buildSubagentSessionUriPrefix('copilot:/session-1//nested/../kept'), 'copilot:/session-1//nested/../kept/subagent/', ); }); suite('mergeSessionWithDefaultChat', () => { function makeSessionState(workingDirectory?: string): SessionState { return { provider: 'copilot', title: 'Session', status: SessionStatus.Idle, lifecycle: SessionLifecycle.Ready, activeClients: [], chats: [], workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } function makeChatState(workingDirectory?: string): ChatState { return { resource: 'copilot:/test-session/chat/peer', title: 'Peer', status: SessionStatus.Idle, modifiedAt: new Date().toISOString(), workingDirectories: workingDirectory ? [workingDirectory] : undefined, turns: [], }; } test('resolves the per-chat working directory override over the session default', () => { const merged = mergeSessionWithDefaultChat( makeSessionState('file:///session-wd'), makeChatState('file:///peer-worktree'), ); assert.strictEqual(merged.workingDirectories?.[0], 'file:///peer-worktree'); }); test('falls back to the session working directory when the chat does not override it', () => { const merged = mergeSessionWithDefaultChat( makeSessionState('file:///session-wd'), makeChatState(undefined), ); assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd'); }); test('falls back to the session working directory when no chat state is hydrated', () => { const merged = mergeSessionWithDefaultChat(makeSessionState('file:///session-wd'), undefined); assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd'); assert.deepStrictEqual(merged.turns, []); }); }); });