Ver Fonte

test(api): complete session manager coverage

pku-xht há 1 mês atrás
pai
commit
b2071a50b9

+ 2 - 2
packages/api/session-controller/src/client/sessions/manager.ts

@@ -395,7 +395,7 @@ export class SessionManager {
           })
         }
       } catch (error: unknown) {
-        const folded = transportResult<never>(error)
+        const folded = transportResult<never>(error) as Extract<ClientResult<never>, { ok: false }>
         this.catalogs.set(parentSessionId, {
           entries: this.withCatalogMutations(
             previous?.entries ?? [], expandableRows, activityRows,
@@ -405,7 +405,7 @@ export class SessionManager {
               ?? previous?.parentAvailable,
           ),
           state: 'error',
-          error: folded.ok ? null : folded.error,
+          error: folded.error,
         })
       } finally {
         this.catalogInflight.delete(parentSessionId)

+ 212 - 3
packages/api/session-controller/tests/manager.client.spec.ts

@@ -47,6 +47,49 @@ describe('SessionManager instances', () => {
     expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
   })
 
+  it('drops absent and resident instances and drains both fulfilled and rejected disposals', async () => {
+    const manager = makeManager()
+    await expect(manager.drop(S2)).resolves.toBeUndefined()
+
+    const dropped = manager.get(S1)
+    const droppedDispose = vi.spyOn(dropped, 'dispose').mockResolvedValue(undefined)
+    await expect(manager.drop(S1)).resolves.toBeUndefined()
+    expect(droppedDispose).toHaveBeenCalledOnce()
+    expect(manager.get(S1)).not.toBe(dropped)
+
+    const fulfilled = deferred<undefined>()
+    const rejected = deferred<undefined>()
+    const first = manager.get(S1)
+    const second = manager.get(S2)
+    const firstDispose = vi.spyOn(first, 'dispose').mockReturnValue(fulfilled.promise)
+    const secondDispose = vi.spyOn(second, 'dispose').mockReturnValue(rejected.promise)
+    const disposal = manager.dispose()
+    expect(firstDispose).toHaveBeenCalledOnce()
+    expect(secondDispose).toHaveBeenCalledOnce()
+    fulfilled.resolve(undefined)
+    rejected.reject(new Error('dispose failed'))
+    await expect(disposal).resolves.toBeUndefined()
+  })
+
+  it('cancels a pending catalog debounce when the manager is disposed', async () => {
+    vi.useFakeTimers()
+    try {
+      const api = new FakeApiClient()
+      const manager = new SessionManager(fakeRemote(api))
+      await manager.refreshSubagents(S1)
+      manager.setSubagentCatalogOpen(S1, true)
+      await manager.refreshSubagents(S1)
+      const calls = api.callsOf('subagents.list').length
+
+      manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
+      await manager.dispose()
+      await vi.advanceTimersByTimeAsync(50)
+
+      expect(api.callsOf('subagents.list')).toHaveLength(calls)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
 })
 
 describe('list lifecycle', () => {
@@ -377,6 +420,37 @@ describe('Host Remote event routing', () => {
 })
 
 describe('subagent catalogs', () => {
+  it('rejects missing, diagnostic, and mode-mismatched catalog selections', async () => {
+    const api = new FakeApiClient()
+    api.onSubagentList = () => Promise.resolve(remoteOk({
+      entries: [
+        { kind: 'diagnostic', id: S1, reason: 'corrupt' },
+        {
+          kind: 'child', id: S2, mode: 'one-shot', activity: 'inactive', hasChildren: false,
+        },
+      ] as never[],
+      parentAvailable: true,
+    }))
+    const manager = new SessionManager(fakeRemote(api))
+    await manager.refreshSubagents(S1)
+
+    expect(() => {
+      manager.selectSubagent({
+        parentSessionId: S1, childSessionId: 'fk-missing' as SessionId, mode: 'continuable',
+      })
+    }).toThrow('is not a healthy catalog child')
+    expect(() => {
+      manager.selectSubagent({
+        parentSessionId: S1, childSessionId: S1, mode: 'continuable',
+      })
+    }).toThrow('is not a healthy catalog child')
+    expect(() => {
+      manager.selectSubagent({
+        parentSessionId: S1, childSessionId: S2, mode: 'continuable',
+      })
+    }).toThrow('is not a healthy catalog child')
+  })
+
   it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(ok({ items: [
@@ -476,6 +550,60 @@ describe('subagent catalogs', () => {
     }
   })
 
+  it('cancels a pending membership refresh when the catalog closes', async () => {
+    vi.useFakeTimers()
+    try {
+      const api = new FakeApiClient()
+      const manager = new SessionManager(fakeRemote(api))
+      await manager.refreshSubagents(S1)
+      manager.setSubagentCatalogOpen(S1, true)
+      await manager.refreshSubagents(S1)
+      const calls = api.callsOf('subagents.list').length
+
+      manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
+      manager.setSubagentCatalogOpen(S1, false)
+      await vi.advanceTimersByTimeAsync(50)
+
+      expect(api.callsOf('subagents.list')).toHaveLength(calls)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('publishes business and transport catalog failures with and without a prior catalog', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(fakeRemote(api))
+
+    api.onSubagentList = () => Promise.resolve(remoteErr({
+      code: 'internal', message: 'business failure', details: {},
+    }))
+    await manager.refreshSubagents(S1)
+    expect(manager.getListSnapshot().subagentsByParent[S1]).toMatchObject({
+      entries: [], state: 'error', error: { message: 'business failure' },
+    })
+
+    api.onSubagentList = () => Promise.reject(new Error('first transport failure'))
+    await manager.refreshSubagents(S2)
+    expect(manager.getListSnapshot().subagentsByParent[S2]).toMatchObject({
+      entries: [], state: 'error', error: { message: 'first transport failure' },
+    })
+
+    const root = 'fk-root' as SessionId
+    api.onSubagentList = () => Promise.resolve(remoteOk({
+      entries: [{ kind: 'diagnostic', id: S1, reason: 'unavailable' }] as never[],
+      parentAvailable: true,
+    }))
+    await manager.refreshSubagents(root)
+    api.onSubagentList = () => Promise.reject(new Error('later transport failure'))
+    await manager.refreshSubagents(root)
+    expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
+      entries: [{ kind: 'diagnostic', id: S1 }],
+      parentAvailable: true,
+      state: 'error',
+      error: { message: 'later transport failure' },
+    })
+  })
+
   it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
     const api = new FakeApiClient()
     const root = 'fk-root' as SessionId
@@ -563,6 +691,7 @@ describe('subagent catalogs', () => {
           kind: 'child', id: S2, mode: 'continuable', label: 'started',
           activity: 'inactive', hasChildren: false,
         },
+        { kind: 'diagnostic', id: 'fk-diagnostic' as SessionId, reason: 'corrupt' },
       ] as never[],
       parentAvailable: true,
     }))
@@ -571,6 +700,14 @@ describe('subagent catalogs', () => {
     expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
       { kind: 'child', id: S1, activity: 'inactive' },
       { kind: 'child', id: S2, activity: 'running' },
+      { kind: 'diagnostic', id: 'fk-diagnostic' },
+    ])
+
+    manager.handleSessionStatus(S1, true)
+    expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
+      { kind: 'child', id: S1, activity: 'running' },
+      { kind: 'child', id: S2, activity: 'running' },
+      { kind: 'diagnostic', id: 'fk-diagnostic' },
     ])
   })
 
@@ -793,6 +930,22 @@ describe('remaining branches', () => {
     })])
   })
 
