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

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