queue-store.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /**
  2. * Queue snapshot semantics: authoritative replacement after every host-side
  3. * change, reconnect re-baselining, pre-instantiation buffering, editable-text
  4. * projection, and snapshot reference stability.
  5. */
  6. import { describe, expect, vi } from 'vitest'
  7. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  8. import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
  9. import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
  10. import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  11. import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
  12. import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
  13. import { SessionManager } from '../src/client/sessions/manager.ts'
  14. import { sessionBench } from './remote/bench.client.ts'
  15. import { pushEvent, sessionWorld } from './remote/session.client.ts'
  16. /** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
  17. const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
  18. const it = createClientTest({ roster: API_ROSTER })
  19. const SID = 'fk-q1' as SessionId
  20. /** The first client boot pays the cold module transform of the api cone. */
  21. const COLD_BOOT_TIMEOUT_MS = 60_000
  22. const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
  23. const rid = (id: string): RpcId => id as RpcId
  24. const iid = (id: string): MessageId => id as MessageId
  25. interface QueueFixture {
  26. id: string
  27. body: string
  28. content?: ContentBlock[]
  29. placement?: 'queued' | 'steering'
  30. message?: UserMessage
  31. }
  32. /** Build one authoritative queue snapshot. */
  33. function queueFrame(items: QueueFixture[]): Extract<SessionControlFrame, { type: 'queue' }> {
  34. return {
  35. type: 'queue',
  36. sessionId: SID,
  37. items: items.map(item => ({
  38. id: iid(item.id),
  39. placement: item.placement ?? 'queued',
  40. message: (item.message ?? createUserMessage({
  41. content: item.content ?? text(item.body),
  42. source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
  43. })) as never,
  44. })),
  45. }
  46. }
  47. describe('Session queue snapshot intake', () => {
  48. it('projects stable ids, flat previews, and complete text', async ({ mock, start }) => {
  49. const session = await sessionBench(mock, start, SID)
  50. session.handleControlFrame(queueFrame([
  51. { id: 'q-1', body: '第一条 排队\n消息' },
  52. ]))
  53. const queue = session.getSnapshot().queue
  54. expect(typeof queue[0]?.messageId).toBe('string')
  55. expect(queue).toMatchObject([
  56. {
  57. id: 'q-1', placement: 'queued',
  58. content: [{ type: 'text', text: '第一条 排队\n消息' }],
  59. preview: '第一条 排队 消息', text: '第一条 排队\n消息',
  60. },
  61. ])
  62. }, COLD_BOOT_TIMEOUT_MS)
  63. it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', async ({ mock, start }) => {
  64. const session = await sessionBench(mock, start, SID)
  65. session.handleControlFrame(queueFrame([{
  66. id: 'q-image',
  67. body: '',
  68. content: [
  69. { type: 'text', text: 'hi' },
  70. { type: 'image', data: 'x' } as never,
  71. { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never,
  72. ],
  73. }]))
  74. const queue = session.getSnapshot().queue
  75. expect(typeof queue[0]?.messageId).toBe('string')
  76. expect(queue).toMatchObject([
  77. {
  78. id: 'q-image', placement: 'queued',
  79. content: [
  80. { type: 'text', text: 'hi' },
  81. { type: 'image', data: 'x' },
  82. { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } },
  83. ],
  84. // Attachment blocks render from `content`, so the preview carries
  85. // only text; other foreign blocks keep their marker.
  86. preview: 'hi', text: null,
  87. },
  88. ])
  89. })
  90. it('caps previews at 200 code points and preserves the full editable text', async ({ mock, start }) => {
  91. const session = await sessionBench(mock, start, SID)
  92. const body = '长'.repeat(201)
  93. session.handleControlFrame(queueFrame([{ id: 'q-cap', body }]))
  94. const row = session.getSnapshot().queue[0]
  95. expect(Array.from(row?.preview ?? '')).toHaveLength(201)
  96. expect(row?.preview.endsWith('…')).toBe(true)
  97. expect(row?.text).toBe(body)
  98. })
  99. it('replaces content, order, and membership from each authoritative frame', async ({ mock, start }) => {
  100. const session = await sessionBench(mock, start, SID)
  101. session.handleControlFrame(queueFrame([
  102. { id: 'q-1', body: 'one' },
  103. { id: 'q-2', body: 'two' },
  104. ]))
  105. session.handleControlFrame(queueFrame([
  106. { id: 'q-2', body: 'two edited' },
  107. ]))
  108. const queue = session.getSnapshot().queue
  109. expect(typeof queue[0]?.messageId).toBe('string')
  110. expect(queue).toMatchObject([
  111. {
  112. id: 'q-2', placement: 'queued',
  113. content: [{ type: 'text', text: 'two edited' }],
  114. preview: 'two edited', text: 'two edited',
  115. },
  116. ])
  117. session.handleControlFrame(queueFrame([]))
  118. expect(session.getSnapshot().queue).toEqual([])
  119. })
  120. it('keeps the queue array reference stable across unrelated snapshot swaps', async ({ mock, start }) => {
  121. const session = await sessionBench(mock, start, SID)
  122. session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }]))
  123. const before = session.getSnapshot().queue
  124. session.handleAgentError('unrelated')
  125. expect(session.getSnapshot().queue).toBe(before)
  126. })
  127. it('retains steering placement and complete content in the same authoritative snapshot', async ({ mock, start }) => {
  128. const session = await sessionBench(mock, start, SID)
  129. session.handleControlFrame(queueFrame([
  130. { id: 'q-next', body: 'later' },
  131. { id: 's-now', body: 'interrupt now', placement: 'steering' },
  132. ]))
  133. expect(session.getSnapshot().queue.map(item => ({
  134. id: item.id, placement: item.placement, content: item.content,
  135. }))).toEqual([
  136. { id: 'q-next', placement: 'queued', content: text('later') },
  137. { id: 's-now', placement: 'steering', content: text('interrupt now') },
  138. ])
  139. })
  140. it('hands off exactly one current occurrence when live steering becomes durable', async ({ mock, start }) => {
  141. const session = await sessionBench(mock, start, SID)
  142. await session.open()
  143. const message = createUserMessage({
  144. content: text('same message'),
  145. source: { kind: 'user' },
  146. })
  147. session.handleControlFrame(queueFrame([
  148. { id: 's-first', body: '', placement: 'steering', message },
  149. { id: 's-second', body: '', placement: 'steering', message },
  150. ]))
  151. const durable = {
  152. seq: SessionSeq(0),
  153. time: 1_700_000_000_000,
  154. type: 'user/message',
  155. surfaceOp: 'append',
  156. data: message,
  157. } satisfies SessionEvent
  158. await pushEvent(mock, durable)
  159. await vi.waitFor(() => {
  160. expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
  161. })
  162. session.handleControlFrame(queueFrame([
  163. { id: 's-later', body: '', placement: 'steering', message },
  164. ]))
  165. await pushEvent(mock, durable)
  166. await vi.waitFor(() => {
  167. expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
  168. })
  169. })
  170. it('hands off live steering when the agent claims it as a user message', async ({ mock, start }) => {
  171. const session = await sessionBench(mock, start, SID)
  172. await session.open()
  173. const message = createUserMessage({
  174. content: text('claimed steering'),
  175. source: { kind: 'user' },
  176. })
  177. session.handleControlFrame(queueFrame([
  178. { id: 's-claimed', body: '', placement: 'steering', message },
  179. ]))
  180. await pushEvent(mock, {
  181. seq: 0,
  182. time: 1_700_000_000_000,
  183. type: 'user/message',
  184. surfaceOp: 'append',
  185. data: message,
  186. } as never)
  187. await vi.waitFor(() => {
  188. expect(session.getSnapshot().queue).toEqual([])
  189. })
  190. })
  191. })
  192. describe('queue operation transport', () => {
  193. it('addresses the session.updateQueue RPC without optimistic local mutation', async ({ mock, start }) => {
  194. const session = await sessionBench(mock, start, SID)
  195. session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
  196. const before = session.getSnapshot().queue
  197. await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
  198. .resolves.toEqual({ ok: true, value: { accepted: true } })
  199. await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
  200. .resolves.toEqual({ ok: true, value: { accepted: true } })
  201. expect(mock.log.requests('session/updateQueue')).toEqual([
  202. {
  203. sessionId: SID,
  204. itemId: 'q-op',
  205. action: { kind: 'edit', content: text('next') },
  206. },
  207. {
  208. sessionId: SID,
  209. itemId: 'q-op',
  210. action: { kind: 'steer' },
  211. },
  212. ])
  213. expect(session.getSnapshot().queue).toBe(before)
  214. })
  215. })
  216. describe('queue reconnect semantics', () => {
  217. it('a control baseline clears stale state before a fresh update lands', async ({ mock, start }) => {
  218. const session = await sessionBench(mock, start, SID)
  219. session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }]))
  220. session.replaceControl([])
  221. expect(session.getSnapshot().queue).toEqual([])
  222. session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }]))
  223. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  224. })
  225. it('resync does not clear a baseline that raced ahead of the host connection signal', async ({ mock, start }) => {
  226. const session = await sessionBench(mock, start, SID)
  227. await session.open()
  228. session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }]))
  229. await session.resync()
  230. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
  231. })
  232. it('running-status changes never guess at queue retirement', async ({ mock, start }) => {
  233. const session = await sessionBench(mock, start, SID)
  234. session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }]))
  235. session.handleRunning(true)
  236. session.handleRunning(false)
  237. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
  238. })
  239. })
  240. describe('manager buffering of queue snapshots', () => {
  241. it('replays only the latest snapshot for an uninstantiated session', async ({ mock, start }) => {
  242. mock.load(sessionWorld)
  243. const { ctx: { remote } } = await start()
  244. const manager = new SessionManager(remote)
  245. manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }]))
  246. manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }]))
  247. expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  248. })
  249. it('a control baseline replaces the prior queue', async ({ mock, start }) => {
  250. mock.load(sessionWorld)
  251. const { ctx: { remote } } = await start()
  252. const manager = new SessionManager(remote)
  253. manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }]))
  254. const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items
  255. manager.handleControlFrame({
  256. type: 'baseline',
  257. value: {
  258. queues: { [SID]: nextQueue },
  259. jobs: {},
  260. projections: {},
  261. },
  262. })
  263. const snapshot = manager.get(SID).getSnapshot()
  264. expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
  265. })
  266. })