sessions-service.client.spec.ts 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150
  1. /**
  2. * ClientSessions: list store projection (manager → {ids, byId, current}
  3. * with derived titles), the current-selection account (open validation and
  4. * persisted mask semantics), scope-tree
  5. * lifecycle (lazy mint / frozen survival / removed teardown with staged
  6. * deferral — the stage follows list.current), binding identity, breadcrumb
  7. * projection, create.
  8. */
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  12. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  13. import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
  14. import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
  15. import { SessionSeq } from '@deepseek-ai/dsh-session/types'
  16. import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
  17. import { scopeOf } from '../src/client/scope.ts'
  18. import type { SessionFollowFrame } from '../src/types.ts'
  19. import {
  20. FakeApiClient,
  21. deferred,
  22. err,
  23. fakeRemote,
  24. ok,
  25. type RuntimeRemotes,
  26. } from './fake-api.client.ts'
  27. const sid = (s: string): SessionId => s as SessionId
  28. interface Bench {
  29. ctx: Context
  30. api: FakeApiClient
  31. svc: ClientSessions
  32. }
  33. function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Bench {
  34. const ctx = new Context()
  35. const api = new FakeApiClient()
  36. const remote = fakeRemote(api)
  37. const svc = new ClientSessions(ctx, configureRemote?.(remote) ?? remote)
  38. return { ctx, api, svc }
  39. }
  40. /** Refresh the manager list from programmable rows and flush the microtask batch. */
  41. type FeedRow = {
  42. id: string
  43. cwd?: string
  44. parentId?: string
  45. origin?: 'subagent'
  46. running?: boolean
  47. blank?: boolean
  48. projections?: Record<string, unknown>
  49. }
  50. async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
  51. b.api.onList = () => Promise.resolve(ok({
  52. items: rows.map(r => ({
  53. sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
  54. ...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
  55. ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
  56. ...(r.origin !== undefined ? { origin: r.origin } : {}),
  57. ...(r.projections === undefined
  58. ? {}
  59. : { projections: { asOfSeq: 0, values: r.projections } }),
  60. })),
  61. }) as never)
  62. await b.svc.refresh()
  63. await Promise.resolve() // manager notifier flush
  64. }
  65. describe('list store projection', () => {
  66. it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
  67. const b = bench()
  68. b.svc.handleControlFrame({
  69. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2,
  70. })
  71. await feedList(b, [
  72. { id: 's1', cwd: '/home/u/proj-a/' },
  73. { id: 's2', parentId: 's1', origin: 'subagent', running: true },
  74. ])
  75. const state = b.svc.list.getSnapshot()
  76. expect(state.ids).toEqual(['s1', 's2'])
  77. expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
  78. expect(state.byId[sid('s2')]).toMatchObject({
  79. displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true,
  80. })
  81. expect(state.byId[sid('s2')]?.title).toBeUndefined()
  82. })
  83. it('reprojects a blank session from the generic agent-preset projection', async () => {
  84. const b = bench()
  85. await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }])
  86. expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard')
  87. b.svc.handleControlFrame({
  88. type: 'projection', sessionId: sid('s1'), key: 'agentPreset', value: 'minimal', seq: 1,
  89. })
  90. await Promise.resolve()
  91. expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal')
  92. })
  93. it('reflects live increments (host stream via manager) into the store', async () => {
  94. const b = bench()
  95. await feedList(b, [{ id: 's1' }])
  96. b.svc.handleSessionAdded({
  97. sessionId: sid('s2'), updatedAt: 2, running: false, blank: true,
  98. })
  99. await Promise.resolve()
  100. expect(b.svc.list.getSnapshot().ids).toContain('s2')
  101. })
  102. })
  103. describe('search', () => {
  104. it('delegates transient content search without changing the list snapshot', async () => {
  105. const b = bench()
  106. await feedList(b, [{ id: 's1' }])
  107. const before = b.svc.list.getSnapshot()
  108. b.api.onSearch = () => Promise.resolve(ok({
  109. items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
  110. hasMore: false,
  111. }))
  112. const signal = new AbortController().signal
  113. await expect(b.svc.search('needle', signal)).resolves.toEqual({
  114. ok: true,
  115. value: {
  116. items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
  117. hasMore: false,
  118. },
  119. })
  120. expect(b.api.lastSearchSignal).toBe(signal)
  121. expect(b.svc.list.getSnapshot()).toBe(before)
  122. })
  123. })
  124. describe('scope tree', () => {
  125. it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async () => {
  126. const b = bench()
  127. await feedList(b, [{ id: 's1' }])
  128. b.svc.open(sid('s1'))
  129. const binding = b.svc.binding(sid('s1'))
  130. if (binding === undefined) throw new Error('expected Session binding')
  131. await vi.waitFor(() => {
  132. expect(binding.session.getSnapshot().openState).toBe('open')
  133. })
  134. const attemptId = LlmAttemptId('web-live-attempt')
  135. const durableMessage = {
  136. type: 'event' as const,
  137. event: {
  138. type: 'assistant/message', seq: 0, time: 2,
  139. data: {
  140. turn: 1,
  141. step: 1,
  142. message: {
  143. role: 'assistant',
  144. content: [{ type: 'text', text: 'live' }],
  145. source: { kind: 'model', provider: 'p', model: 'm' },
  146. id: 'message-1',
  147. },
  148. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['live'] }],
  149. },
  150. surfaceOp: 'append' as const,
  151. },
  152. }
  153. const publications: string[][] = []
  154. const dispose = binding.eventSource.subscribe(() => {
  155. publications.push(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
  156. })
  157. await b.api.pushFollow(sid('s1'), {
  158. type: 'assistant-stream',
  159. frame: {
  160. type: 'start', attemptId, revision: 1, startedTime: 1, startedAfterSeq: -1,
  161. turn: 1, step: 1,
  162. },
  163. })
  164. await b.api.pushFollow(sid('s1'), {
  165. type: 'assistant-stream',
  166. frame: {
  167. type: 'chunk', attemptId, revision: 2, index: 0,
  168. time: 1,
  169. chunk: { type: 'text-delta', index: 0, text: 'live' },
  170. },
  171. })
  172. await vi.waitFor(() => {
  173. expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
  174. })
  175. await b.api.pushFollow(sid('s1'), durableMessage)
  176. await Promise.resolve()
  177. expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
  178. await b.api.pushFollow(sid('s1'), {
  179. type: 'assistant-stream',
  180. frame: {
  181. type: 'end', attemptId, revision: 3, index: 1,
  182. outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
  183. },
  184. })
  185. await vi.waitFor(() => {
  186. expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
  187. })
  188. expect(publications).toEqual([
  189. ['assistant/live-chunk'],
  190. ['assistant/message'],
  191. ])
  192. dispose()
  193. })
  194. it('replaces an active assistant baseline on reconnect without duplicate chunks', async () => {
  195. const b = bench()
  196. const attemptId = LlmAttemptId('reconnect-attempt')
  197. let records: never[] = []
  198. b.api.onHistory = () => Promise.resolve(ok({ records, hasMore: false }))
  199. b.api.assistantStreamBaseline = {
  200. revision: 2,
  201. activeAttempt: {
  202. attemptId, startedTime: 1, startedAfterSeq: -1, turn: 1, step: 1,
  203. nextIndex: 1,
  204. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }],
  205. },
  206. }
  207. await feedList(b, [{ id: 's1' }])
  208. b.svc.open(sid('s1'))
  209. const binding = b.svc.binding(sid('s1'))
  210. if (binding === undefined) throw new Error('expected Session binding')
  211. await vi.waitFor(() => {
  212. expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
  213. })
  214. records = []
  215. b.api.assistantStreamBaseline = {
  216. revision: 3,
  217. activeAttempt: {
  218. attemptId, startedTime: 1, startedAfterSeq: -1, turn: 1, step: 1,
  219. nextIndex: 2,
  220. stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a', 'b'] }],
  221. },
  222. }
  223. b.api.failStreams(new RemoteStreamCarrierError('lost'))
  224. await vi.waitFor(() => {
  225. expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
  226. expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
  227. })
  228. expect(binding.eventSource.getSnapshot().entries.map(entry => (
  229. entry.event.type === 'assistant/live-chunk' && entry.event.data.chunk.type === 'text-delta'
  230. ? entry.event.data.chunk.text
  231. : undefined
  232. ))).toEqual(['a', 'b'])
  233. })
  234. it('stages a post-opening assistant settlement behind its exact active attempt', async () => {
  235. const b = bench()
  236. const attemptId = LlmAttemptId('reconnect-settlement-attempt')
  237. const priorMessage = {
  238. type: 'event' as const,
  239. event: {
  240. type: 'assistant/message', seq: 0, time: 30,
  241. data: {
  242. turn: 1,
  243. step: 1,
  244. message: {
  245. role: 'assistant',
  246. content: [{ type: 'text', text: 'retry ' }],
  247. source: { kind: 'model', provider: 'p', model: 'm' },
  248. id: 'prior-attempt-message',
  249. },
  250. stream: [{ type: 'text-chunks', time0: 10, index: 0, dt: [], texts: ['retry '] }],
  251. },
  252. surfaceOp: 'append' as const,
  253. },
  254. }
  255. const currentMessage = {
  256. type: 'event' as const,
  257. event: {
  258. type: 'assistant/message', seq: 1, time: 19,
  259. data: {
  260. turn: 1,
  261. step: 1,
  262. message: {
  263. role: 'assistant',
  264. content: [{ type: 'text', text: 'settled' }],
  265. source: { kind: 'model', provider: 'p', model: 'm' },
  266. id: 'current-attempt-message',
  267. },
  268. stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [], texts: ['settled'] }],
  269. },
  270. surfaceOp: 'append' as const,
  271. },
  272. }
  273. b.api.onHistory = () => Promise.resolve(ok({
  274. records: [priorMessage] as never[],
  275. hasMore: false,
  276. }))
  277. b.api.assistantStreamBaseline = {
  278. revision: 2,
  279. activeAttempt: {
  280. attemptId,
  281. startedTime: 20,
  282. startedAfterSeq: SessionSeq(0),
  283. turn: 1,
  284. step: 1,
  285. nextIndex: 1,
  286. stream: currentMessage.event.data.stream,
  287. },
  288. }
  289. await feedList(b, [{ id: 's1' }])
  290. b.svc.open(sid('s1'))
  291. const binding = b.svc.binding(sid('s1'))
  292. if (binding === undefined) throw new Error('expected Session binding')
  293. await vi.waitFor(() => {
  294. expect(binding.session.getSnapshot().openState).toBe('open')
  295. })
  296. expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
  297. .toEqual(['assistant/message', 'assistant/live-chunk'])
  298. expect(binding.eventSource.getSnapshot().entries[0]?.event).toBe(priorMessage.event)
  299. await b.api.pushFollow(sid('s1'), currentMessage)
  300. await Promise.resolve()
  301. expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
  302. .toEqual(['assistant/message', 'assistant/live-chunk'])
  303. await b.api.pushFollow(sid('s1'), {
  304. type: 'assistant-stream',
  305. frame: {
  306. type: 'end', attemptId, revision: 3, index: 1,
  307. outcome: { kind: 'committed', eventType: 'assistant/message', seq: 1 },
  308. },
  309. })
  310. await vi.waitFor(() => {
  311. expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
  312. .toEqual(['assistant/message', 'assistant/message'])
  313. })
  314. expect(binding.eventSource.getSnapshot().change).toEqual({
  315. kind: 'replace', entries: [priorMessage, currentMessage],
  316. })
  317. })
  318. it('replaces an invalid settlement with the authoritative post-end baseline', async () => {
  319. const b = bench()
  320. const attemptId = LlmAttemptId('reconnect-end-index-attempt')
  321. const prior = {
  322. type: 'event' as const,
  323. event: { type: 'turn/start', seq: 0, time: 19, data: { turn: 1 } },
  324. }
  325. const message = {
  326. type: 'event' as const,
  327. event: {
  328. type: 'assistant/message', seq: 1, time: 21,
  329. data: {
  330. turn: 1,
  331. step: 1,
  332. message: {
  333. role: 'assistant',
  334. content: [{ type: 'text', text: 'settled' }],
  335. source: { kind: 'model', provider: 'p', model: 'm' },
  336. id: 'current-attempt-message',
  337. },
  338. stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [], texts: ['settled'] }],
  339. },
  340. surfaceOp: 'append' as const,
  341. },
  342. }
  343. let records = [prior] as never[]
  344. b.api.onHistory = () => Promise.resolve(ok({
  345. records,
  346. hasMore: false,
  347. }))
  348. b.api.assistantStreamBaseline = {
  349. revision: 2,
  350. activeAttempt: {
  351. attemptId,
  352. startedTime: 20,
  353. startedAfterSeq: SessionSeq(0),
  354. turn: 1,
  355. step: 1,
  356. nextIndex: 1,
  357. stream: message.event.data.stream,
  358. },
  359. }
  360. await feedList(b, [{ id: 's1' }])
  361. b.svc.open(sid('s1'))
  362. const binding = b.svc.binding(sid('s1'))
  363. if (binding === undefined) throw new Error('expected Session binding')
  364. await vi.waitFor(() => {
  365. expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
  366. .toEqual(['turn/start', 'assistant/live-chunk'])
  367. })
  368. const openingRevision = binding.eventSource.getSnapshot().revision
  369. await b.api.pushFollow(sid('s1'), message)
  370. await Promise.resolve()
  371. expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
  372. .toEqual(['turn/start', 'assistant/live-chunk'])
  373. records = [prior, message] as never[]
  374. b.api.assistantStreamBaseline = { revision: 3 }
  375. await b.api.pushFollow(sid('s1'), {
  376. type: 'assistant-stream',
  377. frame: {
  378. type: 'end', attemptId, revision: 3, index: 0,
  379. outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
  380. },
  381. })
  382. await vi.waitFor(() => {
  383. expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
  384. expect(b.api.activeFollows(sid('s1'))).toBe(1)
  385. expect(binding.eventSource.getSnapshot().revision).toBeGreaterThan(openingRevision)
  386. expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
  387. .toEqual(['turn/start', 'assistant/message'])
  388. })
  389. })
  390. it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => {
  391. const b = bench()
  392. const scoped = b.svc.resolveAgentScope(sid('s-early'))
  393. expect(scopeOf(scoped)).toBe('s-early')
  394. b.svc.handleControlFrame({
  395. type: 'baseline',
  396. value: { queues: {}, jobs: {}, projections: {} },
  397. })
  398. await Promise.resolve()
  399. expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped)
  400. await feedList(b, [])
  401. expect(b.svc.scope(sid('s-early'))).toBeUndefined()
  402. })
  403. it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
  404. const b = bench()
  405. await feedList(b, [{ id: 's1' }])
  406. expect(b.svc.scope(sid('unknown'))).toBeUndefined()
  407. const scoped = b.svc.scope(sid('s1'))
  408. expect(scoped).toBeDefined()
  409. expect(scopeOf(scoped as Context)).toBe('s1')
  410. expect(scopeOf(b.ctx)).toBeUndefined()
  411. const binding = b.svc.binding(sid('s1'))
  412. b.svc.open(sid('s1'))
  413. expect(b.svc.sessionOf(scoped as Context)).toBe(binding?.session)
  414. expect(b.svc.binding(sid('s1'))).toBe(binding)
  415. expect(binding?.ctx).toBe(scoped)
  416. })
  417. it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
  418. const b = bench()
  419. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  420. const ctx1 = b.svc.scope(sid('s1'))
  421. b.svc.open(sid('s1')) // s1 staged (current)
  422. b.svc.scope(sid('s2')) // s2 scoped but off stage
  423. await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
  424. expect(b.svc.scope(sid('s2'))).toBeUndefined()
  425. await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
  426. expect(b.svc.scope(sid('s1'))).toBe(ctx1)
  427. await feedList(b, [{ id: 's3' }])
  428. b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
  429. expect(b.svc.scope(sid('s1'))).toBeUndefined()
  430. })
  431. it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => {
  432. const b = bench()
  433. await feedList(b, [{ id: 's1', running: true }])
  434. const scoped = b.svc.scope(sid('s1'))
  435. await feedList(b, [{ id: 's1', running: false }])
  436. expect(b.svc.scope(sid('s1'))).toBe(scoped)
  437. })
  438. it('cancels a deferred teardown when the id reappears in the list', async () => {
  439. const b = bench()
  440. await feedList(b, [{ id: 's1' }])
  441. const scoped = b.svc.scope(sid('s1'))
  442. b.svc.open(sid('s1'))
  443. await feedList(b, []) // removed while staged → deferred
  444. await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
  445. b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
  446. expect(b.svc.scope(sid('s1'))).toBe(scoped)
  447. })
  448. it('closes an opened journal when its removed scope drops', async () => {
  449. const b = bench()
  450. await feedList(b, [{ id: 's1' }])
  451. b.svc.open(sid('s1'))
  452. const session = b.svc.binding(sid('s1'))?.session
  453. if (session === undefined) throw new Error('expected the selected Session binding')
  454. await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(1) })
  455. const notified = vi.fn()
  456. session.subscribe(notified)
  457. await feedList(b, [])
  458. await feedList(b, [{ id: 's2' }])
  459. b.svc.open(sid('s2'))
  460. await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) })
  461. notified.mockClear()
  462. await b.api.pushFollow(sid('s1'), {
  463. type: 'event',
  464. event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never,
  465. })
  466. await Promise.resolve()
  467. expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1)
  468. expect(notified).not.toHaveBeenCalled()
  469. })
  470. })
  471. describe('Agent scope disposal lifecycle', () => {
  472. it('root disposal runs Agent scope effects', async () => {
  473. const b = bench()
  474. const readiness = b.ctx.plugin(() => undefined)
  475. await readiness
  476. b.svc.handleSessionAdded({
  477. sessionId: sid('live'), updatedAt: 1, running: false, blank: true,
  478. })
  479. await Promise.resolve()
  480. const scoped = b.svc.scope(sid('live'))
  481. if (scoped === undefined) throw new Error('fixture Agent Context was not minted')
  482. await scoped.fiber.await()
  483. const scopeDisposed = vi.fn()
  484. scoped.effect(() => scopeDisposed, 'fixture Agent scope effect')
  485. await b.ctx.fiber.dispose()
  486. expect(scopeDisposed).toHaveBeenCalledOnce()
  487. expect(b.svc.sessionOf(scoped)).toBeUndefined()
  488. })
  489. it('root disposal waits for an opened Session source to finish closing', async () => {
  490. const closeGate = deferred<undefined>()
  491. const abortObserved = vi.fn()
  492. let followSignal: AbortSignal | undefined
  493. const b = bench(remote => ({
  494. ...remote,
  495. session: {
  496. ...remote.session,
  497. follow: (request, signal) => {
  498. if (signal === undefined) throw new Error('fixture requires a signal')
  499. followSignal = signal
  500. let opened = false
  501. return {
  502. [Symbol.asyncIterator]: () => ({
  503. next: () => {
  504. if (!opened) {
  505. opened = true
  506. return Promise.resolve({
  507. done: false,
  508. value: {
  509. type: 'snapshot',
  510. header: {
  511. version: 2,
  512. id: request.address.kind === 'session'
  513. ? request.address.sessionId
  514. : request.address.childSessionId,
  515. createdAt: 0,
  516. isSeeded: false,
  517. },
  518. cursor: -1,
  519. records: [],
  520. hasMore: false,
  521. projections: { asOfSeq: -1, values: {} },
  522. assistantStream: { revision: 0 },
  523. } as const,
  524. })
  525. }
  526. return new Promise((_resolve, reject) => {
  527. signal.addEventListener('abort', () => {
  528. abortObserved()
  529. void closeGate.promise.then(() => {
  530. reject(signal.reason instanceof Error
  531. ? signal.reason
  532. : new Error(String(signal.reason)))
  533. })
  534. }, { once: true })
  535. })
  536. },
  537. }),
  538. }
  539. },
  540. },
  541. }))
  542. const readiness = b.ctx.plugin(() => undefined)
  543. await readiness
  544. await feedList(b, [{ id: 's1' }])
  545. b.svc.open(sid('s1'))
  546. await vi.waitFor(() => {
  547. expect(b.svc.binding(sid('s1'))?.session.getSnapshot().openState).toBe('open')
  548. })
  549. const disposal = b.ctx.fiber.dispose()
  550. const settled = vi.fn()
  551. const observed = disposal.then(settled)
  552. await vi.waitFor(() => { expect(abortObserved).toHaveBeenCalledOnce() })
  553. expect(followSignal?.aborted).toBe(true)
  554. expect(settled).not.toHaveBeenCalled()
  555. closeGate.resolve(undefined)
  556. await observed
  557. expect(settled).toHaveBeenCalledOnce()
  558. })
  559. it('root disposal joins every Session drop already started by pruning under load', async () => {
  560. const closeGates = new Map<SessionId, ReturnType<typeof deferred<undefined>>>()
  561. const aborted = new Set<SessionId>()
  562. const b = bench(remote => ({
  563. ...remote,
  564. session: {
  565. ...remote.session,
  566. follow: (request, signal) => {
  567. if (signal === undefined) throw new Error('fixture requires a signal')
  568. const sessionId = request.address.kind === 'session'
  569. ? request.address.sessionId
  570. : request.address.childSessionId
  571. const closeGate = deferred<undefined>()
  572. closeGates.set(sessionId, closeGate)
  573. let opened = false
  574. return {
  575. [Symbol.asyncIterator]: () => ({
  576. next: () => {
  577. if (!opened) {
  578. opened = true
  579. return Promise.resolve({
  580. done: false,
  581. value: {
  582. type: 'snapshot',
  583. header: { version: 2, id: sessionId, createdAt: 0, isSeeded: false },
  584. cursor: -1,
  585. records: [],
  586. hasMore: false,
  587. projections: { asOfSeq: -1, values: {} },
  588. assistantStream: { revision: 0 },
  589. } as const,
  590. })
  591. }
  592. return new Promise<IteratorResult<SessionFollowFrame>>((_resolve, reject) => {
  593. signal.addEventListener('abort', () => {
  594. aborted.add(sessionId)
  595. void closeGate.promise.then(() => {
  596. reject(signal.reason instanceof Error
  597. ? signal.reason
  598. : new Error(String(signal.reason)))
  599. })
  600. }, { once: true })
  601. })
  602. },
  603. }),
  604. }
  605. },
  606. },
  607. }))
  608. const readiness = b.ctx.plugin(() => undefined)
  609. await readiness
  610. const sessionIds = Array.from({ length: 24 }, (_, index) => sid(`load-${String(index)}`))
  611. const retained = sessionIds.at(-1)
  612. const held = sessionIds[0]
  613. if (retained === undefined || held === undefined) throw new Error('fixture requires sessions')
  614. await feedList(b, sessionIds.map(id => ({ id })))
  615. for (const id of sessionIds) b.svc.open(id)
  616. await vi.waitFor(() => {
  617. for (const id of sessionIds) {
  618. expect(b.svc.binding(id)?.session.getSnapshot().openState).toBe('open')
  619. }
  620. })
  621. const pruned = sessionIds.slice(0, -1)
  622. await feedList(b, [{ id: retained }])
  623. await vi.waitFor(() => { expect(aborted.size).toBe(pruned.length) })
  624. for (const id of pruned) expect(b.svc.scope(id)).toBeUndefined()
  625. const disposal = b.ctx.fiber.dispose()
  626. const settled = vi.fn()
  627. const observed = disposal.then(settled)
  628. await vi.waitFor(() => { expect(aborted.size).toBe(sessionIds.length) })
  629. const otherClosures: Promise<void>[] = []
  630. for (const [id, gate] of closeGates) {
  631. if (id === held) continue
  632. gate.resolve(undefined)
  633. otherClosures.push(gate.promise)
  634. }
  635. await Promise.all(otherClosures)
  636. await new Promise((resolve) => { setTimeout(resolve, 0) })
  637. expect(settled).not.toHaveBeenCalled()
  638. closeGates.get(held)?.resolve(undefined)
  639. await observed
  640. expect(settled).toHaveBeenCalledOnce()
  641. })
  642. })
  643. describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
  644. afterEach(() => { vi.unstubAllGlobals() })
  645. it('open() writes list.current; unknown ids fail loud', async () => {
  646. const b = bench()
  647. await feedList(b, [{ id: 's1' }])
  648. expect(b.svc.list.getSnapshot().current).toBeUndefined()
  649. b.svc.open(sid('s1'))
  650. expect(b.svc.list.getSnapshot().current).toBe('s1')
  651. expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
  652. expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
  653. })
  654. it('clear() blanks list.current and the persisted selection', async () => {
  655. const storage = new Map<string, string>()
  656. vi.stubGlobal('localStorage', {
  657. getItem: (k: string) => storage.get(k) ?? null,
  658. setItem: (k: string, v: string) => { storage.set(k, v) },
  659. removeItem: (k: string) => { storage.delete(k) },
  660. clear: () => { storage.clear() },
  661. })
  662. const b = bench()
  663. await feedList(b, [{ id: 's1' }])
  664. b.svc.open(sid('s1'))
  665. expect(storage.get('dsh.sessions.current')).toContain('s1')
  666. b.svc.clear()
  667. expect(b.svc.list.getSnapshot().current).toBeUndefined()
  668. // Persisted wipe: a fresh service with the same storage stays on empty.
  669. const again = bench()
  670. await feedList(again, [{ id: 's1' }])
  671. expect(again.svc.list.getSnapshot().current).toBeUndefined()
  672. })
  673. it('masks (not destroys) the selection while its session is off the list', async () => {
  674. const b = bench()
  675. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  676. b.svc.open(sid('s1'))
  677. await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
  678. expect(b.svc.list.getSnapshot().current).toBeUndefined()
  679. await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
  680. expect(b.svc.list.getSnapshot().current).toBe('s1')
  681. })
  682. it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
  683. const storage = new Map<string, string>()
  684. vi.stubGlobal('localStorage', {
  685. getItem: (k: string) => storage.get(k) ?? null,
  686. setItem: (k: string, v: string) => { storage.set(k, v) },
  687. })
  688. const first = bench()
  689. await feedList(first, [{ id: 's1' }])
  690. first.svc.open(sid('s1'))
  691. expect(storage.get('dsh.sessions.current')).toContain('s1')
  692. // A fresh boot (same storage) recovers the selection once the list holds the session.
  693. const second = bench()
  694. await feedList(second, [{ id: 's1' }])
  695. expect(second.svc.list.getSnapshot().current).toBe('s1')
  696. })
  697. })
  698. describe('binding and stage lifecycle', () => {
  699. it('binding() is pure resolution: no staging, no deferred sweep', async () => {
  700. const b = bench()
  701. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  702. b.svc.open(sid('s1')) // staged
  703. b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
  704. await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
  705. expect(b.svc.scope(sid('s1'))).toBeDefined()
  706. })
  707. it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
  708. const b = bench()
  709. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  710. const followStarts = () => b.api.followStarts.map(String)
  711. // Resolution is addressing, not staging: no window pull.
  712. b.svc.scope(sid('s1'))
  713. b.svc.binding(sid('s1'))
  714. expect(followStarts()).toEqual([])
  715. b.svc.open(sid('s1'))
  716. await vi.waitFor(() => {
  717. expect(followStarts()).toEqual(['s1'])
  718. })
  719. // Same current again: no second pull.
  720. b.svc.open(sid('s1'))
  721. expect(followStarts()).toHaveLength(1)
  722. // Stage moves: the new occupant opens.
  723. b.svc.open(sid('s2'))
  724. await vi.waitFor(() => {
  725. expect(followStarts()).toEqual(['s1', 's2'])
  726. })
  727. })
  728. it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
  729. const storage = new Map<string, string>([
  730. ['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
  731. ])
  732. vi.stubGlobal('localStorage', {
  733. getItem: (k: string) => storage.get(k) ?? null,
  734. setItem: (k: string, v: string) => { storage.set(k, v) },
  735. })
  736. try {
  737. const b = bench()
  738. expect(b.api.followStarts).toEqual([])
  739. await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
  740. await vi.waitFor(() => {
  741. expect(b.api.followStarts.map(String)).toEqual(['s1'])
  742. })
  743. } finally {
  744. vi.unstubAllGlobals()
  745. }
  746. })
  747. })
  748. describe('catalog-addressed navigation', () => {
  749. it('uses catalog labels for a listed addressed route', async () => {
  750. const b = bench()
  751. b.api.onSubagentList = (payload) => {
  752. const parentSessionId = payload as SessionId
  753. if (parentSessionId === sid('root')) {
  754. return Promise.resolve(ok({
  755. entries: [{
  756. kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
  757. activity: 'inactive', hasChildren: true,
  758. }] as never[],
  759. parentAvailable: true,
  760. }))
  761. }
  762. if (parentSessionId === sid('child')) {
  763. return Promise.resolve(ok({
  764. entries: [{
  765. kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
  766. activity: 'inactive', hasChildren: false,
  767. }] as never[],
  768. parentAvailable: false,
  769. }))
  770. }
  771. return Promise.resolve(ok({ entries: [], parentAvailable: false }))
  772. }
  773. await feedList(b, [
  774. { id: 'root' },
  775. { id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
  776. { id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
  777. ])
  778. await b.svc.refreshSubagents(sid('root'))
  779. await b.svc.refreshSubagents(sid('child'))
  780. b.svc.openSubagent({
  781. parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
  782. })
  783. expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
  784. expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
  785. })
  786. it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
  787. const b = bench()
  788. b.api.onSubagentList = (payload) => {
  789. const parentSessionId = payload as SessionId
  790. if (parentSessionId === sid('root')) {
  791. return Promise.resolve(ok({
  792. entries: [{
  793. kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
  794. activity: 'inactive', hasChildren: true,
  795. }] as never[],
  796. parentAvailable: true,
  797. }))
  798. }
  799. if (parentSessionId === sid('child')) {
  800. return Promise.resolve(ok({
  801. entries: [{
  802. kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
  803. activity: 'inactive', hasChildren: false,
  804. }] as never[],
  805. parentAvailable: false,
  806. }))
  807. }
  808. return Promise.resolve(ok({ entries: [], parentAvailable: false }))
  809. }
  810. await feedList(b, [{ id: 'root' }])
  811. await b.svc.refreshSubagents(sid('root'))
  812. await b.svc.refreshSubagents(sid('child'))
  813. b.svc.openSubagent({
  814. parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
  815. })
  816. const list = b.svc.list.getSnapshot()
  817. expect(list.ids).toEqual([sid('root')])
  818. expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
  819. expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
  820. expect(b.svc.binding(sid('child'))).toBeUndefined()
  821. expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
  822. b.svc.open(sid('child'))
  823. expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
  824. expect(b.svc.subagentAddress(sid('child'))).toEqual({
  825. parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
  826. })
  827. })
  828. })
  829. describe('create', () => {
  830. it('passes a preallocated id and preserves it on ordinary failure', async () => {
  831. const b = bench()
  832. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
  833. await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
  834. expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
  835. b.api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', '爆了', {})))
  836. const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
  837. expect(failure).toBeInstanceOf(SessionCreateError)
  838. expect(failure).toMatchObject({
  839. requestedSessionId: 'candidate',
  840. rpcError: { code: 'gateway/internal', message: '爆了' },
  841. })
  842. })
  843. it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => {
  844. const b = bench()
  845. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') }))
  846. const born = await b.svc.create({ workspaceId: 'ws' as never })
  847. // Synchronously after resolution — the draft hand-off contract: the
  848. // create echo IS the entity entering the client's view (blank row +
  849. // resolvable scope/binding), no notifier flush in between.
  850. expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true })
  851. expect(b.svc.binding(born)).toBeDefined()
  852. expect(b.svc.scope(born)).toBeDefined()
  853. })
  854. it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
  855. const b = bench()
  856. b.api.onCreate = () => Promise.resolve(err(new RemoteError(
  857. 'session/workspace-attach-failed',
  858. 'ledger unavailable',
  859. { sessionId: sid('published'), workspaceId: 'ws' },
  860. )))
  861. const failure = await b.svc.create({
  862. workspaceId: 'ws' as never,
  863. sessionId: sid('published'),
  864. }).catch((error: unknown) => error)
  865. await Promise.resolve()
  866. expect(failure).toBeInstanceOf(SessionCreateError)
  867. expect(failure).toMatchObject({
  868. requestedSessionId: 'published',
  869. rpcError: { code: 'session/workspace-attach-failed' },
  870. })
  871. expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true })
  872. })
  873. })
  874. describe('fork', () => {
  875. it.each([
  876. ['Roadmap', 'Roadmap (1)'],
  877. ['Roadmap (1)', 'Roadmap (2)'],
  878. ['计划(1)', '计划(2)'],
  879. ['计划 (9)', '计划 (10)'],
  880. ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
  881. const b = bench()
  882. b.svc.handleControlFrame({
  883. type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2,
  884. })
  885. await feedList(b, [{ id: 'source', cwd: '/work' }])
  886. b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
  887. b.api.onRename = (payload) => {
  888. const { title } = payload as { title: string }
  889. return Promise.resolve(ok({ title, seq: 3 }))
  890. }
  891. await expect(b.svc.fork({
  892. sessionId: sid('source'), atSeq: 7, increaseTitle: true,
  893. })).resolves.toBe('child')
  894. expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
  895. expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
  896. await Promise.resolve()
  897. expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
  898. title: childTitle,
  899. displayTitle: childTitle,
  900. parentId: 'source',
  901. })
  902. })
  903. it('floors a fractional anchor to the real event seq the wire accepts', async () => {
  904. const b = bench()
  905. await feedList(b, [{ id: 'source', cwd: '/work' }])
  906. b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
  907. // The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
  908. await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
  909. expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
  910. })
  911. it('does not rename without the title policy or a durable source title', async () => {
  912. const b = bench()
  913. await feedList(b, [{ id: 'source', cwd: '/work' }])
  914. b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
  915. await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
  916. expect(b.api.callsOf('session.rename')).toEqual([])
  917. b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
  918. await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
  919. expect(b.api.callsOf('session.rename')).toEqual([])
  920. })
  921. it('rejects when child rename fails while keeping the published child addressable', async () => {
  922. const b = bench()
  923. b.svc.handleControlFrame({
  924. type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2,
  925. })
  926. await feedList(b, [{ id: 'source' }])
  927. b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
  928. b.api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
  929. await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
  930. .rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
  931. expect(b.svc.binding(sid('child'))).toBeDefined()
  932. })
  933. })
  934. describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
  935. it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
  936. const b = bench()
  937. await feedList(b, [])
  938. expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
  939. b.svc.handleSessionAdded({
  940. sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
  941. })
  942. await Promise.resolve()
  943. const scoped = b.svc.scope(sid('s-new'))
  944. expect(scoped).toBeDefined()
  945. expect(scopeOf(scoped as Context)).toBe('s-new')
  946. b.svc.handleSessionRemoved(sid('s-new'))
  947. await Promise.resolve()
  948. expect(b.svc.scope(sid('s-new'))).toBeUndefined()
  949. })
  950. })
  951. describe('blank mirror', () => {
  952. it('flips blank=false from the running:true status frame (cross-client conversion)', async () => {
  953. const b = bench()
  954. await feedList(b, [{ id: 's1', blank: true }])
  955. expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
  956. b.svc.handleSessionStatus(sid('s1'), true)
  957. await Promise.resolve()
  958. expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
  959. // The instantiated Session mirrors the same flip.
  960. expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
  961. })
  962. it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => {
  963. const b = bench()
  964. await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
  965. const session = b.svc.binding(sid('s1'))!.session
  966. expect(session.getSnapshot().blank).toBe(true)
  967. const gate = deferred<Awaited<ReturnType<FakeApiClient['onPrompt']>>>()
  968. b.api.onPrompt = () => gate.promise
  969. const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue')
  970. // In flight: still blank (the flip point is the success response, which
  971. // proves the user message reached the host log).
  972. expect(session.getSnapshot().blank).toBe(true)
  973. gate.resolve(ok({ accepted: true as const }))
  974. await send
  975. expect(session.getSnapshot().blank).toBe(false)
  976. await Promise.resolve()
  977. expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
  978. })
  979. it('keeps a rejected first prompt blank: hidden and still reusable', async () => {
  980. const b = bench()
  981. await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
  982. const session = b.svc.binding(sid('s1'))!.session
  983. b.api.onPrompt = () => Promise.resolve(err(new RemoteError('gateway/internal', 'agent busy', {})))
  984. const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
  985. expect(result.ok).toBe(false)
  986. // No flip on failure: local stays aligned with the host authority
  987. // (events.length still 0), so the session stays hidden and reusable.
  988. expect(session.getSnapshot().blank).toBe(true)
  989. await Promise.resolve()
  990. expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
  991. })
  992. it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => {
  993. const b = bench()
  994. await feedList(b, [])
  995. b.svc.handleSessionAdded({
  996. sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
  997. })
  998. await Promise.resolve()
  999. expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true })
  1000. // Reconnect re-pull: the summary's blank=false wins (authoritative alignment).
  1001. await feedList(b, [{ id: 's-new', blank: false, cwd: '/w/a' }])
  1002. expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false })
  1003. })
  1004. it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => {
  1005. const b = bench()
  1006. await feedList(b, [{ id: 's1', blank: true }])
  1007. const session = b.svc.binding(sid('s1'))!.session
  1008. await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
  1009. await Promise.resolve()
  1010. expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
  1011. // The next list pull still claims blank (host hasn't logged the message yet).
  1012. await feedList(b, [{ id: 's1', blank: true }])
  1013. expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
  1014. })
  1015. })
  1016. describe('coverage tails (branch duals)', () => {
  1017. it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
  1018. const b = bench()
  1019. await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
  1020. const { byId } = b.svc.list.getSnapshot()
  1021. expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
  1022. expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
  1023. expect(byId[sid('no-base')]?.title).toBeUndefined()
  1024. })
  1025. it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
  1026. const b = bench()
  1027. await feedList(b, [{ id: 's1' }])
  1028. b.svc.open(sid('s1'))
  1029. expect(b.svc.binding(sid('ghost'))).toBeUndefined()
  1030. // Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
  1031. await feedList(b, [])
  1032. expect(b.svc.scope(sid('s1'))).toBeDefined()
  1033. })
  1034. it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
  1035. const b = bench()
  1036. await feedList(b, [{ id: 's1' }])
  1037. b.svc.open(sid('s1'))
  1038. await vi.waitFor(() => { expect(b.api.followStarts).toHaveLength(1) })
  1039. await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
  1040. expect(b.svc.scope(sid('s1'))).toBeDefined()
  1041. // Resurfacing re-projects current = s1: same stage occupant, no second pull.
  1042. await feedList(b, [{ id: 's1' }])
  1043. expect(b.api.followStarts).toHaveLength(1)
  1044. expect(b.svc.list.getSnapshot().current).toBe('s1')
  1045. })
  1046. it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
  1047. const b = bench()
  1048. await feedList(b, [{ id: 'a' }, { id: 'b' }])
  1049. b.svc.scope(sid('a'))
  1050. b.svc.open(sid('b')) // stage: b; both scoped
  1051. await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
  1052. // Move the stage to a THIRD id while b stays deferred: sweep walks a set
  1053. // containing b (torn).
  1054. await feedList(b, [{ id: 'c' }])
  1055. b.svc.open(sid('c'))
  1056. expect(b.svc.scope(sid('b'))).toBeUndefined()
  1057. // Deferral for an id whose record was never minted: force the deferral
  1058. // via removed list state — sweep must tolerate the missing record.
  1059. await feedList(b, []) // c removed while staged → deferred (scope exists)
  1060. await feedList(b, [{ id: 'd' }])
  1061. b.svc.open(sid('d')) // sweep tears c
  1062. expect(b.svc.scope(sid('c'))).toBeUndefined()
  1063. })
  1064. })