queue-store.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  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 } from '@deepseek-ai/dsh-llm/types'
  9. import type {
  10. InboxItemId, MuxFrame, RpcId, SessionId,
  11. } from '@deepseek-ai/dsh-client-connection/client'
  12. import { Session } from '../src/client/sessions/session.ts'
  13. import { SessionManager } from '../src/client/sessions/manager.ts'
  14. import { FakeApiClient } from './fake-api.ts'
  15. const SID = 'fk-q1' as SessionId
  16. const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
  17. const rid = (id: string): RpcId => id as RpcId
  18. const iid = (id: string): InboxItemId => id as InboxItemId
  19. interface QueueFixture {
  20. id: string
  21. body: string
  22. placement?: 'queued' | 'steering'
  23. content?: ContentBlock[]
  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. message: createUserMessage({
  33. content: item.content ?? text(item.body),
  34. source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
  35. }),
  36. placement: item.placement ?? 'queued',
  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, complete text, and placement', () => {
  45. const session = makeSession()
  46. session.handleMuxEnvelope(rid('env-1'), queueFrame([
  47. { id: 'q-1', body: '第一条 排队\n消息' },
  48. { id: 'q-2', body: '插话', placement: 'steering' },
  49. ]))
  50. expect(session.getSnapshot().queue).toEqual([
  51. { id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息', placement: 'queued' },
  52. { id: 'q-2', preview: '插话', text: '插话', placement: 'steering' },
  53. ])
  54. })
  55. it('marks mixed-content messages non-editable while retaining their preview', () => {
  56. const session = makeSession()
  57. session.handleMuxEnvelope(rid('env-2'), queueFrame([{
  58. id: 'q-image',
  59. body: '',
  60. content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
  61. }]))
  62. expect(session.getSnapshot().queue).toEqual([
  63. { id: 'q-image', preview: 'hi [image]', text: null, placement: 'queued' },
  64. ])
  65. })
  66. it('caps previews at 200 code points and preserves the full editable text', () => {
  67. const session = makeSession()
  68. const body = '长'.repeat(201)
  69. session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }]))
  70. const row = session.getSnapshot().queue[0]
  71. expect(Array.from(row?.preview ?? '')).toHaveLength(201)
  72. expect(row?.preview.endsWith('…')).toBe(true)
  73. expect(row?.text).toBe(body)
  74. })
  75. it('replaces content, order, and membership from each authoritative frame', () => {
  76. const session = makeSession()
  77. session.handleMuxEnvelope(rid('env-4'), queueFrame([
  78. { id: 'q-1', body: 'one' },
  79. { id: 'q-2', body: 'two' },
  80. ]))
  81. session.handleMuxEnvelope(rid('env-5'), queueFrame([
  82. { id: 'q-2', body: 'two edited' },
  83. ]))
  84. expect(session.getSnapshot().queue).toEqual([
  85. { id: 'q-2', preview: 'two edited', text: 'two edited', placement: 'queued' },
  86. ])
  87. session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
  88. expect(session.getSnapshot().queue).toEqual([])
  89. })
  90. it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
  91. const session = makeSession()
  92. session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }]))
  93. const before = session.getSnapshot().queue
  94. session.handleAgentError('unrelated')
  95. expect(session.getSnapshot().queue).toBe(before)
  96. })
  97. })
  98. describe('queue operation transport', () => {
  99. it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
  100. const api = new FakeApiClient()
  101. const session = new Session(SID, api)
  102. session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
  103. const before = session.getSnapshot().queue
  104. await expect(session.updateQueue(iid('q-op'), { kind: 'promote' }))
  105. .resolves.toEqual({ ok: true, value: { accepted: true } })
  106. expect(api.callsOf('session.updateQueue')).toEqual([{
  107. sessionId: SID,
  108. itemId: 'q-op',
  109. action: { kind: 'promote' },
  110. }])
  111. expect(session.getSnapshot().queue).toBe(before)
  112. })
  113. })
  114. describe('queue reconnect semantics', () => {
  115. it('session/subscribed clears stale state before the fresh snapshot lands', () => {
  116. const session = makeSession()
  117. session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }]))
  118. session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
  119. expect(session.getSnapshot().queue).toEqual([])
  120. session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }]))
  121. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  122. })
  123. it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
  124. const session = makeSession()
  125. session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
  126. session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }]))
  127. await session.resync()
  128. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
  129. })
  130. it('running-status changes never guess at queue retirement', () => {
  131. const session = makeSession()
  132. session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }]))
  133. session.handleRunning(true)
  134. session.handleRunning(false)
  135. expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
  136. })
  137. })
  138. describe('manager buffering of queue snapshots', () => {
  139. it('replays only the latest snapshot for an uninstantiated session', () => {
  140. const manager = new SessionManager(new FakeApiClient())
  141. manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
  142. manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
  143. expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
  144. })
  145. it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
  146. const manager = new SessionManager(new FakeApiClient())
  147. manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
  148. manager.handleMuxEnvelope({
  149. rpcId: rid('g1b'),
  150. payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
  151. })
  152. manager.handleMuxEnvelope({
  153. rpcId: rid('g2a'),
  154. payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 },
  155. })
  156. manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) })
  157. const snapshot = manager.get(SID).getSnapshot()
  158. expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
  159. expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval'])
  160. })
  161. })