/** * SessionManager orchestration: lazy resident instances, list lifecycle, host * frame routing, and the pending-frame buffer for uninstantiated sessions. */ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId type SummaryOver = Partial<{ updatedAt: number running: boolean blank: boolean parentSessionId: SessionId origin: 'subagent' }> function summary(sessionId: SessionId, over: SummaryOver = {}) { return { sessionId, updatedAt: 100, running: false, blank: false, ...over } } describe('instances', () => { it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) const manager = new SessionManager(api) await manager.refreshList() const session = manager.get(S1) expect(manager.get(S1)).toBe(session) // resident: same instance forever expect(session.getSnapshot().running).toBe(true) // list preceded instantiation }) it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => { const api = new FakeApiClient() const manager = new SessionManager(api) // Uninstantiated: approval buffers, plain session/event drops. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } }) const session = manager.get(S1) expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }]) // Buffer cleared: a second instantiation of another id gets nothing. expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) it('retains every live answerable request and compacts resolutions before instantiation', () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) for (let i = 0; i < 40; i++) { manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) } expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') for (let i = 0; i < 40; i++) { manager.handleMuxEnvelope({ rpcId: `r${i}` as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' }, }) } expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() expect(manager.get(S1).getSnapshot().pending).toEqual([]) }) it('drops buffered answerable requests on session removal', () => { const manager = new SessionManager(new FakeApiClient()) // Removed session: buffered frames must not replay on a future instantiation. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) }) describe('list lifecycle', () => { it('single-flights refreshList and preserves the Host baseline order', async () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise const manager = new SessionManager(api) const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] })) await Promise.all([first, second]) expect(api.callsOf('session.list')).toHaveLength(1) const snapshot = manager.getListSnapshot() expect(snapshot.state).toBe('idle') expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) }) it('replays incremental frames over hydration and never batch-reorders established ids', async () => { const api = new FakeApiClient() const first = deferred>>() api.onList = () => first.promise const manager = new SessionManager(api) const hydration = manager.refreshList() manager.handleHostEnvelope({ rpcId: 'during-first' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2 }, }) first.resolve(ok({ items: [summary(S1)] as never[] })) await hydration expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[], })) await manager.refreshList() expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) const manager = new SessionManager(api) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) // A failed pull does not step the arrival phase: still pending. expect(manager.getListSnapshot().phase).toBe('pending') }) it('phase steps pending → ready on the first successful pull and never returns', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) expect(manager.getListSnapshot().phase).toBe('pending') await manager.refreshList() expect(manager.getListSnapshot().phase).toBe('ready') // Sticky across later failures: the pull-activity axis reports the error, // the arrival phase holds. api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' }) // And across an empty re-pull (empty-with-ready = truly no sessions). api.onList = () => Promise.resolve(ok({ items: [] as never[] })) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' }) expect(manager.getListSnapshot().items).toEqual([]) }) it('merges create into the list immediately without waiting for a refresh', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) const manager = new SessionManager(api) const result = await manager.create() expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) }) it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) const titleFrame = (rpcId: string, title: string, seq: number) => { manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never, }) } titleFrame('title-new', 'Newest', 4) titleFrame('title-stale', 'Stale', 3) titleFrame('title-equal', 'Equal', 4) api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[], })) await manager.refreshList() const titled = manager.getListSnapshot() expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2]) expect(titled.items[0]?.title).toBe('Newest') expect(titled.items[1]?.title).toBeUndefined() manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) // A push frame landed before the list (S2's title is newer than the block's cut). manager.handleMuxEnvelope({ rpcId: 'push-newer' as never, payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never, }) api.onList = () => Promise.resolve(ok({ items: [ { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, ] as never[], })) await manager.refreshList() const items = manager.getListSnapshot().items // Cold row: title surfaces straight from the list block — no open, no history. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) const manager = new SessionManager(api) await manager.refreshList() const frame = (rpcId: string, payload: object) => { manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never }) } frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) // The durable baseline says the host only knows up to seq 2: the phantom // row rode lost state and must drop, or last-wins pins it forever. frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }) expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 }) expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') // A baseline at or past the row's seq keeps it (nothing phantom to drop). frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }) expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') }) }) describe('search', () => { it('returns bounded Host results and forwards the caller signal', async () => { const api = new FakeApiClient() api.onSearch = () => Promise.resolve(ok({ items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, })) const manager = new SessionManager(api) const signal = new AbortController().signal await expect(manager.search('exact phrase', signal)).resolves.toEqual({ ok: true, value: { items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, }, }) expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }]) expect(api.lastSearchSignal).toBe(signal) }) it('preserves business errors and folds transport failures', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) api.onSearch = () => Promise.resolve(err({ code: 'internal', message: 'index unavailable', details: {}, })) const signal = new AbortController().signal await expect(manager.search('first', signal)).resolves.toMatchObject({ ok: false, error: { code: 'internal', message: 'index unavailable' }, }) api.onSearch = () => Promise.reject(new Error('wire down')) await expect(manager.search('second', signal)).resolves.toMatchObject({ ok: false, error: { code: 'internal', message: 'wire down' }, }) }) }) describe('host frame routing', () => { it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) const session = manager.get(S1) manager.handleHostEnvelope({ rpcId: 'h3' as never, payload: { type: 'host/session-status', sessionId: S1, running: true } }) expect(session.getSnapshot().running).toBe(true) expect(manager.getListSnapshot().items[0]?.running).toBe(true) manager.handleHostEnvelope({ rpcId: 'h4' as never, payload: { type: 'host/agent-error', sessionId: S1, message: '炸了' } }) expect(session.getSnapshot().lastAgentError).toBe('炸了') manager.handleHostEnvelope({ rpcId: 'h5' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) expect(manager.getListSnapshot().items).toHaveLength(0) expect(session.getSnapshot().removed).toBe(true) expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal }) }) describe('subagent catalogs', () => { it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [ summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] })) api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, }] as never[], parentAvailable: true, })) const manager = new SessionManager(api) await manager.refreshList() await manager.refreshSubagents(S1) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) expect(manager.getListSnapshot().currentAddress).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'continuable', }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, parentAvailable: true, }) // Clicking the same child through an ordinary list-selection path must not // erase the catalog-derived address and fall back to session.* transport. manager.select(S2) expect(manager.getListSnapshot().currentAddress).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'continuable', }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, parentAvailable: true, }) await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') expect(api.callsOf('subagent.history')).toEqual([ { parentSessionId: S1, childSessionId: S2, mode: 'continuable', maxMessages: 50 }, ]) expect(api.callsOf('subagent.prompt')).toEqual([ { parentSessionId: S1, childSessionId: S2, mode: 'continuable', content: [{ type: 'text', text: 'continue' }], }, ]) expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) const listCalls = api.callsOf('subagent.list').length manager.handleHostEnvelope({ rpcId: 'child-complete' as never, payload: { type: 'host/session-status', sessionId: S2, running: false }, }) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({ kind: 'child', id: S2, activity: 'inactive', }) expect(api.callsOf('subagent.list')).toHaveLength(listCalls) manager.handleHostEnvelope({ rpcId: 'child-detached' as never, payload: { type: 'host/session-removed', sessionId: S2 }, }) expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({ origin: 'subagent', parentSessionId: S1, running: false, }) expect(manager.get(S2).getSnapshot()).toMatchObject({ removed: false, subagent: { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, }, }) }) it('refetches debounced membership only while the parent catalog is open', async () => { vi.useFakeTimers() try { const api = new FakeApiClient() const manager = new SessionManager(api) await manager.refreshSubagents(S1) manager.setSubagentCatalogOpen(S1, true) await Promise.resolve() const baseline = api.callsOf('subagent.list').length manager.handleHostEnvelope({ rpcId: 'child-added' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false, }, }) manager.handleHostEnvelope({ rpcId: 'child-added-again' as never, payload: { type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false, }, }) await vi.advanceTimersByTimeAsync(50) expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) manager.setSubagentCatalogOpen(S1, false) manager.handleHostEnvelope({ rpcId: 'child-added-closed' as never, payload: { type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false, }, }) await vi.advanceTimersByTimeAsync(50) expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) } finally { vi.useRealTimers() } }) it('marks a loaded parent row expandable only for a direct subagent publication', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId api.onSubagentList = () => Promise.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, }, { kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent', activity: 'inactive', hasChildren: false, }, ] as never[], parentAvailable: true, })) const manager = new SessionManager(api) await manager.refreshSubagents(root) manager.handleHostEnvelope({ rpcId: 'nested-subagent' as never, payload: { type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId, parentSessionId: S1, origin: 'subagent', blank: false, }, }) manager.handleHostEnvelope({ rpcId: 'ordinary-fork' as never, payload: { type: 'host/session-added', sessionId: 'fk-fork' as SessionId, parentSessionId: S2, blank: false, }, }) expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, hasChildren: true }, { kind: 'child', id: S2, hasChildren: false }, ]) }) it('preserves a live expandability hint across only the older in-flight catalog response', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise const manager = new SessionManager(api) const refresh = manager.refreshSubagents(root) manager.handleHostEnvelope({ rpcId: 'nested-subagent' as never, payload: { type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId, parentSessionId: S1, origin: 'subagent', blank: false, }, }) response.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, }] as never[], parentAvailable: true, })) await refresh expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, hasChildren: true }, ]) api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, }] as never[], parentAvailable: true, })) await manager.refreshSubagents(root) expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, hasChildren: false }, ]) }) it('replays status frames over an older in-flight catalog response', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise const manager = new SessionManager(api) const refresh = manager.refreshSubagents(root) manager.handleHostEnvelope({ rpcId: 'child-stopped' as never, payload: { type: 'host/session-status', sessionId: S1, running: false }, }) manager.handleHostEnvelope({ rpcId: 'child-started' as never, payload: { type: 'host/session-status', sessionId: S2, running: true }, }) response.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'stopped', activity: 'running', hasChildren: false, }, { kind: 'child', id: S2, mode: 'continuable', label: 'started', activity: 'inactive', hasChildren: false, }, ] as never[], parentAvailable: true, })) await refresh expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, activity: 'inactive' }, { kind: 'child', id: S2, activity: 'running' }, ]) }) it('marks a detached catalog child inactive without requiring a selected address', async () => { const api = new FakeApiClient() api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, }] as never[], parentAvailable: true, })) const manager = new SessionManager(api) await manager.refreshSubagents(S1) manager.handleHostEnvelope({ rpcId: 'child-detached' as never, payload: { type: 'host/session-removed', sessionId: S2 }, }) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([ { kind: 'child', id: S2, activity: 'inactive' }, ]) }) it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId const first = deferred>>() api.onSubagentList = () => first.promise const manager = new SessionManager(api) const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) first.resolve(ok({ entries: [], parentAvailable: true })) await refresh expect(api.callsOf('subagent.list')).toHaveLength(1) }) it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { vi.useFakeTimers() try { const api = new FakeApiClient() const root = 'fk-root' as SessionId const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise const manager = new SessionManager(api, root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced // refresh it schedules fires 50ms later and is coalesced into the pull — // which was requested before the new child existed. The stale mark must // queue one trailing pull carrying the change. manager.handleHostEnvelope({ rpcId: 'child-added' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false, }, }) await vi.advanceTimersByTimeAsync(50) api.onSubagentList = () => second.promise first.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'older', activity: 'inactive', hasChildren: false, }] as never[], parentAvailable: true, })) await refresh // The trailing pull is already in flight (kicked synchronously in finally). second.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'older', activity: 'inactive', hasChildren: false, }, { kind: 'child', id: S2, mode: 'continuable', label: 'new child', activity: 'inactive', hasChildren: false, }, ] as never[], parentAvailable: true, })) await second.promise expect(api.callsOf('subagent.list')).toHaveLength(2) expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, label: 'older' }, { kind: 'child', id: S2, label: 'new child' }, ]) } finally { vi.useRealTimers() } }) it('keeps removal invalidation across a stale success and failed trailing pull', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId const child = () => ({ kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', activity: 'inactive' as const, hasChildren: false, }) const first = deferred>>() api.onSubagentList = () => first.promise const manager = new SessionManager(api) const refresh = manager.refreshSubagents(root) first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) // The removal lands while a second pull is in flight: the invalidation // must survive the pre-removal ok response, so one trailing pull runs. const mid = deferred>>() api.onSubagentList = () => mid.promise const midRefresh = manager.refreshSubagents(root) manager.handleHostEnvelope({ rpcId: 'parent-removed-mid-pull' as never, payload: { type: 'host/session-removed', sessionId: root }, }) const trailing = deferred>>() api.onSubagentList = () => trailing.promise mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await midRefresh expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} })) await vi.waitFor(() => { expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ state: 'error', parentAvailable: false, }) }) const rootCalls = api.callsOf('subagent.list') .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) expect(rootCalls).toHaveLength(3) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) }) it('invalidates catalog availability when the owning parent is removed', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false, }] as never[], parentAvailable: true, })) const manager = new SessionManager(api) await manager.refreshSubagents(root) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) manager.handleHostEnvelope({ rpcId: 'parent-removed' as never, payload: { type: 'host/session-removed', sessionId: root }, }) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) }) }) describe('remaining branches', () => { it('refreshList folds a transport throw into the error state', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) const manager = new SessionManager(api) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) const session = manager.get(S1) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) await manager.refreshList() expect(session.getSnapshot().running).toBe(true) }) it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) const manager = new SessionManager(api) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) api.onCreate = () => Promise.reject(new Error('create wire down')) expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } }) // Business error passes through untouched. api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} })) expect(await manager.create()).toMatchObject({ ok: false }) }) it('publishes a real Ungrouped summary from workspace-attach-failed', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(err({ code: 'workspace-attach-failed', message: 'published but unattached', details: { sessionId: S1, workspaceId: 'w1' }, } as never)) const manager = new SessionManager(api) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) it('reconciles a fork child published before workspace attachment fails', async () => { const api = new FakeApiClient() api.onFork = () => Promise.resolve(err({ code: 'workspace-attach-failed', message: 'forked but unattached', details: { sessionId: S2, workspaceId: 'w1' }, } as never)) const manager = new SessionManager(api) const result = await manager.fork({ sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S2, parentSessionId: S1, blank: false, })]) }) it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) const manager = new SessionManager(api) const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) expect(manager.getListSnapshot().items).toEqual([]) manager.handleHostEnvelope({ rpcId: 'published-later' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, }) expect(manager.getListSnapshot().items).toEqual([ expect.objectContaining({ sessionId: S1, cwd: '/w/one' }), ]) manager.handleHostEnvelope({ rpcId: 'duplicate-frame' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, }) expect(manager.getListSnapshot().items).toHaveLength(1) }) it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) let notified = 0 const unsubscribe = manager.subscribe(() => { notified++ }) await manager.refreshList() await new Promise(resolve => setTimeout(resolve, 0)) expect(notified).toBeGreaterThan(0) const seen = notified unsubscribe() manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) await new Promise(resolve => setTimeout(resolve, 0)) expect(notified).toBe(seen) }) it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never }) const session = manager.get(S1) manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) expect(session.getSnapshot().pending).toMatchObject([{ kind: 'question' }]) // status flip for an unknown session only touches summaries (no crash). manager.handleHostEnvelope({ rpcId: 'h9' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) manager.handleHostEnvelope({ rpcId: 'ha' as never, payload: { type: 'host/agent-error', sessionId: S2, message: '无实例' } }) }) it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) const manager = new SessionManager(api) await manager.refreshList() const before = manager.getListSnapshot() manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) const after = manager.getListSnapshot() expect(after.items).not.toBe(before.items) const beforeS1 = before.items.find(e => e.sessionId === S1) const afterS1 = after.items.find(e => e.sessionId === S1) expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache) // Same-order same-entries snapshot reuses the items array. manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/agent-error', sessionId: S1, message: 'x' } }) expect(manager.getListSnapshot().items).toBe(after.items) }) it('carries parentSessionId from host/session-added into the lineage row', () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1, origin: 'subagent', }, }) const items = manager.getListSnapshot().items expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, origin: 'subagent', depth: 1, }) }) }) describe('connected generation', () => { it('refreshes the list and resyncs only opened instances', async () => { const api = new FakeApiClient() api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) const manager = new SessionManager(api) const openedSession = manager.get(S1) await openedSession.open() manager.get(S2) // instantiated but never opened const historyCallsBefore = api.callsOf('session.history').length manager.handleConnected() await vi.waitFor(() => { expect(api.callsOf('session.list').length).toBe(1) // Only the opened instance repulls history; the cold one stays silent. expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1) }) }) it('reloads the durable parent address for a restored child selection', async () => { const api = new FakeApiClient() const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } const manager = new SessionManager(api, S2, address) manager.handleConnected() await vi.waitFor(() => { expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 }) }) expect(manager.getListSnapshot().currentAddress).toEqual(address) }) }) describe('pending-interaction list status', () => { it('tracks approval requests through replay and resolution without instantiation', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Mux-open replay of the same question (same approvalId) is idempotent. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'q2' as never, payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'plan', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }], intent: { kind: 'plan-review', approve: 'Approve' }, }], }, }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review') manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) it.each([ ['missing detail', {}], ['multi-select', { detail: '# Plan', multiSelect: true }], ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'q-plan' as never, payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }], intent: { kind: 'plan-review', approve: 'Approve' }, ...over, }], }, }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') }) it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) expect(manager.getListSnapshot().items).toHaveLength(0) }) it('drops stale status at generation death before replay re-adds live interactions', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') // Generation death clears (resolved-while-disconnected questions send no frame)… manager.handleDisconnected() expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() // …and a replayed frame arriving before onConnected (stream open precedes // the readiness handshake) survives the later handleConnected untouched. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleConnected() expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') }) it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { const manager = new SessionManager(new FakeApiClient()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) // Buffered pre-instantiation: an approval pair and a queued row. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) manager.handleDisconnected() // Instantiate after the death sweep: no zombie interaction replays (the // pendingBuffers held only dead-generation rpcIds), so the session mints // no pending waits. const session = manager.get(S1) expect(session.getSnapshot().pending).toEqual([]) }) })