queue-store.spec.ts 7.0 KB

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