queue-store.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /**
  2. * Queue mirror semantics (web input-triggers queue cut 1): session/queued
  3. * intake, host-rule retirement (message turn/start claims oldest non-steering;
  4. * steering/message drains by source), leave-running sweep, reconnect reset,
  5. * pre-instantiation buffering, and snapshot reference stability.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  9. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
  10. import type { 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. import { ev } from './event-script.ts'
  15. const SID = 'fk-q1' as SessionId
  16. const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
  17. const rid = (id: string): RpcId => id as RpcId
  18. /** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
  19. function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
  20. return {
  21. type: 'session/queued',
  22. sessionId: SID,
  23. message: createUserMessage({
  24. content: text(body),
  25. source: { kind: 'user', rpcId: rid(rpcId) } as never,
  26. }),
  27. steering,
  28. }
  29. }
  30. function makeSession(): Session {
  31. return new Session(SID, new FakeApiClient())
  32. }
  33. describe('queue intake', () => {
  34. it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => {
  35. const session = makeSession()
  36. session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1'))
  37. const queue = session.getSnapshot().queue
  38. expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }])
  39. })
  40. it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
  41. const session = makeSession()
  42. session.handleMuxEnvelope(rid('env-2'), {
  43. type: 'session/queued',
  44. sessionId: SID,
  45. message: createUserMessage({
  46. content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
  47. source: { kind: 'plugin', plugin: 'loop' },
  48. }),
  49. steering: false,
  50. })
  51. expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
  52. })
  53. it('caps the preview at 200 code points with an ellipsis', () => {
  54. const session = makeSession()
  55. session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
  56. const preview = session.getSnapshot().queue[0]?.preview ?? ''
  57. expect(Array.from(preview)).toHaveLength(201) // 200 + …
  58. expect(preview.endsWith('…')).toBe(true)
  59. })
  60. it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
  61. const session = makeSession()
  62. session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s'))
  63. const before = session.getSnapshot().queue
  64. session.handleAgentError('unrelated') // dirties the snapshot without touching the queue
  65. expect(session.getSnapshot().queue).toBe(before)
  66. })
  67. })
  68. describe('queue retirement (host queuedMirror rules)', () => {
  69. it('a message-triggered turn/start claims the oldest non-steering row', () => {
  70. const session = makeSession()
  71. session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1'))
  72. session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2'))
  73. session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) })
  74. expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2'])
  75. })
  76. it('an injection-triggered turn/start claims nothing', () => {
  77. const session = makeSession()
  78. session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
  79. const injection = {
  80. ...ev.turnStart(0, 0),
  81. data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
  82. } as never
  83. session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
  84. expect(session.getSnapshot().queue).toHaveLength(1)
  85. })
  86. it('steering/message drains the source-matched steering row only', () => {
  87. const session = makeSession()
  88. session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering
  89. session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true))
  90. // Loop-authored steering (different source) must not consume the user entry.
  91. const foreignSteering = {
  92. seq: 0, time: 1,
  93. type: 'steering/message', surfaceOp: 'append',
  94. data: {
  95. turn: 0,
  96. message: createUserMessage({
  97. content: text('loop'),
  98. source: { kind: 'plugin', plugin: 'loop' },
  99. }),
  100. },
  101. } as never
  102. session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
  103. expect(session.getSnapshot().queue).toHaveLength(2)
  104. const matchedSteering = {
  105. seq: 1, time: 2,
  106. type: 'steering/message', surfaceOp: 'append',
  107. data: {
  108. turn: 0,
  109. message: createUserMessage({
  110. content: text('插话'),
  111. source: { kind: 'user', rpcId: rid('p-2') },
  112. }),
  113. },
  114. } as never
  115. session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
  116. expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
  117. })
  118. it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => {
  119. const session = makeSession()
  120. session.handleRunning(true)
  121. session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1'))
  122. session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2'))
  123. session.handleRunning(false)
  124. expect(session.getSnapshot().queue).toEqual([])
  125. })
  126. it('a stale not-running relay on an idle session still sweeps replayed rows', () => {
  127. const session = makeSession()
  128. session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1'))
  129. session.handleRunning(false) // running already false: equality path must not skip the sweep
  130. expect(session.getSnapshot().queue).toEqual([])
  131. })
  132. })
  133. describe('queue reconnect semantics', () => {
  134. it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => {
  135. const session = makeSession()
  136. session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old'))
  137. // New mux generation: subscribed arrives first on the same stream...
  138. session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
  139. expect(session.getSnapshot().queue).toEqual([])
  140. // ...then the queue snapshot replays the live inbox.
  141. session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new'))
  142. expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new'])
  143. })
  144. it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => {
  145. const session = makeSession()
  146. // Reconnect ordering that broke: mux opened first and already delivered
  147. // the fresh generation's baseline; host stream (and with it onConnected →
  148. // resync) lands after. The host never resends — clearing here left the
  149. // dock empty until the next enqueue.
  150. session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
  151. session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh'))
  152. await session.resync()
  153. expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
  154. })
  155. it('replayed steering retires without a replayed turn/start', () => {
  156. const session = makeSession()
  157. session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
  158. session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true))
  159. const committed = {
  160. seq: 6, time: 2,
  161. type: 'steering/message', surfaceOp: 'append',
  162. data: {
  163. turn: 1,
  164. message: createUserMessage({
  165. content: text('重连插话'),
  166. source: { kind: 'user', rpcId: rid('p-steer') },
  167. }),
  168. },
  169. } as never
  170. session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
  171. expect(session.getSnapshot().queue).toEqual([])
  172. })
  173. })
  174. describe('manager buffering of queued frames', () => {
  175. it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => {
  176. const api = new FakeApiClient()
  177. const manager = new SessionManager(api)
  178. manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') })
  179. // Instantiation replays the buffer; no summary exists, so no running sweep runs.
  180. const session = manager.get(SID)
  181. expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1'])
  182. // The buffer is consumed: a second get must not double-replay.
  183. expect(manager.get(SID).getSnapshot().queue).toHaveLength(1)
  184. })
  185. it('a not-running list summary sweeps replayed rows at instantiation', async () => {
  186. const api = new FakeApiClient()
  187. api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }]))
  188. const manager = new SessionManager(api)
  189. await manager.refreshList()
  190. manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') })
  191. expect(manager.get(SID).getSnapshot().queue).toEqual([])
  192. })
  193. it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => {
  194. const api = new FakeApiClient()
  195. const manager = new SessionManager(api)
  196. // Generation 1 baseline lands while the session is uninstantiated, along
  197. // with a pending approval (never re-derivable from history).
  198. manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') })
  199. manager.handleMuxEnvelope({
  200. rpcId: rid('g1b'),
  201. payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
  202. })
  203. // Reconnect: generation 2 replays subscribed + the SAME live queue entry.
  204. manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } })
  205. manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') })
  206. const snapshot = manager.get(SID).getSnapshot()
  207. // One queue row (no duplicate batch); the approval survived the re-baseline.
  208. expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1'])
  209. expect(snapshot.pending.map(p => p.kind)).toEqual(['approval'])
  210. })
  211. })
  212. /** ok wrapper with a typed items payload (the shared helper pins value to never[]). */
  213. function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) {
  214. return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } }
  215. }