transport.host.spec.ts 28 KB

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