|
|
@@ -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 }))
|