manager.spec.ts 19 KB

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