session-pending-submissions.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. /** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  4. import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  5. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
  6. import { Session } from '../src/client/sessions/session.ts'
  7. import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
  8. import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
  9. import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
  10. import { historyValue } from './event-script.client.ts'
  11. const SID = 'fk-s1' as SessionId
  12. afterEach(() => {
  13. vi.unstubAllGlobals()
  14. })
  15. function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
  16. return { api, session: new Session(SID, fakeRemote(api)) }
  17. }
  18. function imageRef(id: string): ImageAttachmentRef {
  19. return {
  20. attachmentId: id,
  21. mediaType: 'image/png',
  22. bytes: 1,
  23. width: 2,
  24. height: 2,
  25. } as unknown as ImageAttachmentRef
  26. }
  27. /** A durable browser-prompt user/message whose source echoes `rpcId`. */
  28. function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent {
  29. return {
  30. seq,
  31. time: 1_700_000_000_000 + seq,
  32. type: 'user/message',
  33. surfaceOp: 'append',
  34. data: createUserMessage({
  35. content: [
  36. ...refs.map(attachment => ({ type: 'image' as const, attachment })),
  37. { type: 'text' as const, text: '发送' },
  38. ],
  39. source: { kind: 'user', rpcId },
  40. }),
  41. } as unknown as SessionEvent
  42. }
  43. function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem {
  44. return {
  45. id: 'm-queued' as SessionQueuedItem['id'],
  46. placement: 'queued',
  47. rpcId,
  48. message: {
  49. id: 'm-queued' as SessionQueuedItem['id'],
  50. content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'],
  51. },
  52. }
  53. }
  54. /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */
  55. function settleFrames(): Promise<void> {
  56. return new Promise(resolve => setTimeout(resolve, 0))
  57. }
  58. describe('beginSubmission', () => {
  59. it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => {
  60. const { session } = makeSession()
  61. expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
  62. const handle = session.beginSubmission({
  63. text: '你好',
  64. images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
  65. })
  66. expect(session.getSnapshot().promptAttempted).toBe(true)
  67. expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
  68. requestId: handle.requestId,
  69. text: '你好',
  70. images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
  71. }])
  72. })
  73. it('abandon retires the echo as failed exactly once', () => {
  74. const { session } = makeSession()
  75. const retirements: PendingSubmissionRetirement[] = []
  76. const handle = session.beginSubmission({
  77. text: '放弃',
  78. images: [],
  79. onRetire: retirement => retirements.push(retirement),
  80. })
  81. handle.abandon()
  82. handle.abandon()
  83. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  84. expect(retirements).toEqual([{ reason: 'failed' }])
  85. })
  86. })
  87. describe('prompt-coupled retirement', () => {
  88. it('a rejected identified prompt retires its echo immediately alongside promptError', async () => {
  89. const { api, session } = makeSession()
  90. api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
  91. const retirements: PendingSubmissionRetirement[] = []
  92. const handle = session.beginSubmission({
  93. text: '失败的',
  94. images: [],
  95. onRetire: retirement => retirements.push(retirement),
  96. })
  97. const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
  98. expect(result.ok).toBe(false)
  99. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  100. expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' })
  101. expect(retirements).toEqual([{ reason: 'failed' }])
  102. })
  103. it('sends the echo identity as the prompt requestId', async () => {
  104. const { api, session } = makeSession()
  105. const handle = session.beginSubmission({ text: '带 id', images: [] })
  106. await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
  107. expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
  108. })
  109. it('an unidentified prompt failure leaves registered echoes alone', async () => {
  110. const { api, session } = makeSession()
  111. api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
  112. session.beginSubmission({ text: '还在', images: [] })
  113. await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
  114. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  115. })
  116. })
  117. describe('observed retirement', () => {
  118. it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => {
  119. const { api, session } = makeSession()
  120. api.onHistory = () => Promise.resolve(ok(historyValue([])))
  121. await session.open()
  122. const retirements: PendingSubmissionRetirement[] = []
  123. const handle = session.beginSubmission({
  124. text: '发送',
  125. images: [{ previewUrl: 'blob:p1' }],
  126. onRetire: retirement => retirements.push(retirement),
  127. })
  128. const refs = [imageRef('att-1')]
  129. await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never })
  130. // Synchronously after the append the echo is still in the snapshot; the
  131. // render-time dedupe owns the overlap frame.
  132. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  133. await settleFrames()
  134. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  135. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  136. })
  137. it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => {
  138. const { session } = makeSession()
  139. const retirements: PendingSubmissionRetirement[] = []
  140. const handle = session.beginSubmission({
  141. text: '排队',
  142. images: [{ previewUrl: 'blob:p1' }],
  143. onRetire: retirement => retirements.push(retirement),
  144. })
  145. const refs = [imageRef('att-q')]
  146. session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] })
  147. await settleFrames()
  148. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  149. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  150. // The queue projection keeps the correlation id for render-time dedupe.
  151. expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
  152. })
  153. it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
  154. const { api, session } = makeSession()
  155. const handle = session.beginSubmission({ text: '重连', images: [] })
  156. api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)])))
  157. await session.open()
  158. await settleFrames()
  159. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  160. })
  161. it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => {
  162. const { api, session } = makeSession()
  163. api.onHistory = () => Promise.resolve(ok(historyValue([])))
  164. await session.open()
  165. const retirements: PendingSubmissionRetirement[] = []
  166. const handle = session.beginSubmission({
  167. text: '先观察',
  168. images: [],
  169. onRetire: retirement => retirements.push(retirement),
  170. })
  171. await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
  172. handle.abandon()
  173. await settleFrames()
  174. expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
  175. })
  176. it('retires once when the queue and durable event report the same request id', async () => {
  177. const { api, session } = makeSession()
  178. api.onHistory = () => Promise.resolve(ok(historyValue([])))
  179. await session.open()
  180. const retirements: PendingSubmissionRetirement[] = []
  181. const handle = session.beginSubmission({
  182. text: '同一请求',
  183. images: [],
  184. onRetire: retirement => retirements.push(retirement),
  185. })
  186. session.handleControlFrame({
  187. type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])],
  188. })
  189. await api.pushFollow(SID, {
  190. type: 'event', event: promptEvent(0, handle.requestId) as never,
  191. })
  192. await settleFrames()
  193. expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
  194. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  195. })
  196. it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => {
  197. const frames: FrameRequestCallback[] = []
  198. vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
  199. frames.push(fn)
  200. return frames.length
  201. })
  202. const { api, session } = makeSession()
  203. api.onHistory = () => Promise.resolve(ok(historyValue([])))
  204. await session.open()
  205. const handle = session.beginSubmission({ text: '帧', images: [] })
  206. await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
  207. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  208. expect(frames).toHaveLength(1)
  209. frames[0]?.(0)
  210. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  211. })
  212. })
  213. describe('disposal', () => {
  214. it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => {
  215. const { api, session } = makeSession()
  216. api.onHistory = () => Promise.resolve(ok(historyValue([])))
  217. await session.open()
  218. const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
  219. const observed = session.beginSubmission({
  220. text: '已观察',
  221. images: [],
  222. onRetire: retirement => retirements.push({ text: '已观察', retirement }),
  223. })
  224. session.beginSubmission({
  225. text: '未settle',
  226. images: [],
  227. onRetire: retirement => retirements.push({ text: '未settle', retirement }),
  228. })
  229. await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never })
  230. await session.dispose()
  231. await settleFrames()
  232. expect(retirements).toEqual([
  233. { text: '未settle', retirement: { reason: 'failed' } },
  234. { text: '已观察', retirement: { reason: 'observed', attachments: [] } },
  235. ])
  236. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  237. })
  238. })