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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /**
  2. * Local submission echoes: synchronous insertion, observed/failed retirement,
  3. * and settlement callbacks. Prompts and the follow stream cross the assembled
  4. * Gateway client and are answered by endpoint name.
  5. */
  6. import { afterEach, describe, expect, vi } from 'vitest'
  7. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  8. import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  9. import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
  10. import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  11. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  12. import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
  13. import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
  14. import type { SessionRequestId } from '../src/types.ts'
  15. import { sessionBench } from './remote/bench.client.ts'
  16. import {
  17. FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent, queueFrame,
  18. } from './remote/session.client.ts'
  19. /** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
  20. const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
  21. const it = createClientTest({ roster: API_ROSTER })
  22. const SID = 'fk-s1' as SessionId
  23. /** The first client boot pays the cold module transform of the api cone. */
  24. const COLD_BOOT_TIMEOUT_MS = 60_000
  25. afterEach(() => {
  26. vi.unstubAllGlobals()
  27. })
  28. type AttachmentRef = ImageAttachmentRef | FileAttachmentRef
  29. function attachmentBlock(attachment: AttachmentRef) {
  30. return 'mediaType' in attachment
  31. ? { type: 'image' as const, attachment }
  32. : { type: 'file' as const, attachment }
  33. }
  34. /** A durable browser-prompt user/message whose source echoes `rpcId`. */
  35. function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent {
  36. return {
  37. seq,
  38. time: 1_700_000_000_000 + seq,
  39. type: 'user/message',
  40. surfaceOp: 'append',
  41. data: createUserMessage({
  42. content: [
  43. ...refs.map(attachmentBlock),
  44. { type: 'text' as const, text: '发送' },
  45. ],
  46. source: { kind: 'user', rpcId },
  47. }),
  48. } as unknown as SessionEvent
  49. }
  50. /** The Host's queue holding one occurrence of the prompt `rpcId`. */
  51. function queuedFrame(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) {
  52. return queueFrame(SID, [{ id: 'm-queued', rpcId, content: refs.map(attachmentBlock) }])
  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', async ({ mock, start }) => {
  60. const session = await sessionBench(mock, start, SID)
  61. expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
  62. const handle = session.beginSubmission({
  63. mode: 'queue',
  64. text: '你好',
  65. attachments: [{
  66. type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
  67. }],
  68. })
  69. expect(session.getSnapshot().promptAttempted).toBe(true)
  70. expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
  71. requestId: handle.requestId,
  72. placement: 'transcript',
  73. text: '你好',
  74. attachments: [{
  75. type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
  76. }],
  77. }])
  78. expect(mock.log.requests()).toEqual([])
  79. }, COLD_BOOT_TIMEOUT_MS)
  80. it('derives and captures the echo placement from running state and delivery mode', async ({ mock, start }) => {
  81. const session = await sessionBench(mock, start, SID)
  82. session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
  83. session.handleRunning(true)
  84. session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
  85. session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] })
  86. session.handleRunning(false)
  87. expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
  88. { text: '空闲', placement: 'transcript' },
  89. { text: '排队', placement: 'queued' },
  90. { text: '纠偏', placement: 'steering' },
  91. ])
  92. })
  93. it('abandon retires the echo as failed exactly once', async ({ mock, start }) => {
  94. const session = await sessionBench(mock, start, SID)
  95. const retirements: PendingSubmissionRetirement[] = []
  96. const handle = session.beginSubmission({
  97. mode: 'queue',
  98. text: '放弃',
  99. attachments: [],
  100. onRetire: retirement => retirements.push(retirement),
  101. })
  102. handle.abandon()
  103. handle.abandon()
  104. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  105. expect(retirements).toEqual([{ reason: 'failed' }])
  106. })
  107. })
  108. describe('prompt-coupled retirement', () => {
  109. it('a rejected identified prompt retires its echo immediately alongside promptError', async ({ mock, start }) => {
  110. const session = await sessionBench(mock, start, SID)
  111. mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
  112. const retirements: PendingSubmissionRetirement[] = []
  113. const handle = session.beginSubmission({
  114. mode: 'queue',
  115. text: '失败的',
  116. attachments: [],
  117. onRetire: retirement => retirements.push(retirement),
  118. })
  119. const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
  120. expect(result.ok).toBe(false)
  121. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  122. expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
  123. expect(retirements).toEqual([{ reason: 'failed' }])
  124. })
  125. it('sends the echo identity as the prompt requestId', async ({ mock, start }) => {
  126. const session = await sessionBench(mock, start, SID)
  127. const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
  128. await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
  129. expect(mock.log.requests('session/prompt')).toMatchObject([{ requestId: handle.requestId, sessionId: SID }])
  130. })
  131. it('an unidentified prompt failure leaves registered echoes alone', async ({ mock, start }) => {
  132. const session = await sessionBench(mock, start, SID)
  133. mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
  134. session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
  135. await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
  136. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  137. })
  138. })
  139. describe('observed retirement', () => {
  140. it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async ({ mock, start }) => {
  141. const session = await sessionBench(mock, start, SID)
  142. await session.open()
  143. const retirements: PendingSubmissionRetirement[] = []
  144. const handle = session.beginSubmission({
  145. mode: 'queue',
  146. text: '发送',
  147. attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
  148. onRetire: retirement => retirements.push(retirement),
  149. })
  150. const refs = [imageRef('att-1')]
  151. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
  152. // Synchronously after the append the echo is still in the snapshot; the
  153. // render-time dedupe owns the overlap frame.
  154. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  155. await settleFrames()
  156. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  157. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  158. })
  159. it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => {
  160. const session = await sessionBench(mock, start, SID)
  161. const retirements: PendingSubmissionRetirement[] = []
  162. session.handleRunning(true)
  163. const handle = session.beginSubmission({
  164. mode: 'queue',
  165. text: '排队',
  166. attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
  167. onRetire: retirement => retirements.push(retirement),
  168. })
  169. const refs = [imageRef('att-q')]
  170. session.handleControlFrame(queuedFrame(handle.requestId, refs))
  171. await settleFrames()
  172. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  173. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  174. // The queue projection keeps the correlation id for render-time dedupe.
  175. expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
  176. })
  177. it('retires a mixed echo with durable references in original selection order', async ({ mock, start }) => {
  178. const session = await sessionBench(mock, start, SID)
  179. await session.open()
  180. const retirements: PendingSubmissionRetirement[] = []
  181. const file = fileRef('file-1')
  182. const handle = session.beginSubmission({
  183. mode: 'queue',
  184. text: 'mixed',
  185. attachments: [
  186. { type: 'image', value: { previewUrl: 'blob:first' } },
  187. { type: 'file', value: file },
  188. { type: 'image', value: { previewUrl: 'blob:last' } },
  189. ],
  190. onRetire: retirement => retirements.push(retirement),
  191. })
  192. const refs = [imageRef('image-1'), file, imageRef('image-2')]
  193. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
  194. await settleFrames()
  195. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  196. })
  197. it('a full-window install (reconnect resync) retires echoes observed in the window', async ({ mock, start }) => {
  198. const session = await sessionBench(mock, start, SID)
  199. const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
  200. mock.stream(FOLLOW, followScript(history([promptEvent(SessionSeq(12), handle.requestId)])))
  201. await session.open()
  202. await settleFrames()
  203. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  204. })
  205. it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async ({ mock, start }) => {
  206. const session = await sessionBench(mock, start, SID)
  207. await session.open()
  208. const retirements: PendingSubmissionRetirement[] = []
  209. const handle = session.beginSubmission({
  210. mode: 'queue',
  211. text: '先观察',
  212. attachments: [],
  213. onRetire: retirement => retirements.push(retirement),
  214. })
  215. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
  216. handle.abandon()
  217. await settleFrames()
  218. expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
  219. })
  220. it('retires once when the queue and durable event report the same request id', async ({ mock, start }) => {
  221. const session = await sessionBench(mock, start, SID)
  222. await session.open()
  223. const retirements: PendingSubmissionRetirement[] = []
  224. const handle = session.beginSubmission({
  225. mode: 'queue',
  226. text: '同一请求',
  227. attachments: [],
  228. onRetire: retirement => retirements.push(retirement),
  229. })
  230. session.handleControlFrame(queuedFrame(handle.requestId))
  231. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
  232. await settleFrames()
  233. expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
  234. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  235. })
  236. it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async ({ mock, start }) => {
  237. const session = await sessionBench(mock, start, SID)
  238. await session.open()
  239. const frames: FrameRequestCallback[] = []
  240. vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
  241. frames.push(fn)
  242. return frames.length
  243. })
  244. const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
  245. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
  246. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  247. expect(frames).toHaveLength(1)
  248. frames[0]?.(0)
  249. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  250. })
  251. })
  252. describe('disposal', () => {
  253. it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => {
  254. const session = await sessionBench(mock, start, SID)
  255. await session.open()
  256. const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
  257. const observed = session.beginSubmission({
  258. mode: 'queue',
  259. text: '已观察',
  260. attachments: [],
  261. onRetire: retirement => retirements.push({ text: '已观察', retirement }),
  262. })
  263. session.beginSubmission({
  264. mode: 'queue',
  265. text: '未settle',
  266. attachments: [],
  267. onRetire: retirement => retirements.push({ text: '未settle', retirement }),
  268. })
  269. await pushEvent(mock, promptEvent(SessionSeq(0), observed.requestId))
  270. await session.dispose()
  271. await settleFrames()
  272. expect(retirements).toEqual([
  273. { text: '未settle', retirement: { reason: 'failed' } },
  274. { text: '已观察', retirement: { reason: 'observed', attachments: [] } },
  275. ])
  276. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  277. })
  278. })