transport.client.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. RemoteStream,
  4. RemoteStreamCarrierError,
  5. type RemoteStreamOptions,
  6. } from '@deepseek-ai/dsh-api-gateway/client'
  7. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  8. import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
  9. import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  10. import {
  11. createSessionControlStream,
  12. SessionEventStream,
  13. type SessionJournalChange,
  14. type SessionRemote,
  15. } from '../src/client/index.ts'
  16. import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
  17. import type {
  18. SessionAddress,
  19. SessionAssistantStreamBaseline,
  20. SessionAssistantStreamFrame,
  21. SessionControlFrame,
  22. SessionEventEntry,
  23. SessionFollowFrame,
  24. SessionFollowRequest,
  25. SessionHistoryRecord,
  26. SessionPage,
  27. SessionPageRequest,
  28. } from '../src/types.ts'
  29. type SessionTransportRemote = Pick<SessionRemote, 'control' | 'follow' | 'page'>
  30. const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never }
  31. const AVAILABLE_CONNECTION = {
  32. generation: {
  33. getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }),
  34. subscribe: () => () => {},
  35. },
  36. }
  37. function entry(seq: number): SessionEventEntry {
  38. return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
  39. }
  40. function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage {
  41. return { records, hasMore }
  42. }
  43. function snapshot(
  44. cursor: number,
  45. records: readonly SessionHistoryRecord[],
  46. hasMore = false,
  47. assistantStream: SessionAssistantStreamBaseline = { revision: 0 },
  48. ): SessionFollowFrame {
  49. return {
  50. type: 'snapshot',
  51. header: {
  52. version: 2,
  53. id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId,
  54. createdAt: 0,
  55. isSeeded: false,
  56. },
  57. cursor,
  58. records,
  59. hasMore,
  60. projections: { asOfSeq: cursor, values: {} },
  61. assistantStream,
  62. }
  63. }
  64. function assistantFrame(frame: SessionAssistantStreamFrame): SessionFollowFrame {
  65. return { type: 'assistant-stream', frame }
  66. }
  67. function sessionClient(remote: SessionTransportRemote): SessionRemotes {
  68. return {
  69. session: remote as SessionRemote,
  70. $stream: <Item>(options: RemoteStreamOptions<Item>) => (
  71. new RemoteStream(AVAILABLE_CONNECTION, options)
  72. ),
  73. commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) },
  74. subagents: {
  75. list: () => Promise.reject(new Error('stream tests never read the subagent catalog')),
  76. prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')),
  77. interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')),
  78. },
  79. }
  80. }
  81. interface FollowGeneration {
  82. readonly frames: readonly SessionFollowFrame[]
  83. readonly terminal?: Error
  84. readonly hold?: boolean
  85. readonly waitAfterFrames?: Promise<void>
  86. }
  87. class ScriptedSessionRemote implements SessionTransportRemote {
  88. readonly followRequests: SessionFollowRequest[] = []
  89. readonly pageRequests: SessionPageRequest[] = []
  90. readonly signals: AbortSignal[] = []
  91. constructor(
  92. private readonly generations: FollowGeneration[],
  93. private readonly pages: RemoteResult<SessionPage>[],
  94. private readonly controlFrames: readonly SessionControlFrame[] = [],
  95. private readonly holdControl = true,
  96. ) {}
  97. async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable<SessionFollowFrame> {
  98. const generation = this.generations.shift()
  99. if (generation === undefined) throw new Error('no scripted Session generation')
  100. this.followRequests.push(request)
  101. this.signals.push(signal)
  102. for (const frame of generation.frames) yield frame
  103. await generation.waitAfterFrames
  104. if (generation.terminal !== undefined) throw generation.terminal
  105. if (generation.hold === true && !signal.aborted) {
  106. await new Promise<void>((resolve) => {
  107. signal.addEventListener('abort', () => { resolve() }, { once: true })
  108. })
  109. }
  110. }
  111. page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
  112. this.pageRequests.push(request)
  113. const result = this.pages.shift()
  114. if (result === undefined) throw new Error('no scripted Session page')
  115. return Promise.resolve(result)
  116. }
  117. async *control(signal = new AbortController().signal): AsyncIterable<SessionControlFrame> {
  118. for (const frame of this.controlFrames) yield frame
  119. if (this.holdControl && !signal.aborted) {
  120. await new Promise<void>((resolve) => {
  121. signal.addEventListener('abort', () => { resolve() }, { once: true })
  122. })
  123. }
  124. }
  125. }
  126. describe('Session Client stream adapters', () => {
  127. it('opts into assistant notifications and publishes the reconnect baseline plus live frame', async () => {
  128. const attemptId = LlmAttemptId('transport-attempt')
  129. const baseline: SessionAssistantStreamBaseline = {
  130. revision: 2,
  131. activeAttempt: {
  132. attemptId,
  133. startedTime: 1,
  134. startedAfterSeq: -1,
  135. turn: 1,
  136. step: 1,
  137. nextIndex: 1,
  138. stream: [{ type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['a'] }],
  139. },
  140. }
  141. const frame: SessionAssistantStreamFrame = {
  142. type: 'chunk', attemptId, revision: 3, index: 1,
  143. time: 1, chunk: { type: 'text-delta', index: 0, text: 'b' },
  144. }
  145. const remote = new ScriptedSessionRemote(
  146. [{ frames: [snapshot(0, [entry(0)], false, baseline), assistantFrame(frame)], hold: true }],
  147. [],
  148. )
  149. const changes: SessionJournalChange[] = []
  150. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  151. publish: (change) => { changes.push(change) },
  152. failed: vi.fn(),
  153. })
  154. await stream.open({})
  155. await vi.waitFor(() => { expect(changes).toHaveLength(2) })
  156. expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }])
  157. expect(changes).toMatchObject([
  158. { type: 'replace', page: { assistantStream: baseline } },
  159. { type: 'assistant-stream', frame },
  160. ])
  161. await stream.dispose()
  162. })
  163. it('rejects an opted-in opening that omits its Assistant baseline', async () => {
  164. const remote = new ScriptedSessionRemote([{
  165. frames: [{
  166. type: 'snapshot',
  167. header: {
  168. version: 2,
  169. id: ADDRESS.sessionId,
  170. createdAt: 0,
  171. isSeeded: false,
  172. },
  173. cursor: -1,
  174. records: [],
  175. hasMore: false,
  176. projections: { asOfSeq: -1, values: {} },
  177. }],
  178. }], [])
  179. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  180. publish: vi.fn(),
  181. failed: vi.fn(),
  182. })
  183. try {
  184. await expect(stream.open({})).rejects.toMatchObject({
  185. code: 'gateway/internal',
  186. message: 'session assistant stream omitted its opted-in opening baseline',
  187. })
  188. } finally {
  189. await stream.dispose()
  190. }
  191. })
  192. it('rejects an Assistant frame that arrives before the opening baseline', async () => {
  193. const remote = new ScriptedSessionRemote([{
  194. frames: [assistantFrame({
  195. type: 'start', attemptId: LlmAttemptId('pre-opening-attempt'),
  196. revision: 1, startedTime: 1, startedAfterSeq: -1, turn: 1, step: 1,
  197. })],
  198. }], [])
  199. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  200. publish: vi.fn(),
  201. failed: vi.fn(),
  202. })
  203. try {
  204. await expect(stream.open({})).rejects.toMatchObject({
  205. code: 'gateway/internal',
  206. message: 'session event stream emitted an entry before its opening cursor',
  207. })
  208. expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }])
  209. } finally {
  210. await stream.dispose()
  211. }
  212. })
  213. it('rebaselines after a transient assistant revision gap without advancing the durable cursor', async () => {
  214. const attemptId = LlmAttemptId('gapped-attempt')
  215. const start: SessionAssistantStreamFrame = {
  216. type: 'start', attemptId, revision: 1, startedTime: 1, startedAfterSeq: -1,
  217. turn: 1, step: 1,
  218. }
  219. const gap: SessionAssistantStreamFrame = {
  220. type: 'chunk', attemptId, revision: 3, index: 0,
  221. time: 1, chunk: { type: 'text-delta', index: 0, text: 'lost predecessor' },
  222. }
  223. const replacement: SessionAssistantStreamBaseline = {
  224. revision: 3,
  225. activeAttempt: {
  226. attemptId,
  227. startedTime: 1,
  228. startedAfterSeq: -1,
  229. turn: 1,
  230. step: 1,
  231. nextIndex: 1,
  232. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['lost predecessor'] }],
  233. },
  234. }
  235. const remote = new ScriptedSessionRemote([
  236. {
  237. frames: [snapshot(0, [entry(0)]), assistantFrame(start), assistantFrame(gap)],
  238. },
  239. { frames: [snapshot(0, [entry(0)], false, replacement)], hold: true },
  240. ], [])
  241. const changes: SessionJournalChange[] = []
  242. const carrierFailed = vi.fn()
  243. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  244. publish: (change) => { changes.push(change) },
  245. carrierFailed,
  246. failed: vi.fn(),
  247. })
  248. await stream.open({})
  249. await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
  250. expect(changes.map(change => change.type)).toEqual([
  251. 'replace', 'assistant-stream', 'replace',
  252. ])
  253. expect(changes.at(-1)).toMatchObject({
  254. type: 'replace', page: { assistantStream: replacement },
  255. })
  256. expect(remote.pageRequests).toEqual([])
  257. expect(carrierFailed).toHaveBeenCalledWith(expect.objectContaining({
  258. message: 'session assistant stream skipped revision 2',
  259. }))
  260. await stream.dispose()
  261. })
  262. it('rebaselines when a replacement Agent lifecycle restarts at revision one', async () => {
  263. const attemptId = LlmAttemptId('replacement-lifecycle-attempt')
  264. const previous: SessionAssistantStreamBaseline = {
  265. revision: 2,
  266. activeAttempt: {
  267. attemptId,
  268. startedTime: 1,
  269. startedAfterSeq: -1,
  270. turn: 1,
  271. step: 1,
  272. nextIndex: 1,
  273. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['old'] }],
  274. },
  275. }
  276. const replacementStart: SessionAssistantStreamFrame = {
  277. type: 'start', attemptId, revision: 1, startedTime: 2, startedAfterSeq: -1,
  278. turn: 2, step: 1,
  279. }
  280. const replacement: SessionAssistantStreamBaseline = {
  281. revision: 1,
  282. activeAttempt: {
  283. attemptId,
  284. startedTime: 2,
  285. startedAfterSeq: -1,
  286. turn: 2,
  287. step: 1,
  288. nextIndex: 0,
  289. stream: [],
  290. },
  291. }
  292. const remote = new ScriptedSessionRemote([
  293. {
  294. frames: [snapshot(0, [entry(0)], false, previous), assistantFrame(replacementStart)],
  295. },
  296. { frames: [snapshot(0, [entry(0)], false, replacement)], hold: true },
  297. ], [])
  298. const changes: SessionJournalChange[] = []
  299. const carrierFailed = vi.fn()
  300. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  301. publish: (change) => { changes.push(change) },
  302. carrierFailed,
  303. failed: vi.fn(),
  304. })
  305. try {
  306. await stream.open({})
  307. await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
  308. expect(changes).toMatchObject([
  309. { type: 'replace', page: { assistantStream: previous } },
  310. { type: 'replace', page: { assistantStream: replacement } },
  311. ])
  312. expect(carrierFailed).toHaveBeenCalledWith(expect.objectContaining({
  313. message: 'session assistant stream skipped revision 3',
  314. }))
  315. } finally {
  316. await stream.dispose()
  317. }
  318. })
  319. it('validates one scalar current-event range before publishing Client entries', async () => {
  320. const remote = new ScriptedSessionRemote(
  321. [{ frames: [snapshot(2, [entry(0), entry(1), entry(2)]), entry(3)], hold: true }],
  322. [],
  323. )
  324. const changes: SessionJournalChange[] = []
  325. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  326. publish: (change) => { changes.push(change) },
  327. failed: vi.fn(),
  328. })
  329. await stream.open({})
  330. await vi.waitFor(() => { expect(changes).toHaveLength(2) })
  331. expect(changes[0]).toMatchObject({
  332. type: 'replace',
  333. entries: [
  334. entry(0),
  335. entry(1),
  336. entry(2),
  337. ],
  338. })
  339. expect(changes[1]).toEqual({ type: 'append', entry: entry(3) })
  340. await stream.dispose()
  341. })
  342. it('binds an event journal to one address and publishes replace, append, and prepend changes', async () => {
  343. const remote = new ScriptedSessionRemote(
  344. [{
  345. frames: [
  346. snapshot(3, [entry(2), entry(3)], true),
  347. entry(3),
  348. entry(4),
  349. ],
  350. hold: true,
  351. }],
  352. [
  353. { ok: true, value: page([entry(0), entry(1)], false) },
  354. ],
  355. )
  356. const changes: SessionJournalChange[] = []
  357. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  358. publish: (change) => { changes.push(change) },
  359. failed: vi.fn(),
  360. })
  361. await stream.open({ maxMessages: 50 })
  362. await vi.waitFor(() => { expect(changes).toHaveLength(2) })
  363. await stream.prepend({ beforeSeq: 2, maxMessages: 50 })
  364. expect(remote.followRequests).toEqual([{
  365. address: ADDRESS, assistantStream: true, maxMessages: 50,
  366. }])
  367. expect(remote.pageRequests).toEqual([
  368. { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 },
  369. ])
  370. expect(changes).toMatchObject([
  371. { type: 'replace', entries: [entry(2), entry(3)], hasMore: true },
  372. { type: 'append', entry: entry(4) },
  373. { type: 'prepend', entries: [entry(0), entry(1)], hasMore: false },
  374. ])
  375. await stream.dispose()
  376. expect(remote.signals[0]?.aborted).toBe(true)
  377. })
  378. it('replaces the retained window from each reconnect snapshot', async () => {
  379. const lost = new RemoteStreamCarrierError('lost')
  380. const remote = new ScriptedSessionRemote(
  381. [
  382. {
  383. frames: [snapshot(1, [entry(0), entry(1)]), entry(2)],
  384. terminal: lost,
  385. },
  386. { frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true },
  387. ],
  388. [],
  389. )
  390. const changes: SessionJournalChange[] = []
  391. const carrierFailed = vi.fn()
  392. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  393. publish: (change) => { changes.push(change) },
  394. carrierFailed,
  395. failed: vi.fn(),
  396. })
  397. await stream.open({ maxMessages: 50 })
  398. await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
  399. expect(remote.followRequests).toEqual([
  400. { address: ADDRESS, assistantStream: true, maxMessages: 50 },
  401. { address: ADDRESS, assistantStream: true, maxMessages: 50 },
  402. ])
  403. expect(remote.pageRequests).toEqual([])
  404. expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
  405. expect(carrierFailed).toHaveBeenCalledWith(lost)
  406. await stream.dispose()
  407. })
  408. it('repairs a resumed event stream without an optional message limit', async () => {
  409. const finish = Promise.withResolvers<undefined>()
  410. const remote = new ScriptedSessionRemote(
  411. [
  412. {
  413. frames: [snapshot(0, [entry(0)])],
  414. waitAfterFrames: finish.promise,
  415. terminal: new RemoteStreamCarrierError('lost'),
  416. },
  417. { frames: [snapshot(1, [entry(0), entry(1)])], hold: true },
  418. ],
  419. [],
  420. )
  421. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  422. publish: vi.fn(),
  423. failed: vi.fn(),
  424. })
  425. await stream.open({})
  426. finish.resolve(undefined)
  427. await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
  428. expect(remote.followRequests).toEqual([
  429. { address: ADDRESS, assistantStream: true },
  430. { address: ADDRESS, assistantStream: true },
  431. ])
  432. expect(remote.pageRequests).toEqual([])
  433. await stream.dispose()
  434. })
  435. it('repairs a live gap without adding an absent message limit', async () => {
  436. const remote = new ScriptedSessionRemote(
  437. [{ frames: [snapshot(0, [entry(0)]), entry(2)], hold: true }],
  438. [{ ok: true, value: page([entry(0), entry(1), entry(2)]) }],
  439. )
  440. const changes: SessionJournalChange[] = []
  441. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  442. publish: (change) => { changes.push(change) },
  443. failed: vi.fn(),
  444. })
  445. await stream.open({})
  446. await vi.waitFor(() => { expect(changes).toHaveLength(2) })
  447. expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2 }])
  448. await stream.dispose()
  449. })
  450. it('turns a pagination failure into a typed stream failure', async () => {
  451. const failure = new RemoteError('session/not-found', 'missing', { sessionId: 'session-1' as never })
  452. const remote = new ScriptedSessionRemote(
  453. [{ frames: [snapshot(-1, [])], hold: true }],
  454. [{ ok: false, error: failure }],
  455. )
  456. const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
  457. publish: vi.fn(),
  458. failed: vi.fn(),
  459. })
  460. await stream.open({})
  461. await expect(stream.prepend({})).rejects.toMatchObject({ code: 'session/not-found' })
  462. await expect(stream.open({})).rejects.toThrow('already opened')
  463. expect(remote.signals[0]?.aborted).toBe(false)
  464. expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }])
  465. await stream.dispose()
  466. expect(remote.signals[0]?.aborted).toBe(true)
  467. })
  468. it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {
  469. const baseline: SessionControlFrame = {
  470. type: 'baseline',
  471. value: { queues: {}, jobs: {}, projections: {} },
  472. }
  473. const update: SessionControlFrame = {
  474. type: 'queue', sessionId: 'session-1' as never, items: [],
  475. }
  476. const remote = new ScriptedSessionRemote([], [], [baseline, update])
  477. const accept = vi.fn<(frame: SessionControlFrame) => void>()
  478. const stream = createSessionControlStream(sessionClient(remote), {
  479. accept,
  480. failed: vi.fn(),
  481. })
  482. stream.start()
  483. stream.start()
  484. await vi.waitFor(() => { expect(accept).toHaveBeenCalledTimes(2) })
  485. expect(accept.mock.calls.map(([frame]) => frame)).toEqual([baseline, update])
  486. await stream.dispose()
  487. await stream.dispose()
  488. })
  489. it('classifies control streams that end before and after their opening baseline', async () => {
  490. const beforeFailed = vi.fn()
  491. const before = createSessionControlStream(
  492. sessionClient(new ScriptedSessionRemote([], [], [], false)),
  493. { accept: vi.fn(), failed: beforeFailed },
  494. )
  495. before.start()
  496. await vi.waitFor(() => { expect(beforeFailed).toHaveBeenCalledOnce() })
  497. expect(beforeFailed.mock.calls[0]?.[0]).toMatchObject({
  498. message: 'session control stream ended before its opening snapshot',
  499. })
  500. await before.dispose()
  501. const baseline: SessionControlFrame = {
  502. type: 'baseline',
  503. value: { queues: {}, jobs: {}, projections: {} },
  504. }
  505. const carrierFailed = vi.fn()
  506. const failed = vi.fn()
  507. const afterRemote = new ScriptedSessionRemote([], [], [baseline], false)
  508. const after = createSessionControlStream(sessionClient(afterRemote), {
  509. accept: vi.fn(),
  510. carrierFailed: (error) => {
  511. carrierFailed(error)
  512. void after.dispose()
  513. },
  514. failed,
  515. })
  516. after.start()
  517. await vi.waitFor(() => { expect(carrierFailed).toHaveBeenCalledOnce() })
  518. expect(carrierFailed.mock.calls[0]?.[0]).toMatchObject({
  519. message: 'session control stream ended without a terminal result',
  520. })
  521. expect(failed).not.toHaveBeenCalled()
  522. await after.dispose()
  523. })
  524. })