manager.client.spec.ts 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305
  1. /**
  2. * SessionManager orchestration: lazy resident instances, list lifecycle, host
  3. * frame routing, and control baselines for uninstantiated sessions.
  4. */
  5. import { describe, expect, it, vi } from 'vitest'
  6. import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  7. import type {
  8. SessionControlFrame,
  9. SessionProjectionHints,
  10. } from '@deepseek-ai/dsh-api-session-controller/types'
  11. import type {} from '@deepseek-ai/dsh-session-title/client'
  12. import { SessionManager } from '../src/client/sessions/manager.ts'
  13. import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts'
  14. import { entries, plainTurn } from './event-script.client.ts'
  15. const S1 = 'fk-m1' as SessionId
  16. const S2 = 'fk-m2' as SessionId
  17. type SummaryOver = Partial<{
  18. updatedAt: number
  19. running: boolean
  20. blank: boolean
  21. cwd: string
  22. parentSessionId: SessionId
  23. origin: 'subagent'
  24. projections: SessionProjectionHints
  25. }>
  26. function summary(sessionId: SessionId, over: SummaryOver = {}) {
  27. return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
  28. }
  29. function makeManager(): SessionManager {
  30. const api = new FakeApiClient()
  31. return new SessionManager(fakeRemote(api))
  32. }
  33. describe('SessionManager instances', () => {
  34. it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
  35. const api = new FakeApiClient()
  36. api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
  37. const manager = new SessionManager(fakeRemote(api))
  38. await manager.refreshList()
  39. const session = manager.get(S1)
  40. expect(manager.get(S1)).toBe(session) // resident: same instance forever
  41. expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
  42. })
  43. it('drops absent and resident instances and drains both fulfilled and rejected disposals', async () => {
  44. const manager = makeManager()
  45. await expect(manager.drop(S2)).resolves.toBeUndefined()
  46. const dropped = manager.get(S1)
  47. const droppedDispose = vi.spyOn(dropped, 'dispose').mockResolvedValue(undefined)
  48. await expect(manager.drop(S1)).resolves.toBeUndefined()
  49. expect(droppedDispose).toHaveBeenCalledOnce()
  50. expect(manager.get(S1)).not.toBe(dropped)
  51. const fulfilled = deferred<undefined>()
  52. const rejected = deferred<undefined>()
  53. const first = manager.get(S1)
  54. const second = manager.get(S2)
  55. const firstDispose = vi.spyOn(first, 'dispose').mockReturnValue(fulfilled.promise)
  56. const secondDispose = vi.spyOn(second, 'dispose').mockReturnValue(rejected.promise)
  57. const disposal = manager.dispose()
  58. expect(firstDispose).toHaveBeenCalledOnce()
  59. expect(secondDispose).toHaveBeenCalledOnce()
  60. fulfilled.resolve(undefined)
  61. rejected.reject(new Error('dispose failed'))
  62. await expect(disposal).resolves.toBeUndefined()
  63. })
  64. it('cancels a pending catalog debounce when the manager is disposed', async () => {
  65. vi.useFakeTimers()
  66. try {
  67. const api = new FakeApiClient()
  68. const manager = new SessionManager(fakeRemote(api))
  69. await manager.refreshSubagents(S1)
  70. manager.setSubagentCatalogOpen(S1, true)
  71. await manager.refreshSubagents(S1)
  72. const calls = api.callsOf('subagents.list').length
  73. manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
  74. await manager.dispose()
  75. await vi.advanceTimersByTimeAsync(50)
  76. expect(api.callsOf('subagents.list')).toHaveLength(calls)
  77. } finally {
  78. vi.useRealTimers()
  79. }
  80. })
  81. })
  82. describe('list lifecycle', () => {
  83. it('single-flights refreshList and preserves the Host baseline order', async () => {
  84. const api = new FakeApiClient()
  85. const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  86. api.onList = () => gate.promise
  87. const manager = new SessionManager(fakeRemote(api))
  88. const first = manager.refreshList()
  89. const second = manager.refreshList()
  90. expect(manager.getListSnapshot().state).toBe('loading')
  91. gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
  92. await Promise.all([first, second])
  93. expect(api.callsOf('session.list')).toHaveLength(1)
  94. const snapshot = manager.getListSnapshot()
  95. expect(snapshot.state).toBe('idle')
  96. expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
  97. })
  98. it('replays incremental frames over hydration and never batch-reorders established ids', async () => {
  99. const api = new FakeApiClient()
  100. const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  101. api.onList = () => first.promise
  102. const manager = new SessionManager(fakeRemote(api))
  103. const hydration = manager.refreshList()
  104. manager.handleSessionAdded(summary(S2, { blank: true }))
  105. first.resolve(ok({ items: [summary(S1)] as never[] }))
  106. await hydration
  107. expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
  108. api.onList = () => Promise.resolve(ok({
  109. items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
  110. }))
  111. await manager.refreshList()
  112. expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
  113. })
  114. it('advances list activity from the filtered Host notification', async () => {
  115. const api = new FakeApiClient()
  116. api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
  117. const manager = new SessionManager(fakeRemote(api))
  118. await manager.refreshList()
  119. manager.handleSessionActivity(S1, 500)
  120. expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
  121. })
  122. it('keeps the error in the list snapshot on failure', async () => {
  123. const api = new FakeApiClient()
  124. api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
  125. const manager = new SessionManager(fakeRemote(api))
  126. await manager.refreshList()
  127. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
  128. // A failed pull does not step the arrival phase: still pending.
  129. expect(manager.getListSnapshot().phase).toBe('pending')
  130. })
  131. it('phase steps pending → ready on the first successful pull and never returns', async () => {
  132. const api = new FakeApiClient()
  133. const manager = new SessionManager(fakeRemote(api))
  134. expect(manager.getListSnapshot().phase).toBe('pending')
  135. await manager.refreshList()
  136. expect(manager.getListSnapshot().phase).toBe('ready')
  137. // Sticky across later failures: the pull-activity axis reports the error,
  138. // the arrival phase holds.
  139. api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
  140. await manager.refreshList()
  141. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
  142. // And across an empty re-pull (empty-with-ready = truly no sessions).
  143. api.onList = () => Promise.resolve(ok({ items: [] as never[] }))
  144. await manager.refreshList()
  145. expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
  146. expect(manager.getListSnapshot().items).toEqual([])
  147. })
  148. it('merges create into the list immediately without waiting for a refresh', async () => {
  149. const api = new FakeApiClient()
  150. api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
  151. const manager = new SessionManager(fakeRemote(api))
  152. const result = await manager.create()
  153. expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
  154. expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
  155. })
  156. it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
  157. const api = new FakeApiClient()
  158. const manager = new SessionManager(fakeRemote(api))
  159. const titleFrame = (title: string, seq: number) => {
  160. manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq })
  161. }
  162. titleFrame('Newest', 4)
  163. titleFrame('Stale', 3)
  164. titleFrame('Equal', 4)
  165. api.onList = () => Promise.resolve(ok({
  166. items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
  167. }))
  168. await manager.refreshList()
  169. const titled = manager.getListSnapshot()
  170. expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
  171. expect(titled.items[0]?.title).toBe('Newest')
  172. expect(titled.items[1]?.title).toBeUndefined()
  173. manager.handleSessionRemoved(S1)
  174. manager.handleSessionAdded(summary(S1, { blank: true }))
  175. expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
  176. })
  177. it('prewarms cold titles without letting later hints override an authoritative frame', async () => {
  178. const api = new FakeApiClient()
  179. const manager = new SessionManager(fakeRemote(api))
  180. // A push frame landed before the list. Its authoritative value wins even
  181. // when a later cache hint claims a higher sequence.
  182. manager.handleControlFrame({
  183. type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
  184. })
  185. api.onList = () => Promise.resolve(ok({
  186. items: [
  187. { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
  188. { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 12, values: { title: 'List stale' } } },
  189. ] as never[],
  190. }))
  191. await manager.refreshList()
  192. const items = manager.getListSnapshot().items
  193. // Cold row: title surfaces straight from the list block — no open, no history.
  194. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
  195. expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
  196. manager.handleSessionAdded({
  197. ...summary(S2, { updatedAt: 300 }),
  198. projections: { asOfSeq: 15, values: { title: 'Added stale' } },
  199. })
  200. expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
  201. })
  202. it('routes resident and list projections through one store across exact control replacements', async () => {
  203. const api = new FakeApiClient()
  204. api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
  205. const manager = new SessionManager(fakeRemote(api))
  206. await manager.refreshList()
  207. const session = manager.get(S1)
  208. const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) }
  209. frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
  210. expect(session.projections.get('title')).toBe('Unflushed')
  211. // The durable baseline says the host only knows up to seq 2: the phantom
  212. // row rode lost state and must drop, or last-wins pins it forever.
  213. frame({
  214. type: 'baseline',
  215. value: {
  216. queues: {}, jobs: {},
  217. projections: { [S1]: { asOfSeq: 2, values: {} } },
  218. },
  219. })
  220. expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
  221. expect(session.projections.get('title')).toBeUndefined()
  222. frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 })
  223. expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
  224. // The next complete generation replaces even a different equal-sequence row.
  225. frame({
  226. type: 'baseline',
  227. value: {
  228. queues: {}, jobs: {},
  229. projections: { [S1]: { asOfSeq: 2, values: { title: 'Recovered' } } },
  230. },
  231. })
  232. expect(manager.getListSnapshot().items[0]?.title).toBe('Recovered')
  233. expect(session.projections.get('title')).toBe('Recovered')
  234. })
  235. it('uses the latest list hint when a later control generation omits the cold Session', async () => {
  236. const api = new FakeApiClient()
  237. api.onList = () => Promise.resolve(ok({
  238. items: [summary(S1, {
  239. projections: { asOfSeq: 4, values: { title: 'Repaired cache' } },
  240. })] as never[],
  241. }))
  242. const manager = new SessionManager(fakeRemote(api))
  243. manager.handleControlFrame({
  244. type: 'projection', sessionId: S1, key: 'title', value: 'Stale live', seq: 9,
  245. })
  246. await manager.refreshList()
  247. expect(manager.getListSnapshot().items[0]?.title).toBe('Stale live')
  248. manager.handleControlFrame({
  249. type: 'baseline', value: { queues: {}, jobs: {}, projections: {} },
  250. })
  251. expect(manager.getListSnapshot().items[0]?.title).toBe('Repaired cache')
  252. })
  253. it('accepts a repaired lower-sequence list hint after an omitted control baseline', async () => {
  254. const api = new FakeApiClient()
  255. api.onList = () => Promise.resolve(ok({
  256. items: [summary(S1, {
  257. projections: { asOfSeq: 12, values: { title: 'Older cache' } },
  258. })] as never[],
  259. }))
  260. const manager = new SessionManager(fakeRemote(api))
  261. await manager.refreshList()
  262. manager.handleControlFrame({
  263. type: 'projection', sessionId: S1, key: 'title', value: 'Stale live', seq: 20,
  264. })
  265. const response = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  266. api.onList = () => response.promise
  267. const refresh = manager.refreshList()
  268. manager.handleControlFrame({
  269. type: 'baseline', value: { queues: {}, jobs: {}, projections: {} },
  270. })
  271. expect(manager.getListSnapshot().items[0]?.title).toBe('Older cache')
  272. response.resolve(ok({
  273. items: [summary(S1, {
  274. projections: { asOfSeq: 3, values: { title: 'Repaired lower cache' } },
  275. })] as never[],
  276. }))
  277. await refresh
  278. expect(manager.getListSnapshot().items[0]?.title).toBe('Repaired lower cache')
  279. })
  280. it('replays a newer session-added hint over an in-flight list response', async () => {
  281. const api = new FakeApiClient()
  282. const response = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  283. api.onList = () => response.promise
  284. const manager = new SessionManager(fakeRemote(api))
  285. const refresh = manager.refreshList()
  286. manager.handleSessionAdded(summary(S1, {
  287. projections: { asOfSeq: 8, values: { title: 'Later added hint' } },
  288. }))
  289. response.resolve(ok({
  290. items: [summary(S1, {
  291. projections: { asOfSeq: 9, values: { title: 'Earlier pull hint' } },
  292. })] as never[],
  293. }))
  294. await refresh
  295. expect(manager.getListSnapshot().items[0]?.title).toBe('Later added hint')
  296. })
  297. it('does not recreate a projection store for a Session removed during a list response', async () => {
  298. const api = new FakeApiClient()
  299. const response = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  300. api.onList = () => response.promise
  301. const manager = new SessionManager(fakeRemote(api))
  302. const refresh = manager.refreshList()
  303. manager.handleSessionRemoved(S1)
  304. response.resolve(ok({
  305. items: [summary(S1, {
  306. projections: { asOfSeq: 9, values: { title: 'Removed pull hint' } },
  307. })] as never[],
  308. }))
  309. await refresh
  310. expect(manager.getListSnapshot().items).toEqual([])
  311. manager.handleSessionAdded(summary(S1, { blank: true }))
  312. expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
  313. })
  314. })
  315. describe('search', () => {
  316. it('returns bounded Host results and forwards the caller signal', async () => {
  317. const api = new FakeApiClient()
  318. api.onSearch = () => Promise.resolve(ok({
  319. items: [{ sessionId: S1, snippet: 'matching excerpt' }],
  320. hasMore: true,
  321. }))
  322. const manager = new SessionManager(fakeRemote(api))
  323. const signal = new AbortController().signal
  324. await expect(manager.search('exact phrase', signal)).resolves.toEqual({
  325. ok: true,
  326. value: {
  327. items: [{ sessionId: S1, snippet: 'matching excerpt' }],
  328. hasMore: true,
  329. },
  330. })
  331. expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
  332. expect(api.lastSearchSignal).toBe(signal)
  333. })
  334. it('preserves business errors and folds transport failures', async () => {
  335. const api = new FakeApiClient()
  336. const manager = new SessionManager(fakeRemote(api))
  337. api.onSearch = () => Promise.resolve(err({
  338. code: 'internal',
  339. message: 'index unavailable',
  340. details: {},
  341. }))
  342. const signal = new AbortController().signal
  343. await expect(manager.search('first', signal)).resolves.toMatchObject({
  344. ok: false,
  345. error: { code: 'internal', message: 'index unavailable' },
  346. })
  347. api.onSearch = () => Promise.reject(new Error('wire down'))
  348. await expect(manager.search('second', signal)).resolves.toMatchObject({
  349. ok: false,
  350. error: { code: 'internal', message: 'wire down' },
  351. })
  352. })
  353. })
  354. describe('Host Remote event routing', () => {
  355. it('adds/removes/flips sessions and keeps removed instances resident', async () => {
  356. const api = new FakeApiClient()
  357. const manager = new SessionManager(fakeRemote(api))
  358. manager.handleSessionAdded(summary(S1, { blank: true }))
  359. manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored
  360. expect(manager.getListSnapshot().items).toHaveLength(1)
  361. const session = manager.get(S1)
  362. manager.handleSessionStatus(S1, true)
  363. expect(session.getSnapshot().running).toBe(true)
  364. expect(manager.getListSnapshot().items[0]?.running).toBe(true)
  365. manager.handleSessionError(S1, '炸了')
  366. expect(session.getSnapshot().lastAgentError).toBe('炸了')
  367. manager.handleSessionRemoved(S1)
  368. expect(manager.getListSnapshot().items).toHaveLength(0)
  369. expect(session.getSnapshot().removed).toBe(true)
  370. expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal
  371. })
  372. })
  373. describe('subagent catalogs', () => {
  374. it('rejects missing, diagnostic, and mode-mismatched catalog selections', async () => {
  375. const api = new FakeApiClient()
  376. api.onSubagentList = () => Promise.resolve(remoteOk({
  377. entries: [
  378. { kind: 'diagnostic', id: S1, reason: 'corrupt' },
  379. {
  380. kind: 'child', id: S2, mode: 'one-shot', activity: 'inactive', hasChildren: false,
  381. },
  382. ] as never[],
  383. parentAvailable: true,
  384. }))
  385. const manager = new SessionManager(fakeRemote(api))
  386. await manager.refreshSubagents(S1)
  387. expect(() => {
  388. manager.selectSubagent({
  389. parentSessionId: S1, childSessionId: 'fk-missing' as SessionId, mode: 'continuable',
  390. })
  391. }).toThrow('is not a healthy catalog child')
  392. expect(() => {
  393. manager.selectSubagent({
  394. parentSessionId: S1, childSessionId: S1, mode: 'continuable',
  395. })
  396. }).toThrow('is not a healthy catalog child')
  397. expect(() => {
  398. manager.selectSubagent({
  399. parentSessionId: S1, childSessionId: S2, mode: 'continuable',
  400. })
  401. }).toThrow('is not a healthy catalog child')
  402. })
  403. it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
  404. const api = new FakeApiClient()
  405. api.onList = () => Promise.resolve(ok({ items: [
  406. summary(S1),
  407. summary(S2, { parentSessionId: S1, origin: 'subagent' }),
  408. ] as never[] }))
  409. api.onSubagentList = () => Promise.resolve(remoteOk({
  410. entries: [{
  411. kind: 'child', id: S2, mode: 'continuable', label: 'worker',
  412. activity: 'running', hasChildren: false,
  413. }] as never[],
  414. parentAvailable: true,
  415. }))
  416. const manager = new SessionManager(fakeRemote(api))
  417. await manager.refreshList()
  418. await manager.refreshSubagents(S1)
  419. manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
  420. expect(manager.getListSnapshot().currentAddress).toEqual({
  421. parentSessionId: S1, childSessionId: S2, mode: 'continuable',
  422. })
  423. expect(manager.get(S2).getSnapshot().subagent).toEqual({
  424. address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
  425. parentAvailable: true,
  426. })
  427. // Clicking the same child through an ordinary list-selection path must not
  428. // erase the catalog-derived address and fall back to session.* transport.
  429. manager.select(S2)
  430. expect(manager.getListSnapshot().currentAddress).toEqual({
  431. parentSessionId: S1, childSessionId: S2, mode: 'continuable',
  432. })
  433. expect(manager.get(S2).getSnapshot().subagent).toEqual({
  434. address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
  435. parentAvailable: true,
  436. })
  437. await manager.get(S2).open()
  438. await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
  439. expect(api.callsOf('session.follow')).toEqual([
  440. {
  441. address: {
  442. kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
  443. },
  444. maxMessages: 50,
  445. },
  446. ])
  447. expect(api.callsOf('subagent.history')).toEqual([])
  448. expect(api.callsOf('subagents.prompt')).toEqual([
  449. {
  450. requestId: expect.any(String) as unknown as string,
  451. parentSessionId: S1, childSessionId: S2,
  452. mode: 'continuable',
  453. content: [{ type: 'text', text: 'continue' }],
  454. clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
  455. },
  456. ])
  457. expect(api.callsOf('session.history')).toEqual([])
  458. expect(api.callsOf('session.prompt')).toEqual([])
  459. const listCalls = api.callsOf('subagents.list').length
  460. manager.handleSessionStatus(S2, false)
  461. expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
  462. kind: 'child', id: S2, activity: 'inactive',
  463. })
  464. expect(api.callsOf('subagents.list')).toHaveLength(listCalls)
  465. manager.handleSessionRemoved(S2)
  466. expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
  467. origin: 'subagent', parentSessionId: S1, running: false,
  468. })
  469. expect(manager.get(S2).getSnapshot()).toMatchObject({
  470. removed: false,
  471. subagent: {
  472. address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
  473. },
  474. })
  475. })
  476. it('refetches debounced membership only while the parent catalog is open', async () => {
  477. vi.useFakeTimers()
  478. try {
  479. const api = new FakeApiClient()
  480. const manager = new SessionManager(fakeRemote(api))
  481. await manager.refreshSubagents(S1)
  482. manager.setSubagentCatalogOpen(S1, true)
  483. await Promise.resolve()
  484. const baseline = api.callsOf('subagents.list').length
  485. manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
  486. manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 }))
  487. await vi.advanceTimersByTimeAsync(50)
  488. expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
  489. manager.setSubagentCatalogOpen(S1, false)
  490. manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 }))
  491. await vi.advanceTimersByTimeAsync(50)
  492. expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
  493. } finally {
  494. vi.useRealTimers()
  495. }
  496. })
  497. it('cancels a pending membership refresh when the catalog closes', async () => {
  498. vi.useFakeTimers()
  499. try {
  500. const api = new FakeApiClient()
  501. const manager = new SessionManager(fakeRemote(api))
  502. await manager.refreshSubagents(S1)
  503. manager.setSubagentCatalogOpen(S1, true)
  504. await manager.refreshSubagents(S1)
  505. const calls = api.callsOf('subagents.list').length
  506. manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
  507. manager.setSubagentCatalogOpen(S1, false)
  508. await vi.advanceTimersByTimeAsync(50)
  509. expect(api.callsOf('subagents.list')).toHaveLength(calls)
  510. } finally {
  511. vi.useRealTimers()
  512. }
  513. })
  514. it('publishes business and transport catalog failures with and without a prior catalog', async () => {
  515. const api = new FakeApiClient()
  516. const manager = new SessionManager(fakeRemote(api))
  517. api.onSubagentList = () => Promise.resolve(remoteErr({
  518. code: 'internal', message: 'business failure', details: {},
  519. }))
  520. await manager.refreshSubagents(S1)
  521. expect(manager.getListSnapshot().subagentsByParent[S1]).toMatchObject({
  522. entries: [], state: 'error', error: { message: 'business failure' },
  523. })
  524. api.onSubagentList = () => Promise.reject(new Error('first transport failure'))
  525. await manager.refreshSubagents(S2)
  526. expect(manager.getListSnapshot().subagentsByParent[S2]).toMatchObject({
  527. entries: [], state: 'error', error: { message: 'first transport failure' },
  528. })
  529. const root = 'fk-root' as SessionId
  530. api.onSubagentList = () => Promise.resolve(remoteOk({
  531. entries: [{ kind: 'diagnostic', id: S1, reason: 'unavailable' }] as never[],
  532. parentAvailable: true,
  533. }))
  534. await manager.refreshSubagents(root)
  535. api.onSubagentList = () => Promise.reject(new Error('later transport failure'))
  536. await manager.refreshSubagents(root)
  537. expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
  538. entries: [{ kind: 'diagnostic', id: S1 }],
  539. parentAvailable: true,
  540. state: 'error',
  541. error: { message: 'later transport failure' },
  542. })
  543. })
  544. it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
  545. const api = new FakeApiClient()
  546. const root = 'fk-root' as SessionId
  547. api.onSubagentList = () => Promise.resolve(remoteOk({
  548. entries: [
  549. {
  550. kind: 'child', id: S1, mode: 'continuable', label: 'parent',
  551. activity: 'inactive', hasChildren: false,
  552. },
  553. {
  554. kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
  555. activity: 'inactive', hasChildren: false,
  556. },
  557. ] as never[],
  558. parentAvailable: true,
  559. }))
  560. const manager = new SessionManager(fakeRemote(api))
  561. await manager.refreshSubagents(root)
  562. manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
  563. parentSessionId: S1, origin: 'subagent',
  564. }))
  565. manager.handleSessionAdded(summary('fk-fork' as SessionId, { parentSessionId: S2 }))
  566. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  567. { kind: 'child', id: S1, hasChildren: true },
  568. { kind: 'child', id: S2, hasChildren: false },
  569. ])
  570. })
  571. it('preserves a live expandability hint across only the older in-flight catalog response', async () => {
  572. const api = new FakeApiClient()
  573. const root = 'fk-root' as SessionId
  574. const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  575. api.onSubagentList = () => response.promise
  576. const manager = new SessionManager(fakeRemote(api))
  577. const refresh = manager.refreshSubagents(root)
  578. manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
  579. parentSessionId: S1, origin: 'subagent',
  580. }))
  581. response.resolve(remoteOk({
  582. entries: [{
  583. kind: 'child', id: S1, mode: 'continuable', label: 'parent',
  584. activity: 'inactive', hasChildren: false,
  585. }] as never[],
  586. parentAvailable: true,
  587. }))
  588. await refresh
  589. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  590. { kind: 'child', id: S1, hasChildren: true },
  591. ])
  592. api.onSubagentList = () => Promise.resolve(remoteOk({
  593. entries: [{
  594. kind: 'child', id: S1, mode: 'continuable', label: 'parent',
  595. activity: 'inactive', hasChildren: false,
  596. }] as never[],
  597. parentAvailable: true,
  598. }))
  599. await manager.refreshSubagents(root)
  600. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  601. { kind: 'child', id: S1, hasChildren: false },
  602. ])
  603. })
  604. it('replays status frames over an older in-flight catalog response', async () => {
  605. const api = new FakeApiClient()
  606. const root = 'fk-root' as SessionId
  607. const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  608. api.onSubagentList = () => response.promise
  609. const manager = new SessionManager(fakeRemote(api))
  610. const refresh = manager.refreshSubagents(root)
  611. manager.handleSessionStatus(S1, false)
  612. manager.handleSessionStatus(S2, true)
  613. response.resolve(remoteOk({
  614. entries: [
  615. {
  616. kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
  617. activity: 'running', hasChildren: false,
  618. },
  619. {
  620. kind: 'child', id: S2, mode: 'continuable', label: 'started',
  621. activity: 'inactive', hasChildren: false,
  622. },
  623. { kind: 'diagnostic', id: 'fk-diagnostic' as SessionId, reason: 'corrupt' },
  624. ] as never[],
  625. parentAvailable: true,
  626. }))
  627. await refresh
  628. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  629. { kind: 'child', id: S1, activity: 'inactive' },
  630. { kind: 'child', id: S2, activity: 'running' },
  631. { kind: 'diagnostic', id: 'fk-diagnostic' },
  632. ])
  633. manager.handleSessionStatus(S1, true)
  634. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  635. { kind: 'child', id: S1, activity: 'running' },
  636. { kind: 'child', id: S2, activity: 'running' },
  637. { kind: 'diagnostic', id: 'fk-diagnostic' },
  638. ])
  639. })
  640. it('marks a detached catalog child inactive without requiring a selected address', async () => {
  641. const api = new FakeApiClient()
  642. api.onSubagentList = () => Promise.resolve(remoteOk({
  643. entries: [{
  644. kind: 'child', id: S2, mode: 'continuable', label: 'worker',
  645. activity: 'running', hasChildren: false,
  646. }] as never[],
  647. parentAvailable: true,
  648. }))
  649. const manager = new SessionManager(fakeRemote(api))
  650. await manager.refreshSubagents(S1)
  651. manager.handleSessionRemoved(S2)
  652. expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
  653. { kind: 'child', id: S2, activity: 'inactive' },
  654. ])
  655. })
  656. it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
  657. const api = new FakeApiClient()
  658. const root = 'fk-root' as SessionId
  659. const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  660. api.onSubagentList = () => first.promise
  661. const manager = new SessionManager(fakeRemote(api))
  662. const refresh = manager.refreshSubagents(root)
  663. expect(manager.refreshSubagents(root)).toBe(refresh)
  664. api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
  665. first.resolve(remoteOk({ entries: [], parentAvailable: true }))
  666. await refresh
  667. expect(api.callsOf('subagents.list')).toHaveLength(1)
  668. })
  669. it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
  670. vi.useFakeTimers()
  671. try {
  672. const api = new FakeApiClient()
  673. const root = 'fk-root' as SessionId
  674. const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  675. const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  676. api.onSubagentList = () => first.promise
  677. const manager = new SessionManager(fakeRemote(api), root)
  678. const refresh = manager.refreshSubagents(root)
  679. manager.setSubagentCatalogOpen(root, true)
  680. // A membership frame arrives while the pull is in flight; the debounced
  681. // refresh it schedules fires 50ms later and is coalesced into the pull —
  682. // which was requested before the new child existed. The stale mark must
  683. // queue one trailing pull carrying the change.
  684. manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
  685. await vi.advanceTimersByTimeAsync(50)
  686. api.onSubagentList = () => second.promise
  687. first.resolve(remoteOk({
  688. entries: [{
  689. kind: 'child', id: S1, mode: 'continuable', label: 'older',
  690. activity: 'inactive', hasChildren: false,
  691. }] as never[],
  692. parentAvailable: true,
  693. }))
  694. await refresh
  695. // The trailing pull is already in flight (kicked synchronously in finally).
  696. second.resolve(remoteOk({
  697. entries: [
  698. {
  699. kind: 'child', id: S1, mode: 'continuable', label: 'older',
  700. activity: 'inactive', hasChildren: false,
  701. },
  702. {
  703. kind: 'child', id: S2, mode: 'continuable', label: 'new child',
  704. activity: 'inactive', hasChildren: false,
  705. },
  706. ] as never[],
  707. parentAvailable: true,
  708. }))
  709. await second.promise
  710. // The Remote face resolves one microtask after the response settles.
  711. await vi.advanceTimersByTimeAsync(0)
  712. expect(api.callsOf('subagents.list')).toHaveLength(2)
  713. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  714. { kind: 'child', id: S1, label: 'older' },
  715. { kind: 'child', id: S2, label: 'new child' },
  716. ])
  717. } finally {
  718. vi.useRealTimers()
  719. }
  720. })
  721. it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
  722. const api = new FakeApiClient()
  723. const root = 'fk-root' as SessionId
  724. const child = () => ({
  725. kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
  726. activity: 'inactive' as const, hasChildren: false,
  727. })
  728. const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  729. api.onSubagentList = () => first.promise
  730. const manager = new SessionManager(fakeRemote(api))
  731. const refresh = manager.refreshSubagents(root)
  732. first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
  733. await refresh
  734. manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
  735. // The removal lands while a second pull is in flight: the invalidation
  736. // must survive the pre-removal ok response, so one trailing pull runs.
  737. const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  738. api.onSubagentList = () => mid.promise
  739. const midRefresh = manager.refreshSubagents(root)
  740. manager.handleSessionRemoved(root)
  741. const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  742. api.onSubagentList = () => trailing.promise
  743. mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
  744. await midRefresh
  745. expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
  746. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
  747. trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} }))
  748. await vi.waitFor(() => {
  749. expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
  750. state: 'error',
  751. parentAvailable: false,
  752. })
  753. })
  754. const rootCalls = api.callsOf('subagents.list').filter(call => call === root)
  755. expect(rootCalls).toHaveLength(3)
  756. expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
  757. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
  758. })
  759. it('invalidates catalog availability when the owning parent is removed', async () => {
  760. const api = new FakeApiClient()
  761. const root = 'fk-root' as SessionId
  762. api.onSubagentList = () => Promise.resolve(remoteOk({
  763. entries: [{
  764. kind: 'child', id: S2, mode: 'continuable', label: 'worker',
  765. activity: 'inactive', hasChildren: false,
  766. }] as never[],
  767. parentAvailable: true,
  768. }))
  769. const manager = new SessionManager(fakeRemote(api))
  770. await manager.refreshSubagents(root)
  771. manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
  772. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
  773. manager.handleSessionRemoved(root)
  774. expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
  775. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
  776. })
  777. })
  778. describe('remaining branches', () => {
  779. it('refreshList folds a transport throw into the error state', async () => {
  780. const api = new FakeApiClient()
  781. api.onList = () => Promise.reject(new Error('list wire down'))
  782. const manager = new SessionManager(fakeRemote(api))
  783. await manager.refreshList()
  784. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
  785. })
  786. it('refreshList pushes running bits down to already-instantiated sessions', async () => {
  787. const api = new FakeApiClient()
  788. const manager = new SessionManager(fakeRemote(api))
  789. const session = manager.get(S1)
  790. api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
  791. await manager.refreshList()
  792. expect(session.getSnapshot().running).toBe(true)
  793. })
  794. it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
  795. const api = new FakeApiClient()
  796. api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
  797. const manager = new SessionManager(fakeRemote(api))
  798. await manager.create({ cwd: '/tmp/w', sessionId: S1 })
  799. expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
  800. expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
  801. await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
  802. expect(manager.getListSnapshot().items).toHaveLength(1)
  803. api.onCreate = () => Promise.reject(new Error('create wire down'))
  804. expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
  805. // Business error passes through untouched.
  806. api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
  807. expect(await manager.create()).toMatchObject({ ok: false })
  808. })
  809. it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
  810. const api = new FakeApiClient()
  811. api.onCreate = () => Promise.resolve(err({
  812. code: 'workspace-attach-failed',
  813. message: 'published but unattached',
  814. details: { sessionId: S1, workspaceId: 'w1' },
  815. } as never))
  816. const manager = new SessionManager(fakeRemote(api))
  817. const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
  818. expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
  819. expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
  820. expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
  821. })
  822. it('reconciles a fork child published before workspace attachment fails', async () => {
  823. const api = new FakeApiClient()
  824. api.onFork = () => Promise.resolve(err({
  825. code: 'workspace-attach-failed',
  826. message: 'forked but unattached',
  827. details: { sessionId: S2, workspaceId: 'w1' },
  828. } as never))
  829. const manager = new SessionManager(fakeRemote(api))
  830. const result = await manager.fork({ sessionId: S1 })
  831. expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
  832. expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
  833. sessionId: S2,
  834. parentSessionId: S1,
  835. blank: false,
  836. })])
  837. })
  838. it('leaves the list unchanged for ordinary fork failures and folds transport throws', async () => {
  839. const api = new FakeApiClient()
  840. const manager = new SessionManager(fakeRemote(api))
  841. api.onFork = () => Promise.resolve(err({ code: 'internal', message: 'fork denied', details: {} }))
  842. await expect(manager.fork({ sessionId: S1, atSeq: 4 })).resolves.toMatchObject({
  843. ok: false, error: { message: 'fork denied' },
  844. })
  845. expect(manager.getListSnapshot().items).toEqual([])
  846. api.onFork = () => Promise.reject(new Error('fork wire down'))
  847. await expect(manager.fork({ sessionId: S1 })).resolves.toMatchObject({
  848. ok: false, error: { code: 'internal', message: 'fork wire down' },
  849. })
  850. expect(manager.getListSnapshot().items).toEqual([])
  851. })
  852. it('reconciles a preallocated id after an ordinary transport failure', async () => {
  853. const api = new FakeApiClient()
  854. api.onCreate = () => Promise.reject(new Error('response lost'))
  855. const manager = new SessionManager(fakeRemote(api))
  856. const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
  857. expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
  858. expect(manager.getListSnapshot().items).toEqual([])
  859. manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' }))
  860. expect(manager.getListSnapshot().items).toEqual([
  861. expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
  862. ])
  863. manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' }))
  864. expect(manager.getListSnapshot().items).toHaveLength(1)
  865. })
  866. it('subscribe notifies on list changes and stops after unsubscribe', async () => {
  867. const api = new FakeApiClient()
  868. const manager = new SessionManager(fakeRemote(api))
  869. let notified = 0
  870. const unsubscribe = manager.subscribe(() => { notified++ })
  871. await manager.refreshList()
  872. await new Promise(resolve => setTimeout(resolve, 0))
  873. expect(notified).toBeGreaterThan(0)
  874. const seen = notified
  875. unsubscribe()
  876. manager.handleSessionAdded(summary(S1, { blank: true }))
  877. await new Promise(resolve => setTimeout(resolve, 0))
  878. expect(notified).toBe(seen)
  879. })
  880. it('ignores Host status and error events for sessions without an instance', () => {
  881. const api = new FakeApiClient()
  882. const manager = new SessionManager(fakeRemote(api))
  883. manager.handleSessionStatus(S2, true)
  884. manager.handleSessionError(S2, '无实例')
  885. })
  886. it('ignores duplicate running and stale activity frames', () => {
  887. const manager = makeManager()
  888. manager.handleSessionAdded(summary(S1, { running: true, updatedAt: 500 }))
  889. const before = manager.getListSnapshot().items[0]
  890. manager.handleSessionStatus(S1, true)
  891. manager.handleSessionActivity(S1, 499)
  892. expect(manager.getListSnapshot().items[0]).toBe(before)
  893. })
  894. it('keeps unrelated blank rows unchanged when a first prompt engages one session', async () => {
  895. const manager = makeManager()
  896. manager.handleSessionAdded(summary(S1, { blank: true }))
  897. manager.handleSessionAdded(summary(S2, { blank: true }))
  898. await expect(manager.get(S1).prompt([{ type: 'text', text: 'hello' }], 'queue'))
  899. .resolves.toMatchObject({ ok: true })
  900. const items = manager.getListSnapshot().items
  901. expect(items.find(item => item.sessionId === S1)?.blank).toBe(false)
  902. expect(items.find(item => item.sessionId === S2)?.blank).toBe(true)
  903. })
  904. it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
  905. const api = new FakeApiClient()
  906. api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
  907. const manager = new SessionManager(fakeRemote(api))
  908. await manager.refreshList()
  909. const before = manager.getListSnapshot()
  910. manager.handleSessionStatus(S2, true)
  911. const after = manager.getListSnapshot()
  912. expect(after.items).not.toBe(before.items)
  913. const beforeS1 = before.items.find(e => e.sessionId === S1)
  914. const afterS1 = after.items.find(e => e.sessionId === S1)
  915. expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache)
  916. // Same-order same-entries snapshot reuses the items array.
  917. manager.handleSessionError(S1, 'x')
  918. expect(manager.getListSnapshot().items).toBe(after.items)
  919. })
  920. it('enriches an existing summary with cwd, parentSessionId, and origin', () => {
  921. const api = new FakeApiClient()
  922. const manager = new SessionManager(fakeRemote(api))
  923. manager.handleSessionAdded(summary(S1, { blank: true }))
  924. manager.handleSessionAdded(summary(S2, { blank: true }))
  925. manager.handleSessionAdded(summary(S2, {
  926. blank: true, cwd: '/work/child', parentSessionId: S1, origin: 'subagent',
  927. }))
  928. const items = manager.getListSnapshot().items
  929. expect(items.find(e => e.sessionId === S2)).toMatchObject({
  930. cwd: '/work/child', parentSessionId: S1, origin: 'subagent', depth: 1,
  931. })
  932. })
  933. })
  934. describe('connected generation', () => {
  935. it('refreshes query baselines without rebuilding independently resumed Session sources', async () => {
  936. const api = new FakeApiClient()
  937. api.onHistory = () => Promise.resolve(ok({
  938. records: entries(plainTurn(0, 0, 'a', 'b')) as never[],
  939. hasMore: false,
  940. modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
  941. }))
  942. const manager = new SessionManager(fakeRemote(api))
  943. const openedSession = manager.get(S1)
  944. await openedSession.open()
  945. manager.get(S2) // instantiated but never opened
  946. const historyCallsBefore = api.callsOf('session.history').length
  947. manager.handleConnected()
  948. await vi.waitFor(() => {
  949. expect(api.callsOf('session.list').length).toBe(1)
  950. })
  951. expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore)
  952. })
  953. it('retains the durable parent address and refreshes its catalogs across reconnect', async () => {
  954. const api = new FakeApiClient()
  955. const address = {
  956. parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
  957. }
  958. const parent = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  959. const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
  960. api.onSubagentList = payload => (payload === S1 ? parent.promise : child.promise)
  961. const manager = new SessionManager(fakeRemote(api), S2, address)
  962. manager.handleConnected()
  963. expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
  964. parent.resolve(remoteOk({ entries: [], parentAvailable: true }))
  965. child.resolve(remoteOk({ entries: [], parentAvailable: true }))
  966. await vi.waitFor(() => {
  967. expect(api.callsOf('session.list')).toHaveLength(1)
  968. })
  969. await vi.waitFor(() => {
  970. expect(api.callsOf('subagents.list')).toEqual([S1, S2])
  971. })
  972. expect(manager.get(S2).getSnapshot().subagent).toEqual({
  973. address,
  974. parentAvailable: true,
  975. })
  976. expect(manager.getListSnapshot().currentAddress).toEqual(address)
  977. })
  978. it('refreshes an open catalog across reconnect even when it is not selected', async () => {
  979. const api = new FakeApiClient()
  980. const manager = new SessionManager(fakeRemote(api))
  981. manager.setSubagentCatalogOpen(S1, true)
  982. await manager.refreshSubagents(S1)
  983. const catalogCalls = api.callsOf('subagents.list').length
  984. manager.handleConnected()
  985. await vi.waitFor(() => {
  986. expect(api.callsOf('session.list')).toHaveLength(1)
  987. expect(api.callsOf('subagents.list')).toHaveLength(catalogCalls + 1)
  988. })
  989. })
  990. })
  991. describe('completed reminder', () => {
  992. const status = (manager: SessionManager, sessionId: SessionId, running: boolean): void => {
  993. manager.handleSessionStatus(sessionId, running)
  994. }
  995. const added = (manager: SessionManager, sessionId: SessionId): void => {
  996. manager.handleSessionAdded(summary(sessionId))
  997. }
  998. const entry = (manager: SessionManager, sessionId: SessionId) =>
  999. manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
  1000. it('arms on a running→idle flip of a non-selected session and clears on select', () => {
  1001. const manager = makeManager()
  1002. added(manager, S1)
  1003. added(manager, S2)
  1004. manager.select(S1)
  1005. expect(entry(manager, S2)?.completed).toBe(false)
  1006. status(manager, S2, true)
  1007. status(manager, S2, false)
  1008. expect(entry(manager, S2)?.completed).toBe(true)
  1009. // Opening the session consumes the reminder.
  1010. manager.select(S2)
  1011. expect(entry(manager, S2)?.completed).toBe(false)
  1012. })
  1013. it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
  1014. const manager = makeManager()
  1015. added(manager, S1)
  1016. added(manager, S2)
  1017. manager.select(S2)
  1018. status(manager, S2, true)
  1019. status(manager, S2, false)
  1020. expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
  1021. // Switch away; a fresh run completing again arms the reminder.
  1022. manager.select(S1)
  1023. status(manager, S2, true)
  1024. status(manager, S2, false)
  1025. expect(entry(manager, S2)?.completed).toBe(true)
  1026. })
  1027. it('a re-run disarms the reminder while running and re-arms on its completion', () => {
  1028. const manager = makeManager()
  1029. added(manager, S1)
  1030. added(manager, S2)
  1031. manager.select(S1)
  1032. status(manager, S2, true)
  1033. status(manager, S2, false)
  1034. expect(entry(manager, S2)?.completed).toBe(true)
  1035. // The user starts a new run without opening the session: running wins.
  1036. status(manager, S2, true)
  1037. expect(entry(manager, S2)?.completed).toBe(false)
  1038. status(manager, S2, false)
  1039. expect(entry(manager, S2)?.completed).toBe(true)
  1040. })
  1041. it('session-removed drops the reminder and a re-add starts clean', () => {
  1042. const manager = makeManager()
  1043. added(manager, S1)
  1044. added(manager, S2)
  1045. manager.select(S1)
  1046. status(manager, S2, true)
  1047. status(manager, S2, false)
  1048. expect(entry(manager, S2)?.completed).toBe(true)
  1049. manager.handleSessionRemoved(S2)
  1050. expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
  1051. added(manager, S2)
  1052. expect(entry(manager, S2)?.completed).toBe(false)
  1053. })
  1054. it('a list refresh carrying the running→idle transition arms the reminder', async () => {
  1055. const api = new FakeApiClient()
  1056. api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
  1057. const manager = new SessionManager(fakeRemote(api))
  1058. await manager.refreshList()
  1059. manager.select(S1)
  1060. expect(entry(manager, S2)?.completed).toBe(false)
  1061. api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
  1062. await manager.refreshList()
  1063. expect(entry(manager, S2)?.completed).toBe(true)
  1064. })
  1065. it('never arms for sessions already idle at first observation', async () => {
  1066. const api = new FakeApiClient()
  1067. api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
  1068. const manager = new SessionManager(fakeRemote(api))
  1069. await manager.refreshList()
  1070. manager.select(S1)
  1071. expect(entry(manager, S2)?.completed).toBe(false)
  1072. api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
  1073. await manager.refreshList()
  1074. expect(entry(manager, S2)?.completed).toBe(false)
  1075. })
  1076. it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
  1077. const api = new FakeApiClient()
  1078. const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  1079. api.onList = () => gate.promise
  1080. const manager = new SessionManager(fakeRemote(api))
  1081. const refresh = manager.refreshList()
  1082. // The session finishes while the first pull is still in flight; the pull
  1083. // response recorded it as running at pull time.
  1084. status(manager, S2, false)
  1085. gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
  1086. await refresh
  1087. expect(entry(manager, S2)?.completed).toBe(true)
  1088. })
  1089. it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
  1090. const api = new FakeApiClient()
  1091. const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  1092. api.onList = () => gate.promise
  1093. const manager = new SessionManager(fakeRemote(api))
  1094. const refresh = manager.refreshList()
  1095. // The unknown session starts and finishes while the first pull is in
  1096. // flight; the pull-time baseline recorded it idle, so the running→idle
  1097. // edge lives entirely inside the replayed mutations.
  1098. status(manager, S2, true)
  1099. status(manager, S2, false)
  1100. gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
  1101. await refresh
  1102. expect(entry(manager, S2)?.completed).toBe(true)
  1103. })
  1104. })
  1105. describe('background-job mirror', () => {
  1106. const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
  1107. id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
  1108. })
  1109. const tasksFrame = (
  1110. sessionId: SessionId,
  1111. jobs: unknown[],
  1112. ): Extract<SessionControlFrame, { type: 'jobs' }> => ({
  1113. type: 'jobs', sessionId, jobs: jobs as never,
  1114. })
  1115. it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
  1116. const manager = makeManager()
  1117. manager.handleControlFrame(tasksFrame(S1, [view()]))
  1118. manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
  1119. const first = manager.getListSnapshot().jobsBySession
  1120. expect(first[S1]).toEqual([view()])
  1121. expect(first[S2]?.[0]?.label).toBe('other')
  1122. // Last-wins: the newer whole set replaces, it does not merge.
  1123. manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })]))
  1124. expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })])
  1125. })
  1126. it('stores an emptied set as an absent key so absence and [] read alike', () => {
  1127. const manager = makeManager()
  1128. manager.handleControlFrame(tasksFrame(S1, [view()]))
  1129. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true)
  1130. manager.handleControlFrame(tasksFrame(S1, []))
  1131. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
  1132. })
  1133. it('clears the mirror when the next control baseline has no jobs', () => {
  1134. const manager = makeManager()
  1135. manager.handleControlFrame(tasksFrame(S1, [view()]))
  1136. manager.handleControlFrame({
  1137. type: 'baseline',
  1138. value: { queues: {}, jobs: {}, projections: {} },
  1139. })
  1140. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
  1141. })
  1142. it('keeps only non-empty job sets from a complete control baseline', () => {
  1143. const manager = makeManager()
  1144. manager.handleControlFrame({
  1145. type: 'baseline',
  1146. value: {
  1147. queues: {}, projections: {},
  1148. jobs: { [S1]: [], [S2]: [view({ id: 'pwsh-1', label: 'kept' })] as never[] },
  1149. },
  1150. })
  1151. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
  1152. expect(manager.getListSnapshot().jobsBySession[S2]).toEqual([
  1153. view({ id: 'pwsh-1', label: 'kept' }),
  1154. ])
  1155. })
  1156. it('drops the rows when the session is removed, whichever stream lands first', () => {
  1157. const manager = makeManager()
  1158. manager.handleSessionAdded(summary(S1, { blank: true }))
  1159. manager.handleControlFrame(tasksFrame(S1, [view()]))
  1160. manager.handleSessionRemoved(S1)
  1161. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
  1162. })
  1163. it('notifies list subscribers so an open header re-renders without a poll', async () => {
  1164. const manager = makeManager()
  1165. const seen = vi.fn()
  1166. manager.subscribe(seen)
  1167. manager.handleControlFrame(tasksFrame(S1, [view()]))
  1168. // The notifier batches on a microtask; the frame itself is already applied.
  1169. await Promise.resolve()
  1170. expect(seen).toHaveBeenCalled()
  1171. })
  1172. })