manager.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. /**
  2. * SessionManager orchestration: lazy resident instances, list lifecycle, host
  3. * frame routing, and the pending-frame buffer for uninstantiated sessions.
  4. */
  5. import { describe, expect, it, vi } from 'vitest'
  6. import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
  7. import { SessionManager } from '../src/client/sessions/manager.ts'
  8. import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
  9. import { entries, plainTurn } from './event-script.ts'
  10. const S1 = 'fk-m1' as SessionId
  11. const S2 = 'fk-m2' as SessionId
  12. function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
  13. return { sessionId, updatedAt: 100, running: false, ...over }
  14. }
  15. describe('instances', () => {
  16. it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
  17. const api = new FakeApiClient()
  18. api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
  19. const manager = new SessionManager(api)
  20. await manager.refreshList()
  21. const session = manager.get(S1)
  22. expect(manager.get(S1)).toBe(session) // resident: same instance forever
  23. expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
  24. })
  25. it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
  26. const api = new FakeApiClient()
  27. const manager = new SessionManager(api)
  28. // Uninstantiated: approval buffers, plain session/event drops.
  29. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
  30. manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
  31. const session = manager.get(S1)
  32. expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
  33. // Buffer cleared: a second instantiation of another id gets nothing.
  34. expect(manager.get(S2).getSnapshot().pending).toEqual([])
  35. })
  36. it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
  37. const api = new FakeApiClient()
  38. const manager = new SessionManager(api)
  39. // 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
  40. for (let i = 0; i < 40; i++) {
  41. manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
  42. }
  43. const pending = manager.get(S1).getSnapshot().pending
  44. expect(pending).toHaveLength(32)
  45. expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
  46. // Removed session: buffered frames must not replay on a future instantiation.
  47. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
  48. manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
  49. expect(manager.get(S2).getSnapshot().pending).toEqual([])
  50. })
  51. })
  52. describe('list lifecycle', () => {
  53. it('single-flights refreshList and preserves the Host baseline order', async () => {
  54. const api = new FakeApiClient()
  55. const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  56. api.onList = () => gate.promise
  57. const manager = new SessionManager(api)
  58. const first = manager.refreshList()
  59. const second = manager.refreshList()
  60. expect(manager.getListSnapshot().state).toBe('loading')
  61. gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
  62. await Promise.all([first, second])
  63. expect(api.callsOf('session.list')).toHaveLength(1)
  64. const snapshot = manager.getListSnapshot()
  65. expect(snapshot.state).toBe('idle')
  66. expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
  67. })
  68. it('replays incremental frames over hydration and never batch-reorders established ids', async () => {
  69. const api = new FakeApiClient()
  70. const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
  71. api.onList = () => first.promise
  72. const manager = new SessionManager(api)
  73. const hydration = manager.refreshList()
  74. manager.handleHostEnvelope({
  75. rpcId: 'during-first' as never,
  76. payload: { type: 'host/session-added', sessionId: S2 },
  77. })
  78. first.resolve(ok({ items: [summary(S1)] as never[] }))
  79. await hydration
  80. expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
  81. api.onList = () => Promise.resolve(ok({
  82. items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
  83. }))
  84. await manager.refreshList()
  85. expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
  86. })
  87. it('keeps the error in the list snapshot on failure', async () => {
  88. const api = new FakeApiClient()
  89. api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
  90. const manager = new SessionManager(api)
  91. await manager.refreshList()
  92. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
  93. // A failed pull does not step the arrival phase: still pending.
  94. expect(manager.getListSnapshot().phase).toBe('pending')
  95. })
  96. it('phase steps pending → ready on the first successful pull and never returns', async () => {
  97. const api = new FakeApiClient()
  98. const manager = new SessionManager(api)
  99. expect(manager.getListSnapshot().phase).toBe('pending')
  100. await manager.refreshList()
  101. expect(manager.getListSnapshot().phase).toBe('ready')
  102. // Sticky across later failures: the pull-activity axis reports the error,
  103. // the arrival phase holds.
  104. api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
  105. await manager.refreshList()
  106. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
  107. // And across an empty re-pull (empty-with-ready = truly no sessions).
  108. api.onList = () => Promise.resolve(ok({ items: [] as never[] }))
  109. await manager.refreshList()
  110. expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
  111. expect(manager.getListSnapshot().items).toEqual([])
  112. })
  113. it('merges create into the list immediately without waiting for a refresh', async () => {
  114. const api = new FakeApiClient()
  115. api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
  116. const manager = new SessionManager(api)
  117. const result = await manager.create()
  118. expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
  119. expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
  120. })
  121. it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
  122. const api = new FakeApiClient()
  123. const manager = new SessionManager(api)
  124. manager.handleMuxEnvelope({
  125. rpcId: 'title-new' as never,
  126. payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
  127. })
  128. manager.handleMuxEnvelope({
  129. rpcId: 'title-stale' as never,
  130. payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
  131. })
  132. manager.handleMuxEnvelope({
  133. rpcId: 'title-equal' as never,
  134. payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
  135. })
  136. api.onList = () => Promise.resolve(ok({
  137. items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
  138. }))
  139. await manager.refreshList()
  140. const titled = manager.getListSnapshot()
  141. expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
  142. expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
  143. expect(titled.items[1]?.title).toBeUndefined()
  144. manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
  145. manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
  146. expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
  147. })
  148. it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
  149. const api = new FakeApiClient()
  150. api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
  151. const manager = new SessionManager(api)
  152. await manager.refreshList()
  153. manager.handleMuxEnvelope({
  154. rpcId: 'title-unflushed' as never,
  155. payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
  156. })
  157. manager.handleMuxEnvelope({
  158. rpcId: 'subscribed-recovered' as never,
  159. payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
  160. })
  161. expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
  162. expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
  163. manager.handleMuxEnvelope({
  164. rpcId: 'title-durable' as never,
  165. payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
  166. })
  167. expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
  168. manager.handleMuxEnvelope({
  169. rpcId: 'subscribed-current' as never,
  170. payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
  171. })
  172. expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
  173. })
  174. })
  175. describe('host frame routing', () => {
  176. it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
  177. const api = new FakeApiClient()
  178. const manager = new SessionManager(api)
  179. manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
  180. manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
  181. expect(manager.getListSnapshot().items).toHaveLength(1)
  182. const session = manager.get(S1)
  183. manager.handleHostEnvelope({ rpcId: 'h3' as never, payload: { type: 'host/session-status', sessionId: S1, running: true } })
  184. expect(session.getSnapshot().running).toBe(true)
  185. expect(manager.getListSnapshot().items[0]?.running).toBe(true)
  186. manager.handleHostEnvelope({ rpcId: 'h4' as never, payload: { type: 'host/agent-error', sessionId: S1, message: '炸了' } })
  187. expect(session.getSnapshot().lastAgentError).toBe('炸了')
  188. manager.handleHostEnvelope({ rpcId: 'h5' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
  189. expect(manager.getListSnapshot().items).toHaveLength(0)
  190. expect(session.getSnapshot().removed).toBe(true)
  191. expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal
  192. })
  193. })
  194. describe('remaining branches', () => {
  195. it('refreshList folds a transport throw into the error state', async () => {
  196. const api = new FakeApiClient()
  197. api.onList = () => Promise.reject(new Error('list wire down'))
  198. const manager = new SessionManager(api)
  199. await manager.refreshList()
  200. expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
  201. })
  202. it('refreshList pushes running bits down to already-instantiated sessions', async () => {
  203. const api = new FakeApiClient()
  204. const manager = new SessionManager(api)
  205. const session = manager.get(S1)
  206. api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
  207. await manager.refreshList()
  208. expect(session.getSnapshot().running).toBe(true)
  209. })
  210. it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
  211. const api = new FakeApiClient()
  212. api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
  213. const manager = new SessionManager(api)
  214. await manager.create({ cwd: '/tmp/w', sessionId: S1 })
  215. expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
  216. expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
  217. await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
  218. expect(manager.getListSnapshot().items).toHaveLength(1)
  219. api.onCreate = () => Promise.reject(new Error('create wire down'))
  220. expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
  221. // Business error passes through untouched.
  222. api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
  223. expect(await manager.create()).toMatchObject({ ok: false })
  224. })
  225. it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
  226. const api = new FakeApiClient()
  227. api.onCreate = () => Promise.resolve(err({
  228. code: 'workspace-attach-failed',
  229. message: 'published but unattached',
  230. details: { sessionId: S1, workspaceId: 'w1' },
  231. } as never))
  232. const manager = new SessionManager(api)
  233. const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
  234. expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
  235. expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
  236. expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
  237. })
  238. it('reconciles a preallocated id after an ordinary transport failure', async () => {
  239. const api = new FakeApiClient()
  240. api.onCreate = () => Promise.reject(new Error('response lost'))
  241. const manager = new SessionManager(api)
  242. const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
  243. expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
  244. expect(manager.getListSnapshot().items).toEqual([])
  245. manager.handleHostEnvelope({
  246. rpcId: 'published-later' as never,
  247. payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
  248. })
  249. expect(manager.getListSnapshot().items).toEqual([
  250. expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
  251. ])
  252. manager.handleHostEnvelope({
  253. rpcId: 'duplicate-frame' as never,
  254. payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
  255. })
  256. expect(manager.getListSnapshot().items).toHaveLength(1)
  257. })
  258. it('subscribe notifies on list changes and stops after unsubscribe', async () => {
  259. const api = new FakeApiClient()
  260. const manager = new SessionManager(api)
  261. let notified = 0
  262. const unsubscribe = manager.subscribe(() => { notified++ })
  263. await manager.refreshList()
  264. await new Promise(resolve => setTimeout(resolve, 0))
  265. expect(notified).toBeGreaterThan(0)
  266. const seen = notified
  267. unsubscribe()
  268. manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
  269. await new Promise(resolve => setTimeout(resolve, 0))
  270. expect(notified).toBe(seen)
  271. })
  272. it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
  273. const api = new FakeApiClient()
  274. const manager = new SessionManager(api)
  275. manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
  276. manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
  277. manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
  278. const session = manager.get(S1)
  279. manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
  280. expect(session.getSnapshot().pending).toMatchObject([{ kind: 'question' }])
  281. // status flip for an unknown session only touches summaries (no crash).
  282. manager.handleHostEnvelope({ rpcId: 'h9' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
  283. manager.handleHostEnvelope({ rpcId: 'ha' as never, payload: { type: 'host/agent-error', sessionId: S2, message: '无实例' } })
  284. })
  285. it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
  286. const api = new FakeApiClient()
  287. api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
  288. const manager = new SessionManager(api)
  289. await manager.refreshList()
  290. const before = manager.getListSnapshot()
  291. manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
  292. const after = manager.getListSnapshot()
  293. expect(after.items).not.toBe(before.items)
  294. const beforeS1 = before.items.find(e => e.sessionId === S1)
  295. const afterS1 = after.items.find(e => e.sessionId === S1)
  296. expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache)
  297. // Same-order same-entries snapshot reuses the items array.
  298. manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/agent-error', sessionId: S1, message: 'x' } })
  299. expect(manager.getListSnapshot().items).toBe(after.items)
  300. })
  301. it('carries parentSessionId from host/session-added into the lineage row', () => {
  302. const api = new FakeApiClient()
  303. const manager = new SessionManager(api)
  304. manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
  305. manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
  306. const items = manager.getListSnapshot().items
  307. expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
  308. })
  309. })
  310. describe('connected generation', () => {
  311. it('refreshes the list and resyncs only opened instances', async () => {
  312. const api = new FakeApiClient()
  313. api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
  314. const manager = new SessionManager(api)
  315. const openedSession = manager.get(S1)
  316. await openedSession.open()
  317. manager.get(S2) // instantiated but never opened
  318. const historyCallsBefore = api.callsOf('session.history').length
  319. manager.handleConnected()
  320. await vi.waitFor(() => {
  321. expect(api.callsOf('session.list').length).toBe(1)
  322. // Only the opened instance repulls history; the cold one stays silent.
  323. expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
  324. })
  325. })
  326. })