assistant-stream.client.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { describe, expect, it } from 'vitest'
  2. import { LlmAttemptId, createAssistantMessage } from '@deepseek-ai/dsh-llm'
  3. import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
  4. import type {
  5. SessionAssistantStreamBaseline,
  6. SessionAssistantStreamFrame,
  7. } from '../src/types.ts'
  8. import { ClientAssistantStream } from '../src/client/sessions/assistant-stream.ts'
  9. import type { SessionLiveEventEntry } from '../src/client/contract/events.ts'
  10. const ATTEMPT = LlmAttemptId('session:1')
  11. function entry(event: SessionEvent): SessionLiveEventEntry {
  12. return { type: 'event', event }
  13. }
  14. function ordinary(seq: number): SessionLiveEventEntry {
  15. return entry({ type: 'turn/start', seq: SessionSeq(seq), time: seq, data: { turn: 1 } })
  16. }
  17. function attemptEvent(seq: number, turn = 1, step = 1): SessionLiveEventEntry {
  18. return entry({
  19. type: 'assistant/attempt',
  20. seq: SessionSeq(seq),
  21. time: seq,
  22. data: { turn, step, stream: [] },
  23. })
  24. }
  25. function messageEvent(
  26. seq: number,
  27. turn = 1,
  28. step = 1,
  29. surfaceOp: 'append' | { readonly op: 'replace'; readonly startSeq: number; readonly endSeq: number } = 'append',
  30. ): SessionLiveEventEntry {
  31. return entry({
  32. type: 'assistant/message',
  33. seq: SessionSeq(seq),
  34. time: seq,
  35. data: {
  36. turn,
  37. step,
  38. message: createAssistantMessage({
  39. content: [{ type: 'text', text: 'done' }],
  40. source: { provider: 'mock', model: 'mock' },
  41. }),
  42. stream: [],
  43. },
  44. surfaceOp: surfaceOp === 'append'
  45. ? surfaceOp
  46. : { ...surfaceOp, startSeq: SessionSeq(surfaceOp.startSeq), endSeq: SessionSeq(surfaceOp.endSeq) },
  47. })
  48. }
  49. function start(
  50. attemptId = ATTEMPT,
  51. startedAfterSeq = -1,
  52. ): SessionAssistantStreamFrame {
  53. return {
  54. type: 'start', attemptId, revision: 1,
  55. startedAfterSeq: startedAfterSeq === -1 ? -1 : SessionSeq(startedAfterSeq),
  56. turn: 1, step: 1,
  57. }
  58. }
  59. function chunkFrame(
  60. index: number,
  61. attemptId = ATTEMPT,
  62. ): SessionAssistantStreamFrame {
  63. return {
  64. type: 'chunk', attemptId, revision: index + 2, index, time: 20 + index,
  65. chunk: { type: 'text-delta', index: 0, text: `chunk-${index}` },
  66. }
  67. }
  68. function end(
  69. index: number,
  70. outcome: Extract<SessionAssistantStreamFrame, { type: 'end' }>['outcome'],
  71. attemptId = ATTEMPT,
  72. ): SessionAssistantStreamFrame {
  73. return { type: 'end', attemptId, revision: index + 2, index, outcome }
  74. }
  75. function baseline(nextIndex = 1): SessionAssistantStreamBaseline {
  76. return {
  77. revision: nextIndex + 1,
  78. activeAttempt: {
  79. attemptId: ATTEMPT,
  80. startedAfterSeq: -1,
  81. turn: 1,
  82. step: 1,
  83. nextIndex,
  84. stream: [
  85. { type: 'chunk', time: 20, chunk: { type: 'text-delta', index: 0, text: 'first' } },
  86. { type: 'chunk', time: 21, chunk: { type: 'text-delta', index: 0, text: 'second' } },
  87. ],
  88. },
  89. }
  90. }
  91. function opened(): ClientAssistantStream {
  92. const stream = new ClientAssistantStream()
  93. expect(stream.acceptFrame(start())).toBeUndefined()
  94. return stream
  95. }
  96. describe('ClientAssistantStream', () => {
  97. it('replaces the durable window and reconstructs only the baseline prefix', () => {
  98. const stream = new ClientAssistantStream()
  99. const durable = ordinary(4)
  100. const visible = stream.replace([durable], baseline(1))
  101. expect(visible[0]).toBe(durable)
  102. expect(visible).toHaveLength(2)
  103. const reconstructed = visible[1]
  104. if (reconstructed?.type !== 'transient') throw new Error('expected reconstructed transient chunk')
  105. expect(reconstructed.event.type).toBe('assistant/live-chunk')
  106. expect(reconstructed.event.seq).toBe(4.5)
  107. expect(reconstructed.event.time).toBe(20)
  108. expect(stream.replace([], baseline(3))).toHaveLength(2)
  109. expect(stream.replace([])).toEqual([])
  110. })
  111. it('passes through durable events not owned by the active attempt', () => {
  112. const stream = new ClientAssistantStream()
  113. stream.acceptFrame(start(ATTEMPT, 1))
  114. for (const durable of [
  115. ordinary(1),
  116. messageEvent(2, 1, 1, { op: 'replace', startSeq: 0, endSeq: 0 }),
  117. attemptEvent(0),
  118. attemptEvent(3, 2, 1),
  119. attemptEvent(4, 1, 2),
  120. ]) {
  121. expect(stream.acceptDurable(durable)).toEqual({ type: 'publish', entry: durable })
  122. }
  123. })
  124. it('stages one owned settlement and releases it from the matching end frame', () => {
  125. const stream = opened()
  126. const durable = messageEvent(2)
  127. expect(stream.acceptDurable(durable)).toBeUndefined()
  128. expect(stream.acceptFrame(chunkFrame(0))).toEqual(expect.objectContaining({ type: 'transient' }))
  129. expect(stream.acceptFrame(end(1, {
  130. kind: 'committed', eventType: 'assistant/message', seq: 2,
  131. }))).toEqual({ type: 'settlement', attemptId: String(ATTEMPT), entry: durable })
  132. })
  133. it('rebaselines duplicate durable settlements or starts', () => {
  134. const duplicate = opened()
  135. const durable = attemptEvent(2)
  136. expect(duplicate.acceptDurable(durable)).toBeUndefined()
  137. expect(duplicate.acceptDurable(durable)).toEqual({ type: 'rebaseline' })
  138. expect(duplicate.acceptFrame(start(LlmAttemptId('session:2')))).toEqual({ type: 'rebaseline' })
  139. const clean = new ClientAssistantStream()
  140. expect(clean.acceptFrame(start())).toBeUndefined()
  141. })
  142. it('falls back to durable settlement for frames from an unknown attempt', () => {
  143. const stream = new ClientAssistantStream()
  144. const unknown = LlmAttemptId('session:unknown')
  145. expect(stream.acceptFrame(chunkFrame(0, unknown))).toBeUndefined()
  146. expect(stream.acceptFrame(end(0, { kind: 'abandoned' }, unknown))).toBeUndefined()
  147. const durable = attemptEvent(2)
  148. expect(stream.acceptDurable(durable)).toEqual({ type: 'publish', entry: durable })
  149. const known = opened()
  150. expect(known.acceptFrame(chunkFrame(0, unknown))).toBeUndefined()
  151. expect(known.acceptFrame(end(0, { kind: 'abandoned' }, unknown))).toBeUndefined()
  152. })
  153. it('rebaselines known attempts on chunk or terminal index mismatch', () => {
  154. const chunkMismatch = opened()
  155. expect(chunkMismatch.acceptFrame(chunkFrame(1))).toEqual({ type: 'rebaseline' })
  156. const endMismatch = opened()
  157. expect(endMismatch.acceptFrame(end(1, { kind: 'abandoned' }))).toEqual({ type: 'rebaseline' })
  158. })
  159. it('settles abandonment only when no durable settlement remains pending', () => {
  160. const empty = opened()
  161. expect(empty.acceptFrame(end(0, { kind: 'abandoned' }))).toEqual({
  162. type: 'abandonment',
  163. attemptId: String(ATTEMPT),
  164. })
  165. const pending = opened()
  166. expect(pending.acceptDurable(attemptEvent(2))).toBeUndefined()
  167. expect(pending.acceptFrame(end(0, { kind: 'abandoned' }))).toEqual({ type: 'rebaseline' })
  168. })
  169. it('rebaselines committed outcomes without one exact staged settlement', () => {
  170. const published = new ClientAssistantStream()
  171. published.replace([attemptEvent(2)], baseline(0))
  172. expect(published.acceptFrame(end(0, {
  173. kind: 'committed', eventType: 'assistant/attempt', seq: 2,
  174. }))).toBeUndefined()
  175. const missing = opened()
  176. expect(missing.acceptFrame(end(0, {
  177. kind: 'committed', eventType: 'assistant/attempt', seq: 2,
  178. }))).toEqual({ type: 'rebaseline' })
  179. const wrongType = opened()
  180. expect(wrongType.acceptDurable(messageEvent(2))).toBeUndefined()
  181. expect(wrongType.acceptFrame(end(0, {
  182. kind: 'committed', eventType: 'assistant/attempt', seq: 2,
  183. }))).toEqual({ type: 'rebaseline' })
  184. })
  185. })