transport.host.spec.ts 28 KB

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