queue-store.spec.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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: 'steering/message',
  141. surfaceOp: 'append',
  142. data: { turn: 1, 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 === 'steering')).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. })
  158. describe('queue operation transport', () => {
  159. it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
  160. const api = new FakeApiClient()
  161. const session = new Session(SID, api)
  162. session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
  163. const before = session.getSnapshot().queue
  164. await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
  165. .resolves.toEqual({ ok: true, value: { accepted: true } })
  166. await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
  167. .resolves.toEqual({ ok: true, value: { accepted: true } })
  168. expect(api.callsOf('session.updateQueue')).toEqual([
  169. {
  170. sessionId: SID,
  171. itemId: 'q-op',
  172. action: { kind: 'edit', content: text('next') },
  173. },
  174. {
  175. sessionId: SID,
  176. itemId: 'q-op',
  177. action: { kind: 'steer' },
  178. },
  179. ])
  180. expect(session.getSnapshot().queue).toBe(before)
  181. })
  182. })
  183. describe('queue reconnect semantics', () => {
  184. it('session/subscribed clears stale state before the fresh snapshot lands', () => {
  185. const session = makeSession()
  186. session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }]))
  187. session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
  188. expect(session.getSnapshot().queue).toEqual([])
  189. session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }]))
  190. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  191. })
  192. it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
  193. const session = makeSession()
  194. session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
  195. session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }]))
  196. await session.resync()
  197. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
  198. })
  199. it('running-status changes never guess at queue retirement', () => {
  200. const session = makeSession()
  201. session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }]))
  202. session.handleRunning(true)
  203. session.handleRunning(false)
  204. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
  205. })
  206. })
  207. describe('manager buffering of queue snapshots', () => {
  208. it('replays only the latest snapshot for an uninstantiated session', () => {
  209. const manager = new SessionManager(new FakeApiClient())
  210. manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
  211. manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
  212. expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  213. })
  214. it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
  215. const manager = new SessionManager(new FakeApiClient())
  216. manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
  217. manager.handleMuxEnvelope({
  218. rpcId: rid('g1b'),
  219. payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
  220. })
  221. manager.handleMuxEnvelope({
  222. rpcId: rid('g2a'),
  223. payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 },
  224. })
  225. manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) })
  226. const snapshot = manager.get(SID).getSnapshot()
  227. expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
  228. expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval'])
  229. })
  230. })