queue-store.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  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, it } from 'vitest'
  7. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  8. import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
  9. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  10. import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
  11. import { Session } from '../src/client/sessions/session.ts'
  12. import { SessionManager } from '../src/client/sessions/manager.ts'
  13. import { FakeApiClient } from './fake-api.ts'
  14. const SID = 'fk-q1' as SessionId
  15. const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
  16. const rid = (id: string): RpcId => id as RpcId
  17. const iid = (id: string): MessageId => id as MessageId
  18. interface QueueFixture {
  19. id: string
  20. body: string
  21. content?: ContentBlock[]
  22. placement?: 'queued' | 'steering'
  23. message?: UserMessage
  24. }
  25. /** Build one authoritative queue snapshot. */
  26. function queueFrame(items: QueueFixture[]): MuxFrame {
  27. return {
  28. type: 'session/queue',
  29. sessionId: SID,
  30. items: items.map(item => ({
  31. id: iid(item.id),
  32. placement: item.placement ?? 'queued',
  33. message: item.message ?? createUserMessage({
  34. content: item.content ?? text(item.body),
  35. source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
  36. }),
  37. })),
  38. }
  39. }
  40. function makeSession(): Session {
  41. return new Session(SID, new FakeApiClient())
  42. }
  43. describe('queue snapshot intake', () => {
  44. it('projects stable ids, flat previews, and complete text', () => {
  45. const session = makeSession()
  46. session.handleMuxEnvelope(rid('env-1'), queueFrame([
  47. { id: 'q-1', body: '第一条 排队\n消息' },
  48. ]))
  49. const queue = session.getSnapshot().queue
  50. expect(typeof queue[0]?.messageId).toBe('string')
  51. expect(queue).toMatchObject([
  52. {
  53. id: 'q-1', placement: 'queued',
  54. content: [{ type: 'text', text: '第一条 排队\n消息' }],
  55. preview: '第一条 排队 消息', text: '第一条 排队\n消息',
  56. },
  57. ])
  58. })
  59. it('marks mixed-content messages non-editable while retaining their preview', () => {
  60. const session = makeSession()
  61. session.handleMuxEnvelope(rid('env-2'), queueFrame([{
  62. id: 'q-image',
  63. body: '',
  64. content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
  65. }]))
  66. const queue = session.getSnapshot().queue
  67. expect(typeof queue[0]?.messageId).toBe('string')
  68. expect(queue).toMatchObject([
  69. {
  70. id: 'q-image', placement: 'queued',
  71. content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
  72. preview: 'hi [image]', text: null,
  73. },
  74. ])
  75. })
  76. it('caps previews at 200 code points and preserves the full editable text', () => {
  77. const session = makeSession()
  78. const body = '长'.repeat(201)
  79. session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }]))
  80. const row = session.getSnapshot().queue[0]
  81. expect(Array.from(row?.preview ?? '')).toHaveLength(201)
  82. expect(row?.preview.endsWith('…')).toBe(true)
  83. expect(row?.text).toBe(body)
  84. })
  85. it('replaces content, order, and membership from each authoritative frame', () => {
  86. const session = makeSession()
  87. session.handleMuxEnvelope(rid('env-4'), queueFrame([
  88. { id: 'q-1', body: 'one' },
  89. { id: 'q-2', body: 'two' },
  90. ]))
  91. session.handleMuxEnvelope(rid('env-5'), queueFrame([
  92. { id: 'q-2', body: 'two edited' },
  93. ]))
  94. const queue = session.getSnapshot().queue
  95. expect(typeof queue[0]?.messageId).toBe('string')
  96. expect(queue).toMatchObject([
  97. {
  98. id: 'q-2', placement: 'queued',
  99. content: [{ type: 'text', text: 'two edited' }],
  100. preview: 'two edited', text: 'two edited',
  101. },
  102. ])
  103. session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
  104. expect(session.getSnapshot().queue).toEqual([])
  105. })
  106. it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
  107. const session = makeSession()
  108. session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }]))
  109. const before = session.getSnapshot().queue
  110. session.handleAgentError('unrelated')
  111. expect(session.getSnapshot().queue).toBe(before)
  112. })
  113. it('retains steering placement and complete content in the same authoritative snapshot', () => {
  114. const session = makeSession()
  115. session.handleMuxEnvelope(rid('env-steering'), queueFrame([
  116. { id: 'q-next', body: 'later' },
  117. { id: 's-now', body: 'interrupt now', placement: 'steering' },
  118. ]))
  119. expect(session.getSnapshot().queue.map(item => ({
  120. id: item.id, placement: item.placement, content: item.content,
  121. }))).toEqual([
  122. { id: 'q-next', placement: 'queued', content: text('later') },
  123. { id: 's-now', placement: 'steering', content: text('interrupt now') },
  124. ])
  125. })
  126. it('hands off exactly one current occurrence when live steering becomes durable', async () => {
  127. const session = makeSession()
  128. await session.open()
  129. const message = createUserMessage({
  130. content: text('same message'),
  131. source: { kind: 'user' },
  132. })
  133. session.handleMuxEnvelope(rid('env-same-id'), queueFrame([
  134. { id: 's-first', body: '', placement: 'steering', message },
  135. { id: 's-second', body: '', placement: 'steering', message },
  136. ]))
  137. const durable = {
  138. seq: 0,
  139. time: 1_700_000_000_000,
  140. type: 'user/message',
  141. surfaceOp: 'append',
  142. data: message,
  143. } as SessionEvent
  144. session.handleMuxEnvelope(rid('env-durable'), {
  145. type: 'session/event', sessionId: SID, event: durable,
  146. })
  147. expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
  148. expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
  149. session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
  150. { id: 's-later', body: '', placement: 'steering', message },
  151. ]))
  152. session.handleMuxEnvelope(rid('env-replayed-durable'), {
  153. type: 'session/event', sessionId: SID, event: durable,
  154. })
  155. expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
  156. })
  157. it('hands off live steering when the agent claims it as a user message', async () => {
  158. const session = makeSession()
  159. await session.open()
  160. const message = createUserMessage({
  161. content: text('claimed steering'),
  162. source: { kind: 'user' },
  163. })
  164. session.handleMuxEnvelope(rid('env-claimed'), queueFrame([
  165. { id: 's-claimed', body: '', placement: 'steering', message },
  166. ]))
  167. session.handleMuxEnvelope(rid('env-user-message'), {
  168. type: 'session/event',
  169. sessionId: SID,
  170. event: {
  171. seq: 0,
  172. time: 1_700_000_000_000,
  173. type: 'user/message',
  174. surfaceOp: 'append',
  175. data: message,
  176. },
  177. })
  178. expect(session.getSnapshot().queue).toEqual([])
  179. })
  180. })
  181. describe('queue operation transport', () => {
  182. it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
  183. const api = new FakeApiClient()
  184. const session = new Session(SID, api)
  185. session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
  186. const before = session.getSnapshot().queue
  187. await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
  188. .resolves.toEqual({ ok: true, value: { accepted: true } })
  189. await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
  190. .resolves.toEqual({ ok: true, value: { accepted: true } })
  191. expect(api.callsOf('session.updateQueue')).toEqual([
  192. {
  193. sessionId: SID,
  194. itemId: 'q-op',
  195. action: { kind: 'edit', content: text('next') },
  196. },
  197. {
  198. sessionId: SID,
  199. itemId: 'q-op',
  200. action: { kind: 'steer' },
  201. },
  202. ])
  203. expect(session.getSnapshot().queue).toBe(before)
  204. })
  205. })
  206. describe('queue reconnect semantics', () => {
  207. it('session/subscribed clears stale state before the fresh snapshot lands', () => {
  208. const session = makeSession()
  209. session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }]))
  210. session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
  211. expect(session.getSnapshot().queue).toEqual([])
  212. session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }]))
  213. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  214. })
  215. it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
  216. const session = makeSession()
  217. session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
  218. session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }]))
  219. await session.resync()
  220. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
  221. })
  222. it('running-status changes never guess at queue retirement', () => {
  223. const session = makeSession()
  224. session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }]))
  225. session.handleRunning(true)
  226. session.handleRunning(false)
  227. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
  228. })
  229. })
  230. describe('manager buffering of queue snapshots', () => {
  231. it('replays only the latest snapshot for an uninstantiated session', () => {
  232. const manager = new SessionManager(new FakeApiClient())
  233. manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
  234. manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
  235. expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  236. })
  237. it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
  238. const manager = new SessionManager(new FakeApiClient())
  239. manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
  240. manager.handleMuxEnvelope({
  241. rpcId: rid('g1b'),
  242. payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
  243. })
  244. manager.handleMuxEnvelope({
  245. rpcId: rid('g2a'),
  246. payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 },
  247. })
  248. manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) })
  249. const snapshot = manager.get(SID).getSnapshot()
  250. expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
  251. expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval'])
  252. })
  253. })