session-history-source.spec.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  3. import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
  4. import { SessionHistorySource } from '../src/client/session-history/source.ts'
  5. import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
  6. import { entries, ev, plainTurn } from './event-script.ts'
  7. const SID = 'history-s1' as SessionId
  8. afterEach(() => {
  9. vi.unstubAllGlobals()
  10. })
  11. function histResponse(events: SessionEvent[], hasMore = false) {
  12. return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
  13. }
  14. describe('SessionHistorySource', () => {
  15. it('loads the tail first and prepends older pages on demand', async () => {
  16. const pages = [
  17. plainTurn(0, 0, '最早问', '最早答'),
  18. plainTurn(6, 1, '中间问', '中间答'),
  19. plainTurn(12, 2, '最新问', '最新答'),
  20. ]
  21. const api = new FakeApiClient()
  22. api.onHistory = (payload) => {
  23. if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
  24. if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
  25. return histResponse(pages[0]!, false)
  26. }
  27. const source = new SessionHistorySource(SID, api)
  28. await source.loadTail()
  29. expect(api.callsOf('session.history')).toHaveLength(1)
  30. expect(source.getSnapshot().hasMore).toBe(true)
  31. expect(source.getSnapshot().baseSeq).toBe(12)
  32. expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
  33. .toEqual([13, 15])
  34. expect(await source.loadOlder()).toBe(true)
  35. expect(await source.loadOlder()).toBe(true)
  36. expect(await source.loadOlder()).toBe(false)
  37. expect(api.callsOf('session.history')).toHaveLength(3)
  38. expect(source.getSnapshot().hasMore).toBe(false)
  39. expect(source.getSnapshot().baseSeq).toBe(0)
  40. expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
  41. .toEqual([1, 3, 7, 9, 13, 15])
  42. })
  43. it('pins a lazy inspection to the entries in its source snapshot', async () => {
  44. const api = new FakeApiClient()
  45. api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
  46. const source = new SessionHistorySource(SID, api)
  47. await source.loadTail()
  48. const before = source.getSnapshot()
  49. source.handleMuxFrame({
  50. type: 'session/event',
  51. sessionId: SID,
  52. event: ev.user(6, 'later'),
  53. })
  54. expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
  55. expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
  56. .toEqual([1, 3, 6])
  57. })
  58. it('publishes multiple assistant chunks once per browser frame', async () => {
  59. const api = new FakeApiClient()
  60. api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
  61. const source = new SessionHistorySource(SID, api)
  62. await source.loadTail()
  63. const frames: FrameRequestCallback[] = []
  64. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
  65. frames.push(callback)
  66. return frames.length
  67. })
  68. let notifications = 0
  69. const unsubscribe = source.subscribe(() => { notifications++ })
  70. const before = source.getSnapshot().inspection
  71. const finalizedNodes = before.eventNodes
  72. const requests = before.requests
  73. const contexts = before.contexts
  74. for (const event of [
  75. ev.chunkStart(6, 1),
  76. ev.chunkText(7, 1, 'stream '),
  77. ev.chunkText(8, 1, 'content'),
  78. ]) {
  79. source.handleMuxFrame({
  80. type: 'session/event',
  81. sessionId: SID,
  82. event,
  83. })
  84. }
  85. expect(frames).toHaveLength(1)
  86. expect(notifications).toBe(0)
  87. frames[0]?.(0)
  88. await Promise.resolve()
  89. expect(notifications).toBe(1)
  90. const streamed = source.getSnapshot().inspection
  91. expect(streamed.eventNodes).toBe(finalizedNodes)
  92. expect(streamed.requests).toBe(requests)
  93. expect(streamed.contexts).toBe(contexts)
  94. expect(streamed.partial?.blocks).toEqual([
  95. { kind: 'text', text: 'stream content' },
  96. ])
  97. source.handleMuxFrame({
  98. type: 'session/event',
  99. sessionId: SID,
  100. event: ev.chunkText(9, 1, ' then final'),
  101. })
  102. source.handleMuxFrame({
  103. type: 'session/event',
  104. sessionId: SID,
  105. event: ev.assistant(10, 1, 'stream content then final'),
  106. })
  107. await Promise.resolve()
  108. expect(notifications).toBe(2)
  109. const finalized = source.getSnapshot().inspection
  110. expect(finalized.eventNodes).not.toBe(finalizedNodes)
  111. expect(finalized.partial).toBeNull()
  112. frames[1]?.(0)
  113. await Promise.resolve()
  114. expect(notifications).toBe(2)
  115. unsubscribe()
  116. })
  117. it('stops loading when an older page fails to advance', async () => {
  118. const api = new FakeApiClient()
  119. api.onHistory = payload => payload.beforeSeq === undefined
  120. ? histResponse(plainTurn(6, 1, '新问', '新答'), true)
  121. : Promise.resolve(err({
  122. code: 'internal',
  123. message: 'page unavailable',
  124. details: {},
  125. }))
  126. const source = new SessionHistorySource(SID, api)
  127. await source.loadTail()
  128. expect(await source.loadOlder()).toBe(false)
  129. expect(api.callsOf('session.history')).toHaveLength(2)
  130. expect(source.getSnapshot().hasMore).toBe(true)
  131. })
  132. it('finishes an already started older page after consumer cancellation', async () => {
  133. const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  134. const olderStarted = deferred<undefined>()
  135. const api = new FakeApiClient()
  136. api.onHistory = (payload) => {
  137. if (payload.beforeSeq === undefined) {
  138. return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
  139. }
  140. olderStarted.resolve(undefined)
  141. return middle.promise
  142. }
  143. const source = new SessionHistorySource(SID, api)
  144. const controller = new AbortController()
  145. await source.loadTail(controller.signal)
  146. const complete = source.loadOlder(controller.signal)
  147. await olderStarted.promise
  148. controller.abort()
  149. middle.resolve(ok({
  150. events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
  151. hasMore: true,
  152. }))
  153. expect(await complete).toBe(true)
  154. expect(api.callsOf('session.history')).toHaveLength(2)
  155. expect(source.getSnapshot().hasMore).toBe(true)
  156. })
  157. })