transport.host.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  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 { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
  6. import { describe, expect, it, vi } from 'vitest'
  7. import { SessionHistoryController } from '../src/history.ts'
  8. const signal = (): AbortSignal => new AbortController().signal
  9. function append(
  10. session: Session,
  11. type: string,
  12. data: unknown,
  13. options?: { readonly surfaceOp?: unknown; readonly sourceEventSeqs?: readonly number[] },
  14. ): SessionEvent {
  15. return (session.append as unknown as (
  16. eventType: string,
  17. eventData: unknown,
  18. eventOptions?: unknown,
  19. ) => SessionEvent)(type, data, options)
  20. }
  21. function event(type: string, seq: number, data: unknown = {}): SessionEvent {
  22. return { type, seq, time: seq + 1, data } as SessionEvent
  23. }
  24. function cold(
  25. ctx: Context,
  26. header: SessionHeader,
  27. events: readonly SessionEvent[],
  28. ): void {
  29. ctx.provide('sessionPersistence', {
  30. list: () => Promise.resolve([header]),
  31. inspect: () => Promise.resolve({ meta: header, events }),
  32. } as never)
  33. }
  34. interface Deferred<T> {
  35. readonly promise: Promise<T>
  36. resolve(value: T): void
  37. }
  38. function deferred<T>(): Deferred<T> {
  39. let resolve!: (value: T) => void
  40. const promise = new Promise<T>((settle) => { resolve = settle })
  41. return { promise, resolve }
  42. }
  43. async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> {
  44. const ctx = new Context()
  45. await ctx.plugin(SessionStore)
  46. const transport = new SessionHistoryController(ctx)
  47. return { ctx, transport }
  48. }
  49. describe('SessionHistoryController', () => {
  50. it('opens at the current cursor and follows later events from an ordinary Session', async () => {
  51. const { ctx, transport } = await setup()
  52. const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } })
  53. session.append('turn/start', { turn: 1 })
  54. const abort = new AbortController()
  55. const iterator = transport.follow(
  56. { address: { kind: 'session', sessionId: session.id } },
  57. abort.signal,
  58. )[Symbol.asyncIterator]()
  59. expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'opened', cursor: 0 } })
  60. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  61. expect(await iterator.next()).toMatchObject({
  62. done: false,
  63. value: { type: 'event', event: { type: 'turn/end', seq: 1 } },
  64. })
  65. const page = await transport.page(
  66. { address: { kind: 'session', sessionId: session.id } },
  67. new AbortController().signal,
  68. )
  69. expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
  70. abort.abort()
  71. expect(await iterator.next()).toMatchObject({ done: true })
  72. })
  73. it('resumes from the last applied seq before delivering later live events', async () => {
  74. const { ctx, transport } = await setup()
  75. const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } })
  76. session.append('turn/start', { turn: 1 })
  77. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  78. session.append('turn/start', { turn: 2 })
  79. const abort = new AbortController()
  80. const iterator = transport.follow({
  81. address: { kind: 'session', sessionId: session.id },
  82. afterSeq: 0,
  83. }, abort.signal)[Symbol.asyncIterator]()
  84. expect(await iterator.next()).toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
  85. expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 1 } } })
  86. expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 2 } } })
  87. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  88. expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } })
  89. abort.abort()
  90. expect(await iterator.next()).toMatchObject({ done: true })
  91. })
  92. it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => {
  93. const { ctx, transport } = await setup()
  94. const sessionId = SessionId('cold-race')
  95. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  96. const listed = deferred<readonly SessionHeader[]>()
  97. ctx.provide('sessionPersistence', {
  98. list: () => listed.promise,
  99. inspect: () => Promise.resolve({ meta: header, events: [event('fixture/start', 0)] }),
  100. } as never)
  101. const abort = new AbortController()
  102. const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
  103. [Symbol.asyncIterator]()
  104. const opening = iterator.next()
  105. ctx.emit('session/event', { id: SessionId('unrelated') } as Session, event('fixture/other', 0))
  106. ctx.emit('session/event', { id: sessionId } as Session, event('fixture/start', 0))
  107. listed.resolve([header])
  108. await expect(opening).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
  109. const waiting = iterator.next()
  110. abort.abort()
  111. await expect(waiting).resolves.toMatchObject({ done: true })
  112. })
  113. it('bridges the unpublished end-seed boundary when a cold source attaches', async () => {
  114. const ctx = new Context()
  115. await ctx.plugin(SessionStore)
  116. let transport!: SessionHistoryController
  117. let agentCtx!: Context
  118. await ctx.plugin(Object.assign(
  119. (inner: Context) => { transport = new SessionHistoryController(inner) },
  120. { inject: ['sessions'] },
  121. ))
  122. await ctx.plugin(Object.assign(
  123. (inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx },
  124. { inject: ['sessions'] },
  125. ))
  126. const sessionId = SessionId('cold-attach')
  127. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  128. const seed = [event('fixture/start', 0)]
  129. cold(ctx, header, seed)
  130. agentCtx.on('session/created', (session) => {
  131. if (session.id !== sessionId) return
  132. append(session, 'fixture/setup-one', {})
  133. append(session, 'fixture/setup-two', {})
  134. })
  135. const abort = new AbortController()
  136. const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
  137. [Symbol.asyncIterator]()
  138. await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
  139. agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } })
  140. const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed })
  141. agentCtx.sessions.enter(attached)
  142. agentCtx.sessions.announce(attached)
  143. await expect(iterator.next()).resolves.toMatchObject({
  144. done: false,
  145. value: { type: 'event', event: { type: 'session/end-seed', seq: 1 } },
  146. })
  147. await expect(iterator.next()).resolves.toMatchObject({
  148. done: false,
  149. value: { type: 'event', event: { type: 'fixture/setup-one', seq: 2 } },
  150. })
  151. await expect(iterator.next()).resolves.toMatchObject({
  152. done: false,
  153. value: { type: 'event', event: { type: 'fixture/setup-two', seq: 3 } },
  154. })
  155. append(attached, 'fixture/live', {})
  156. await expect(iterator.next()).resolves.toMatchObject({
  157. done: false,
  158. value: { type: 'event', event: { type: 'fixture/live', seq: 4 } },
  159. })
  160. abort.abort()
  161. await expect(iterator.next()).resolves.toMatchObject({ done: true })
  162. })
  163. it('rejects gaps in replayed and live event sequences', async () => {
  164. const replay = await setup()
  165. const replayId = SessionId('replay-gap')
  166. const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' }
  167. cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)])
  168. const replayed = replay.transport.follow({
  169. address: { kind: 'session', sessionId: replayId }, afterSeq: -1,
  170. }, signal())[Symbol.asyncIterator]()
  171. await expect(replayed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
  172. await expect(replayed.next()).resolves.toMatchObject({ done: false, value: { event: { seq: 0 } } })
  173. await expect(replayed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
  174. const live = await setup()
  175. const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } })
  176. append(session, 'fixture/start', {})
  177. live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never)
  178. const followed = live.transport.follow({
  179. address: { kind: 'session', sessionId: session.id },
  180. }, signal())[Symbol.asyncIterator]()
  181. await expect(followed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
  182. live.ctx.emit('session/event', session, event('fixture/gap', 2))
  183. await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
  184. })
  185. it('opens an empty source at cursor -1', async () => {
  186. const { ctx, transport } = await setup()
  187. const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } })
  188. const abort = new AbortController()
  189. const iterator = transport.follow({
  190. address: { kind: 'session', sessionId: session.id },
  191. }, abort.signal)[Symbol.asyncIterator]()
  192. await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: -1 } })
  193. abort.abort()
  194. await expect(iterator.next()).resolves.toMatchObject({ done: true })
  195. })
  196. it('requires the durable parent and mode for a direct subagent address', async () => {
  197. const { ctx, transport } = await setup()
  198. const parentSessionId = SessionId('parent')
  199. const childSessionId = SessionId('child')
  200. ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } })
  201. const child = ctx.sessions.create(childSessionId, {
  202. meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId },
  203. })
  204. child.append('subagent/descriptor', snapshotSubagentDescriptor({
  205. mode: 'continuable',
  206. provider: 'test',
  207. label: 'child',
  208. }))
  209. const signal = new AbortController().signal
  210. await expect(transport.page({
  211. address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
  212. }, signal)).resolves.toMatchObject({ events: [{ event: { type: 'subagent/descriptor' } }] })
  213. await expect(transport.page({
  214. address: {
  215. kind: 'subagent',
  216. parentSessionId: SessionId('other-parent'),
  217. childSessionId,
  218. mode: 'continuable',
  219. },
  220. }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
  221. await expect(transport.page({
  222. address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' },
  223. }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
  224. await expect(transport.page({
  225. address: { kind: 'session', sessionId: childSessionId },
  226. }, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
  227. })
  228. it('preserves a cold inspection failure for the Gateway error branch', async () => {
  229. const { ctx, transport } = await setup()
  230. const sessionId = SessionId('corrupt-cold')
  231. const failure = new Error('cold log is corrupt')
  232. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  233. ctx.provide('sessionPersistence', {
  234. list: () => Promise.resolve([header]),
  235. inspect: () => Promise.reject(failure),
  236. } as never)
  237. await expect(transport.page({
  238. address: { kind: 'session', sessionId },
  239. }, new AbortController().signal)).rejects.toBe(failure)
  240. })
  241. it('rejects malformed page and follow cursors at the service boundary', async () => {
  242. const { ctx, transport } = await setup()
  243. const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } })
  244. const address = { kind: 'session' as const, sessionId: session.id }
  245. for (const request of [
  246. { address, beforeSeq: -1 },
  247. { address, beforeSeq: 1.5 },
  248. { address, maxMessages: 0 },
  249. { address, maxMessages: 1.5 },
  250. ]) {
  251. await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } })
  252. }
  253. for (const afterSeq of [-2, 0.5]) {
  254. const iterator = transport.follow({ address, afterSeq }, signal())[Symbol.asyncIterator]()
  255. await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
  256. }
  257. const past = transport.follow({ address, afterSeq: 0 }, signal())[Symbol.asyncIterator]()
  258. await expect(past.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
  259. })
  260. it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => {
  261. const { ctx, transport } = await setup()
  262. const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') }
  263. await expect(transport.page({ address: ordinary }, signal()))
  264. .rejects.toMatchObject({ failure: { code: 'internal' } })
  265. ctx.provide('sessionPersistence', {
  266. list: () => Promise.resolve([]),
  267. inspect: () => Promise.reject(new Error('must not inspect')),
  268. } as never)
  269. await expect(transport.page({ address: ordinary }, signal()))
  270. .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
  271. await expect(transport.page({
  272. address: {
  273. kind: 'subagent',
  274. parentSessionId: SessionId('parent'),
  275. childSessionId: SessionId('missing-child'),
  276. mode: 'continuable',
  277. },
  278. }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
  279. })
  280. it('rejects incomplete cold metadata before serving a source', async () => {
  281. const first = await setup()
  282. const sessionId = SessionId('incomplete')
  283. const address = { kind: 'session' as const, sessionId }
  284. first.ctx.provide('sessionPersistence', {
  285. list: () => Promise.resolve([{ version: 0, id: sessionId, createdAt: 1 }]),
  286. inspect: () => Promise.reject(new Error('must not inspect')),
  287. } as never)
  288. await expect(first.transport.page({ address }, signal()))
  289. .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
  290. const second = await setup()
  291. const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  292. second.ctx.provide('sessionPersistence', {
  293. list: () => Promise.resolve([listed]),
  294. inspect: () => Promise.resolve({ meta: { ...listed, cwd: undefined }, events: [] }),
  295. } as never)
  296. await expect(second.transport.page({ address }, signal()))
  297. .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
  298. })
  299. it('serves cold ordinary history and validates every durable subagent descriptor state', async () => {
  300. const ordinaryBench = await setup()
  301. const ordinaryId = SessionId('cold-ordinary')
  302. const ordinaryHeader = { version: 0, id: ordinaryId, createdAt: 1, cwd: '/workspace' }
  303. cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', 0, { turn: 1 })])
  304. await expect(ordinaryBench.transport.page({
  305. address: { kind: 'session', sessionId: ordinaryId },
  306. }, signal())).resolves.toMatchObject({ events: [{ event: { seq: 0 } }] })
  307. const parentSessionId = SessionId('cold-parent')
  308. const childSessionId = SessionId('cold-child')
  309. const childHeader = {
  310. version: 0,
  311. id: childSessionId,
  312. createdAt: 1,
  313. cwd: '/workspace',
  314. origin: 'subagent' as const,
  315. parentSession: parentSessionId,
  316. }
  317. const childAddress = {
  318. kind: 'subagent' as const,
  319. parentSessionId,
  320. childSessionId,
  321. mode: 'continuable' as const,
  322. }
  323. const missing = await setup()
  324. cold(missing.ctx, childHeader, [])
  325. await expect(missing.transport.page({ address: childAddress }, signal()))
  326. .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } } })
  327. const corrupt = await setup()
  328. cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
  329. await expect(corrupt.transport.page({ address: childAddress }, signal()))
  330. .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
  331. const ordinaryChild = await setup()
  332. const { origin: _origin, ...ordinaryChildHeader } = childHeader
  333. cold(ordinaryChild.ctx, ordinaryChildHeader, [])
  334. await expect(ordinaryChild.transport.page({ address: childAddress }, signal()))
  335. .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
  336. })
  337. it('uses attached and detached projection cuts and isolates a child projection failure', async () => {
  338. const attached = await setup()
  339. const session = attached.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
  340. session.append('turn/start', { turn: 1 })
  341. const snapshot = vi.fn(() => ({ asOfSeq: 0, values: { title: 'attached' } }))
  342. attached.ctx.provide('sessionProjections', { snapshot, restore: vi.fn() } as never)
  343. await expect(attached.transport.page({
  344. address: { kind: 'session', sessionId: session.id },
  345. }, signal())).resolves.toMatchObject({ projections: { asOfSeq: 0, values: { title: 'attached' } } })
  346. expect(snapshot).toHaveBeenCalledWith(session)
  347. const older = await attached.transport.page({
  348. address: { kind: 'session', sessionId: session.id }, beforeSeq: 1,
  349. }, signal())
  350. expect('projections' in older).toBe(false)
  351. const detached = await setup()
  352. const coldId = SessionId('projected-cold')
  353. const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace' }
  354. cold(detached.ctx, header, [event('turn/start', 0, { turn: 1 })])
  355. const restore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'cold' } } }))
  356. detached.ctx.provide('sessionProjections', { snapshot: vi.fn(), restore } as never)
  357. await expect(detached.transport.page({
  358. address: { kind: 'session', sessionId: coldId },
  359. }, signal())).resolves.toMatchObject({ projections: { values: { title: 'cold' } } })
  360. expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0)
  361. const failed = await setup()
  362. cold(failed.ctx, header, [event('turn/start', 0, { turn: 1 })])
  363. failed.ctx.provide('sessionProjections', {
  364. snapshot: vi.fn(),
  365. restore: () => { throw new Error('projection failed') },
  366. } as never)
  367. await expect(failed.transport.page({
  368. address: { kind: 'session', sessionId: coldId },
  369. }, signal())).rejects.toThrow('projection failed')
  370. const child = await setup()
  371. const parentSessionId = SessionId('projection-parent')
  372. const childSessionId = SessionId('projection-child')
  373. const childSession = child.ctx.sessions.create(childSessionId, {
  374. meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId },
  375. })
  376. childSession.append('subagent/descriptor', snapshotSubagentDescriptor({
  377. mode: 'continuable', provider: 'test', label: 'child',
  378. }))
  379. const warn = vi.spyOn(child.ctx.logger, 'warn').mockImplementation(() => undefined)
  380. child.ctx.provide('sessionProjections', {
  381. snapshot: () => { throw new Error('child projection failed') },
  382. restore: vi.fn(),
  383. } as never)
  384. const page = await child.transport.page({
  385. address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
  386. }, signal())
  387. expect('projections' in page).toBe(false)
  388. expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
  389. })
  390. it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => {
  391. const live = await setup()
  392. const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } })
  393. const liveAgent = { id: liveSession.id }
  394. const preset = vi.fn(() => Promise.resolve('preset-scope'))
  395. live.ctx.provide('agents', { get: () => liveAgent } as never)
  396. live.ctx.provide('agentPresets', { standingKeyFor: preset } as never)
  397. await live.transport.page({ address: { kind: 'session', sessionId: liveSession.id } }, signal())
  398. expect(preset).not.toHaveBeenCalled()
  399. const attached = await setup()
  400. const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), {
  401. meta: { cwd: '/workspace', agentPreset: 'minimal' },
  402. })
  403. const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope'))
  404. attached.ctx.provide('agentPresets', { standingKeyFor } as never)
  405. await attached.transport.page({
  406. address: { kind: 'session', sessionId: attachedSession.id },
  407. }, signal())
  408. expect(standingKeyFor).toHaveBeenCalledWith('minimal')
  409. const detached = await setup()
  410. const detachedId = SessionId('detached-scope')
  411. const header = {
  412. version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
  413. }
  414. cold(detached.ctx, header, [])
  415. const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable')))
  416. detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never)
  417. await expect(detached.transport.page({
  418. address: { kind: 'session', sessionId: detachedId },
  419. }, signal())).resolves.toMatchObject({ events: [] })
  420. expect(rejected).toHaveBeenCalledWith('standard')
  421. const switched = await setup()
  422. const switchedId = SessionId('switched-scope')
  423. const switchedHeader = {
  424. version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
  425. }
  426. cold(switched.ctx, switchedHeader, [
  427. event('agent-preset/selected', 0, { agentPreset: 'minimal' }),
  428. ])
  429. const switchedKey = vi.fn(() => Promise.resolve('switched-scope'))
  430. switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never)
  431. await switched.transport.page({ address: { kind: 'session', sessionId: switchedId } }, signal())
  432. expect(switchedKey).toHaveBeenCalledWith('minimal')
  433. })
  434. it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
  435. const { ctx, transport } = await setup()
  436. const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } })
  437. session.append('turn/start', { turn: 1 })
  438. append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
  439. const firstReply = append(session, 'assistant/message', { turn: 1, step: 1, message: {} }, { surfaceOp: 'append' })
  440. append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
  441. append(session, 'assistant/message', { turn: 1, step: 2, message: {} }, { surfaceOp: 'append' })
  442. const summary = append(session, 'fixture/summary', {})
  443. const replacement = append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, {
  444. surfaceOp: { op: 'replace', start: 1, end: 4 },
  445. sourceEventSeqs: [1, firstReply.seq, 3, 4, summary.seq],
  446. })
  447. const page = await transport.page({
  448. address: { kind: 'session', sessionId: session.id }, maxMessages: 2,
  449. }, signal())
  450. expect(page.events.map(entry => entry.event.seq)).toEqual([3, 4, 5, replacement.seq])
  451. expect(page.hasMore).toBe(true)
  452. const before = await transport.page({
  453. address: { kind: 'session', sessionId: session.id }, beforeSeq: 3, maxMessages: 1,
  454. }, signal())
  455. expect(before.events.map(entry => entry.event.seq)).toEqual([2])
  456. })
  457. it('keeps cited source events in the page that owns their appended message', async () => {
  458. const { ctx, transport } = await setup()
  459. const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } })
  460. const source = append(session, 'fixture/source', {})
  461. append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, {
  462. surfaceOp: 'append', sourceEventSeqs: [source.seq],
  463. })
  464. const page = await transport.page({
  465. address: { kind: 'session', sessionId: session.id }, maxMessages: 1,
  466. }, signal())
  467. expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
  468. expect(page.hasMore).toBe(false)
  469. })
  470. it('projects tool call and result views and contains malformed presenters', async () => {
  471. const { ctx, transport } = await setup()
  472. const sessionId = SessionId('presenters')
  473. const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  474. const events = [
  475. event('fixture/start', 0),
  476. event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }),
  477. event('tool/result', 2, {
  478. message: {
  479. source: { callId: 'c1' },
  480. content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }],
  481. },
  482. meta: { persisted: true },
  483. }),
  484. event('tool/result', 3, {
  485. message: {
  486. source: { callId: 'missing' },
  487. content: [{ content: [{ type: 'text', text: 'missing' }] }],
  488. },
  489. }),
  490. event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }),
  491. event('tool/result', 5, {
  492. message: {
  493. source: { callId: 'c2' },
  494. content: [{ content: [{ type: 'text', text: 'bad args' }] }],
  495. },
  496. }),
  497. event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }),
  498. event('tool/result', 7, {
  499. message: {
  500. source: { callId: 'c3' },
  501. content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }],
  502. },
  503. }),
  504. event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }),
  505. event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }),
  506. event('tool/result', 10, {
  507. message: {
  508. source: { callId: 'c5' },
  509. content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }],
  510. },
  511. }),
  512. ]
  513. cold(ctx, header, events)
  514. ctx.provide('tools', {
  515. get: (name: string) => {
  516. if (name === 'present') {
  517. return {
  518. presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }),
  519. presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }),
  520. }
  521. }
  522. if (name === 'empty') return {}
  523. if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } }
  524. if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } }
  525. return undefined
  526. },
  527. } as never)
  528. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  529. const page = await transport.page({ address: { kind: 'session', sessionId } }, signal())
  530. expect(page.events[1]?.view).toEqual({
  531. for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } },
  532. })
  533. expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } })
  534. for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) {
  535. expect(page.events[index]).not.toHaveProperty('view')
  536. }
  537. expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed'))
  538. expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed'))
  539. })
  540. })