session-cold.host.spec.ts 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060
  1. /**
  2. * Cold-session and degenerate-composition paths of the Session Controller:
  3. * metadata-only listing, Agent-free history reads, subagent ownership
  4. * isolation, and prompt failure mapping.
  5. */
  6. import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
  7. import { describe, expect, it, vi } from 'vitest'
  8. import { Context } from '@deepseek-ai/cordis'
  9. import SessionStore from '@deepseek-ai/dsh-session'
  10. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  11. import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
  12. import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
  13. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  14. import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
  15. import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
  16. import type { Agent } from '@deepseek-ai/dsh-agent'
  17. import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  18. import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
  19. import {
  20. SessionPersistenceRevision,
  21. type SessionPersistenceSnapshot,
  22. } from '@deepseek-ai/dsh-session-persistence'
  23. import { ApiSessionList } from '../src/list.ts'
  24. import {
  25. createSessionTestRemote,
  26. installSessionReadTestServices,
  27. testSessionPersistence,
  28. } from './test-remote.ts'
  29. const sid = (id: string): SessionId => id as SessionId
  30. function request<P>(payload: P): P {
  31. return payload
  32. }
  33. let nextRequestId = 1
  34. function promptRequest(
  35. payload: Omit<SessionPromptRequest, 'requestId'>,
  36. ): SessionPromptRequest {
  37. return {
  38. ...payload,
  39. requestId: `cold-${String(nextRequestId++)}` as SessionRequestId,
  40. }
  41. }
  42. function inboxFor(session: Session): Inbox {
  43. return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  44. }
  45. function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
  46. return { version: 0, id: sid(id), createdAt, isSeeded: false, cwd: '/proj', ...extra }
  47. }
  48. function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
  49. return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
  50. }
  51. function statSnapshot(
  52. meta: SessionHeader,
  53. metrics: Partial<Pick<SessionPersistenceSnapshot, 'eventCount' | 'sizeBytes'>> = {},
  54. ): SessionPersistenceSnapshot {
  55. return { header: meta, revision: SessionPersistenceRevision(`test:${meta.id}:stat`), ...metrics }
  56. }
  57. /** A stored log whose only event is the seed boundary: still blank. */
  58. function blankEvents(): SessionEvent[] {
  59. return [{ type: 'session/end-seed', seq: SessionSeq(0), time: 700, data: {} }] as SessionEvent[]
  60. }
  61. /** A stored log with one human prompt at time 1200: proven non-blank. */
  62. function conversationEvents(): SessionEvent[] {
  63. return [
  64. { type: 'turn/start', seq: SessionSeq(0), time: 800, data: { turn: 1 } },
  65. {
  66. type: 'user/message', seq: SessionSeq(1), time: 1200,
  67. data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
  68. surfaceOp: 'append',
  69. },
  70. ] as SessionEvent[]
  71. }
  72. describe('sessions.list cold merge', () => {
  73. it('serves cold rows from cached projections when stat offers no size metadata', async () => {
  74. const ctx = new Context()
  75. await ctx.plugin(SessionStore)
  76. const metas = [
  77. header('cached-blank', 100),
  78. header('cached-conversation', 200),
  79. header('uncached', 300, { parentSession: sid('session-parent'), origin: 'subagent' }),
  80. header('seeded-cold', 450, { isSeeded: true }),
  81. { version: 0, id: sid('missing-cwd'), createdAt: 800, isSeeded: false },
  82. ]
  83. const inspect = vi.fn()
  84. providePersistence(ctx, {
  85. list: () => Promise.resolve(metas),
  86. inspect,
  87. })
  88. const cacheCalls: string[] = []
  89. ctx.provide('sessionProjectionCache', {
  90. cachedSnapshot: (meta: SessionHeader) => {
  91. cacheCalls.push(String(meta.id))
  92. if (meta.id === sid('cached-blank')) {
  93. return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
  94. }
  95. if (meta.id === sid('cached-conversation')) {
  96. return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } }
  97. }
  98. return undefined
  99. },
  100. } as never)
  101. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  102. const response = await remote.list(request({}))
  103. expect(response.ok).toBe(true)
  104. if (!response.ok) throw new Error('unreachable')
  105. const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item]))
  106. expect(byId['cached-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
  107. expect(byId['cached-conversation']).toMatchObject({ blank: false, updatedAt: 1000 })
  108. // A cache miss with a metadata-less stat leaves blankness unknown; the row stays visible.
  109. expect(byId['uncached']).toMatchObject({
  110. blank: false,
  111. updatedAt: 300,
  112. parentSessionId: 'session-parent',
  113. origin: 'subagent',
  114. })
  115. expect(byId['missing-cwd']).toBeUndefined()
  116. // A cold seeded header never consults the cache: its cut is not 0, so a
  117. // cut-0 lookup would alias a different projection identity.
  118. expect(byId['seeded-cold']).toMatchObject({ blank: false, updatedAt: 450 })
  119. expect(cacheCalls).not.toContain('seeded-cold')
  120. expect(inspect).not.toHaveBeenCalled()
  121. })
  122. it('fully observes only small possibly-blank logs gated by stat eventCount', async () => {
  123. const ctx = new Context()
  124. await ctx.plugin(SessionStore)
  125. const metas = [
  126. header('small-blank', 100),
  127. header('small-conversation', 200),
  128. header('large-unknown', 300),
  129. header('cached-nonblank', 400),
  130. header('vanished', 600),
  131. { version: 0, id: sid('missing-cwd'), createdAt: 800, isSeeded: false },
  132. ]
  133. const inspect = vi.fn(async (id: SessionId) => {
  134. if (id === sid('small-blank')) return { meta: metas[0]!, events: blankEvents() }
  135. if (id === sid('small-conversation')) return { meta: metas[1]!, events: conversationEvents() }
  136. throw new Error(`unexpected cold read: ${id}`)
  137. })
  138. const stat = vi.fn(async (id: SessionId) => {
  139. if (id === sid('small-blank')) return statSnapshot(metas[0]!, { eventCount: 1 })
  140. if (id === sid('small-conversation')) return statSnapshot(metas[1]!, { eventCount: 2 })
  141. if (id === sid('large-unknown')) return statSnapshot(metas[2]!, { eventCount: 17 })
  142. if (id === sid('vanished')) return undefined
  143. throw new Error(`unexpected stat: ${id}`)
  144. })
  145. providePersistence(ctx, {
  146. list: () => Promise.resolve(metas),
  147. stat,
  148. inspect,
  149. })
  150. ctx.provide('sessionProjectionCache', {
  151. cachedSnapshot: (meta: SessionHeader) => {
  152. if (meta.id === sid('small-blank')) {
  153. return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
  154. }
  155. if (meta.id === sid('small-conversation')) {
  156. return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } }
  157. }
  158. if (meta.id === sid('cached-nonblank')) {
  159. return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } }
  160. }
  161. return undefined
  162. },
  163. hydratePrepared: (session: Session, events: readonly SessionEvent[]) =>
  164. ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)),
  165. } as never)
  166. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  167. const response = await remote.list(request({}))
  168. expect(response.ok).toBe(true)
  169. if (!response.ok) throw new Error('unreachable')
  170. const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item]))
  171. expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
  172. expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 })
  173. expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
  174. expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
  175. expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 })
  176. expect(byId['missing-cwd']).toBeUndefined()
  177. // A cache row proving blank:false is never re-probed.
  178. expect(stat.mock.calls.map(([id]) => id)).not.toContain(sid('cached-nonblank'))
  179. expect(inspect).toHaveBeenCalledTimes(2)
  180. expect(inspect.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
  181. sid('small-blank'),
  182. sid('small-conversation'),
  183. ]))
  184. })
  185. it('falls back to the stat sizeBytes gate when no eventCount is offered', async () => {
  186. const ctx = new Context()
  187. await ctx.plugin(SessionStore)
  188. const metas = [header('small-jsonl', 100), header('large-jsonl', 200)]
  189. const inspect = vi.fn(async (id: SessionId) => {
  190. if (id === sid('small-jsonl')) return { meta: metas[0]!, events: conversationEvents() }
  191. throw new Error(`unexpected cold read: ${id}`)
  192. })
  193. providePersistence(ctx, {
  194. list: () => Promise.resolve(metas),
  195. stat: (id: SessionId) => Promise.resolve(id === sid('small-jsonl')
  196. ? statSnapshot(metas[0]!, { sizeBytes: 1024 })
  197. : statSnapshot(metas[1]!, { sizeBytes: 1025 })),
  198. inspect,
  199. })
  200. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  201. const response = await remote.list(request({}))
  202. if (!response.ok) throw new Error('list failed')
  203. const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item]))
  204. expect(byId['small-jsonl']).toMatchObject({ blank: false, updatedAt: 1200 })
  205. expect(byId['large-jsonl']).toMatchObject({ blank: false, updatedAt: 200 })
  206. expect(inspect).toHaveBeenCalledTimes(1)
  207. expect(inspect).toHaveBeenCalledWith(sid('small-jsonl'), expect.anything())
  208. })
  209. it('skips the observation when stat offers neither eventCount nor sizeBytes', async () => {
  210. const ctx = new Context()
  211. await ctx.plugin(SessionStore)
  212. const meta = header('no-metrics', 100)
  213. const inspect = vi.fn()
  214. const stat = vi.fn(async () => statSnapshot(meta))
  215. providePersistence(ctx, {
  216. list: () => Promise.resolve([meta]),
  217. stat,
  218. inspect,
  219. })
  220. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  221. const response = await remote.list(request({}))
  222. if (!response.ok) throw new Error('list failed')
  223. expect(response.value.items).toEqual([
  224. expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
  225. ])
  226. expect(stat).toHaveBeenCalledOnce()
  227. expect(inspect).not.toHaveBeenCalled()
  228. })
  229. it('can disable both probe gates without hiding cold Sessions or calling stat', async () => {
  230. const ctx = new Context()
  231. await ctx.plugin(SessionStore)
  232. const meta = header('probe-disabled', 100)
  233. const inspect = vi.fn()
  234. const stat = vi.fn()
  235. providePersistence(ctx, {
  236. list: () => Promise.resolve([meta]),
  237. stat,
  238. inspect,
  239. })
  240. const remote = createSessionTestRemote(ctx, {
  241. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  242. cwd: '/tmp',
  243. coldBlankProbeMaxEvents: 0,
  244. coldBlankProbeMaxBytes: 0,
  245. })
  246. const response = await remote.list(request({}))
  247. if (!response.ok) throw new Error('unreachable')
  248. expect(response.value.items).toEqual([
  249. expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
  250. ])
  251. expect(stat).not.toHaveBeenCalled()
  252. expect(inspect).not.toHaveBeenCalled()
  253. })
  254. it('treats zero as disabling one gate without falling back to the other metric', async () => {
  255. const bench = async (
  256. metrics: Partial<Pick<SessionPersistenceSnapshot, 'eventCount' | 'sizeBytes'>>,
  257. thresholds: { coldBlankProbeMaxEvents?: number; coldBlankProbeMaxBytes?: number },
  258. ) => {
  259. const ctx = new Context()
  260. await ctx.plugin(SessionStore)
  261. const meta = header('gate-off', 100)
  262. const inspect = vi.fn()
  263. providePersistence(ctx, {
  264. list: () => Promise.resolve([meta]),
  265. stat: () => Promise.resolve(statSnapshot(meta, metrics)),
  266. inspect,
  267. })
  268. const remote = createSessionTestRemote(ctx, {
  269. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  270. cwd: '/tmp',
  271. ...thresholds,
  272. })
  273. const response = await remote.list(request({}))
  274. if (!response.ok) throw new Error('list failed')
  275. expect(response.value.items).toEqual([
  276. expect.objectContaining({ sessionId: meta.id, blank: false }),
  277. ])
  278. expect(inspect).not.toHaveBeenCalled()
  279. }
  280. // An offered eventCount never falls through to the byte gate, even disabled.
  281. await bench({ eventCount: 1, sizeBytes: 10 }, { coldBlankProbeMaxEvents: 0 })
  282. await bench({ sizeBytes: 10 }, { coldBlankProbeMaxBytes: 0 })
  283. })
  284. it('prefers a Session that attaches during its bounded cold observation', async () => {
  285. const ctx = new Context()
  286. await ctx.plugin(SessionStore)
  287. await ctx.plugin(AgentRegistry)
  288. const meta = header('attached-during-probe', 100)
  289. providePersistence(ctx, {
  290. list: () => Promise.resolve([meta]),
  291. stat: () => {
  292. const session = ctx.sessions.create(meta.id, {
  293. meta,
  294. seed: [{ type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } }],
  295. })
  296. ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
  297. return Promise.resolve(statSnapshot(meta, { eventCount: 1 }))
  298. },
  299. })
  300. const remote = createSessionTestRemote(ctx, {
  301. defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
  302. })
  303. const response = await remote.list(request({}))
  304. if (!response.ok) throw new Error('list failed')
  305. expect(response.value.items).toEqual([
  306. expect.objectContaining({ sessionId: meta.id, running: true, blank: false }),
  307. ])
  308. await ctx.fiber.dispose()
  309. })
  310. it('serves a session whose cold stat fails as visible instead of failing the list', async () => {
  311. const ctx = new Context()
  312. await ctx.plugin(SessionStore)
  313. const meta = header('broken-stat', 100)
  314. providePersistence(ctx, {
  315. list: () => Promise.resolve([meta]),
  316. stat: () => Promise.reject(new Error('stat failed')),
  317. })
  318. const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  319. const remote = createSessionTestRemote(ctx, {
  320. defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
  321. })
  322. const response = await remote.list(request({}))
  323. if (!response.ok) throw new Error('list failed')
  324. expect(response.value.items).toEqual([
  325. expect.objectContaining({ sessionId: meta.id, running: false }),
  326. ])
  327. expect(warned.mock.calls.join('\n')).toContain('cold stat for "broken-stat" failed')
  328. warned.mockRestore()
  329. await ctx.fiber.dispose()
  330. })
  331. it('a stat rejection after cancellation propagates instead of degrading', async () => {
  332. const ctx = new Context()
  333. await ctx.plugin(SessionStore)
  334. const meta = header('stat-abort', 100)
  335. const controller = new AbortController()
  336. providePersistence(ctx, {
  337. list: () => Promise.resolve([meta]),
  338. stat: () => {
  339. controller.abort(new Error('caller left'))
  340. return Promise.reject(new Error('stat failed'))
  341. },
  342. })
  343. const remote = createSessionTestRemote(ctx, {
  344. defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
  345. })
  346. await expect(remote.list(request({}), controller.signal)).resolves.toMatchObject({
  347. ok: false,
  348. error: { code: 'gateway/cancelled' },
  349. })
  350. await ctx.fiber.dispose()
  351. })
  352. it('serves a small cold Session as visible when its observation fails', async () => {
  353. const ctx = new Context()
  354. await ctx.plugin(SessionStore)
  355. const meta = header('read-failure', 700)
  356. const inspect = vi.fn(async () => { throw new Error('simulated read failure') })
  357. providePersistence(ctx, {
  358. list: () => Promise.resolve([meta]),
  359. stat: () => Promise.resolve(statSnapshot(meta, { eventCount: 1 })),
  360. inspect,
  361. })
  362. const remote = createSessionTestRemote(ctx, {
  363. defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
  364. })
  365. const response = await remote.list(request({}))
  366. if (!response.ok) throw new Error('list failed')
  367. expect(response.value.items).toEqual([
  368. expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: 700 }),
  369. ])
  370. expect(inspect).toHaveBeenCalledOnce()
  371. })
  372. it('supports an unsignalled probe whose observation has no projection block', async () => {
  373. const ctx = new Context()
  374. await ctx.plugin(SessionStore)
  375. await ctx.plugin(AgentRegistry)
  376. installSessionReadTestServices(ctx)
  377. const meta = header('unprojected-small', 100)
  378. ctx.provide('sessionPersistence', {
  379. list: () => Promise.resolve([meta]),
  380. stat: () => Promise.resolve(statSnapshot(meta, { eventCount: 0 })),
  381. } as never)
  382. vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{
  383. header: meta, live: false, persisted: true,
  384. }])
  385. vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({
  386. source: 'prepared', header: meta, inheritedEventCount: SessionLogOffset(0), events: [], cursor: -1,
  387. retain: vi.fn(), [Symbol.dispose]: vi.fn(),
  388. })
  389. const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 })
  390. await expect(list.list()).resolves.toEqual([
  391. expect.objectContaining({ sessionId: meta.id, blank: false }),
  392. ])
  393. await ctx.fiber.dispose()
  394. })
  395. it('serves a cold row visible when no persistence service can stat it', async () => {
  396. const ctx = new Context()
  397. await ctx.plugin(SessionStore)
  398. installSessionReadTestServices(ctx)
  399. const meta = header('service-less', 100)
  400. vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{
  401. header: meta, live: false, persisted: true,
  402. }])
  403. const list = new ApiSessionList(ctx, { coldBlankProbeMaxEvents: 16, coldBlankProbeMaxBytes: 1024 })
  404. await expect(list.list()).resolves.toEqual([
  405. expect.objectContaining({ sessionId: meta.id, blank: false }),
  406. ])
  407. await ctx.fiber.dispose()
  408. })
  409. it('prefers a live row attached during the query without folding its seed', async () => {
  410. const ctx = new Context()
  411. await ctx.plugin(SessionStore)
  412. await ctx.plugin(AgentRegistry)
  413. const meta = header('attached-during-list', 100)
  414. const started = Promise.withResolvers<undefined>()
  415. const release = Promise.withResolvers<undefined>()
  416. providePersistence(ctx, {
  417. list: async () => {
  418. started.resolve(undefined)
  419. await release.promise
  420. return [meta]
  421. },
  422. })
  423. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  424. const listing = remote.list(request({}))
  425. await started.promise
  426. const session = ctx.sessions.create(meta.id, {
  427. seed: [
  428. { type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } },
  429. {
  430. type: 'user/message', seq: SessionSeq(1), time: 300,
  431. data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }),
  432. surfaceOp: 'append',
  433. },
  434. ],
  435. meta: {
  436. ...meta.cwd === undefined ? {} : { cwd: meta.cwd },
  437. createdAt: meta.createdAt,
  438. },
  439. })
  440. ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
  441. release.resolve(undefined)
  442. const response = await listing
  443. if (!response.ok) throw new Error('list failed')
  444. expect(response.value.items).toEqual([
  445. expect.objectContaining({
  446. sessionId: meta.id,
  447. blank: false,
  448. running: true,
  449. updatedAt: 100,
  450. }),
  451. ])
  452. })
  453. })
  454. describe('attached updatedAt tracks human prompts', () => {
  455. it('ignores pickup and non-prompt work after the latest human message', async () => {
  456. const ctx = new Context()
  457. await ctx.plugin(SessionStore)
  458. await ctx.plugin(AgentRegistry)
  459. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  460. await new Promise(resolve => setTimeout(resolve, 0))
  461. // Old work, resumed just now: the log tail would report the pickup.
  462. const worked = 1_000_000
  463. const resumed = ctx.sessions.create(sid('resumed-untouched'), {
  464. seed: [
  465. { type: 'turn/start', seq: SessionSeq(0), time: worked, data: { turn: 1 } },
  466. {
  467. type: 'user/message', seq: SessionSeq(1), time: worked,
  468. data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
  469. surfaceOp: 'append',
  470. },
  471. { type: 'turn/end', seq: SessionSeq(2), time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } },
  472. ],
  473. meta: { cwd: '/proj', createdAt: 500 },
  474. })
  475. ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
  476. const boundary = resumed.snapshotEvents().at(-1)
  477. expect(boundary?.type).toBe('session/end-seed')
  478. expect(boundary?.time).toBeGreaterThan(worked)
  479. const listed = await remote.list(request({}))
  480. if (!listed.ok) throw new Error('list failed')
  481. const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched')
  482. expect(summary?.updatedAt).toBe(500)
  483. // A lifecycle boundary is not a human update.
  484. resumed.append('turn/start', { turn: 2 })
  485. const afterBoundary = await remote.list(request({}))
  486. if (!afterBoundary.ok) throw new Error('list failed')
  487. expect(afterBoundary.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt)
  488. .toBe(worked)
  489. const prompt = resumed.append('user/message', createUserMessage({
  490. content: [{ type: 'text', text: 'new prompt' }],
  491. source: { kind: 'user' },
  492. }), { surfaceOp: 'append' })
  493. const after = await remote.list(request({}))
  494. if (!after.ok) throw new Error('list failed')
  495. const moved = after.value.items.find(item => item.sessionId === 'resumed-untouched')
  496. expect(moved?.updatedAt).toBe(prompt.time)
  497. })
  498. })
  499. describe('cold history recovery view', () => {
  500. it('serves the stored interrupted prefix verbatim without activating the session', async () => {
  501. // Semantic crash repair is the resuming agent loop's job (it appends the
  502. // closers durably through its write handle); a cold history read shows the
  503. // stored prefix exactly as persisted.
  504. const ctx = new Context()
  505. await ctx.plugin(SessionStore)
  506. const sessionId = sid('session-interrupted')
  507. const meta = header(sessionId, 1000)
  508. const events = [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }] as SessionEvent[]
  509. providePersistence(ctx, {
  510. list: () => Promise.resolve([structuredClone(meta)]),
  511. inspect: () => Promise.resolve({ meta: structuredClone(meta), events: structuredClone(events) }),
  512. })
  513. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  514. const history = await remote.page({
  515. address: { kind: 'session', sessionId },
  516. throughSeq: 0,
  517. beforeSeq: 1,
  518. maxMessages: 10,
  519. })
  520. if (!history.ok) throw new Error('history failed')
  521. expect(history.value.records.map(record => record.event)).toMatchInlineSnapshot(`
  522. [
  523. {
  524. "data": {
  525. "turn": 1,
  526. },
  527. "seq": 0,
  528. "time": 1,
  529. "type": "turn/start",
  530. },
  531. ]
  532. `)
  533. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  534. await ctx.fiber.dispose()
  535. })
  536. })
  537. describe('Remote Agent and Session lookup policy', () => {
  538. it('deduplicates a cold resume across Agent and Session parameters', async () => {
  539. const ctx = new Context()
  540. await ctx.plugin(TypertRegistry)
  541. await ctx.plugin(SessionStore)
  542. await ctx.plugin(AgentRegistry)
  543. const sessionId = sid('session-remote-cold')
  544. const meta = header(sessionId, 1000)
  545. const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
  546. providePersistence(ctx, {
  547. list: () => Promise.resolve([meta]),
  548. inspect,
  549. })
  550. const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session
  551. const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
  552. const release = Promise.withResolvers<undefined>()
  553. const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
  554. await release.promise
  555. return { agent: resumedAgent, dispose: () => Promise.resolve() }
  556. })
  557. const defaultAgentLookup = ctx.typert.lookups.get('agent')
  558. const defaultSessionLookup = ctx.typert.lookups.get('session')
  559. createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  560. await vi.waitFor(() => {
  561. expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
  562. expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
  563. })
  564. const agentLookup = ctx.typert.lookups.get('agent')
  565. const sessionLookup = ctx.typert.lookups.get('session')
  566. if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
  567. const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId))
  568. const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId))
  569. await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() })
  570. release.resolve(undefined)
  571. await expect(resolvedAgent).resolves.toBe(resumedAgent)
  572. await expect(resolvedSession).resolves.toBe(resumedSession)
  573. expect(inspect).toHaveBeenCalledOnce()
  574. })
  575. it('preserves the subagent ownership fence for cold and live Remote lookups', async () => {
  576. const ctx = new Context()
  577. await ctx.plugin(TypertRegistry)
  578. await ctx.plugin(SessionStore)
  579. await ctx.plugin(AgentRegistry)
  580. const coldId = sid('session-remote-cold-child')
  581. const coldMeta = header(coldId, 1000, {
  582. parentSession: sid('session-parent'),
  583. origin: 'subagent',
  584. })
  585. const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
  586. providePersistence(ctx, {
  587. list: () => Promise.resolve([coldMeta]),
  588. inspect,
  589. })
  590. const liveSession = ctx.sessions.create(sid('session-remote-live-child'), {
  591. meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
  592. })
  593. const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent
  594. ctx.agents.register(liveAgent)
  595. const resume = vi.spyOn(ctx.agents, 'resume')
  596. const defaultAgentLookup = ctx.typert.lookups.get('agent')
  597. const defaultSessionLookup = ctx.typert.lookups.get('session')
  598. createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  599. await vi.waitFor(() => {
  600. expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
  601. expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
  602. })
  603. const agentLookup = ctx.typert.lookups.get('agent')
  604. const sessionLookup = ctx.typert.lookups.get('session')
  605. if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
  606. const ownershipFailure = {
  607. code: 'session/agent-busy',
  608. details: { reason: 'use subagent delivery for this child session' },
  609. }
  610. const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
  611. const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
  612. await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
  613. await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
  614. expect(resume).not.toHaveBeenCalled()
  615. expect(inspect).toHaveBeenCalledOnce()
  616. })
  617. })
  618. describe('subagent ownership fence', () => {
  619. it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
  620. const ctx = new Context()
  621. await ctx.plugin(SessionStore)
  622. await ctx.plugin(AgentRegistry)
  623. const sessionId = sid('session-child')
  624. const meta = header('session-child', 1000, {
  625. parentSession: sid('session-parent'),
  626. origin: 'subagent',
  627. })
  628. const events = [
  629. { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  630. {
  631. type: 'user/message',
  632. seq: SessionSeq(1),
  633. time: 2,
  634. data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
  635. surfaceOp: 'append',
  636. },
  637. {
  638. type: 'subagent/descriptor',
  639. seq: SessionSeq(2),
  640. time: 3,
  641. data: snapshotSubagentDescriptor({
  642. mode: 'continuable',
  643. provider: 'spawn',
  644. label: 'child',
  645. }),
  646. },
  647. { type: 'turn/end', seq: SessionSeq(3), time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
  648. ] as SessionEvent[]
  649. const inspect = vi.fn(() => Promise.resolve({ meta, events }))
  650. providePersistence(ctx, {
  651. list: () => Promise.resolve([meta]),
  652. inspect,
  653. })
  654. const resume = vi.spyOn(ctx.agents, 'resume')
  655. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  656. ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
  657. const history = await new SessionHistoryController(
  658. ctx,
  659. (observation) => { observation[Symbol.dispose]() },
  660. ).page({
  661. address: {
  662. kind: 'subagent',
  663. parentSessionId: meta.parentSession as SessionId,
  664. childSessionId: sessionId,
  665. mode: 'continuable',
  666. },
  667. throughSeq: 3,
  668. }, new AbortController().signal)
  669. expect(history.records.map(record => record.event.type))
  670. .toEqual(events.map(event => event.type))
  671. expect(ctx.agents.get(sessionId)).toBeUndefined()
  672. const prompt = await remote.prompt(promptRequest({
  673. sessionId,
  674. mode: 'queue',
  675. content: [{ type: 'text', text: 'follow up' }],
  676. }))
  677. expect(prompt.ok).toBe(false)
  678. if (!prompt.ok) {
  679. expect(prompt.error).toMatchObject({
  680. code: 'session/agent-busy',
  681. details: { reason: 'use subagent delivery for this child session' },
  682. })
  683. }
  684. const create = await remote.create(request({ sessionId, cwd: '/proj' }))
  685. expect(create.ok).toBe(false)
  686. if (!create.ok) expect(create.error.code).toBe('session/agent-busy')
  687. expect(resume).not.toHaveBeenCalled()
  688. expect(ctx.agents.get(sessionId)).toBeUndefined()
  689. // One log open serves all three cold reads: the observation cache reuses
  690. // the prepared Session while the stat revision is unchanged.
  691. expect(inspect).toHaveBeenCalledTimes(1)
  692. })
  693. it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => {
  694. const ctx = new Context()
  695. await ctx.plugin(SessionStore)
  696. await ctx.plugin(AgentRegistry)
  697. const sessionId = sid('session-legacy-child')
  698. const meta = header('session-legacy-child', 1000, {
  699. parentSession: sid('session-parent'),
  700. })
  701. const events = [
  702. {
  703. type: 'subagent/descriptor',
  704. seq: SessionSeq(0),
  705. time: 1,
  706. data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
  707. },
  708. ] as SessionEvent[]
  709. providePersistence(ctx, {
  710. list: () => Promise.resolve([meta]),
  711. inspect: () => Promise.resolve({ meta, events }) })
  712. // Stores whose headers predate `origin` classify a child only through the
  713. // descriptor event; the pre-release decision stops recognizing them, so
  714. // the ownership fence lets generic resume reach the registry instead of
  715. // answering `agent-busy`.
  716. const resume = vi.spyOn(ctx.agents, 'resume')
  717. .mockRejectedValue(new Error('registry unavailable in this bench'))
  718. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  719. const prompt = await remote.prompt(promptRequest({
  720. sessionId,
  721. mode: 'queue',
  722. content: [{ type: 'text', text: 'follow up' }],
  723. }))
  724. expect(resume).toHaveBeenCalledTimes(1)
  725. expect(prompt.ok).toBe(false)
  726. if (!prompt.ok) expect(prompt.error.code).toBe('gateway/internal')
  727. })
  728. it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
  729. const ctx = new Context()
  730. await ctx.plugin(SessionStore)
  731. await ctx.plugin(AgentRegistry)
  732. const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
  733. const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
  734. ctx.agents.register(parent)
  735. const originSession = ctx.sessions.create(sid('session-origin-child'), {
  736. meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
  737. })
  738. const cancel = vi.fn()
  739. const updateInbox = vi.fn(() => 'applied' as const)
  740. const originChild = {
  741. id: originSession.id,
  742. session: originSession,
  743. status: 'idle',
  744. ctx,
  745. cancel,
  746. updateInbox,
  747. } as unknown as Agent
  748. ctx.agents.register(originChild)
  749. const startingSession = ctx.sessions.create(sid('session-starting-child'), {
  750. meta: { cwd: '/proj', parentSession: parent.id },
  751. })
  752. const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
  753. ctx.agents.enter(startingChild, parent)
  754. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  755. const stopped = await remote.cancel(request({ sessionId: originChild.id }))
  756. expect(stopped.ok).toBe(false)
  757. if (!stopped.ok) expect(stopped.error.code).toBe('session/agent-busy')
  758. expect(cancel).not.toHaveBeenCalled()
  759. const queued = await remote.updateQueue(request({
  760. sessionId: originChild.id,
  761. itemId: MessageId('queued-item'),
  762. action: { kind: 'remove' },
  763. }))
  764. expect(queued.ok).toBe(false)
  765. if (!queued.ok) expect(queued.error.code).toBe('session/agent-busy')
  766. expect(updateInbox).not.toHaveBeenCalled()
  767. const selection = await remote.selectModel(request({
  768. sessionId: startingChild.id,
  769. provider: 'p',
  770. model: 'm',
  771. }))
  772. expect(selection.ok).toBe(false)
  773. if (!selection.ok) expect(selection.error.code).toBe('session/agent-busy')
  774. const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' }))
  775. expect(create.ok).toBe(false)
  776. if (!create.ok) expect(create.error.code).toBe('session/agent-busy')
  777. expect(ctx.agents.get(originChild.id)).toBe(originChild)
  778. })
  779. it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
  780. const ctx = new Context()
  781. await ctx.plugin(SessionStore)
  782. await ctx.plugin(AgentRegistry)
  783. const session = ctx.sessions.create(sid('session-ordinary-fork'), {
  784. seed: [{
  785. type: 'subagent/descriptor',
  786. seq: SessionSeq(0),
  787. time: 1,
  788. data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' },
  789. }],
  790. meta: { cwd: '/proj', parentSession: sid('session-source'), isSeeded: true },
  791. inheritedEventCount: SessionLogOffset(1),
  792. })
  793. const followup = vi.fn()
  794. const agent = {
  795. id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
  796. } as unknown as Agent
  797. ctx.agents.register(agent)
  798. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  799. const response = await remote.prompt(promptRequest({
  800. sessionId: agent.id,
  801. mode: 'queue',
  802. content: [{ type: 'text', text: 'ordinary work' }],
  803. }))
  804. expect(response.ok).toBe(true)
  805. expect(followup).toHaveBeenCalledOnce()
  806. })
  807. it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => {
  808. const ctx = new Context()
  809. await ctx.plugin(SessionStore)
  810. await ctx.plugin(AgentRegistry)
  811. const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
  812. const followup = vi.fn()
  813. const agent = {
  814. id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
  815. } as unknown as Agent
  816. ctx.agents.register(agent)
  817. const remote = createSessionTestRemote(ctx, {
  818. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  819. cwd: '/tmp',
  820. })
  821. const alias = 'US/Pacific'
  822. const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
  823. .resolvedOptions().timeZone
  824. const zonedRequest = promptRequest({
  825. sessionId: agent.id,
  826. mode: 'queue' as const,
  827. content: [{ type: 'text' as const, text: 'zoned work' }],
  828. clientTimeZone: alias,
  829. })
  830. await expect(remote.prompt(zonedRequest)).resolves.toMatchObject({ ok: true })
  831. expect(followup).toHaveBeenNthCalledWith(1, expect.objectContaining({
  832. source: { kind: 'user', rpcId: zonedRequest.requestId, clientTimeZone: canonical },
  833. }))
  834. const utcRequest = promptRequest({
  835. sessionId: agent.id,
  836. mode: 'queue' as const,
  837. content: [{ type: 'text' as const, text: 'UTC work' }],
  838. clientTimeZone: 'UTC',
  839. })
  840. await expect(remote.prompt(utcRequest)).resolves.toMatchObject({ ok: true })
  841. expect(followup).toHaveBeenNthCalledWith(2, expect.objectContaining({
  842. source: { kind: 'user', rpcId: utcRequest.requestId, clientTimeZone: 'UTC' },
  843. }))
  844. const unzonedRequest = promptRequest({
  845. sessionId: agent.id,
  846. mode: 'queue' as const,
  847. content: [{ type: 'text' as const, text: 'headless work' }],
  848. })
  849. await expect(remote.prompt(unzonedRequest)).resolves.toMatchObject({ ok: true })
  850. expect(followup).toHaveBeenNthCalledWith(3, expect.objectContaining({
  851. source: { kind: 'user', rpcId: unzonedRequest.requestId },
  852. }))
  853. for (const clientTimeZone of ['', ' UTC', 'CST', 'Not/A_Real_Zone']) {
  854. const invalid = await remote.prompt(promptRequest({
  855. sessionId: agent.id,
  856. mode: 'queue' as const,
  857. content: [{ type: 'text' as const, text: 'invalid zone' }],
  858. clientTimeZone,
  859. }))
  860. expect(invalid).toMatchObject({
  861. ok: false,
  862. error: {
  863. code: 'session/invalid-time-zone',
  864. message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
  865. details: { value: clientTimeZone },
  866. },
  867. })
  868. }
  869. expect(followup).toHaveBeenCalledTimes(3)
  870. })
  871. })
  872. describe('degenerate composition (no persistence, no factory)', () => {
  873. it('lists no cold rows and reports an absent point source as not found', async () => {
  874. const ctx = new Context()
  875. await ctx.plugin(SessionStore)
  876. await ctx.plugin(AgentRegistry)
  877. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  878. const listed = await remote.list(request({}))
  879. expect(listed.ok).toBe(true)
  880. if (listed.ok) expect(listed.value.items).toEqual([])
  881. // No persistence means cold history cannot inspect a transcript.
  882. const response = await remote.page({
  883. address: { kind: 'session', sessionId: sid('session-ghost') },
  884. throughSeq: -1,
  885. })
  886. expect(response.ok).toBe(false)
  887. if (!response.ok) {
  888. expect(response.error.code).toBe('session/not-found')
  889. }
  890. })
  891. it('maps a missing direct persistence read to session-not-found', async () => {
  892. const ctx = new Context()
  893. await ctx.plugin(SessionStore)
  894. await ctx.plugin(AgentRegistry)
  895. const inspect = vi.fn()
  896. const stat = vi.fn(() => Promise.resolve(undefined))
  897. providePersistence(ctx, {
  898. list: () => Promise.resolve([]),
  899. stat,
  900. inspect,
  901. })
  902. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  903. const response = await remote.page({
  904. address: { kind: 'session', sessionId: sid('session-missing') },
  905. throughSeq: -1,
  906. })
  907. expect(response.ok).toBe(false)
  908. if (!response.ok) expect(response.error.code).toBe('session/not-found')
  909. // Absence is decided by the stat preflight; the log itself is never opened.
  910. expect(stat).toHaveBeenCalledOnce()
  911. expect(inspect).not.toHaveBeenCalled()
  912. })
  913. })
  914. describe('sessions.prompt synchronous rejection', () => {
  915. it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
  916. const ctx = new Context()
  917. await ctx.plugin(SessionStore)
  918. await ctx.plugin(AgentRegistry)
  919. const session = ctx.sessions.create(sid('session-throwing'))
  920. // A live structural stub whose delivery verbs throw synchronously, the
  921. // shape a disposed loop presents at this gateway boundary.
  922. ctx.agents.register({
  923. id: session.id,
  924. session,
  925. inbox: inboxFor(session),
  926. status: 'idle',
  927. ctx,
  928. followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
  929. steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
  930. } as unknown as Agent)
  931. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  932. for (const mode of ['queue', 'steer'] as const) {
  933. const response = await remote.prompt(promptRequest({
  934. sessionId: session.id, mode, content: [{ type: 'text' as const, text: 'x' }],
  935. }))
  936. expect(response.ok).toBe(false)
  937. if (!response.ok) {
  938. expect(response.error.code).toBe('session/agent-busy')
  939. expect(response.error.message).toBe('prompt rejected')
  940. expect(response.error.details).toEqual({
  941. reason: 'Error: agent "session-throwing" lifecycle disposed',
  942. })
  943. }
  944. }
  945. })
  946. it('classifies a raced cold-resume ID collision as agent-busy', async () => {
  947. const ctx = new Context()
  948. await ctx.plugin(SessionStore)
  949. await ctx.plugin(AgentRegistry)
  950. const sessionId = sid('race-resume')
  951. const meta: SessionHeader = header('race-resume', 1000)
  952. providePersistence(ctx, {
  953. list: () => Promise.resolve([meta]),
  954. inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }) })
  955. // The raced winner: a live parent-owned subagent publishes the identity
  956. // while the generic cold resume is in flight, so the resume collides.
  957. const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
  958. const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
  959. ctx.agents.register(parent)
  960. const childSession = ctx.sessions.create(sessionId, {
  961. meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
  962. })
  963. const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
  964. vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
  965. // The parent's `enter()` wins the identity between the pre-resume
  966. // re-check and publication; the generic resume then collides.
  967. ctx.agents.register(child)
  968. throw new Error('session id already published')
  969. })
  970. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  971. const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' }))
  972. expect(selection.ok).toBe(false)
  973. if (!selection.ok) {
  974. expect(selection.error).toMatchObject({
  975. code: 'session/agent-busy',
  976. details: { reason: 'use subagent delivery for this child session' },
  977. })
  978. }
  979. })
  980. })