+  it('leaves the list unchanged for ordinary fork failures and folds transport throws', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(fakeRemote(api))
+    api.onFork = () => Promise.resolve(err({ code: 'internal', message: 'fork denied', details: {} }))
+    await expect(manager.fork({ sessionId: S1, atSeq: 4 })).resolves.toMatchObject({
+      ok: false, error: { message: 'fork denied' },
+    })
+    expect(manager.getListSnapshot().items).toEqual([])
+
+    api.onFork = () => Promise.reject(new Error('fork wire down'))
+    await expect(manager.fork({ sessionId: S1 })).resolves.toMatchObject({
+      ok: false, error: { code: 'internal', message: 'fork wire down' },
+    })
+    expect(manager.getListSnapshot().items).toEqual([])
+  })
+
   it('reconciles a preallocated id after an ordinary transport failure', async () => {
     const api = new FakeApiClient()
     api.onCreate = () => Promise.reject(new Error('response lost'))
@@ -831,6 +984,30 @@ describe('remaining branches', () => {
     manager.handleSessionError(S2, '无实例')
   })
 
+  it('ignores duplicate running and stale activity frames', () => {
+    const manager = makeManager()
+    manager.handleSessionAdded(summary(S1, { running: true, updatedAt: 500 }))
+    const before = manager.getListSnapshot().items[0]
+
+    manager.handleSessionStatus(S1, true)
+    manager.handleSessionActivity(S1, 499)
+
+    expect(manager.getListSnapshot().items[0]).toBe(before)
+  })
+
+  it('keeps unrelated blank rows unchanged when a first prompt engages one session', async () => {
+    const manager = makeManager()
+    manager.handleSessionAdded(summary(S1, { blank: true }))
+    manager.handleSessionAdded(summary(S2, { blank: true }))
+
+    await expect(manager.get(S1).prompt([{ type: 'text', text: 'hello' }], 'queue'))
+      .resolves.toMatchObject({ ok: true })
+
+    const items = manager.getListSnapshot().items
+    expect(items.find(item => item.sessionId === S1)?.blank).toBe(false)
+    expect(items.find(item => item.sessionId === S2)?.blank).toBe(true)
+  })
+
   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[] }))
