transport.host.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { createScope } from '@deepseek-ai/dsh-scope'
  3. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  4. import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  5. import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
  6. import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
  7. import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
  8. import { describe, expect, it, vi } from 'vitest'
  9. import { SessionHistoryController } from '../src/history.ts'
  10. import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
  11. const signal = (): AbortSignal => new AbortController().signal
  12. function append(
  13. session: Session,
  14. type: string,
  15. data: unknown,
  16. options?: { readonly surfaceOp?: unknown; readonly sourceEventSeqs?: readonly number[] },
  17. ): SessionEvent {
  18. return (session.append as unknown as (
  19. eventType: string,
  20. eventData: unknown,
  21. eventOptions?: unknown,
  22. ) => SessionEvent)(type, data, options)
  23. }
  24. function event(type: string, seq: number, data: unknown = {}): SessionEvent {
  25. return {
  26. type,
  27. seq,
  28. time: seq + 1,
  29. data,
  30. ...type.startsWith('fixture/') ? { ignorable: true } : {},
  31. } as SessionEvent
  32. }
  33. function cold(
  34. ctx: Context,
  35. header: SessionHeader,
  36. events: readonly SessionEvent[],
  37. ): void {
  38. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  39. list: () => Promise.resolve([header]),
  40. inspect: () => Promise.resolve({ meta: header, events }),
  41. }) as never)
  42. }
  43. interface Deferred<T> {
  44. readonly promise: Promise<T>
  45. resolve(value: T): void
  46. }
  47. function deferred<T>(): Deferred<T> {
  48. let resolve!: (value: T) => void
  49. const promise = new Promise<T>((settle) => { resolve = settle })
  50. return { promise, resolve }
  51. }
  52. async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> {
  53. const ctx = new Context()
  54. await ctx.plugin(SessionStore)
  55. installSessionReadTestServices(ctx)
  56. ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
  57. const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
  58. return { ctx, transport }
  59. }
  60. describe('SessionHistoryController', () => {
  61. it('opens at the current cursor and follows later events from an ordinary Session', async () => {
  62. const { ctx, transport } = await setup()
  63. const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } })
  64. session.append('turn/start', { turn: 1 })
  65. const abort = new AbortController()
  66. const iterator = transport.follow(
  67. { address: { kind: 'session', sessionId: session.id } },
  68. abort.signal,
  69. )[Symbol.asyncIterator]()
  70. expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
  71. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  72. expect(await iterator.next()).toMatchObject({
  73. done: false,
  74. value: { type: 'event', event: { type: 'turn/end', seq: 1 } },
  75. })
  76. const page = await transport.page(
  77. { address: { kind: 'session', sessionId: session.id }, throughSeq: 1 },
  78. new AbortController().signal,
  79. )
  80. expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1])
  81. abort.abort()
  82. expect(await iterator.next()).toMatchObject({ done: true })
  83. })
  84. it('ends active followers when the owning Controller unloads', async () => {
  85. const ctx = new Context()
  86. await ctx.plugin(SessionStore)
  87. installSessionReadTestServices(ctx)
  88. let transport!: SessionHistoryController
  89. const owner = ctx.plugin(Object.assign(
  90. (inner: Context) => {
  91. transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
  92. },
  93. { inject: ['sessions', 'sessionQuery'] },
  94. ))
  95. await owner.await()
  96. const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } })
  97. const iterator = transport.follow(
  98. { address: { kind: 'session', sessionId: session.id } },
  99. new AbortController().signal,
  100. )[Symbol.asyncIterator]()
  101. await expect(iterator.next()).resolves.toMatchObject({
  102. done: false,
  103. value: { type: 'snapshot', cursor: -1 },
  104. })
  105. const pending = iterator.next()
  106. await owner.dispose()
  107. await expect(pending).resolves.toEqual({ done: true, value: undefined })
  108. await ctx.fiber.dispose()
  109. })
  110. it('reconnects with a complete replacement snapshot before later live events', async () => {
  111. const { ctx, transport } = await setup()
  112. const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } })
  113. session.append('turn/start', { turn: 1 })
  114. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  115. session.append('turn/start', { turn: 2 })
  116. const abort = new AbortController()
  117. const iterator = transport.follow({
  118. address: { kind: 'session', sessionId: session.id },
  119. }, abort.signal)[Symbol.asyncIterator]()
  120. expect(await iterator.next()).toMatchObject({
  121. done: false,
  122. value: {
  123. type: 'snapshot',
  124. cursor: 2,
  125. records: [
  126. { type: 'event', event: { seq: 0 } },
  127. { type: 'event', event: { seq: 1 } },
  128. { type: 'event', event: { seq: 2 } },
  129. ],
  130. },
  131. })
  132. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  133. expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } })
  134. abort.abort()
  135. expect(await iterator.next()).toMatchObject({ done: true })
  136. })
  137. it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => {
  138. const { ctx, transport } = await setup()
  139. const sessionId = SessionId('cold-race')
  140. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  141. const inspected = deferred<{ meta: SessionHeader; events: readonly SessionEvent[] }>()
  142. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  143. inspect: () => inspected.promise,
  144. }) as never)
  145. const abort = new AbortController()
  146. const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
  147. [Symbol.asyncIterator]()
  148. const opening = iterator.next()
  149. const unrelatedId = SessionId('unrelated')
  150. ctx.emit('session/event', {
  151. id: unrelatedId,
  152. header: { version: 0, id: unrelatedId, createdAt: 1 },
  153. events: [event('fixture/other', 0)],
  154. } as unknown as Session, event('fixture/other', 0))
  155. ctx.emit('session/event', {
  156. id: sessionId,
  157. header,
  158. events: [event('fixture/start', 0)],
  159. } as unknown as Session, event('fixture/start', 0))
  160. inspected.resolve({ meta: header, events: [event('fixture/start', 0)] })
  161. await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
  162. const waiting = iterator.next()
  163. abort.abort()
  164. await expect(waiting).resolves.toMatchObject({ done: true })
  165. })
  166. it('buffers creation while the opening observation is unresolved', async () => {
  167. const ctx = new Context()
  168. await ctx.plugin(SessionStore)
  169. const sessionId = SessionId('created-during-observation')
  170. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  171. const observed = deferred<SessionObservation>()
  172. ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never)
  173. const transport = new SessionHistoryController(ctx, vi.fn())
  174. const abort = new AbortController()
  175. const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
  176. [Symbol.asyncIterator]()
  177. const opening = iterator.next()
  178. const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', 0)] })
  179. observed.resolve({
  180. source: 'live',
  181. header: attached.header,
  182. events: attached.events,
  183. cursor: attached.seq - 1,
  184. projections: { asOfSeq: attached.seq - 1, values: {} },
  185. retain: vi.fn(),
  186. [Symbol.dispose]: vi.fn(),
  187. } as unknown as SessionObservation)
  188. await expect(opening).resolves.toMatchObject({
  189. done: false,
  190. value: {
  191. type: 'snapshot',
  192. cursor: 1,
  193. records: [
  194. { type: 'event', event: { seq: 0 } },
  195. { type: 'event', event: { seq: 1 } },
  196. ],
  197. },
  198. })
  199. expect(attached.id).toBe(sessionId)
  200. abort.abort()
  201. await expect(iterator.next()).resolves.toMatchObject({ done: true })
  202. })
  203. it('bridges the unpublished end-seed boundary when a cold source attaches', async () => {
  204. const ctx = new Context()
  205. await ctx.plugin(SessionStore)
  206. installSessionReadTestServices(ctx)
  207. let transport!: SessionHistoryController
  208. let agentCtx!: Context
  209. await ctx.plugin(Object.assign(
  210. (inner: Context) => {
  211. transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
  212. },
  213. { inject: ['sessions', 'sessionQuery'] },
  214. ))
  215. await ctx.plugin(Object.assign(
  216. (inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx },
  217. { inject: ['sessions'] },
  218. ))
  219. const sessionId = SessionId('cold-attach')
  220. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  221. const seed = [event('fixture/start', 0)]
  222. cold(ctx, header, seed)
  223. agentCtx.on('session/created', (session) => {
  224. if (session.id !== sessionId) return
  225. append(session, 'fixture/setup-one', {})
  226. append(session, 'fixture/setup-two', {})
  227. })
  228. const abort = new AbortController()
  229. const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
  230. [Symbol.asyncIterator]()
  231. await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
  232. agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } })
  233. const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed })
  234. agentCtx.sessions.enter(attached)
  235. agentCtx.sessions.announce(attached)
  236. await expect(iterator.next()).resolves.toMatchObject({
  237. done: false,
  238. value: { type: 'event', event: { type: 'session/end-seed', seq: 1 } },
  239. })
  240. await expect(iterator.next()).resolves.toMatchObject({
  241. done: false,
  242. value: { type: 'event', event: { type: 'fixture/setup-one', seq: 2 } },
  243. })
  244. await expect(iterator.next()).resolves.toMatchObject({
  245. done: false,
  246. value: { type: 'event', event: { type: 'fixture/setup-two', seq: 3 } },
  247. })
  248. append(attached, 'fixture/live', {})
  249. await expect(iterator.next()).resolves.toMatchObject({
  250. done: false,
  251. value: { type: 'event', event: { type: 'fixture/live', seq: 4 } },
  252. })
  253. abort.abort()
  254. await expect(iterator.next()).resolves.toMatchObject({ done: true })
  255. })
  256. it('rejects gaps in replayed and live event sequences', async () => {
  257. const replay = await setup()
  258. const replayId = SessionId('replay-gap')
  259. const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' }
  260. cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)])
  261. const replayed = replay.transport.follow({
  262. address: { kind: 'session', sessionId: replayId },
  263. }, signal())[Symbol.asyncIterator]()
  264. await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
  265. const live = await setup()
  266. const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } })
  267. append(session, 'fixture/start', {})
  268. live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never)
  269. const followed = live.transport.follow({
  270. address: { kind: 'session', sessionId: session.id },
  271. }, signal())[Symbol.asyncIterator]()
  272. await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
  273. const skipped = event('fixture/skipped', 1)
  274. const gap = event('fixture/gap', 2)
  275. live.ctx.emit('session/event', {
  276. id: session.id,
  277. header: session.header,
  278. events: [event('fixture/start', 0), skipped, gap],
  279. } as unknown as Session, gap)
  280. await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
  281. })
  282. it('opens an empty source at cursor -1', async () => {
  283. const { ctx, transport } = await setup()
  284. const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } })
  285. const abort = new AbortController()
  286. const iterator = transport.follow({
  287. address: { kind: 'session', sessionId: session.id },
  288. }, abort.signal)[Symbol.asyncIterator]()
  289. await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } })
  290. await expect(transport.page({
  291. address: { kind: 'session', sessionId: session.id }, throughSeq: -1,
  292. }, signal())).resolves.toMatchObject({ records: [], hasMore: false })
  293. abort.abort()
  294. await expect(iterator.next()).resolves.toMatchObject({ done: true })
  295. })
  296. it('publishes an empty projection baseline when the query has no registry', async () => {
  297. const ctx = new Context()
  298. await ctx.plugin(SessionStore)
  299. const sessionId = SessionId('projectionless-follow')
  300. const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  301. ctx.provide('sessionQuery', {
  302. observeSession: () => Promise.resolve({
  303. source: 'live', header: meta, events: [], cursor: -1,
  304. retain: vi.fn(), [Symbol.dispose]: vi.fn(),
  305. } satisfies SessionObservation),
  306. } as never)
  307. const history = new SessionHistoryController(ctx, vi.fn())
  308. const abort = new AbortController()
  309. const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal)
  310. [Symbol.asyncIterator]()
  311. await expect(iterator.next()).resolves.toMatchObject({
  312. value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } },
  313. })
  314. abort.abort()
  315. await expect(iterator.next()).resolves.toMatchObject({ done: true })
  316. await ctx.fiber.dispose()
  317. })
  318. it('disposes a retained promotion when background activation rejects synchronously', async () => {
  319. const ctx = new Context()
  320. await ctx.plugin(SessionStore)
  321. const sessionId = SessionId('promotion-failure')
  322. const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  323. const disposePromotion = vi.fn()
  324. const promotion = {
  325. source: 'prepared', header: meta, events: [], cursor: -1,
  326. projections: { asOfSeq: -1, values: {} },
  327. retain: vi.fn(), [Symbol.dispose]: disposePromotion,
  328. } as unknown as SessionObservation
  329. const source = {
  330. ...promotion,
  331. retain: () => promotion,
  332. [Symbol.dispose]: vi.fn(),
  333. } as SessionObservation
  334. ctx.provide('sessionQuery', {
  335. observeSession: () => Promise.resolve(source),
  336. } as never)
  337. const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') })
  338. const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal())
  339. [Symbol.asyncIterator]()
  340. await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
  341. await expect(iterator.next()).rejects.toThrow('activation failed')
  342. expect(disposePromotion).toHaveBeenCalledOnce()
  343. await ctx.fiber.dispose()
  344. })
  345. it('requires the durable parent and mode for a direct subagent address', async () => {
  346. const { ctx, transport } = await setup()
  347. const parentSessionId = SessionId('parent')
  348. const childSessionId = SessionId('child')
  349. ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } })
  350. const child = ctx.sessions.create(childSessionId, {
  351. meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId },
  352. })
  353. child.append('subagent/descriptor', snapshotSubagentDescriptor({
  354. mode: 'continuable',
  355. provider: 'test',
  356. label: 'child',
  357. }))
  358. const signal = new AbortController().signal
  359. await expect(transport.page({
  360. address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
  361. throughSeq: 0,
  362. }, signal)).resolves.toMatchObject({
  363. records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
  364. })
  365. await expect(transport.page({
  366. address: {
  367. kind: 'subagent',
  368. parentSessionId: SessionId('other-parent'),
  369. childSessionId,
  370. mode: 'continuable',
  371. },
  372. throughSeq: 0,
  373. }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
  374. await expect(transport.page({
  375. address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' },
  376. throughSeq: 0,
  377. }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
  378. await expect(transport.page({
  379. address: { kind: 'session', sessionId: childSessionId },
  380. throughSeq: 0,
  381. }, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
  382. })
  383. it('preserves a cold inspection failure for the Gateway error branch', async () => {
  384. const { ctx, transport } = await setup()
  385. const sessionId = SessionId('corrupt-cold')
  386. const failure = new Error('cold log is corrupt')
  387. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  388. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  389. list: () => Promise.resolve([header]),
  390. inspect: () => Promise.reject(failure),
  391. }) as never)
  392. await expect(transport.page({
  393. address: { kind: 'session', sessionId },
  394. throughSeq: -1,
  395. }, new AbortController().signal)).rejects.toMatchObject({
  396. code: 'SESSION_QUERY_PERSISTENCE_FAILED',
  397. cause: failure,
  398. })
  399. })
  400. it('rejects malformed page and follow cursors at the service boundary', async () => {
  401. const { ctx, transport } = await setup()
  402. const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } })
  403. const address = { kind: 'session' as const, sessionId: session.id }
  404. for (const request of [
  405. { address, throughSeq: -2 },
  406. { address, throughSeq: 0.5 },
  407. { address, throughSeq: -1, beforeSeq: -1 },
  408. { address, throughSeq: -1, beforeSeq: 1.5 },
  409. { address, throughSeq: -1, maxMessages: 0 },
  410. { address, throughSeq: -1, maxMessages: 1.5 },
  411. ]) {
  412. await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } })
  413. }
  414. await expect(transport.page({ address, throughSeq: 0 }, signal()))
  415. .rejects.toMatchObject({ failure: { code: 'bad-request' } })
  416. const corrupt = await setup()
  417. const corruptId = SessionId('missing-through-seq')
  418. cold(
  419. corrupt.ctx,
  420. { version: 0, id: corruptId, createdAt: 1, cwd: '/workspace' },
  421. [event('fixture/start', 0), event('fixture/gap', 2)],
  422. )
  423. await expect(corrupt.transport.page({
  424. address: { kind: 'session', sessionId: corruptId }, throughSeq: 1,
  425. }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
  426. for (const maxMessages of [0, 0.5]) {
  427. const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]()
  428. await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
  429. }
  430. })
  431. it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => {
  432. const { ctx, transport } = await setup()
  433. const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') }
  434. await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
  435. .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
  436. const inspect = vi.fn(() => Promise.resolve(undefined))
  437. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  438. list: () => Promise.resolve([]),
  439. inspect,
  440. }) as never)
  441. await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
  442. .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
  443. await expect(transport.page({
  444. address: {
  445. kind: 'subagent',
  446. parentSessionId: SessionId('parent'),
  447. childSessionId: SessionId('missing-child'),
  448. mode: 'continuable',
  449. },
  450. throughSeq: -1,
  451. }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
  452. expect(inspect).toHaveBeenCalledTimes(2)
  453. })
  454. it('rejects incomplete cold metadata before serving a source', async () => {
  455. const first = await setup()
  456. const sessionId = SessionId('incomplete')
  457. const address = { kind: 'session' as const, sessionId }
  458. const firstHeader = { version: 0, id: sessionId, createdAt: 1 }
  459. first.ctx.provide('sessionPersistence', testSessionPersistence(first.ctx, {
  460. list: () => Promise.resolve([firstHeader]),
  461. inspect: () => Promise.resolve({ meta: firstHeader, events: [] }),
  462. }) as never)
  463. await expect(first.transport.page({ address, throughSeq: -1 }, signal()))
  464. .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
  465. const second = await setup()
  466. const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  467. const inspected = { version: 0, id: sessionId, createdAt: 1 }
  468. second.ctx.provide('sessionPersistence', testSessionPersistence(second.ctx, {
  469. list: () => Promise.resolve([listed]),
  470. inspect: () => Promise.resolve({ meta: inspected, events: [] }),
  471. }) as never)
  472. await expect(second.transport.page({ address, throughSeq: -1 }, signal()))
  473. .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
  474. })
  475. it('serves cold ordinary history and validates every durable subagent descriptor state', async () => {
  476. const ordinaryBench = await setup()
  477. const ordinaryId = SessionId('cold-ordinary')
  478. const ordinaryHeader = { version: 0, id: ordinaryId, createdAt: 1, cwd: '/workspace' }
  479. cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', 0, { turn: 1 })])
  480. await expect(ordinaryBench.transport.page({
  481. address: { kind: 'session', sessionId: ordinaryId },
  482. throughSeq: 0,
  483. }, signal())).resolves.toMatchObject({
  484. records: [{ type: 'event', event: { seq: 0 } }],
  485. })
  486. const parentSessionId = SessionId('cold-parent')
  487. const childSessionId = SessionId('cold-child')
  488. const childHeader = {
  489. version: 0,
  490. id: childSessionId,
  491. createdAt: 1,
  492. cwd: '/workspace',
  493. origin: 'subagent' as const,
  494. parentSession: parentSessionId,
  495. }
  496. const childAddress = {
  497. kind: 'subagent' as const,
  498. parentSessionId,
  499. childSessionId,
  500. mode: 'continuable' as const,
  501. }
  502. const missing = await setup()
  503. cold(missing.ctx, childHeader, [])
  504. await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
  505. .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
  506. const corrupt = await setup()
  507. cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
  508. await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal()))
  509. .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
  510. const ordinaryChild = await setup()
  511. const { origin: _origin, ...ordinaryChildHeader } = childHeader
  512. cold(ordinaryChild.ctx, ordinaryChildHeader, [])
  513. await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
  514. .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
  515. })
  516. it('reports an unavailable descriptor when an observed child has no projection value', async () => {
  517. const ctx = new Context()
  518. await ctx.plugin(SessionStore)
  519. const parentSessionId = SessionId('missing-projection-parent')
  520. const childSessionId = SessionId('missing-projection-child')
  521. const meta: SessionHeader = {
  522. version: 0,
  523. id: childSessionId,
  524. createdAt: 1,
  525. cwd: '/workspace',
  526. origin: 'subagent',
  527. parentSession: parentSessionId,
  528. }
  529. ctx.provide('sessionQuery', {
  530. observeSession: () => Promise.resolve({
  531. source: 'live', header: meta, events: [], cursor: -1,
  532. projections: { asOfSeq: -1, values: {} },
  533. retain: vi.fn(), [Symbol.dispose]: vi.fn(),
  534. } as unknown as SessionObservation),
  535. } as never)
  536. const history = new SessionHistoryController(ctx, vi.fn())
  537. await expect(history.page({
  538. address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
  539. throughSeq: -1,
  540. }, signal())).rejects.toMatchObject({
  541. failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } },
  542. })
  543. await ctx.fiber.dispose()
  544. })
  545. it('keeps pages projection-free and computes projections only for child authorization', async () => {
  546. const ordinary = await setup()
  547. const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
  548. session.append('turn/start', { turn: 1 })
  549. const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot')
  550. const ordinaryPage = await ordinary.transport.page({
  551. address: { kind: 'session', sessionId: session.id },
  552. throughSeq: 0,
  553. }, signal())
  554. expect('projections' in ordinaryPage).toBe(false)
  555. expect(ordinarySnapshot).not.toHaveBeenCalled()
  556. const child = await setup()
  557. const parentSessionId = SessionId('projection-parent')
  558. const childSessionId = SessionId('projection-child')
  559. const childSession = child.ctx.sessions.create(childSessionId, {
  560. meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId },
  561. })
  562. childSession.append('subagent/descriptor', snapshotSubagentDescriptor({
  563. mode: 'continuable', provider: 'test', label: 'child',
  564. }))
  565. const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot')
  566. const page = await child.transport.page({
  567. address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
  568. throughSeq: 0,
  569. }, signal())
  570. expect('projections' in page).toBe(false)
  571. expect(childSnapshot).toHaveBeenCalledWith(childSession)
  572. })
  573. it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
  574. const { ctx, transport } = await setup()
  575. const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } })
  576. session.append('turn/start', { turn: 1 })
  577. append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
  578. const firstReply = append(session, 'assistant/message', { turn: 1, step: 1, message: {} }, { surfaceOp: 'append' })
  579. append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
  580. append(session, 'assistant/message', { turn: 1, step: 2, message: {} }, { surfaceOp: 'append' })
  581. const summary = append(session, 'fixture/summary', {})
  582. const replacement = append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, {
  583. surfaceOp: { op: 'replace', start: 1, end: 4 },
  584. sourceEventSeqs: [1, firstReply.seq, 3, 4, summary.seq],
  585. })
  586. const page = await transport.page({
  587. address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2,
  588. }, signal())
  589. expect(page.records.map(entry => entry.event.seq))
  590. .toEqual([3, 4, 5, replacement.seq])
  591. expect(page.hasMore).toBe(true)
  592. const before = await transport.page({
  593. address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1,
  594. }, signal())
  595. expect(before.records.map(entry => entry.event.seq)).toEqual([2])
  596. })
  597. it('keeps cited source events in the page that owns their appended message', async () => {
  598. const { ctx, transport } = await setup()
  599. const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } })
  600. const source = append(session, 'fixture/source', {})
  601. append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, {
  602. surfaceOp: 'append', sourceEventSeqs: [source.seq],
  603. })
  604. const page = await transport.page({
  605. address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1,
  606. }, signal())
  607. expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1])
  608. expect(page.hasMore).toBe(false)
  609. })
  610. })