manager.client.spec.ts 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079
  1. /**
  2. * SessionManager orchestration: lazy resident instances, list lifecycle, host
  3. * frame routing, and control baselines for uninstantiated sessions.
  4. */
  5. import { describe, expect, vi } from 'vitest'
  6. import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  7. import { SessionSeq } from '@deepseek-ai/dsh-session/types'
  8. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  9. import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
  10. import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
  11. import {
  12. createClientTest, type ClientTestFixtures, webApp,
  13. } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
  14. import type {} from '@deepseek-ai/dsh-session-title/client'
  15. import { SessionManager } from '../src/client/sessions/manager.ts'
  16. import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
  17. import { entries, plainTurn } from './event-script.client.ts'
  18. import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts'
  19. const S1 = 'fk-m1' as SessionId
  20. const S2 = 'fk-m2' as SessionId
  21. /** Gateway Client cone used by the subagent-catalog and connected-generation cases. */
  22. const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
  23. const it = createClientTest({ roster: API_ROSTER })
  24. /** The first client boot pays the cold module transform of the api cone. */
  25. const COLD_BOOT_TIMEOUT_MS = 60_000
  26. type SummaryOver = Partial<{
  27. updatedAt: number
  28. running: boolean
  29. blank: boolean
  30. cwd: string
  31. parentSessionId: SessionId
  32. origin: 'subagent'
  33. }>
  34. function summary(sessionId: SessionId, over: SummaryOver = {}) {
  35. return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
  36. }
  37. function makeManager(
  38. mock: RemoteMock,
  39. remote: ClientTestFixtures['remote'],
  40. ): SessionManager {
  41. mock.load(sessionWorld)
  42. // Cases using this helper never open a Session, so they do not need the broader Client Remote's $stream member.
  43. return new SessionManager(remote as unknown as SessionRemotes)
  44. }
  45. describe('SessionManager instances', () => {
  46. it('lazily builds one resident instance per id and syncs the running bit from the list', async ({ mock, remote }) => {
  47. remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] }))
  48. const manager = makeManager(mock, remote)
  49. await manager.refreshList()
  50. const session = manager.get(S1)
  51. expect(manager.get(S1)).toBe(session) // resident: same instance forever
  52. expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
  53. })
  54. })
  55. describe('SessionManager query lifetime', () => {
  56. it.for(['list', 'catalog'] as const)('forwards an unexpected %s failure and still completes teardown', async (target, { mock, remote }) => {
  57. const manager = makeManager(mock, remote)
  58. const failure = new Error('query implementation failed')
  59. if (target === 'list') remote.session.list.mockRejectedValueOnce(failure)
  60. else remote.subagents.list.mockRejectedValueOnce(failure)
  61. try {
  62. await expect(target === 'list' ? manager.refreshList() : manager.refreshSubagents(S1)).rejects.toBe(failure)
  63. } finally {
  64. await manager.dispose()
  65. }
  66. })
  67. it('keeps a rejected Remote list failure in the observable state', async ({ mock, remote }) => {
  68. const manager = makeManager(mock, remote)
  69. const failure = new RemoteError('gateway/internal', 'list unavailable', {})
  70. remote.session.list.mockRejectedValueOnce(failure)
  71. try {
  72. await manager.refreshList()
  73. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: failure })
  74. } finally {
  75. await manager.dispose()
  76. }
  77. })
  78. it.for([false, true])('preserves a rejected Remote catalog failure with prior baseline %s', async (warm, { mock, remote }) => {
  79. const manager = makeManager(mock, remote)
  80. const failure = new RemoteError('gateway/internal', 'catalog unavailable', {})
  81. const entries = [{ kind: 'child' as const, id: S2, mode: 'one-shot' as const, activity: 'inactive' as const, hasChildren: false }]
  82. try {
  83. if (warm) {
  84. remote.subagents.list.mockResolvedValueOnce(ok({ entries, parentAvailable: true }))
  85. await manager.refreshSubagents(S1)
  86. manager.handleSessionRemoved(S1)
  87. }
  88. remote.subagents.list.mockRejectedValueOnce(failure)
  89. const refresh = manager.refreshSubagents(S1)
  90. await refresh
  91. expect(manager.getListSnapshot().subagentsByParent[S1]).toMatchObject({
  92. state: 'error', error: failure, entries: warm ? entries : [],
  93. })
  94. expect(manager.getListSnapshot().subagentsByParent[S1]?.parentAvailable).toBe(warm ? false : undefined)
  95. } finally {
  96. await manager.dispose()
  97. }
  98. })
  99. it('cancels a queued catalog membership refresh when its consumer closes', async ({ mock, remote }) => {
  100. vi.useFakeTimers()
  101. const manager = makeManager(mock, remote)
  102. try {
  103. manager.setSubagentCatalogOpen(S1, true)
  104. await manager.refreshSubagents(S1)
  105. manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
  106. manager.setSubagentCatalogOpen(S1, false)
  107. await vi.runAllTimersAsync()
  108. expect(remote.subagents.list).toHaveBeenCalledOnce()
  109. } finally {
  110. await manager.dispose()
  111. vi.useRealTimers()
  112. }
  113. })
  114. })
  115. describe('list lifecycle', () => {
  116. it('fills missing durable links without overwriting established rows and projects first-send engagement', async ({ mock, remote }) => {
  117. const manager = makeManager(mock, remote)
  118. remote.session.list.mockResolvedValueOnce(ok({ items: [
  119. summary(S1, { blank: true }), summary(S2, { cwd: '/existing', blank: true }),
  120. ] }))
  121. try {
  122. await manager.refreshList()
  123. manager.handleSessionAdded(summary(S1, { cwd: '/filled', parentSessionId: S2, origin: 'subagent', blank: false }))
  124. manager.handleSessionAdded(summary(S2, { cwd: '/ignored', blank: true }))
  125. manager.handleSessionActivity(S1, 50)
  126. manager.handleSessionActivity(S2, 200)
  127. await manager.get(S2).prompt([{ type: 'text', text: 'first message' }], 'queue')
  128. expect(manager.getListSnapshot().items).toEqual(expect.arrayContaining([
  129. expect.objectContaining({ sessionId: S1, cwd: '/filled', parentSessionId: S2, origin: 'subagent', blank: false }),
  130. expect.objectContaining({ sessionId: S2, cwd: '/existing', updatedAt: 200, blank: false }),
  131. ]))
  132. } finally {
  133. await manager.dispose()
  134. }
  135. })
  136. it('projects cold Session additions and an empty-cut control baseline before history exists', async ({ mock, remote }) => {
  137. const manager = makeManager(mock, remote)
  138. try {
  139. manager.handleSessionAdded({
  140. ...summary(S1), projections: { asOfSeq: -1, values: { title: 'before history' } },
  141. })
  142. manager.handleControlFrame({
  143. type: 'baseline', value: { jobs: { [S1]: [] }, projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } },
  144. })
  145. expect(manager.getListSnapshot().items[0]?.title).toBe('before history')
  146. expect(manager.getListSnapshot().jobsBySession).toEqual({})
  147. } finally {
  148. await manager.dispose()
  149. }
  150. })
  151. it('single-flights refreshList and preserves the Host baseline order', async ({ mock, remote }) => {
  152. const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  153. remote.session.list.mockReturnValue(gate.promise)
  154. const manager = makeManager(mock, remote)
  155. const first = manager.refreshList()
  156. const second = manager.refreshList()
  157. expect(manager.getListSnapshot().state).toBe('loading')
  158. gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
  159. await Promise.all([first, second])
  160. expect(remote.session.list).toHaveBeenCalledOnce()
  161. const snapshot = manager.getListSnapshot()
  162. expect(snapshot.state).toBe('idle')
  163. expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
  164. })
  165. it('replays incremental frames over hydration and never batch-reorders established ids', async ({ mock, remote }) => {
  166. const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  167. remote.session.list.mockReturnValue(first.promise)
  168. const manager = makeManager(mock, remote)
  169. const hydration = manager.refreshList()
  170. manager.handleSessionAdded(summary(S2, { blank: true }))
  171. first.resolve(ok({ items: [summary(S1)] as never[] }))
  172. await hydration
  173. expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
  174. remote.session.list.mockResolvedValue(ok({
  175. items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
  176. }))
  177. await manager.refreshList()
  178. expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
  179. })
  180. it('advances list activity from the filtered Host notification', async ({ mock, remote }) => {
  181. remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
  182. const manager = makeManager(mock, remote)
  183. await manager.refreshList()
  184. manager.handleSessionActivity(S1, 500)
  185. expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
  186. })
  187. it('keeps the error in the list snapshot on failure', async ({ mock, remote }) => {
  188. remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'boom', {})))
  189. const manager = makeManager(mock, remote)
  190. await manager.refreshList()
  191. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal' } })
  192. // A failed pull does not step the arrival phase: still pending.
  193. expect(manager.getListSnapshot().phase).toBe('pending')
  194. })
  195. it('phase steps pending → ready on the first successful pull and never returns', async ({ mock, remote }) => {
  196. const manager = makeManager(mock, remote)
  197. expect(manager.getListSnapshot().phase).toBe('pending')
  198. await manager.refreshList()
  199. expect(manager.getListSnapshot().phase).toBe('ready')
  200. // Sticky across later failures: the pull-activity axis reports the error,
  201. // the arrival phase holds.
  202. remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'down', {})))
  203. await manager.refreshList()
  204. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
  205. // And across an empty re-pull (empty-with-ready = truly no sessions).
  206. remote.session.list.mockResolvedValue(ok({ items: [] as never[] }))
  207. await manager.refreshList()
  208. expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
  209. expect(manager.getListSnapshot().items).toEqual([])
  210. })
  211. it('merges create into the list immediately without waiting for a refresh', async ({ mock, remote }) => {
  212. remote.session.create.mockResolvedValue(ok({ sessionId: S2 }))
  213. const manager = makeManager(mock, remote)
  214. const result = await manager.create()
  215. expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
  216. expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
  217. })
  218. it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async ({ mock, remote }) => {
  219. const manager = makeManager(mock, remote)
  220. const titleFrame = (title: string, seq: number) => {
  221. manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq })
  222. }
  223. titleFrame('Newest', 4)
  224. titleFrame('Stale', 3)
  225. titleFrame('Equal', 4)
  226. remote.session.list.mockResolvedValue(ok({
  227. items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
  228. }))
  229. await manager.refreshList()
  230. const titled = manager.getListSnapshot()
  231. expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
  232. expect(titled.items[0]?.title).toBe('Newest')
  233. expect(titled.items[1]?.title).toBeUndefined()
  234. manager.handleSessionRemoved(S1)
  235. manager.handleSessionAdded(summary(S1, { blank: true }))
  236. expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
  237. })
  238. it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async ({ mock, remote }) => {
  239. const manager = makeManager(mock, remote)
  240. // A push frame landed before the list (S2's title is newer than the block's cut).
  241. manager.handleControlFrame({
  242. type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
  243. })
  244. remote.session.list.mockResolvedValue(ok({
  245. items: [
  246. { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
  247. { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
  248. ] as never[],
  249. }))
  250. await manager.refreshList()
  251. const items = manager.getListSnapshot().items
  252. // Cold row: title surfaces straight from the list block — no open, no history.
  253. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
  254. // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
  255. expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
  256. })
  257. it('discards the previous generation title before accepting its lower-seq replay', async ({ mock, remote }) => {
  258. remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
  259. const manager = makeManager(mock, remote)
  260. try {
  261. await manager.refreshList()
  262. manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
  263. manager.handleConnected()
  264. await manager.refreshList()
  265. expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
  266. manager.handleControlFrame({
  267. type: 'baseline',
  268. value: {
  269. jobs: {},
  270. projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } },
  271. },
  272. })
  273. expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
  274. } finally {
  275. await manager.dispose()
  276. }
  277. })
  278. })
  279. describe('search', () => {
  280. it('returns bounded Host results and forwards the caller signal', async ({ mock, remote }) => {
  281. remote.session.search.mockResolvedValue(ok({
  282. items: [{ sessionId: S1, snippet: 'matching excerpt' }],
  283. hasMore: true,
  284. }))
  285. const manager = makeManager(mock, remote)
  286. const signal = new AbortController().signal
  287. await expect(manager.search('exact phrase', signal)).resolves.toEqual({
  288. ok: true,
  289. value: {
  290. items: [{ sessionId: S1, snippet: 'matching excerpt' }],
  291. hasMore: true,
  292. },
  293. })
  294. expect(remote.session.search).toHaveBeenCalledExactlyOnceWith({ query: 'exact phrase' }, signal)
  295. })
  296. it('preserves business errors and propagates a non-Remote throw', async ({ mock, remote }) => {
  297. const manager = makeManager(mock, remote)
  298. remote.session.search.mockResolvedValue(err(new RemoteError('gateway/internal', 'index unavailable', {})))
  299. const signal = new AbortController().signal
  300. await expect(manager.search('first', signal)).resolves.toMatchObject({
  301. ok: false,
  302. error: { code: 'gateway/internal', message: 'index unavailable' },
  303. })
  304. remote.session.search.mockRejectedValue(new Error('wire down'))
  305. await expect(manager.search('second', signal)).rejects.toThrow('wire down')
  306. })
  307. })
  308. describe('Host Remote event routing', () => {
  309. it('adds/removes/flips sessions and keeps removed instances resident', async ({ mock, remote }) => {
  310. const manager = makeManager(mock, remote)
  311. manager.handleSessionAdded(summary(S1, { blank: true }))
  312. manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored
  313. expect(manager.getListSnapshot().items).toHaveLength(1)
  314. const session = manager.get(S1)
  315. manager.handleSessionStatus(S1, true)
  316. expect(session.getSnapshot().running).toBe(true)
  317. expect(manager.getListSnapshot().items[0]?.running).toBe(true)
  318. manager.handleSessionError(S1, '炸了')
  319. expect(session.getSnapshot().lastAgentError).toBe('炸了')
  320. manager.handleSessionRemoved(S1)
  321. expect(manager.getListSnapshot().items).toHaveLength(0)
  322. expect(session.getSnapshot().removed).toBe(true)
  323. expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal
  324. })
  325. })
  326. describe('subagent catalogs', () => {
  327. it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => {
  328. remote.session.list.mockResolvedValue(ok({ items: [
  329. summary(S1),
  330. summary(S2, { parentSessionId: S1, origin: 'subagent' }),
  331. ] as never[] }))
  332. remote.subagents.list.mockResolvedValue(ok({
  333. entries: [{
  334. kind: 'child', id: S2, mode: 'continuable', label: 'worker',
  335. activity: 'running', hasChildren: false,
  336. }] as never[],
  337. parentAvailable: true,
  338. }))
  339. mock.load(sessionWorld)
  340. const client = await start()
  341. const manager = new SessionManager(client.ctx.remote)
  342. await manager.refreshList()
  343. await manager.refreshSubagents(S1)
  344. manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
  345. expect(manager.subagentAddress(S2)).toEqual({
  346. parentSessionId: S1, childSessionId: S2, mode: 'continuable',
  347. })
  348. expect(manager.get(S2).getSnapshot().subagent).toEqual({
  349. address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
  350. parentAvailable: true,
  351. })
  352. // Clicking the same child through an ordinary list-selection path must not
  353. // erase the catalog-derived address and fall back to session.* transport.
  354. manager.resolveTarget(S2)
  355. expect(manager.subagentAddress(S2)).toEqual({
  356. parentSessionId: S1, childSessionId: S2, mode: 'continuable',
  357. })
  358. expect(manager.get(S2).getSnapshot().subagent).toEqual({
  359. address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
  360. parentAvailable: true,
  361. })
  362. await manager.get(S2).open()
  363. await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
  364. expect(remote.session.follow.mock.calls.map(([request]) => request)).toEqual([
  365. {
  366. address: {
  367. kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
  368. },
  369. assistantStream: true,
  370. maxMessages: 50,
  371. },
  372. ])
  373. expect(remote.session.page).not.toHaveBeenCalled()
  374. expect(remote.subagents.prompt.mock.calls.map(([request]) => request)).toEqual([
  375. {
  376. requestId: expect.any(String) as unknown as string,
  377. parentSessionId: S1, childSessionId: S2,
  378. mode: 'continuable',
  379. delivery: 'queue',
  380. content: [{ type: 'text', text: 'continue' }],
  381. clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
  382. },
  383. ])
  384. expect(remote.session.prompt).not.toHaveBeenCalled()
  385. const listCalls = remote.subagents.list.mock.calls.length
  386. manager.handleSessionStatus(S2, false)
  387. expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
  388. kind: 'child', id: S2, activity: 'inactive',
  389. })
  390. expect(remote.subagents.list).toHaveBeenCalledTimes(listCalls)
  391. manager.handleSessionRemoved(S2)
  392. expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
  393. origin: 'subagent', parentSessionId: S1, running: false,
  394. })
  395. expect(manager.get(S2).getSnapshot()).toMatchObject({
  396. removed: false,
  397. subagent: {
  398. address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
  399. },
  400. })
  401. }, COLD_BOOT_TIMEOUT_MS)
  402. it('refetches debounced membership only while the parent catalog is open', async ({ mock, remote }) => {
  403. vi.useFakeTimers()
  404. try {
  405. const manager = makeManager(mock, remote)
  406. await manager.refreshSubagents(S1)
  407. manager.setSubagentCatalogOpen(S1, true)
  408. await Promise.resolve()
  409. const baseline = remote.subagents.list.mock.calls.length
  410. manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
  411. manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 }))
  412. await vi.advanceTimersByTimeAsync(50)
  413. expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1)
  414. manager.setSubagentCatalogOpen(S1, false)
  415. manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 }))
  416. await vi.advanceTimersByTimeAsync(50)
  417. expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1)
  418. } finally {
  419. vi.useRealTimers()
  420. }
  421. })
  422. it('marks a loaded parent row expandable only for a direct subagent publication', async ({ mock, remote }) => {
  423. const root = 'fk-root' as SessionId
  424. remote.subagents.list.mockResolvedValue(ok({
  425. entries: [
  426. {
  427. kind: 'child', id: S1, mode: 'continuable', label: 'parent',
  428. activity: 'inactive', hasChildren: false,
  429. },
  430. {
  431. kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
  432. activity: 'inactive', hasChildren: false,
  433. },
  434. ] as never[],
  435. parentAvailable: true,
  436. }))
  437. const manager = makeManager(mock, remote)
  438. await manager.refreshSubagents(root)
  439. manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
  440. parentSessionId: S1, origin: 'subagent',
  441. }))
  442. manager.handleSessionAdded(summary('fk-fork' as SessionId, { parentSessionId: S2 }))
  443. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  444. { kind: 'child', id: S1, hasChildren: true },
  445. { kind: 'child', id: S2, hasChildren: false },
  446. ])
  447. })
  448. it('preserves a live expandability hint across only the older in-flight catalog response', async ({ mock, remote }) => {
  449. const root = 'fk-root' as SessionId
  450. const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  451. remote.subagents.list.mockReturnValue(response.promise)
  452. const manager = makeManager(mock, remote)
  453. const refresh = manager.refreshSubagents(root)
  454. manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
  455. parentSessionId: S1, origin: 'subagent',
  456. }))
  457. response.resolve(ok({
  458. entries: [{
  459. kind: 'child', id: S1, mode: 'continuable', label: 'parent',
  460. activity: 'inactive', hasChildren: false,
  461. }] as never[],
  462. parentAvailable: true,
  463. }))
  464. await refresh
  465. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  466. { kind: 'child', id: S1, hasChildren: true },
  467. ])
  468. remote.subagents.list.mockResolvedValue(ok({
  469. entries: [{
  470. kind: 'child', id: S1, mode: 'continuable', label: 'parent',
  471. activity: 'inactive', hasChildren: false,
  472. }] as never[],
  473. parentAvailable: true,
  474. }))
  475. await manager.refreshSubagents(root)
  476. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  477. { kind: 'child', id: S1, hasChildren: false },
  478. ])
  479. })
  480. it('replays status frames over an older in-flight catalog response', async ({ mock, remote }) => {
  481. const root = 'fk-root' as SessionId
  482. const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  483. remote.subagents.list.mockReturnValue(response.promise)
  484. const manager = makeManager(mock, remote)
  485. const refresh = manager.refreshSubagents(root)
  486. manager.handleSessionStatus(S1, false)
  487. manager.handleSessionStatus(S2, true)
  488. response.resolve(ok({
  489. entries: [
  490. {
  491. kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
  492. activity: 'running', hasChildren: false,
  493. },
  494. {
  495. kind: 'child', id: S2, mode: 'continuable', label: 'started',
  496. activity: 'inactive', hasChildren: false,
  497. },
  498. ] as never[],
  499. parentAvailable: true,
  500. }))
  501. await refresh
  502. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  503. { kind: 'child', id: S1, activity: 'inactive' },
  504. { kind: 'child', id: S2, activity: 'running' },
  505. ])
  506. })
  507. it('marks a detached catalog child inactive without requiring a selected address', async ({ mock, remote }) => {
  508. remote.subagents.list.mockResolvedValue(ok({
  509. entries: [{
  510. kind: 'child', id: S2, mode: 'continuable', label: 'worker',
  511. activity: 'running', hasChildren: false,
  512. }] as never[],
  513. parentAvailable: true,
  514. }))
  515. const manager = makeManager(mock, remote)
  516. await manager.refreshSubagents(S1)
  517. manager.handleSessionRemoved(S2)
  518. expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
  519. { kind: 'child', id: S2, activity: 'inactive' },
  520. ])
  521. })
  522. it('coalesces overlapping catalog reads without scheduling a trailing pull', async ({ mock, remote }) => {
  523. const root = 'fk-root' as SessionId
  524. const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  525. remote.subagents.list.mockReturnValue(first.promise)
  526. const manager = makeManager(mock, remote)
  527. const refresh = manager.refreshSubagents(root)
  528. expect(manager.refreshSubagents(root)).toBe(refresh)
  529. remote.subagents.list.mockResolvedValue(ok({ entries: [], parentAvailable: true }))
  530. first.resolve(ok({ entries: [], parentAvailable: true }))
  531. await refresh
  532. expect(remote.subagents.list).toHaveBeenCalledOnce()
  533. })
  534. it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async ({ mock, remote }) => {
  535. vi.useFakeTimers()
  536. try {
  537. const root = 'fk-root' as SessionId
  538. const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  539. const second = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  540. remote.subagents.list.mockReturnValue(first.promise)
  541. const manager = makeManager(mock, remote)
  542. const refresh = manager.refreshSubagents(root)
  543. manager.setSubagentCatalogOpen(root, true)
  544. // A membership frame arrives while the pull is in flight; the debounced
  545. // refresh it schedules fires 50ms later and is coalesced into the pull —
  546. // which was requested before the new child existed. The stale mark must
  547. // queue one trailing pull carrying the change.
  548. manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
  549. await vi.advanceTimersByTimeAsync(50)
  550. remote.subagents.list.mockReturnValueOnce(second.promise)
  551. first.resolve(ok({
  552. entries: [{
  553. kind: 'child', id: S1, mode: 'continuable', label: 'older',
  554. activity: 'inactive', hasChildren: false,
  555. }] as never[],
  556. parentAvailable: true,
  557. }))
  558. await refresh
  559. // The trailing pull is already in flight (kicked synchronously in finally).
  560. second.resolve(ok({
  561. entries: [
  562. {
  563. kind: 'child', id: S1, mode: 'continuable', label: 'older',
  564. activity: 'inactive', hasChildren: false,
  565. },
  566. {
  567. kind: 'child', id: S2, mode: 'continuable', label: 'new child',
  568. activity: 'inactive', hasChildren: false,
  569. },
  570. ] as never[],
  571. parentAvailable: true,
  572. }))
  573. await second.promise
  574. // The Remote face resolves one microtask after the response settles.
  575. await vi.advanceTimersByTimeAsync(0)
  576. expect(remote.subagents.list).toHaveBeenCalledTimes(2)
  577. expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
  578. { kind: 'child', id: S1, label: 'older' },
  579. { kind: 'child', id: S2, label: 'new child' },
  580. ])
  581. } finally {
  582. vi.useRealTimers()
  583. }
  584. })
  585. it('keeps removal invalidation across a stale success and failed trailing pull', async ({ mock, remote }) => {
  586. const root = 'fk-root' as SessionId
  587. const child = () => ({
  588. kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
  589. activity: 'inactive' as const, hasChildren: false,
  590. })
  591. const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  592. remote.subagents.list.mockReturnValue(first.promise)
  593. const manager = makeManager(mock, remote)
  594. const refresh = manager.refreshSubagents(root)
  595. first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
  596. await refresh
  597. manager.resolveTarget({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
  598. // The removal lands while a second pull is in flight: the invalidation
  599. // must survive the pre-removal ok response, so one trailing pull runs.
  600. const mid = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  601. remote.subagents.list.mockReturnValueOnce(mid.promise)
  602. const midRefresh = manager.refreshSubagents(root)
  603. manager.handleSessionRemoved(root)
  604. const trailing = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  605. remote.subagents.list.mockReturnValueOnce(trailing.promise)
  606. mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
  607. await midRefresh
  608. expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
  609. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
  610. trailing.resolve(err(new RemoteError('gateway/internal', 'trailing pull failed', {})))
  611. await vi.waitFor(() => {
  612. expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
  613. state: 'error',
  614. parentAvailable: false,
  615. })
  616. })
  617. const rootCalls = remote.subagents.list.mock.calls.filter(([call]) => call === root)
  618. expect(rootCalls).toHaveLength(3)
  619. expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
  620. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
  621. })
  622. it('invalidates catalog availability when the owning parent is removed', async ({ mock, remote }) => {
  623. const root = 'fk-root' as SessionId
  624. remote.subagents.list.mockResolvedValue(ok({
  625. entries: [{
  626. kind: 'child', id: S2, mode: 'continuable', label: 'worker',
  627. activity: 'inactive', hasChildren: false,
  628. }] as never[],
  629. parentAvailable: true,
  630. }))
  631. const manager = makeManager(mock, remote)
  632. await manager.refreshSubagents(root)
  633. manager.resolveTarget({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
  634. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
  635. manager.handleSessionRemoved(root)
  636. expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
  637. expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
  638. })
  639. })
  640. describe('remaining branches', () => {
  641. it('refreshList propagates a non-Remote throw', async ({ mock, remote }) => {
  642. remote.session.list.mockRejectedValue(new Error('list wire down'))
  643. const manager = makeManager(mock, remote)
  644. await expect(manager.refreshList()).rejects.toThrow('list wire down')
  645. })
  646. it('refreshList pushes running bits down to already-instantiated sessions', async ({ mock, remote }) => {
  647. const manager = makeManager(mock, remote)
  648. const session = manager.get(S1)
  649. remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] }))
  650. await manager.refreshList()
  651. expect(session.getSnapshot().running).toBe(true)
  652. })
  653. it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async ({ mock, remote }) => {
  654. remote.session.create.mockResolvedValue(ok({ sessionId: S1 }))
  655. const manager = makeManager(mock, remote)
  656. await manager.create({ cwd: '/tmp/w', sessionId: S1 })
  657. expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ cwd: '/tmp/w', sessionId: S1 })
  658. expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
  659. await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
  660. expect(manager.getListSnapshot().items).toHaveLength(1)
  661. remote.session.create.mockRejectedValue(new Error('create wire down'))
  662. await expect(manager.create()).rejects.toThrow('create wire down')
  663. // Business error passes through untouched.
  664. remote.session.create.mockResolvedValue(err(new RemoteError('gateway/internal', 'no', {})))
  665. expect(await manager.create()).toMatchObject({ ok: false })
  666. })
  667. it('publishes a real Ungrouped summary from workspace-attach-failed', async ({ mock, remote }) => {
  668. remote.session.create.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', {
  669. sessionId: S1, workspaceId: 'w1',
  670. })))
  671. const manager = makeManager(mock, remote)
  672. const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
  673. expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
  674. expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
  675. expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
  676. })
  677. it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => {
  678. remote.session.fork.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', {
  679. sessionId: S2, workspaceId: 'w1',
  680. })))
  681. const manager = makeManager(mock, remote)
  682. const result = await manager.fork({ sessionId: S1 })
  683. expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
  684. expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
  685. sessionId: S2,
  686. parentSessionId: S1,
  687. blank: false,
  688. })])
  689. })
  690. it('reconciles a preallocated id after an ordinary transport failure', async ({ mock, remote }) => {
  691. remote.session.create.mockRejectedValue(new Error('response lost'))
  692. const manager = makeManager(mock, remote)
  693. await expect(manager.create({ workspaceId: 'w1' as never, sessionId: S1 }))
  694. .rejects.toThrow('response lost')
  695. expect(manager.getListSnapshot().items).toEqual([])
  696. manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' }))
  697. expect(manager.getListSnapshot().items).toEqual([
  698. expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
  699. ])
  700. manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' }))
  701. expect(manager.getListSnapshot().items).toHaveLength(1)
  702. })
  703. it('subscribe notifies on list changes and stops after unsubscribe', async ({ mock, remote }) => {
  704. const manager = makeManager(mock, remote)
  705. let notified = 0
  706. const unsubscribe = manager.subscribe(() => { notified++ })
  707. await manager.refreshList()
  708. await new Promise(resolve => setTimeout(resolve, 0))
  709. expect(notified).toBeGreaterThan(0)
  710. const seen = notified
  711. unsubscribe()
  712. manager.handleSessionAdded(summary(S1, { blank: true }))
  713. await new Promise(resolve => setTimeout(resolve, 0))
  714. expect(notified).toBe(seen)
  715. })
  716. it('ignores Host status and error events for sessions without an instance', ({ mock, remote }) => {
  717. const manager = makeManager(mock, remote)
  718. manager.handleSessionStatus(S2, true)
  719. manager.handleSessionError(S2, '无实例')
  720. })
  721. it('keeps list-entry identity for unchanged rows across an unrelated list change', async ({ mock, remote }) => {
  722. remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
  723. const manager = makeManager(mock, remote)
  724. await manager.refreshList()
  725. const before = manager.getListSnapshot()
  726. manager.handleSessionStatus(S2, true)
  727. const after = manager.getListSnapshot()
  728. expect(after.items).not.toBe(before.items)
  729. const beforeS1 = before.items.find(e => e.sessionId === S1)
  730. const afterS1 = after.items.find(e => e.sessionId === S1)
  731. expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache)
  732. // Same-order same-entries snapshot reuses the items array.
  733. manager.handleSessionError(S1, 'x')
  734. expect(manager.getListSnapshot().items).toBe(after.items)
  735. })
  736. it('reuses refreshed rows and evicts missing rows independently of Client instances', async ({ mock, remote }) => {
  737. const manager = makeManager(mock, remote)
  738. remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
  739. await manager.refreshList()
  740. const first = manager.getListSnapshot()
  741. remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
  742. await manager.refreshList()
  743. expect(manager.getListSnapshot().items).toBe(first.items)
  744. remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
  745. await manager.refreshList()
  746. expect(manager.getListSnapshot().items).toEqual([first.items[0]])
  747. expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
  748. remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
  749. await manager.refreshList()
  750. expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
  751. expect(manager.getListSnapshot().items[1]).not.toBe(first.items[1])
  752. remote.session.list.mockResolvedValue(ok({ items: [] as never[] }))
  753. await manager.refreshList()
  754. expect(manager.getListSnapshot().items).toEqual([])
  755. remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
  756. await manager.refreshList()
  757. expect(manager.getListSnapshot().items[0]).not.toBe(first.items[0])
  758. })
  759. it('bounds cached-row ID reads linearly during repeated list refreshes', async ({ mock, remote }) => {
  760. const count = 1_000
  761. const summaries = Array.from({ length: count }, (_, i) => summary(`list-${i}` as SessionId))
  762. const manager = makeManager(mock, remote)
  763. remote.session.list.mockResolvedValue(ok({ items: summaries as never[] }))
  764. await manager.refreshList()
  765. const first = manager.getListSnapshot()
  766. let reads = 0
  767. // Instance-local accessors count membership work without a machine-dependent timing budget.
  768. for (const entry of first.items) {
  769. const id = entry.sessionId
  770. Object.defineProperty(entry, 'sessionId', { get: () => { reads++; return id }, configurable: true })
  771. }
  772. for (let refresh = 0; refresh < 2; refresh++) {
  773. reads = 0
  774. remote.session.list.mockResolvedValue(ok({ items: summaries.map(item => ({ ...item })) as never[] }))
  775. await manager.refreshList()
  776. const snapshot = manager.getListSnapshot()
  777. expect(snapshot.items).toBe(first.items)
  778. expect(reads).toBeLessThanOrEqual(count * 3)
  779. }
  780. })
  781. it('carries parentSessionId from the added event into the lineage row', ({ mock, remote }) => {
  782. const manager = makeManager(mock, remote)
  783. manager.handleSessionAdded(summary(S1, { blank: true }))
  784. manager.handleSessionAdded(summary(S2, {
  785. blank: true, parentSessionId: S1, origin: 'subagent',
  786. }))
  787. const items = manager.getListSnapshot().items
  788. expect(items.find(e => e.sessionId === S2)).toMatchObject({
  789. parentSessionId: S1, origin: 'subagent', depth: 1,
  790. })
  791. })
  792. })
  793. describe('connected generation', () => {
  794. it.for(['old-first', 'new-first'] as const)(
  795. 'ignores a previous generation list response (%s)',
  796. async (order, { mock, remote }) => {
  797. const oldList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  798. const newList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  799. let calls = 0
  800. remote.session.list.mockImplementation(() => calls++ === 0 ? oldList.promise : newList.promise)
  801. const manager = makeManager(mock, remote)
  802. const oldResult = ok({ items: [{ ...summary(S1), projections: {
  803. asOfSeq: 20, values: { title: 'Unpersisted title' },
  804. } }] as never[] })
  805. const newResult = ok({ items: [{ ...summary(S1), projections: {
  806. asOfSeq: 1, values: { title: 'Durable title' },
  807. } }] as never[] })
  808. const oldPull = manager.refreshList()
  809. let newPull: Promise<void> | undefined
  810. try {
  811. manager.handleConnected()
  812. newPull = manager.refreshList()
  813. expect(remote.session.list.mock.calls).toHaveLength(2)
  814. if (order === 'old-first') {
  815. oldList.resolve(oldResult)
  816. await oldPull
  817. expect(manager.getListSnapshot().state).toBe('loading')
  818. expect(manager.refreshList()).toBe(newPull)
  819. }
  820. newList.resolve(newResult)
  821. await newPull
  822. oldList.resolve(oldResult)
  823. await oldPull
  824. expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', error: null })
  825. expect(manager.getListSnapshot().items[0]?.title).toBe('Durable title')
  826. } finally {
  827. oldList.resolve(oldResult)
  828. newList.resolve(newResult)
  829. await Promise.all([oldPull, newPull])
  830. await manager.dispose()
  831. }
  832. },
  833. )
  834. it('ignores a previous generation request failure while the new list is loading', async ({ mock, remote }) => {
  835. const oldList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  836. const newList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  837. let calls = 0
  838. remote.session.list.mockImplementation(() => calls++ === 0 ? oldList.promise : newList.promise)
  839. const manager = makeManager(mock, remote)
  840. const oldPull = manager.refreshList()
  841. let newPull: Promise<void> | undefined
  842. try {
  843. manager.handleConnected()
  844. newPull = manager.refreshList()
  845. oldList.reject(new RemoteError('gateway/internal', 'old Host disconnected', {}))
  846. await oldPull
  847. expect(manager.getListSnapshot()).toMatchObject({ state: 'loading', error: null })
  848. expect(manager.refreshList()).toBe(newPull)
  849. newList.resolve(ok({ items: [summary(S1)] as never[] }))
  850. await newPull
  851. expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', error: null })
  852. } finally {
  853. oldList.resolve(ok({ items: [] }))
  854. newList.resolve(ok({ items: [] }))
  855. await Promise.all([oldPull, newPull])
  856. await manager.dispose()
  857. }
  858. })
  859. it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => {
  860. mock.stream(FOLLOW, followScript(ok({
  861. records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[],
  862. hasMore: false,
  863. modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
  864. })))
  865. const client = await start()
  866. const manager = new SessionManager(client.ctx.remote)
  867. const openedSession = manager.get(S1)
  868. await openedSession.open()
  869. manager.get(S2) // instantiated but never opened
  870. const historyCallsBefore = remote.session.page.mock.calls.length
  871. manager.handleConnected()
  872. await vi.waitFor(() => {
  873. expect(remote.session.list).toHaveBeenCalledOnce()
  874. })
  875. expect(remote.session.follow).toHaveBeenCalledOnce()
  876. expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore)
  877. })
  878. it('retains the durable parent address and refreshes that parent across reconnect', async ({ mock, remote }) => {
  879. const address = {
  880. parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
  881. }
  882. const parent = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  883. const child = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
  884. remote.subagents.list.mockImplementation(payload => (payload === S1 ? parent.promise : child.promise))
  885. const manager = makeManager(mock, remote)
  886. remote.subagents.list.mockResolvedValueOnce(ok({
  887. entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false }],
  888. parentAvailable: true,
  889. }))
  890. await manager.refreshSubagents(S1)
  891. manager.resolveTarget(address)
  892. manager.get(S2)
  893. remote.subagents.list.mockClear()
  894. manager.handleConnected()
  895. expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, parentAvailable: true })
  896. parent.resolve(ok({ entries: [], parentAvailable: true }))
  897. child.resolve(ok({ entries: [], parentAvailable: true }))
  898. await vi.waitFor(() => {
  899. expect(remote.session.list).toHaveBeenCalledOnce()
  900. })
  901. await vi.waitFor(() => {
  902. expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1])
  903. })
  904. expect(manager.get(S2).getSnapshot().subagent).toEqual({
  905. address,
  906. parentAvailable: true,
  907. })
  908. expect(manager.subagentAddress(S2)).toEqual(address)
  909. })
  910. })
  911. describe('running facts without UI reminders', () => {
  912. it('replays running status during hydration without publishing a completion marker', async ({ mock, remote }) => {
  913. const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  914. remote.session.list.mockReturnValueOnce(response.promise)
  915. const manager = makeManager(mock, remote)
  916. const refreshing = manager.refreshList()
  917. manager.handleSessionStatus(S1, true)
  918. manager.handleSessionStatus(S1, false)
  919. response.resolve(ok({ items: [summary(S1)] }))
  920. await refreshing
  921. const entry = manager.getListSnapshot().items.find(item => item.sessionId === S1)
  922. expect(entry).toMatchObject({ running: false })
  923. expect(entry).not.toHaveProperty('completed')
  924. expect(manager.getListSnapshot()).not.toHaveProperty('current')
  925. })
  926. })
  927. describe('background-job mirror', () => {
  928. const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
  929. id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
  930. })
  931. const tasksFrame = (
  932. sessionId: SessionId,
  933. jobs: unknown[],
  934. ): Extract<SessionControlFrame, { type: 'jobs' }> => ({
  935. type: 'jobs', sessionId, jobs: jobs as never,
  936. })
  937. it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', ({ mock, remote }) => {
  938. const manager = makeManager(mock, remote)
  939. manager.handleControlFrame(tasksFrame(S1, [view()]))
  940. manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
  941. const first = manager.getListSnapshot().jobsBySession
  942. expect(first[S1]).toEqual([view()])
  943. expect(first[S2]?.[0]?.label).toBe('other')
  944. // Last-wins: the newer whole set replaces, it does not merge.
  945. manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })]))
  946. expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })])
  947. })
  948. it('stores an emptied set as an absent key so absence and [] read alike', ({ mock, remote }) => {
  949. const manager = makeManager(mock, remote)
  950. manager.handleControlFrame(tasksFrame(S1, [view()]))
  951. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true)
  952. manager.handleControlFrame(tasksFrame(S1, []))
  953. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
  954. })
  955. it('clears the mirror when the next control baseline has no jobs', ({ mock, remote }) => {
  956. const manager = makeManager(mock, remote)
  957. manager.handleControlFrame(tasksFrame(S1, [view()]))
  958. manager.handleControlFrame({
  959. type: 'baseline',
  960. value: { jobs: {}, projections: {} },
  961. })
  962. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
  963. })
  964. it('drops the rows when the session is removed, whichever stream lands first', ({ mock, remote }) => {
  965. const manager = makeManager(mock, remote)
  966. manager.handleSessionAdded(summary(S1, { blank: true }))
  967. manager.handleControlFrame(tasksFrame(S1, [view()]))
  968. manager.handleSessionRemoved(S1)
  969. expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
  970. })
  971. it('notifies list subscribers so an open header re-renders without a poll', async ({ mock, remote }) => {
  972. const manager = makeManager(mock, remote)
  973. const seen = vi.fn()
  974. manager.subscribe(seen)
  975. manager.handleControlFrame(tasksFrame(S1, [view()]))
  976. // The notifier batches on a microtask; the frame itself is already applied.
  977. await Promise.resolve()
  978. expect(seen).toHaveBeenCalled()
  979. })
  980. })