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

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