transport.host.spec.ts 30 KB

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