@@ -848,16 +1025,17 @@ describe('remaining branches', () => {
     expect(manager.getListSnapshot().items).toBe(after.items)
   })
 
-  it('carries parentSessionId from the added event into the lineage row', () => {
+  it('enriches an existing summary with cwd, parentSessionId, and origin', () => {
     const api = new FakeApiClient()
     const manager = new SessionManager(fakeRemote(api))
     manager.handleSessionAdded(summary(S1, { blank: true }))
+    manager.handleSessionAdded(summary(S2, { blank: true }))
     manager.handleSessionAdded(summary(S2, {
-      blank: true, parentSessionId: S1, origin: 'subagent',
+      blank: true, cwd: '/work/child', parentSessionId: S1, origin: 'subagent',
     }))
     const items = manager.getListSnapshot().items
     expect(items.find(e => e.sessionId === S2)).toMatchObject({
-      parentSessionId: S1, origin: 'subagent', depth: 1,
+      cwd: '/work/child', parentSessionId: S1, origin: 'subagent', depth: 1,
     })
   })
 })
@@ -909,6 +1087,21 @@ describe('connected generation', () => {
     })
     expect(manager.getListSnapshot().currentAddress).toEqual(address)
   })
+
+  it('refreshes an open catalog across reconnect even when it is not selected', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(fakeRemote(api))
+    manager.setSubagentCatalogOpen(S1, true)
+    await manager.refreshSubagents(S1)
+    const catalogCalls = api.callsOf('subagents.list').length
+
+    manager.handleConnected()
+
+    await vi.waitFor(() => {
+      expect(api.callsOf('session.list')).toHaveLength(1)
+      expect(api.callsOf('subagents.list')).toHaveLength(catalogCalls + 1)
+    })
+  })
 })
 
 describe('completed reminder', () => {
@@ -1076,6 +1269,22 @@ describe('background-job mirror', () => {
     expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
   })
 
+  it('keeps only non-empty job sets from a complete control baseline', () => {
+    const manager = makeManager()
+    manager.handleControlFrame({
+      type: 'baseline',
+      value: {
+        queues: {}, projections: {},
+        jobs: { [S1]: [], [S2]: [view({ id: 'pwsh-1', label: 'kept' })] as never[] },
+      },
+    })
+
+    expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
+    expect(manager.getListSnapshot().jobsBySession[S2]).toEqual([
+      view({ id: 'pwsh-1', label: 'kept' }),
+    ])
+  })
+
   it('drops the rows when the session is removed, whichever stream lands first', () => {
     const manager = makeManager()
     manager.handleSessionAdded(summary(S1, { blank: true }))