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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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,
  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. function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) {
  51. return createUserMessage({
  52. source: { kind: 'user', rpcId },
  53. content: refs.map(attachmentBlock),
  54. })
  55. }
  56. /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */
  57. async function settleFrames(): Promise<void> {
  58. await Promise.resolve()
  59. await new Promise(resolve => setTimeout(resolve, 0))
  60. }
  61. describe('beginSubmission', () => {
  62. it('inserts the echo synchronously and flips the engaging edge before any prompt call', async ({ mock, start }) => {
  63. const session = await sessionBench(mock, start, SID)
  64. expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
  65. const handle = session.beginSubmission({
  66. mode: 'queue',
  67. text: '你好',
  68. attachments: [{
  69. type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
  70. }],
  71. })
  72. expect(session.getSnapshot().promptAttempted).toBe(true)
  73. expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
  74. requestId: handle.requestId,
  75. placement: 'transcript',
  76. text: '你好',
  77. attachments: [{
  78. type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
  79. }],
  80. }])
  81. expect(mock.log.requests()).toEqual([])
  82. }, COLD_BOOT_TIMEOUT_MS)
  83. it('derives and captures the echo placement from running state and delivery mode', async ({ mock, start }) => {
  84. const session = await sessionBench(mock, start, SID)
  85. session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
  86. session.handleRunning(true)
  87. session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
  88. session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] })
  89. session.handleRunning(false)
  90. expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
  91. { text: '空闲', placement: 'transcript' },
  92. { text: '排队', placement: 'queued' },
  93. { text: '纠偏', placement: 'steering' },
  94. ])
  95. })
  96. it('abandon retires the echo as failed exactly once', async ({ mock, start }) => {
  97. const session = await sessionBench(mock, start, SID)
  98. const retirements: PendingSubmissionRetirement[] = []
  99. const handle = session.beginSubmission({
  100. mode: 'queue',
  101. text: '放弃',
  102. attachments: [],
  103. onRetire: retirement => retirements.push(retirement),
  104. })
  105. handle.abandon()
  106. handle.abandon()
  107. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  108. expect(retirements).toEqual([{ reason: 'failed' }])
  109. })
  110. })
  111. describe('prompt-coupled retirement', () => {
  112. it('a rejected identified prompt retires its echo immediately alongside promptError', async ({ mock, start }) => {
  113. const session = await sessionBench(mock, start, SID)
  114. mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
  115. const retirements: PendingSubmissionRetirement[] = []
  116. const handle = session.beginSubmission({
  117. mode: 'queue',
  118. text: '失败的',
  119. attachments: [],
  120. onRetire: retirement => retirements.push(retirement),
  121. })
  122. const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
  123. expect(result.ok).toBe(false)
  124. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  125. expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
  126. expect(retirements).toEqual([{ reason: 'failed' }])
  127. })
  128. it('sends the echo identity as the prompt requestId', async ({ mock, start }) => {
  129. const session = await sessionBench(mock, start, SID)
  130. const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
  131. await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
  132. expect(mock.log.requests('session/prompt')).toMatchObject([{ requestId: handle.requestId, sessionId: SID }])
  133. })
  134. it('an unidentified prompt failure leaves registered echoes alone', async ({ mock, start }) => {
  135. const session = await sessionBench(mock, start, SID)
  136. mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
  137. session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
  138. await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
  139. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  140. })
  141. })
  142. describe('observed retirement', () => {
  143. it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async ({ mock, start }) => {
  144. const session = await sessionBench(mock, start, SID)
  145. await session.open()
  146. const retirements: PendingSubmissionRetirement[] = []
  147. const handle = session.beginSubmission({
  148. mode: 'queue',
  149. text: '发送',
  150. attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
  151. onRetire: retirement => retirements.push(retirement),
  152. })
  153. const refs = [imageRef('att-1')]
  154. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
  155. // Synchronously after the append the echo is still in the snapshot; the
  156. // render-time dedupe owns the overlap frame.
  157. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  158. await settleFrames()
  159. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  160. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  161. })
  162. it('retires an accepted echo when a claim clears the projection before its notification', async ({ mock, start }) => {
  163. const session = await sessionBench(mock, start, SID)
  164. await session.open()
  165. const onRetire = vi.fn()
  166. const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
  167. const refs = [imageRef('claimed-image')]
  168. const message = queuedItem(handle.requestId, refs)
  169. session.projections.apply('inbox', { 'next-turn': [], 'next-step': [message] }, SessionSeq(0))
  170. session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(1))
  171. await pushEvent(mock, {
  172. type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
  173. data: { target: 'next-step', start: 0, inserted: [message] },
  174. })
  175. await settleFrames()
  176. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  177. expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
  178. })
  179. it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => {
  180. const session = await sessionBench(mock, start, SID)
  181. const retirements: PendingSubmissionRetirement[] = []
  182. session.handleRunning(true)
  183. const handle = session.beginSubmission({
  184. mode: 'queue',
  185. text: '排队',
  186. attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
  187. onRetire: retirement => retirements.push(retirement),
  188. })
  189. const refs = [imageRef('att-q')]
  190. session.projections.apply('inbox', { 'next-turn': [queuedItem(handle.requestId, refs)], 'next-step': [] }, SessionSeq(1))
  191. await settleFrames()
  192. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  193. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  194. // The queue projection keeps the correlation id for render-time dedupe.
  195. expect(session.projections.get('inbox')).toMatchObject({
  196. 'next-turn': [{ source: { rpcId: handle.requestId } }],
  197. })
  198. })
  199. it('retires a mixed echo with durable references in original selection order', async ({ mock, start }) => {
  200. const session = await sessionBench(mock, start, SID)
  201. await session.open()
  202. const retirements: PendingSubmissionRetirement[] = []
  203. const file = fileRef('file-1')
  204. const handle = session.beginSubmission({
  205. mode: 'queue',
  206. text: 'mixed',
  207. attachments: [
  208. { type: 'image', value: { previewUrl: 'blob:first' } },
  209. { type: 'file', value: file },
  210. { type: 'image', value: { previewUrl: 'blob:last' } },
  211. ],
  212. onRetire: retirement => retirements.push(retirement),
  213. })
  214. const refs = [imageRef('image-1'), file, imageRef('image-2')]
  215. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
  216. await settleFrames()
  217. expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
  218. })
  219. it('a full-window install (reconnect resync) retires echoes observed in the window', async ({ mock, start }) => {
  220. const session = await sessionBench(mock, start, SID)
  221. const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
  222. mock.stream(FOLLOW, followScript(history([promptEvent(SessionSeq(12), handle.requestId)])))
  223. await session.open()
  224. await settleFrames()
  225. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  226. })
  227. it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async ({ mock, start }) => {
  228. const session = await sessionBench(mock, start, SID)
  229. await session.open()
  230. const retirements: PendingSubmissionRetirement[] = []
  231. const handle = session.beginSubmission({
  232. mode: 'queue',
  233. text: '先观察',
  234. attachments: [],
  235. onRetire: retirement => retirements.push(retirement),
  236. })
  237. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
  238. handle.abandon()
  239. await settleFrames()
  240. expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
  241. })
  242. it('retires once when the queue and durable event report the same request id', async ({ mock, start }) => {
  243. const session = await sessionBench(mock, start, SID)
  244. await session.open()
  245. const retirements: PendingSubmissionRetirement[] = []
  246. const handle = session.beginSubmission({
  247. mode: 'queue',
  248. text: '同一请求',
  249. attachments: [],
  250. onRetire: retirement => retirements.push(retirement),
  251. })
  252. session.projections.apply('inbox', { 'next-turn': [queuedItem(handle.requestId, [])], 'next-step': [] }, SessionSeq(1))
  253. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
  254. await settleFrames()
  255. expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
  256. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  257. })
  258. it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async ({ mock, start }) => {
  259. const session = await sessionBench(mock, start, SID)
  260. await session.open()
  261. const frames: FrameRequestCallback[] = []
  262. vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
  263. frames.push(fn)
  264. return frames.length
  265. })
  266. const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
  267. await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
  268. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
  269. expect(frames).toHaveLength(1)
  270. frames[0]?.(0)
  271. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  272. })
  273. })
  274. describe('disposal', () => {
  275. it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => {
  276. const session = await sessionBench(mock, start, SID)
  277. await session.open()
  278. const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
  279. const observed = session.beginSubmission({
  280. mode: 'queue',
  281. text: '已观察',
  282. attachments: [],
  283. onRetire: retirement => retirements.push({ text: '已观察', retirement }),
  284. })
  285. session.beginSubmission({
  286. mode: 'queue',
  287. text: '未settle',
  288. attachments: [],
  289. onRetire: retirement => retirements.push({ text: '未settle', retirement }),
  290. })
  291. await pushEvent(mock, promptEvent(SessionSeq(0), observed.requestId))
  292. await session.dispose()
  293. await settleFrames()
  294. expect(retirements).toEqual([
  295. { text: '未settle', retirement: { reason: 'failed' } },
  296. { text: '已观察', retirement: { reason: 'observed', attachments: [] } },
  297. ])
  298. expect(session.getSnapshot().pendingSubmissions).toEqual([])
  299. })
  300. })