transport.client.spec.ts 20 KB

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