session.client.spec.ts 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105
  1. /** Session object lifecycle, event-window transport, commands, and resync behavior. */
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
  4. import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  5. import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
  6. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  7. import { JUMP_PAGE_MESSAGES, Session, type SessionOptions } from '../src/client/sessions/session.ts'
  8. import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
  9. import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
  10. const SID = 'fk-s1' as SessionId
  11. const PARENT = 'fk-parent' as SessionId
  12. afterEach(() => {
  13. vi.unstubAllGlobals()
  14. })
  15. function makeSession(
  16. api = new FakeApiClient(),
  17. options: SessionOptions = {},
  18. ): { api: FakeApiClient; session: Session } {
  19. return { api, session: new Session(SID, fakeRemote(api), options) }
  20. }
  21. function follow(
  22. api: FakeApiClient,
  23. event: SessionEvent,
  24. ): Promise<void> {
  25. return api.pushFollow(SID, {
  26. type: 'event',
  27. event: event as never,
  28. })
  29. }
  30. function windowEntries(session: Session) {
  31. return session.eventSource.getSnapshot().entries
  32. }
  33. function eventSeqs(session: Session): number[] {
  34. return windowEntries(session).map(entry => entry.event.seq)
  35. }
  36. function histResponse(events: SessionEvent[], hasMore = false) {
  37. return Promise.resolve(ok(historyValue(events, hasMore)))
  38. }
  39. describe('Session file upload', () => {
  40. it('uses the background body carrier, reports progress, and validates its receipt', async () => {
  41. const progress = vi.fn()
  42. const post = vi.fn(async (request: {
  43. path: string
  44. body: Blob
  45. headers?: Readonly<Record<string, string>>
  46. signal?: AbortSignal
  47. onProgress?: (progress: { loaded: number; total?: number }) => void
  48. }) => {
  49. request.onProgress?.({ loaded: 2, total: 4 })
  50. return {
  51. status: 200,
  52. body: JSON.stringify({
  53. ok: true,
  54. value: {
  55. receiptId: 'receipt-1',
  56. file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
  57. },
  58. }),
  59. }
  60. })
  61. const { api, session } = makeSession(undefined, { backgroundUploads: { post } })
  62. const abort = new AbortController()
  63. const file = new Blob([Uint8Array.of(1, 2, 3, 4)])
  64. await expect(session.uploadFile(file, 'notes & refs.pdf', abort.signal, progress)).resolves.toEqual({
  65. ok: true,
  66. value: {
  67. receiptId: 'receipt-1',
  68. file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
  69. },
  70. })
  71. expect(post).toHaveBeenCalledWith(expect.objectContaining({
  72. path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=notes+%26+refs.pdf',
  73. body: file,
  74. headers: { 'content-type': 'application/octet-stream' },
  75. signal: abort.signal,
  76. }))
  77. expect(progress).toHaveBeenCalledWith({ loaded: 2, total: 4 })
  78. expect(api.callsOf('session.uploadFile')).toEqual([])
  79. })
  80. it('preserves a background business failure and supports unnamed files without observers', async () => {
  81. const post = vi.fn((_request: { readonly body: Blob }) => Promise.resolve({
  82. status: 200,
  83. body: JSON.stringify({
  84. ok: false,
  85. error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
  86. }),
  87. }))
  88. const { session } = makeSession(undefined, { backgroundUploads: { post } })
  89. await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
  90. ok: false,
  91. error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
  92. })
  93. const request = post.mock.calls[0]?.[0]
  94. expect(request).toMatchObject({
  95. path: '/api/session/uploadFileBinary?sessionId=fk-s1',
  96. headers: { 'content-type': 'application/octet-stream' },
  97. })
  98. expect(request?.body).toBeInstanceOf(Blob)
  99. })
  100. it('keeps byte and Blob fallback uploads on the generated Remote carrier', async () => {
  101. const { api, session } = makeSession()
  102. await expect(session.uploadFile(Uint8Array.of(0, 0, 0), 'bytes.bin')).resolves.toMatchObject({ ok: true })
  103. await expect(session.uploadFile(new Blob([Uint8Array.of(1)]))).resolves.toMatchObject({ ok: true })
  104. expect(api.callsOf('session.uploadFile')).toEqual([
  105. { sessionId: SID, data: 'AAAA', name: 'bytes.bin' },
  106. { sessionId: SID, data: 'AQ==' },
  107. ])
  108. })
  109. it('folds non-200 and malformed background responses into transport failures', async () => {
  110. const bodies: unknown[] = [
  111. null,
  112. { ok: 'yes' },
  113. { ok: false, error: null },
  114. { ok: false, error: { code: 1, message: 'x', details: {} } },
  115. { ok: false, error: { code: 'x', message: 1, details: {} } },
  116. { ok: false, error: { code: 'x', message: 'x', details: null } },
  117. { ok: true, value: null },
  118. { ok: true, value: { receiptId: 1, file: {} } },
  119. { ok: true, value: { receiptId: 'r', file: null } },
  120. { ok: true, value: { receiptId: 'r', file: { attachmentId: 1, name: 'x', bytes: 1 } } },
  121. { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 1, bytes: 1 } } },
  122. { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: '1' } } },
  123. { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: 1.5 } } },
  124. { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
  125. ]
  126. for (const body of bodies) {
  127. const { session } = makeSession(undefined, {
  128. backgroundUploads: { post: () => Promise.resolve({ status: 200, body: JSON.stringify(body) }) },
  129. })
  130. await expect(session.uploadFile(new Blob([])))
  131. .rejects.toThrow(/file upload transport returned an invalid/)
  132. }
  133. const { session } = makeSession(undefined, {
  134. backgroundUploads: { post: () => Promise.resolve({ status: 503, body: 'unavailable' }) },
  135. })
  136. await expect(session.uploadFile(new Blob([])))
  137. .rejects.toThrow('file upload transport failed with HTTP 503')
  138. })
  139. it('refuses a direct file upload for a continuable subagent before either carrier runs', async () => {
  140. const post = vi.fn()
  141. const api = new FakeApiClient()
  142. const session = new Session(SID, fakeRemote(api), {
  143. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  144. backgroundUploads: { post },
  145. })
  146. await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
  147. ok: false,
  148. error: { code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } },
  149. })
  150. expect(post).not.toHaveBeenCalled()
  151. expect(api.callsOf('session.uploadFile')).toEqual([])
  152. })
  153. })
  154. describe('Session open', () => {
  155. it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => {
  156. const { session } = makeSession()
  157. expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false })
  158. session.handleRunning(true)
  159. expect(session.getSnapshot()).toMatchObject({ blank: false, running: true })
  160. })
  161. it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
  162. const { api, session } = makeSession()
  163. const page = plainTurn(SessionSeq(10), 3, '问', '答')
  164. api.onHistory = () => histResponse(page, true)
  165. expect(session.getSnapshot().openState).toBe('cold')
  166. const opening = session.open()
  167. expect(session.getSnapshot().openState).toBe('loading')
  168. await opening
  169. const snapshot = session.getSnapshot()
  170. expect(snapshot.openState).toBe('open')
  171. expect(snapshot.hasMore).toBe(true)
  172. expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15])
  173. expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' })
  174. })
  175. it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async () => {
  176. const { api, session } = makeSession()
  177. await Promise.all([session.open(), session.open()])
  178. await session.open()
  179. expect(api.callsOf('session.follow')).toHaveLength(1)
  180. expect(api.callsOf('session.history')).toEqual([])
  181. })
  182. it('lands an error result in openState=error with the Remote failure kept', async () => {
  183. const { api, session } = makeSession()
  184. api.onHistory = () => Promise.resolve(err(new RemoteError('session/not-found', 'gone', { sessionId: SID })))
  185. await session.open()
  186. const snapshot = session.getSnapshot()
  187. expect(snapshot.openState).toBe('error')
  188. expect(snapshot.openError?.code).toBe('session/not-found')
  189. })
  190. it('lands exhausted carrier retries in openState=error as gateway/internal', async () => {
  191. const { api, session } = makeSession()
  192. // Two consecutive carrier losses before any opening is accepted exhaust the
  193. // Gateway's retry budget; the escaping failure crosses the stream boundary marked.
  194. api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down'))
  195. await session.open()
  196. expect(session.getSnapshot().openState).toBe('error')
  197. expect(session.getSnapshot().openError).toMatchObject({
  198. code: 'gateway/internal', message: 'history carrier down',
  199. })
  200. expect(api.followStarts).toHaveLength(2)
  201. })
  202. it('lands a packed live record in openState=error instead of crashing the stream loop', async () => {
  203. const { api, session } = makeSession()
  204. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  205. await session.open()
  206. expect(session.getSnapshot().openState).toBe('open')
  207. // The live tail may carry only events; a packed record breaks that contract.
  208. await api.pushFollow(SID, {
  209. type: 'chunks',
  210. event: {
  211. type: 'chunkrow/text-chunks',
  212. seq: 6,
  213. time: 6,
  214. data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] },
  215. },
  216. } as never)
  217. await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
  218. expect(session.getSnapshot().openError).toMatchObject({
  219. code: 'gateway/internal', message: 'session live stream emitted a packed history record',
  220. })
  221. })
  222. it('lands a Gateway-marked stream failure in openState=error', async () => {
  223. const { api, session } = makeSession()
  224. api.onHistory = () => Promise.reject(new Error('socket died'))
  225. await session.open()
  226. expect(session.getSnapshot().openState).toBe('error')
  227. expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' })
  228. })
  229. it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
  230. const { api, session } = makeSession()
  231. const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  232. api.onHistory = () => gate.promise
  233. const opening = session.open()
  234. // Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail.
  235. const page = plainTurn(SessionSeq(10), 0, '早', '安')
  236. const deliveries = [
  237. follow(api, ev.turnStart(SessionSeq(15), 1)),
  238. follow(api, ev.user(SessionSeq(16), '插进来的')),
  239. ]
  240. gate.resolve(ok({
  241. records: entries(page) as never[],
  242. hasMore: false,
  243. modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  244. }))
  245. await Promise.all([opening, ...deliveries])
  246. const seqs = eventSeqs(session)
  247. // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
  248. expect(seqs).toEqual([10, 11, 12, 13, 14, 15, 16])
  249. })
  250. })
  251. describe('live event path', () => {
  252. async function opened(events: SessionEvent[] = plainTurn(SessionSeq(0), 0, 'a', 'b')) {
  253. const { api, session } = makeSession()
  254. api.onHistory = () => histResponse(events)
  255. await session.open()
  256. return { api, session }
  257. }
  258. it('drops replayed frames at or below the window tail', async () => {
  259. const { api, session } = await opened()
  260. const before = session.eventSource.getSnapshot()
  261. await follow(api, ev.user(SessionSeq(3), '重放'))
  262. expect(session.eventSource.getSnapshot()).toBe(before)
  263. })
  264. it('keeps the authoritative host blank bit across unrelated log events', async () => {
  265. const { api, session } = await opened([])
  266. session.handleBlank(true)
  267. await Promise.all([
  268. follow(api, ev.commandRun(SessionSeq(0), 'cmd-perm', 'permission', ' danger-full-access')),
  269. follow(api, ev.commandDone(SessionSeq(1), 'cmd-perm', 'success', 'preset danger-full-access')),
  270. ])
  271. const snapshot = session.getSnapshot()
  272. expect(eventSeqs(session)).toEqual([0, 1])
  273. expect(snapshot.blank).toBe(true)
  274. })
  275. it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
  276. const { api, session } = await opened(plainTurn(SessionSeq(0), 0, 'a', 'b')) // tail seq = 5
  277. const repaired = [...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')]
  278. api.onHistory = () => histResponse(repaired)
  279. // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
  280. await follow(api, ev.assistant(SessionSeq(9), 1, 'd'))
  281. await vi.waitFor(() => {
  282. expect(api.callsOf('session.history')).toHaveLength(1)
  283. })
  284. await vi.waitFor(() => {
  285. expect(eventSeqs(session)).toEqual(
  286. repaired.filter(event => event.seq <= 9).map(event => event.seq),
  287. )
  288. })
  289. })
  290. })
  291. describe('paging', () => {
  292. it('prepends an older page and keeps seq continuity', async () => {
  293. const older = plainTurn(SessionSeq(0), 0, '旧问', '旧答')
  294. const newer = plainTurn(SessionSeq(6), 1, '新问', '新答')
  295. const { api, session } = makeSession()
  296. api.onHistory = payload => payload.beforeSeq === undefined
  297. ? histResponse(newer, true)
  298. : histResponse(older, false)
  299. await session.open()
  300. await session.loadOlder()
  301. const snapshot = session.getSnapshot()
  302. expect(api.callsOf('session.follow')).toHaveLength(1)
  303. expect(api.callsOf('session.history')).toMatchObject([
  304. { sessionId: SID, throughSeq: 11, beforeSeq: 6 },
  305. ])
  306. expect(snapshot.hasMore).toBe(false)
  307. expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq))
  308. })
  309. it('installs a page without interpreting business replacement metadata', async () => {
  310. const { api, session } = makeSession()
  311. api.onHistory = () => histResponse([
  312. ev.compactSummary(SessionSeq(80), '窗外范围的摘要', SessionSeq(3), SessionSeq(40)),
  313. ev.compactCheckpoint(SessionSeq(81), SessionSeq(80), SessionSeq(3), SessionSeq(40)),
  314. ev.user(SessionSeq(82), '压缩后的新问题'),
  315. ], true)
  316. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  317. try {
  318. await session.open()
  319. const snapshot = session.getSnapshot()
  320. expect(snapshot.openState).toBe('open')
  321. expect(eventSeqs(session)).toEqual([80, 81, 82])
  322. expect(errorSpy).not.toHaveBeenCalled()
  323. } finally {
  324. errorSpy.mockRestore()
  325. }
  326. })
  327. it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
  328. const { api, session } = makeSession()
  329. api.onHistory = payload => payload.beforeSeq === undefined
  330. ? histResponse(plainTurn(SessionSeq(10), 1, '新', '页'), true)
  331. : histResponse(plainTurn(SessionSeq(0), 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole
  332. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  333. try {
  334. await session.open()
  335. const windowBefore = session.eventSource.getSnapshot()
  336. await session.loadOlder()
  337. const snapshot = session.getSnapshot()
  338. expect(session.eventSource.getSnapshot().entries).toEqual(windowBefore.entries)
  339. expect(snapshot.hasMore).toBe(false)
  340. } finally {
  341. errorSpy.mockRestore()
  342. }
  343. })
  344. it('loadThrough pages repeatedly until the window covers the target seq', async () => {
  345. const oldest = plainTurn(SessionSeq(0), 0, '最旧问', '最旧答')
  346. const middle = plainTurn(SessionSeq(6), 1, '中问', '中答')
  347. const newest = plainTurn(SessionSeq(12), 2, '新问', '新答')
  348. const { api, session } = makeSession()
  349. api.onHistory = (payload) => {
  350. if (payload.beforeSeq === undefined) return histResponse(newest, true)
  351. return payload.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false)
  352. }
  353. await session.open()
  354. const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  355. api.onHistory = (payload) => {
  356. api.onHistory = payload2 => payload2.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false)
  357. void payload
  358. return gate.promise
  359. }
  360. const jump = session.loadThrough(SessionSeq(0))
  361. expect(session.getSnapshot().loadingOlder).toBe(true)
  362. gate.resolve(ok(historyValue(middle, true)))
  363. await jump
  364. const snapshot = session.getSnapshot()
  365. expect(snapshot.loadingOlder).toBe(false)
  366. expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq))
  367. expect(api.callsOf('session.history')).toMatchObject([
  368. { beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES },
  369. { beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES },
  370. ])
  371. })
  372. it('loadThrough is a no-op when the window already covers the target or the session is not open', async () => {
  373. const { api, session } = makeSession()
  374. await session.loadThrough(SessionSeq(0)) // cold: no-op
  375. expect(api.calls).toEqual([])
  376. api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true)
  377. await session.open()
  378. const calls = api.calls.length
  379. await session.loadThrough(SessionSeq(6)) // baseSeq is already 6
  380. await session.loadThrough(SessionSeq(9)) // inside the window
  381. expect(api.calls.length).toBe(calls)
  382. })
  383. it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async () => {
  384. const oldest = plainTurn(SessionSeq(0), 0, 'a', 'b')
  385. const middle = plainTurn(SessionSeq(6), 1, 'c', 'd')
  386. const { api, session } = makeSession()
  387. api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'e', 'f'), true)
  388. await session.open()
  389. const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  390. api.onHistory = () => {
  391. api.onHistory = () => histResponse(oldest, false)
  392. return gate.promise
  393. }
  394. const first = session.loadThrough(SessionSeq(6))
  395. const second = session.loadThrough(SessionSeq(0))
  396. gate.resolve(ok(historyValue(middle, true)))
  397. await Promise.all([first, second])
  398. expect(eventSeqs(session)).toEqual([
  399. ...[...oldest, ...middle].map(event => event.seq),
  400. 12, 13, 14, 15, 16, 17,
  401. ])
  402. expect(api.callsOf('session.history')).toHaveLength(2)
  403. })
  404. it('loadThrough refused by a busy pager leaves no target behind for later jumps', async () => {
  405. const middle = plainTurn(SessionSeq(6), 1, 'c', 'd')
  406. const { api, session } = makeSession()
  407. api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'e', 'f'), true)
  408. await session.open()
  409. // A plain single-page pull holds the busy flag while the jump is refused.
  410. const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  411. api.onHistory = () => gate.promise
  412. const older = session.loadOlder()
  413. await session.loadThrough(SessionSeq(0)) // refused: must not park seq 0 anywhere
  414. gate.resolve(ok(historyValue(middle, true)))
  415. await older
  416. // A later jump to a nearer seq pages exactly to it — a leaked 0 target
  417. // would keep pulling three-event pages all the way to the head.
  418. api.onHistory = (payload) => {
  419. const start = ((payload as { beforeSeq?: number }).beforeSeq ?? 0) - 3
  420. return histResponse(
  421. [ev.user(SessionSeq(start), `u${String(start)}`), ev.user(SessionSeq(start + 1), `u${String(start + 1)}`), ev.user(SessionSeq(start + 2), `u${String(start + 2)}`)],
  422. start > 0,
  423. )
  424. }
  425. await session.loadThrough(SessionSeq(4))
  426. // Covered at seq 3 (≤ 4) after one page; a leaked 0 target would add a
  427. // third call at beforeSeq 3 and pull the head to 0.
  428. expect(api.callsOf('session.history').map(call => (call as { beforeSeq?: number }).beforeSeq))
  429. .toEqual([12, 6])
  430. expect(eventSeqs(session)[0]).toBe(3)
  431. })
  432. it('loadThrough stops paging when the event stream generation moves mid-loop', async () => {
  433. const { api, session } = makeSession()
  434. api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true)
  435. await session.open()
  436. const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  437. api.onHistory = () => gate.promise
  438. const jump = session.loadThrough(SessionSeq(0))
  439. // The address is rebuilt while the first page is in flight.
  440. api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true)
  441. const rebuilt = session.resync()
  442. gate.resolve(ok(historyValue(plainTurn(SessionSeq(6), 1, 'c', 'd'), true)))
  443. await jump
  444. await rebuilt
  445. // The stale loop must not page the new generation toward its old target:
  446. // history calls are the gated page and the resync tail only.
  447. expect(api.callsOf('session.history')).toHaveLength(1)
  448. expect(session.getSnapshot().loadingOlder).toBe(false)
  449. })
  450. it('loadThrough stops on a page that makes no progress instead of looping', async () => {
  451. const { api, session } = makeSession()
  452. api.onHistory = payload => payload.beforeSeq === undefined
  453. ? histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true)
  454. : histResponse([], true) // empty page still claiming more history
  455. await session.open()
  456. await session.loadThrough(SessionSeq(0))
  457. expect(session.getSnapshot().loadingOlder).toBe(false)
  458. expect(api.callsOf('session.history')).toHaveLength(1)
  459. })
  460. it('loadThrough fails soft on a thrown page and clears its busy state', async () => {
  461. const { api, session } = makeSession()
  462. api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true)
  463. await session.open()
  464. api.onHistory = () => Promise.reject(new Error('page wire down'))
  465. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  466. try {
  467. await session.loadThrough(SessionSeq(0))
  468. expect(errorSpy).toHaveBeenCalled()
  469. expect(session.getSnapshot().loadingOlder).toBe(false)
  470. } finally {
  471. errorSpy.mockRestore()
  472. }
  473. })
  474. it('ignores loadOlder while one is in flight (single request)', async () => {
  475. const { api, session } = makeSession()
  476. api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true)
  477. await session.open()
  478. const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  479. api.onHistory = () => gate.promise
  480. const first = session.loadOlder()
  481. const second = session.loadOlder()
  482. gate.resolve(ok({
  483. records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[],
  484. hasMore: false,
  485. modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  486. }))
  487. await Promise.all([first, second])
  488. expect(api.callsOf('session.follow')).toHaveLength(1)
  489. expect(api.callsOf('session.history')).toHaveLength(1)
  490. })
  491. })
  492. describe('prompt and cancel errors', () => {
  493. it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
  494. const api = new FakeApiClient()
  495. const session = new Session(SID, fakeRemote(api), {
  496. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  497. parentAvailable: true,
  498. })
  499. await session.open()
  500. const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
  501. const cancelled = await session.cancel()
  502. expect(prompted).toEqual({ ok: true, value: { accepted: true } })
  503. expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
  504. expect(api.callsOf('session.follow')).toEqual([
  505. {
  506. address: {
  507. kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
  508. },
  509. maxMessages: 50,
  510. },
  511. ])
  512. expect(api.callsOf('subagent.history')).toEqual([])
  513. expect(api.callsOf('subagents.prompt')).toEqual([
  514. {
  515. requestId: expect.any(String) as unknown as string,
  516. parentSessionId: PARENT, childSessionId: SID,
  517. mode: 'continuable',
  518. content: [{ type: 'text', text: '继续' }],
  519. clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
  520. },
  521. ])
  522. expect(api.callsOf('subagents.interruptByParent')).toEqual([
  523. { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
  524. ])
  525. expect(api.callsOf('session.history')).toEqual([])
  526. expect(api.callsOf('session.prompt')).toEqual([])
  527. expect(api.callsOf('session.cancel')).toEqual([])
  528. // A successful interrupt leaves no stop error behind.
  529. expect(session.getSnapshot().promptError).toBeNull()
  530. expect(session.getSnapshot().subagent).toEqual({
  531. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  532. parentAvailable: true,
  533. })
  534. })
  535. it('forwards continuation image parts to the subagent prompt Remote unstripped', async () => {
  536. const api = new FakeApiClient()
  537. const session = new Session(SID, fakeRemote(api), {
  538. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  539. parentAvailable: true,
  540. })
  541. await session.open()
  542. const content = [
  543. { type: 'text' as const, text: '看这张图' },
  544. { type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=', name: 'shot.png' },
  545. ]
  546. const prompted = await session.prompt(content, 'queue')
  547. expect(prompted).toEqual({ ok: true, value: { accepted: true } })
  548. expect(api.callsOf('subagents.prompt')).toEqual([
  549. {
  550. requestId: expect.any(String) as unknown as string,
  551. parentSessionId: PARENT, childSessionId: SID,
  552. mode: 'continuable',
  553. content,
  554. clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
  555. },
  556. ])
  557. expect(session.getSnapshot().promptError).toBeNull()
  558. })
  559. it('lands an interrupt business failure in promptError with op=stop', async () => {
  560. const api = new FakeApiClient()
  561. api.onSubagentInterrupt = () => Promise.resolve(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID })))
  562. const session = new Session(SID, fakeRemote(api), {
  563. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  564. parentAvailable: true,
  565. })
  566. await session.open()
  567. const cancelled = await session.cancel()
  568. expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } })
  569. expect(session.getSnapshot().promptError).toMatchObject({
  570. op: 'stop', error: { code: 'subagent/unauthorized' },
  571. })
  572. })
  573. it('rejects staged files instead of dropping them from subagent continuations', async () => {
  574. const api = new FakeApiClient()
  575. const session = new Session(SID, fakeRemote(api), {
  576. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  577. parentAvailable: true,
  578. })
  579. await session.open()
  580. const prompted = await session.prompt([
  581. { type: 'file', receiptId: 'receipt' as never },
  582. { type: 'text', text: '继续' },
  583. ], 'queue')
  584. expect(prompted).toMatchObject({
  585. ok: false,
  586. error: {
  587. code: 'subagent/attachment-invalid',
  588. details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
  589. },
  590. })
  591. expect(api.callsOf('subagents.prompt')).toEqual([])
  592. })
  593. it('sends a one-shot address to the Host under the continuable marker', async () => {
  594. const api = new FakeApiClient()
  595. api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(
  596. 'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID },
  597. )))
  598. const session = new Session(SID, fakeRemote(api), {
  599. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
  600. })
  601. await session.open()
  602. const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
  603. const cancelled = await session.cancel()
  604. // The Host reads the durable descriptor; the wire marker stays 'continuable'.
  605. expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } })
  606. expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
  607. expect(api.callsOf('subagents.prompt')).toMatchObject([
  608. { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  609. ])
  610. expect(api.callsOf('subagents.interruptByParent')).toEqual([
  611. { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
  612. ])
  613. expect(api.callsOf('session.follow')).toEqual([
  614. {
  615. address: {
  616. kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot',
  617. },
  618. maxMessages: 50,
  619. },
  620. ])
  621. expect(api.callsOf('subagent.history')).toEqual([])
  622. expect(api.callsOf('session.cancel')).toEqual([])
  623. })
  624. it('delivers an image continuation to the Host without narrowing its upload parts', async () => {
  625. const api = new FakeApiClient()
  626. const session = new Session(SID, fakeRemote(api), {
  627. address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
  628. })
  629. await session.open()
  630. const prompted = await session.prompt(
  631. [{ type: 'text', text: '看图' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }],
  632. 'queue',
  633. )
  634. expect(prompted).toEqual({ ok: true, value: { accepted: true } })
  635. expect(api.callsOf('subagents.prompt')).toMatchObject([
  636. { content: [{ type: 'text' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }] },
  637. ])
  638. })
  639. it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => {
  640. const { api, session } = makeSession()
  641. session.handleBlank(true)
  642. expect(session.getSnapshot()).toMatchObject({
  643. blank: true, promptAttempted: false, awaitingFirstTurn: false,
  644. })
  645. const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
  646. expect(session.getSnapshot()).toMatchObject({
  647. blank: true, promptAttempted: true, awaitingFirstTurn: true,
  648. })
  649. const result = await inFlight
  650. expect(result.ok).toBe(true)
  651. expect(session.getSnapshot()).toMatchObject({
  652. blank: false, promptAttempted: true, awaitingFirstTurn: true,
  653. })
  654. expect(api.callsOf('session.prompt')).toMatchObject([{
  655. sessionId: SID,
  656. mode: 'queue',
  657. content: [{ type: 'text', text: '要发的' }],
  658. clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
  659. }])
  660. session.handleRunning(true)
  661. expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false })
  662. })
  663. it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => {
  664. const { api, session } = makeSession()
  665. session.handleBlank(true)
  666. api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' })))
  667. const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
  668. expect(result.ok).toBe(false)
  669. expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
  670. expect(session.getSnapshot()).toMatchObject({
  671. blank: true, promptAttempted: true, awaitingFirstTurn: true,
  672. })
  673. })
  674. it('propagates a non-Remote throw raised while cancelling', async () => {
  675. const { api, session } = makeSession()
  676. api.onCancel = () => Promise.reject(new Error('cancel transport down'))
  677. await expect(session.cancel()).rejects.toThrow('cancel transport down')
  678. expect(session.getSnapshot().promptError).toBeNull()
  679. })
  680. it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
  681. const { api, session } = makeSession()
  682. const result = await session.readAttachment('attachment-1' as never)
  683. expect(result).toEqual({
  684. ok: true,
  685. value: {
  686. attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
  687. data: Uint8Array.of(0),
  688. },
  689. })
  690. expect(api.callsOf('session.attachment')).toEqual([{
  691. sessionId: SID, attachmentId: 'attachment-1',
  692. }])
  693. })
  694. })
  695. describe('rename', () => {
  696. it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
  697. const { api, session } = makeSession()
  698. api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
  699. const result = await session.rename(' 正名 ')
  700. expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
  701. expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
  702. expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
  703. // A stale lower-seq apply (the push-frame path routes into this same
  704. // store) must not roll the settled value back.
  705. session.projections.apply('title', '旧名', SessionSeq(3))
  706. expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
  707. })
  708. it('returns the business error untouched and folds a transport throw to internal', async () => {
  709. const { api, session } = makeSession()
  710. api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID })))
  711. const rejected = await session.rename(' ')
  712. expect(rejected).toMatchObject({ ok: false, error: { code: 'session/title-invalid' } })
  713. expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
  714. api.onRename = () => Promise.reject(new Error('rename transport down'))
  715. await expect(session.rename('x')).rejects.toThrow('rename transport down')
  716. })
  717. })
  718. describe('remaining branches', () => {
  719. it('propagates a non-Remote throw raised while prompting', async () => {
  720. const { api, session } = makeSession()
  721. api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
  722. await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('prompt wire down')
  723. expect(session.getSnapshot().promptError).toBeNull()
  724. })
  725. it('cancel business error also lands op=stop promptError', async () => {
  726. const { api, session } = makeSession()
  727. api.onCancel = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' })))
  728. await session.cancel()
  729. expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'session/agent-busy' } })
  730. })
  731. it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => {
  732. const { api, session } = makeSession()
  733. await session.loadOlder() // cold: no-op, zero calls
  734. expect(api.calls).toEqual([])
  735. api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true)
  736. await session.open()
  737. // err result: window unchanged
  738. api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {})))
  739. await session.loadOlder()
  740. expect(eventSeqs(session)).toHaveLength(6)
  741. expect(session.getSnapshot().hasMore).toBe(true)
  742. // empty page: hasMore adopts the response
  743. api.onHistory = () => histResponse([], false)
  744. await session.loadOlder()
  745. expect(session.getSnapshot().hasMore).toBe(false)
  746. // hasMore false now: further loadOlder is a guard no-op
  747. const calls = api.calls.length
  748. await session.loadOlder()
  749. expect(api.calls.length).toBe(calls)
  750. // throw path: fail-soft with console.error
  751. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  752. try {
  753. await session.resync()
  754. api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true)
  755. await session.resync()
  756. api.onHistory = () => Promise.reject(new Error('page wire down'))
  757. await session.loadOlder()
  758. expect(errorSpy).toHaveBeenCalled()
  759. expect(session.getSnapshot().loadingOlder).toBe(false)
  760. } finally {
  761. errorSpy.mockRestore()
  762. }
  763. })
  764. it('subscribe delivers snapshot-change notifications and unsubscribes', async () => {
  765. const { api, session } = makeSession()
  766. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  767. let notified = 0
  768. const unsubscribe = session.subscribe(() => { notified++ })
  769. await session.open()
  770. await new Promise(resolve => setTimeout(resolve, 0))
  771. expect(notified).toBeGreaterThan(0)
  772. const seen = notified
  773. unsubscribe()
  774. session.handleRunning(true) // any snapshot mutation; the listener must stay silent
  775. await new Promise(resolve => setTimeout(resolve, 0))
  776. expect(notified).toBe(seen)
  777. })
  778. it('rejects an opening page that does not end at the opening cursor', async () => {
  779. const { api, session } = makeSession()
  780. let call = 0
  781. api.onHistory = () => {
  782. call++
  783. return histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  784. }
  785. api.followCursor = 11
  786. await session.open()
  787. expect(call).toBe(1)
  788. const snapshot = session.getSnapshot()
  789. expect(snapshot.openState).toBe('error')
  790. expect(snapshot.openError).toMatchObject({
  791. code: 'gateway/internal', message: 'session event stream page did not end at its requested cursor',
  792. })
  793. expect(eventSeqs(session)).toEqual([])
  794. })
  795. it('deduplicates repeated running flips and records removal', () => {
  796. const { session } = makeSession()
  797. const before = session.getSnapshot()
  798. session.handleRunning(false) // already false: dedup branch
  799. expect(session.getSnapshot()).toBe(before)
  800. session.handleRemoved()
  801. expect(session.getSnapshot().removed).toBe(true)
  802. })
  803. it('drops live events while cold/error (no window upkeep)', async () => {
  804. const { api, session } = makeSession()
  805. await follow(api, ev.user(SessionSeq(0), '冷态帧'))
  806. expect(eventSeqs(session)).toEqual([])
  807. api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {})))
  808. await session.open()
  809. await follow(api, ev.user(SessionSeq(0), '错态帧'))
  810. expect(eventSeqs(session)).toEqual([])
  811. })
  812. it('preserves a Host-reported failure that terminates the live source', async () => {
  813. const { api, session } = makeSession()
  814. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  815. await session.open()
  816. const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID })
  817. api.failStreams(failure)
  818. await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
  819. expect(session.getSnapshot().openError).toMatchObject({
  820. code: failure.code, message: failure.message, details: failure.details,
  821. })
  822. })
  823. it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => {
  824. const { api, session } = makeSession()
  825. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  826. await session.open()
  827. const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  828. let repairs = 0
  829. api.onHistory = () => {
  830. repairs++
  831. return gate.promise
  832. }
  833. const deliveries = Promise.all([
  834. follow(api, ev.user(SessionSeq(9), '洞一')),
  835. follow(api, ev.user(SessionSeq(10), '洞二')),
  836. ])
  837. await vi.waitFor(() => { expect(repairs).toBe(1) })
  838. gate.reject(new RemoteError('gateway/internal', 'repair wire down', {}))
  839. await deliveries
  840. await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
  841. expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'repair wire down' })
  842. expect(eventSeqs(session)).toHaveLength(6)
  843. })
  844. it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => {
  845. const { api, session } = makeSession()
  846. const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  847. api.onHistory = () => stale.promise
  848. const opening = session.open()
  849. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  850. const resynced = session.resync()
  851. stale.reject(new Error('stale wire'))
  852. await Promise.all([opening, resynced])
  853. expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error
  854. })
  855. it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => {
  856. const { api, session } = makeSession()
  857. const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  858. api.onHistory = () => stale.promise
  859. const opening = session.open()
  860. api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代'))
  861. const resynced = session.resync()
  862. stale.resolve(ok({
  863. records: entries(plainTurn(SessionSeq(0), 0, '旧', '代')) as never[],
  864. hasMore: false,
  865. modelSelection: { provider: 'deepseek-official', model: 'stale' },
  866. })) // success, but its generation is gone
  867. await Promise.all([opening, resynced])
  868. expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq))
  869. })
  870. it('drops a gap repair superseded by a full resync while its pull was in flight', async () => {
  871. const { api, session } = makeSession()
  872. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  873. await session.open()
  874. const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  875. api.onHistory = () => repairPull.promise
  876. const delivery = follow(api, ev.user(SessionSeq(9), '洞'))
  877. await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) })
  878. api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'c', 'd'))
  879. const resynced = session.resync() // bumps the generation
  880. repairPull.resolve(ok({
  881. records: entries(plainTurn(SessionSeq(0), 0, '旧', '页')) as never[],
  882. hasMore: false,
  883. modelSelection: { provider: 'deepseek-official', model: 'stale' },
  884. })) // repair result: stale, dropped
  885. await Promise.all([delivery, resynced])
  886. expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, 'c', 'd').map(event => event.seq))
  887. })
  888. it('successful cancel leaves no promptError', async () => {
  889. const { api, session } = makeSession()
  890. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  891. await session.open()
  892. const result = await session.cancel()
  893. expect(result.ok).toBe(true)
  894. expect(session.getSnapshot().promptError).toBeNull()
  895. })
  896. it('dispose is a reserved no-op on resident instances', async () => {
  897. const { session } = makeSession()
  898. await expect(session.dispose()).resolves.toBeUndefined()
  899. })
  900. it('carries raw history and follow events through the event feed', async () => {
  901. const { api, session } = makeSession()
  902. const historyCall = ev.toolCall(SessionSeq(6), 1, 'h1', 'bash', '{"cmd":"pwd"}')
  903. const historyResult = ev.toolResult(SessionSeq(7), 1, 'h1', 'done')
  904. api.onHistory = () => Promise.resolve(ok({
  905. records: [
  906. ...entries(plainTurn(SessionSeq(0), 0, 'a', 'b')),
  907. { type: 'event', event: historyCall },
  908. { type: 'event', event: historyResult },
  909. ] as never[],
  910. hasMore: false,
  911. modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  912. }))
  913. await session.open()
  914. expect(windowEntries(session).slice(-2)).toEqual([
  915. { type: 'event', event: historyCall },
  916. { type: 'event', event: historyResult },
  917. ])
  918. const liveCall = ev.toolCall(SessionSeq(8), 2, 'l1', 'write', '{"file_path":"a.ts"}')
  919. await follow(api, liveCall)
  920. expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveCall })
  921. const liveResult = ev.toolResult(SessionSeq(9), 2, 'l1', 'ok')
  922. await follow(api, liveResult)
  923. expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveResult })
  924. })
  925. })
  926. describe('resync', () => {
  927. it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => {
  928. const { api, session } = makeSession()
  929. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '旧', '窗'))
  930. await session.open()
  931. const oldWindow = session.eventSource.getSnapshot()
  932. const replacement = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  933. api.followCursor = 15
  934. api.onHistory = () => replacement.promise
  935. const publications: ReturnType<Session['eventSource']['getSnapshot']>[] = []
  936. const off = session.eventSource.subscribe(() => {
  937. publications.push(session.eventSource.getSnapshot())
  938. })
  939. const syncing = session.resync()
  940. await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) })
  941. expect(session.eventSource.getSnapshot()).toBe(oldWindow)
  942. expect(publications).toEqual([])
  943. api.onHistory = () => histResponse([
  944. ...plainTurn(SessionSeq(10), 2, '终', '页'),
  945. ev.user(SessionSeq(16), '后到低位'),
  946. ev.user(SessionSeq(17), '后到高位'),
  947. ])
  948. const liveDeliveries = Promise.all([
  949. follow(api, ev.user(SessionSeq(17), '后到高位')),
  950. follow(api, ev.user(SessionSeq(16), '后到低位')),
  951. ])
  952. expect(session.eventSource.getSnapshot()).toBe(oldWindow)
  953. replacement.resolve(ok({
  954. records: entries(plainTurn(SessionSeq(10), 2, '终', '页')) as never[],
  955. hasMore: false,
  956. modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  957. }))
  958. await Promise.all([syncing, liveDeliveries])
  959. await vi.waitFor(() => {
  960. expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
  961. })
  962. expect(publications).toHaveLength(2)
  963. expect(publications.map(snapshot => snapshot.change.kind)).toEqual(['replace', 'replace'])
  964. expect(publications[0]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15])
  965. expect(publications[1]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
  966. off()
  967. })
  968. it('rebuilds the window without clearing control state; cold instances no-op', async () => {
  969. const { api, session } = makeSession()
  970. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
  971. await session.open()
  972. session.handleRunning(true)
  973. session.handleAgentError('still visible')
  974. api.onHistory = () => histResponse([...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')])
  975. await session.resync()
  976. const snapshot = session.getSnapshot()
  977. expect(snapshot.openState).toBe('open')
  978. expect(snapshot.running).toBe(true)
  979. expect(snapshot.lastAgentError).toBe('still visible')
  980. expect(eventSeqs(session)).toHaveLength(12)
  981. const cold = makeSession()
  982. await cold.session.resync()
  983. expect(cold.api.calls).toEqual([]) // never opened: no traffic
  984. })
  985. it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
  986. const { api, session } = makeSession()
  987. const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  988. api.onHistory = () => stale.promise
  989. const firstOpen = session.open()
  990. api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代'))
  991. const resynced = session.resync()
  992. stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late
  993. await firstOpen
  994. await resynced
  995. const snapshot = session.getSnapshot()
  996. expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
  997. expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq))
  998. })
  999. })
  1000. describe('snapshot ownership', () => {
  1001. it('publishes event-window appends without changing an unrelated Session snapshot', async () => {
  1002. const { api, session } = makeSession()
  1003. api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '稳', '定'))
  1004. await session.open()
  1005. const sessionBefore = session.getSnapshot()
  1006. const windowBefore = session.eventSource.getSnapshot()
  1007. const firstEntry = windowBefore.entries[0]
  1008. await follow(api, ev.user(SessionSeq(6), '追加'))
  1009. const windowAfter = session.eventSource.getSnapshot()
  1010. expect(session.getSnapshot()).toBe(sessionBefore)
  1011. expect(windowAfter).not.toBe(windowBefore)
  1012. expect(windowAfter.entries[0]).toBe(firstEntry)
  1013. expect(windowAfter.change).toMatchObject({ kind: 'append' })
  1014. })
  1015. })