realtimeBroker.test.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import { mkdtempSync, readFileSync } from 'node:fs'
  2. import { join } from 'node:path'
  3. import { tmpdir } from 'node:os'
  4. import { once } from 'node:events'
  5. import { WebSocketServer, type WebSocket } from 'ws'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { RealtimeBroker, type RealtimeActivity, type RealtimePrincipal, type StreamEntry } from '../../src/server/realtimeBroker.js'
  8. import { RealtimeReceipts } from '../../src/server/realtimeReceipts.js'
  9. const cleanup: Array<() => void> = []
  10. afterEach(() => { cleanup.splice(0).reverse().forEach(f => f()); vi.restoreAllMocks() })
  11. async function fixture() {
  12. const home = mkdtempSync(join(tmpdir(), 'yaoyao-realtime-test-'))
  13. const ws = new WebSocketServer({ port: 0, host: '127.0.0.1' })
  14. await once(ws, 'listening')
  15. cleanup.push(() => { for (const c of ws.clients) c.terminate(); ws.close() })
  16. const commands: any[] = []
  17. let count = 0
  18. let seq = 0, truncate = false, dropPrompt = false
  19. let cwd = '/old-session', busy = false, rejectCwd = false
  20. let loseResumeReply = false
  21. let resumeError: number | undefined
  22. const history: any[] = []
  23. let peer: WebSocket
  24. ws.on('connection', socket => {
  25. peer = socket; count++
  26. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'test' } } }))
  27. socket.on('message', raw => {
  28. const f = JSON.parse(raw.toString()); commands.push(f)
  29. if (f.method === 'prompt.submit' && dropPrompt) { socket.terminate(); return }
  30. if (f.method === 'session.resume' && loseResumeReply) { loseResumeReply = false; return }
  31. if (f.method === 'session.resume' && resumeError !== undefined) {
  32. socket.send(JSON.stringify({ id: f.id, error: { code: resumeError, message: resumeError === 4007 ? 'session not found' : 'database temporarily unavailable' } }))
  33. resumeError = undefined; return
  34. }
  35. if (f.method === 'profiles.list') {
  36. socket.send(JSON.stringify({id:f.id,result:{profiles:[{name:'default',ui_meta:{'hermes-bots':{chat:'stored-1'}}}]}}))
  37. } else if (f.method === 'session.events.since') {
  38. socket.send(JSON.stringify({ id: f.id, result: { epoch: 'test', truncated: truncate,
  39. events: history.filter(e => e.seq > f.params.last_seen) } }))
  40. } else if (f.method === 'session.branch') {
  41. socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-branch', stored_session_id: 'stored-branch' } }))
  42. } else if (f.method === 'session.create' || f.method === 'session.resume') {
  43. if (f.method === 'session.create' && f.params.cwd) cwd = f.params.cwd
  44. socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-1', stored_session_id: 'stored-1', running: busy, info: { cwd } } }))
  45. } else if (f.method === 'session.cwd.set') {
  46. if (rejectCwd) socket.send(JSON.stringify({ id: f.id, error: { message: 'working directory does not exist' } }))
  47. else { cwd = f.params.cwd; socket.send(JSON.stringify({ id: f.id, result: { cwd } })) }
  48. } else {
  49. setTimeout(() => socket.send(JSON.stringify({ id: f.id, result: { status: 'submitted' } })), 20)
  50. }
  51. })
  52. })
  53. let now = Date.now()
  54. const activities: RealtimeActivity[] = []
  55. const broker = new RealtimeBroker(home, () => now, change => activities.push(change))
  56. cleanup.push(() => broker.close())
  57. const principal = (key: string): RealtimePrincipal => ({ key, upstreamKey: 'one-service', paired: false,
  58. valid: () => true, url: async () => new URL(`ws://127.0.0.1:${(ws.address() as any).port}`) })
  59. return { home, broker, principal, commands, activities, count: () => count, advance: (n: number) => { now += n },
  60. cwd: () => cwd, busy: () => { busy = true }, rejectCwd: () => { rejectCwd = true },
  61. disconnect: () => peer!.terminate(), truncate: () => { truncate = true }, dropPrompt: () => { dropPrompt = true },
  62. loseResumeReply: () => { loseResumeReply = true },
  63. failNextResume: (code: number) => { resumeError = code },
  64. emit: (type: string, payload: unknown) => {
  65. const params = { type, session_id: 'runtime-1', seq: ++seq, payload }; history.push(params)
  66. if (peer!.readyState === 1) peer!.send(JSON.stringify({ method: 'event', params }))
  67. } }
  68. }
  69. const resume = { method: 'session.resume', params: { session_id: 'stored-1', profile: 'default' } }
  70. describe('realtime broker', () => {
  71. it.each([
  72. { source: 'web', paired: false }, { source: 'ios', paired: false }, { source: 'ios', paired: true },
  73. ])('uses the profile terminal.cwd for $source (paired=$paired), ignoring client cwd', async ({ source, paired }) => {
  74. const f = await fixture()
  75. const read = vi.fn(async (_profile: string) => '/Hermes/配置目录')
  76. const principal = { ...f.principal('alice'), paired, configuredWorkingDirectory: read }
  77. const channel = await f.broker.create(principal, 'chat')
  78. const created = await f.broker.command(channel, 'create', {
  79. method: 'session.create', params: { profile: 'work', source, cwd: '/client/override' },
  80. })
  81. expect(created.state).toBe('confirmed')
  82. expect(f.commands[0].params.cwd).toBe('/Hermes/配置目录')
  83. const sent = await f.broker.command(channel, 'send', {
  84. method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'run' },
  85. })
  86. expect(sent.state).toBe('confirmed')
  87. expect(read.mock.calls.every(([profile]) => profile === 'work')).toBe(true)
  88. expect(f.commands.map(x => x.method)).toEqual(['session.create', 'prompt.submit'])
  89. expect(f.cwd()).toBe('/Hermes/配置目录')
  90. })
  91. it('corrects an old cwd before attachments and follows config changes on the next turn', async () => {
  92. const f = await fixture()
  93. let configured = '/configured/first'
  94. const channel = await f.broker.create({ ...f.principal('alice'),
  95. configuredWorkingDirectory: async () => configured }, 'chat')
  96. await f.broker.command(channel, 'resume', resume)
  97. expect(f.cwd()).toBe('/old-session')
  98. await f.broker.command(channel, 'attach', {
  99. method: 'file.attach', params: { session_id: 'runtime-1', data_url: 'data:text/plain;base64,eA==' },
  100. })
  101. expect(f.cwd()).toBe('/configured/first')
  102. await f.broker.command(channel, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'one' } })
  103. expect(f.commands.filter(x => x.method === 'session.cwd.set')).toHaveLength(1)
  104. configured = '/configured/second'
  105. const events: StreamEntry[] = []
  106. f.broker.subscribe(channel, undefined, entry => events.push(entry))
  107. f.emit('message.complete', {})
  108. await vi.waitFor(() => expect(events.some(e => e.data.includes('message.complete'))).toBe(true))
  109. await f.broker.command(channel, 'send-next', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'two' } })
  110. expect(f.cwd()).toBe('/configured/second')
  111. expect(f.commands.filter(x => x.method === 'session.cwd.set')).toHaveLength(2)
  112. expect(f.commands.some(x => x.method === 'config.set')).toBe(false)
  113. })
  114. it('does not switch a running session when another client resumes or queues a message', async () => {
  115. const f = await fixture(); f.busy()
  116. const read = vi.fn(async () => '/configured')
  117. const channel = await f.broker.create({ ...f.principal('alice'), configuredWorkingDirectory: read }, 'chat')
  118. await f.broker.command(channel, 'resume', resume)
  119. await f.broker.command(channel, 'queued', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'next', queued: true } })
  120. expect(read).not.toHaveBeenCalled()
  121. expect(f.commands.map(x => x.method)).toEqual(['session.resume', 'prompt.submit'])
  122. })
  123. it('does not submit a prompt when Hermes rejects the configured directory', async () => {
  124. const f = await fixture(); f.rejectCwd()
  125. const channel = await f.broker.create({ ...f.principal('alice'), configuredWorkingDirectory: async () => '/missing' }, 'chat')
  126. await f.broker.command(channel, 'resume', resume)
  127. const sent = await f.broker.command(channel, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'run' } })
  128. expect(sent).toMatchObject({ state: 'rejected', response: { error: { code: 'working_directory_rejected' } } })
  129. expect(f.commands.some(x => x.method === 'prompt.submit')).toBe(false)
  130. })
  131. it('checks revocation again after loading the configured directory', async () => {
  132. const f = await fixture()
  133. let valid = true
  134. const channel = await f.broker.create({ ...f.principal('alice'), valid: () => valid,
  135. configuredWorkingDirectory: async () => { valid = false; return '/configured' } }, 'chat')
  136. await f.broker.command(channel, 'resume', resume)
  137. const sent = await f.broker.command(channel, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'run' } })
  138. expect(sent).toMatchObject({ state: 'rejected', response: { error: { code: 'authentication_required' } } })
  139. expect(f.commands.map(x => x.method)).toEqual(['session.resume'])
  140. })
  141. it('rejects resuming a history-only session before opening an upstream route', async () => {
  142. const f = await fixture()
  143. const routes: string[] = []
  144. f.broker.onNativeRoute = (_owner, _profile, stored) => routes.push(stored)
  145. const principal = { ...f.principal('alice'), canResume: async () => false }
  146. const channel = await f.broker.create(principal, 'chat')
  147. const receipt = await f.broker.command(channel, 'history-resume', resume)
  148. expect(receipt).toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  149. expect(f.commands).toEqual([])
  150. expect(routes).toEqual([])
  151. })
  152. it('allows only a native Bots configured chat without claiming arbitrary history', async () => {
  153. const f = await fixture()
  154. const principal = {...f.principal('native:user:alice:1'), nativeBot:true, canResume:async()=>false}
  155. const channel = await f.broker.create(principal,'chat')
  156. expect(await f.broker.command(channel,'native-canonical',resume)).toMatchObject({state:'confirmed'})
  157. expect(await f.broker.command(channel,'wrong-profile',{...resume,params:{...resume.params,profile:'other'}}))
  158. .toMatchObject({state:'rejected',response:{error:{code:'history_session_read_only'}}})
  159. expect(await f.broker.command(channel,'arbitrary-history',{...resume,params:{...resume.params,session_id:'history-2'}}))
  160. .toMatchObject({state:'rejected',response:{error:{code:'history_session_read_only'}}})
  161. expect(f.commands.filter(c=>c.method==='session.resume')).toHaveLength(1)
  162. })
  163. it('registers and resumes sessions only within one stable device owner', async () => {
  164. const f = await fixture()
  165. const owned = new Set<string>()
  166. const commands: Array<{ owner: string; stored: string; method: string }> = []
  167. const events: Array<{ owner: string; stored: string; type: string }> = []
  168. const ownershipKey = (owner: string, profile: string, stored: string) =>
  169. `${owner}\u0000${profile}\u0000${stored}`
  170. f.broker.onNativeRoute = (owner, profile, stored) => {
  171. owned.add(ownershipKey(owner, profile, stored))
  172. }
  173. f.broker.onNativeCommand = (owner, _profile, stored, method) => {
  174. commands.push({ owner, stored, method })
  175. }
  176. f.broker.onNativeEvent = (owner, _profile, stored, frame) => {
  177. events.push({ owner, stored, type: String(frame.type) })
  178. }
  179. const device = (id: string): RealtimePrincipal => ({
  180. ...f.principal(`device:${id}`),
  181. upstreamKey: `device-service:${id}`,
  182. paired: true,
  183. canResume: async (profile, stored) =>
  184. owned.has(ownershipKey(`device:${id}`, profile, stored)),
  185. })
  186. const deviceA = device('device-a')
  187. const first = await f.broker.create(deviceA, 'chat')
  188. const created = await f.broker.command(first, 'device-create', {
  189. method: 'session.create', params: { profile: 'default', source: 'web' },
  190. })
  191. expect(created.state).toBe('confirmed')
  192. expect(owned).toContain(ownershipKey('device:device-a', 'default', 'stored-1'))
  193. expect(commands).toContainEqual({ owner: 'device:device-a', stored: 'stored-1', method: 'session.create' })
  194. f.emit('message.delta', { text: 'device event' })
  195. await vi.waitFor(() => expect(events).toContainEqual({
  196. owner: 'device:device-a', stored: 'stored-1', type: 'message.delta',
  197. }))
  198. const sameDevice = await f.broker.create(device('device-a'), 'chat')
  199. expect((await f.broker.command(sameDevice, 'same-device-resume', resume)).state).toBe('confirmed')
  200. const unowned = await f.broker.command(sameDevice, 'source-web-unowned', {
  201. method: 'session.resume', params: { session_id: 'unowned-source-web', profile: 'default' },
  202. })
  203. expect(unowned).toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  204. const anotherDevice = await f.broker.create(device('device-b'), 'chat')
  205. expect(await f.broker.command(anotherDevice, 'other-device-resume', resume))
  206. .toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  207. const user = {
  208. ...f.principal('user:alice:1'),
  209. upstreamKey: 'user-service',
  210. canResume: async (profile: string, stored: string) =>
  211. owned.has(ownershipKey('alice', profile, stored)),
  212. }
  213. const userChannel = await f.broker.create(user, 'chat')
  214. expect(await f.broker.command(userChannel, 'user-resume-device-session', resume))
  215. .toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
  216. })
  217. it('broadcasts a route title as an owner-scoped session list change', async () => {
  218. const f = await fixture()
  219. const globalOwners: string[] = []
  220. f.broker.onNativeGlobalEvent = (owner, type) => {
  221. if (type === 'sessions.changed') globalOwners.push(owner)
  222. }
  223. const routedPrincipal = { ...f.principal('user:alice:1'), canResume: async () => true }
  224. const routed = await f.broker.create(routedPrincipal, 'chat')
  225. const listOnly = await f.broker.create(f.principal('user:alice:1'), 'chat')
  226. const anotherUser = await f.broker.create(f.principal('user:bob:1'), 'chat')
  227. await f.broker.command(routed, 'owner-title-route', resume)
  228. const listFrames: StreamEntry[] = []
  229. const otherFrames: StreamEntry[] = []
  230. f.broker.subscribe(listOnly, undefined, entry => listFrames.push(entry))
  231. f.broker.subscribe(anotherUser, undefined, entry => otherFrames.push(entry))
  232. f.emit('session.title', { session_id: 'stored-1', title: '跨端标题' })
  233. await vi.waitFor(() => expect(listFrames.some(entry =>
  234. entry.data.includes('sessions.changed'))).toBe(true))
  235. expect(otherFrames.some(entry => entry.data.includes('sessions.changed'))).toBe(false)
  236. expect(globalOwners).toEqual(['alice'])
  237. })
  238. it('preserves restricted create provenance and never forwards source on resume', async () => {
  239. const f = await fixture()
  240. const channel = await f.broker.create(f.principal('alice'), 'chat')
  241. await f.broker.command(channel, 'create-ios', {
  242. method: 'session.create', params: { profile: 'default', source: 'ios' },
  243. })
  244. await f.broker.command(channel, 'create-web-default', {
  245. method: 'session.create', params: { profile: 'default' },
  246. })
  247. await f.broker.command(channel, 'resume-with-client-source', {
  248. method: 'session.resume', params: { session_id: 'stored-1', profile: 'default', source: 'ios' },
  249. })
  250. const openFrames = f.commands.filter(frame => frame.method === 'session.create' || frame.method === 'session.resume')
  251. expect(openFrames[0]?.params.source).toBe('ios')
  252. expect(openFrames[1]?.params.source).toBe('web')
  253. expect(openFrames[2]?.params).not.toHaveProperty('source')
  254. expect(() => f.broker.command(channel, 'create-invalid-source', {
  255. method: 'session.create', params: { profile: 'default', source: 'telegram' },
  256. })).toThrow(/source must be web or ios/)
  257. })
  258. it('shares upstream across subscribers and isolates unregistered routes', async () => {
  259. const f = await fixture()
  260. const a = await f.broker.create(f.principal('alice'), 'chat')
  261. const b = await f.broker.create(f.principal('bob'), 'chat')
  262. const c = await f.broker.create(f.principal('alice'), 'chat')
  263. await f.broker.command(a, 'resume-a', resume)
  264. await f.broker.command(c, 'resume-c', resume)
  265. const seenA: StreamEntry[] = [], seenB: StreamEntry[] = [], seenC: StreamEntry[] = []
  266. f.broker.subscribe(a, undefined, x => seenA.push(x)); f.broker.subscribe(b, undefined, x => seenB.push(x)); f.broker.subscribe(c, undefined, x => seenC.push(x))
  267. f.emit('message.delta', { text: '你好' })
  268. await new Promise(r => setTimeout(r, 30))
  269. expect(f.count()).toBe(1)
  270. expect(seenA.some(x => x.data.includes('你好'))).toBe(true)
  271. expect(seenC.some(x => x.data.includes('你好'))).toBe(true)
  272. expect(seenB.some(x => x.data.includes('你好'))).toBe(false)
  273. expect(() => f.broker.get(f.principal('bob'), a.id)).toThrow('Channel not found')
  274. })
  275. it('deduplicates simultaneous commands and rejects a changed payload', async () => {
  276. const f = await fixture(), p = f.principal('alice')
  277. const c = await f.broker.create(p, 'chat')
  278. await f.broker.command(c, 'open', resume)
  279. const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'secret prompt' } }
  280. const [one, two] = await Promise.all([f.broker.command(c, 'outbox-1', prompt), f.broker.command(c, 'outbox-1', prompt)])
  281. expect(one).toEqual(two); expect(one.state).toBe('confirmed')
  282. expect(f.commands.filter(x => x.method === 'prompt.submit')).toHaveLength(1)
  283. expect(() => f.broker.command(c, 'outbox-1', { ...prompt, params: { ...prompt.params, text: 'different' } })).toThrow('conflict')
  284. expect(() => f.broker.receipt(f.principal('bob'), 'outbox-1')).toThrow('Receipt not found')
  285. for (const file of ['realtime-receipts.sqlite3', 'realtime-receipts.sqlite3-wal']) expect(readFileSync(join(f.home, file)).includes(Buffer.from('secret prompt'))).toBe(false)
  286. })
  287. it('keeps upstream alive after downstream detach and replays exact cursor', async () => {
  288. const f = await fixture(), p = f.principal('alice')
  289. const c = await f.broker.create(p, 'chat')
  290. await f.broker.command(c, 'open', resume)
  291. const entries: StreamEntry[] = []
  292. const off = f.broker.subscribe(c, undefined, x => entries.push(x))
  293. const cursor = entries.at(-1)!.id
  294. off(); f.emit('message.start', {}); f.emit('message.delta', { text: 'background' })
  295. await new Promise(r => setTimeout(r, 30))
  296. const replay: StreamEntry[] = []
  297. f.broker.subscribe(c, cursor, x => replay.push(x))
  298. expect(replay).toHaveLength(2); expect(f.count()).toBe(1)
  299. f.advance(600_001)
  300. expect(() => f.broker.subscribe(c, cursor, () => {})).toThrow('Replay window expired')
  301. })
  302. it('does not execute runtime commands without a route subscription', async () => {
  303. const f = await fixture()
  304. const c = await f.broker.create(f.principal('alice'), 'chat')
  305. const result = await f.broker.command(c, 'forbidden', { method: 'session.interrupt', params: { session_id: 'guessed' } })
  306. expect(result.state).toBe('rejected'); expect(f.commands).toHaveLength(0)
  307. })
  308. it('marks write-ahead admissions unknown after a process restart', () => {
  309. const home = mkdtempSync(join(tmpdir(), 'yaoyao-receipts-test-'))
  310. const a = new RealtimeReceipts(home)
  311. a.reserve('alice', 'submitted', '{"text":"private"}'); a.close()
  312. const b = new RealtimeReceipts(home)
  313. expect(b.reserve('alice', 'submitted', '{"text":"private"}')).toMatchObject({ state: 'unknown' })
  314. b.close()
  315. })
  316. it('registers branch identities without losing the original subscription', async () => {
  317. const f = await fixture()
  318. const ownershipCommands: Array<{ owner: string; stored: string; method: string }> = []
  319. f.broker.onNativeCommand = (owner, _profile, stored, method) => {
  320. ownershipCommands.push({ owner, stored, method })
  321. }
  322. const c = await f.broker.create(f.principal('user:alice:1'), 'chat')
  323. await f.broker.command(c, 'open', resume)
  324. const branch = await f.broker.command(c, 'branch', { method: 'session.branch', params: { session_id: 'runtime-1' } })
  325. expect(branch.state).toBe('confirmed')
  326. const sent = await f.broker.command(c, 'branched-prompt', { method: 'prompt.submit', params: { session_id: 'runtime-branch', text: 'branch' } })
  327. expect(sent.state).toBe('confirmed')
  328. expect(c.routes.size).toBe(2)
  329. expect(ownershipCommands).toContainEqual({ owner: 'alice', stored: 'stored-branch', method: 'session.branch' })
  330. })
  331. it('does not let guessed approval IDs escape a subscribed session', async () => {
  332. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  333. await f.broker.command(c, 'open', resume)
  334. const result = await f.broker.command(c, 'approval', { method: 'approval.respond', params: { session_id: 'runtime-1', request_id: 'another-users-approval', approved: true } })
  335. expect(result).toMatchObject({ state: 'rejected', response: { error: { code: 'interaction_forbidden' } } })
  336. expect(f.commands.filter(x => x.method === 'approval.respond')).toHaveLength(0)
  337. })
  338. it('page reloads cannot exhaust live-stream capacity with detached handles', async () => {
  339. const f = await fixture(), p = f.principal('alice')
  340. for (let i = 0; i < 40; i++) await f.broker.create(p, 'chat')
  341. expect(f.broker.channels.size).toBe(32)
  342. expect(f.count()).toBe(1)
  343. })
  344. it('recovers the upstream replay ring without losing offline deltas', async () => {
  345. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  346. await f.broker.command(c, 'open', resume)
  347. const received: StreamEntry[] = []
  348. f.broker.subscribe(c, undefined, e => received.push(e))
  349. f.emit('message.delta', { text: 'before' })
  350. await vi.waitFor(() => expect(received.some(e => e.data.includes('before'))).toBe(true))
  351. f.disconnect(); f.emit('message.delta', { text: 'offline' })
  352. await vi.waitFor(() => expect(received.filter(e => e.data.includes('offline'))).toHaveLength(1), { timeout: 2500 })
  353. expect(f.count()).toBe(2)
  354. expect(received.filter(e => e.event === 'reset')).toHaveLength(0)
  355. })
  356. it('keeps retrying credential failures and replays missed events without resubmitting prompts', async () => {
  357. const f = await fixture(), p = f.principal('alice'), url = vi.fn(p.url)
  358. const channel = await f.broker.create({ ...p, url }, 'chat')
  359. await f.broker.command(channel, 'open', resume)
  360. await f.broker.command(channel, 'send-once', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'once' } })
  361. const received: StreamEntry[] = []
  362. f.broker.subscribe(channel, undefined, entry => received.push(entry))
  363. url.mockRejectedValueOnce(new Error('Hermes is restarting')).mockRejectedValueOnce(new Error('Ticket endpoint unavailable'))
  364. f.disconnect(); f.emit('message.delta', { text: 'missed-during-restart' })
  365. await vi.waitFor(() => expect(received.filter(e => e.data.includes('missed-during-restart'))).toHaveLength(1), { timeout: 5000 })
  366. expect(url).toHaveBeenCalledTimes(4)
  367. expect(f.count()).toBe(2)
  368. expect(f.commands.filter(command => command.method === 'prompt.submit')).toHaveLength(1)
  369. }, 7000)
  370. it('shares one credential attempt when clients reconnect during the retry delay', async () => {
  371. const f = await fixture(), p = f.principal('alice'), url = vi.fn(p.url)
  372. await f.broker.create({ ...p, url }, 'chat')
  373. let release!: (url: URL) => void
  374. url.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
  375. f.disconnect()
  376. await vi.waitFor(() => expect(url).toHaveBeenCalledTimes(2))
  377. const second = f.broker.create({ ...p, url }, 'chat')
  378. const third = f.broker.create({ ...p, url }, 'chat')
  379. release(await p.url('chat'))
  380. await Promise.all([second, third])
  381. expect(url).toHaveBeenCalledTimes(2)
  382. expect(f.count()).toBe(2)
  383. })
  384. it('retries a connected gateway whose session recovery never replies', async () => {
  385. const f = await fixture(), channel = await f.broker.create(f.principal('alice'), 'chat')
  386. await f.broker.command(channel, 'open', resume)
  387. const received: StreamEntry[] = []
  388. f.broker.subscribe(channel, undefined, entry => received.push(entry))
  389. const setTimeout = globalThis.setTimeout
  390. vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: (...args: any[]) => void, ms?: number, ...args: any[]) =>
  391. setTimeout(callback, ms === 120_000 ? 30 : ms, ...args)) as typeof globalThis.setTimeout)
  392. f.loseResumeReply(); f.disconnect(); f.emit('message.delta', { text: 'after-recovery-timeout' })
  393. await vi.waitFor(() => expect(received.some(e => e.data.includes('after-recovery-timeout'))).toBe(true), { timeout: 3000 })
  394. expect(f.count()).toBe(3)
  395. expect(received.some(e => e.data.includes('upstream_recovery_failed'))).toBe(true)
  396. })
  397. it('retries an explicit temporary session recovery error instead of reporting ready', async () => {
  398. const f = await fixture(), channel = await f.broker.create(f.principal('alice'), 'chat')
  399. await f.broker.command(channel, 'open', resume)
  400. const received: StreamEntry[] = []
  401. f.broker.subscribe(channel, undefined, entry => received.push(entry))
  402. f.failNextResume(5000); f.disconnect(); f.emit('message.delta', { text: 'after-temporary-resume-error' })
  403. await vi.waitFor(() => expect(received.some(e => e.data.includes('after-temporary-resume-error'))).toBe(true), { timeout: 3000 })
  404. expect(f.count()).toBe(3)
  405. expect(channel.routes.size).toBe(1)
  406. })
  407. it('retires a missing stored session without endlessly reconnecting the service', async () => {
  408. const f = await fixture(), channel = await f.broker.create(f.principal('alice'), 'chat')
  409. await f.broker.command(channel, 'open', resume)
  410. const received: StreamEntry[] = []
  411. f.broker.subscribe(channel, undefined, entry => received.push(entry))
  412. f.failNextResume(4007); f.disconnect()
  413. await vi.waitFor(() => expect(received.some(e => e.data.includes('session_not_found'))).toBe(true), { timeout: 2500 })
  414. expect(channel.routes.size).toBe(0)
  415. await new Promise(resolve => setTimeout(resolve, 1200))
  416. expect(f.count()).toBe(2)
  417. expect(f.commands.filter(command => command.method === 'session.resume')).toHaveLength(2)
  418. })
  419. it('does not reopen a socket when authorization is revoked during credential lookup', async () => {
  420. const f = await fixture(), p = f.principal('alice')
  421. let valid = true, release!: (url: URL) => void
  422. const url = vi.fn(p.url)
  423. await f.broker.create({ ...p, valid: () => valid, url }, 'chat')
  424. url.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
  425. f.disconnect()
  426. await vi.waitFor(() => expect(url).toHaveBeenCalledTimes(2))
  427. valid = false
  428. release(await p.url('chat'))
  429. await new Promise(resolve => setTimeout(resolve, 650))
  430. expect(url).toHaveBeenCalledTimes(2)
  431. expect(f.count()).toBe(1)
  432. })
  433. it('does not reopen a socket after shutdown with credentials still pending', async () => {
  434. const f = await fixture(), p = f.principal('alice'), url = vi.fn(p.url)
  435. await f.broker.create({ ...p, url }, 'chat')
  436. let release!: (url: URL) => void
  437. url.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
  438. f.disconnect()
  439. await vi.waitFor(() => expect(url).toHaveBeenCalledTimes(2))
  440. f.broker.close()
  441. release(await p.url('chat'))
  442. await new Promise(resolve => setTimeout(resolve, 650))
  443. expect(url).toHaveBeenCalledTimes(2)
  444. expect(f.count()).toBe(1)
  445. })
  446. it('recovers a shared service connection for another authorized user after its first user expires', async () => {
  447. const f = await fixture()
  448. let firstValid = true
  449. const firstURL = vi.fn(f.principal('alice').url), secondURL = vi.fn(f.principal('bob').url)
  450. await f.broker.create({ ...f.principal('alice'), valid: () => firstValid, url: firstURL }, 'chat')
  451. const channel = await f.broker.create({ ...f.principal('bob'), url: secondURL }, 'chat')
  452. await f.broker.command(channel, 'open', resume)
  453. const received: StreamEntry[] = []
  454. f.broker.subscribe(channel, undefined, entry => received.push(entry))
  455. firstValid = false
  456. f.disconnect(); f.emit('message.delta', { text: 'still-authorized' })
  457. await vi.waitFor(() => expect(received.some(e => e.data.includes('still-authorized'))).toBe(true), { timeout: 2500 })
  458. expect(firstURL).toHaveBeenCalledTimes(1)
  459. expect(secondURL).toHaveBeenCalledTimes(1)
  460. })
  461. it('reports reset when upstream history was truncated', async () => {
  462. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  463. await f.broker.command(c, 'open', resume)
  464. const received: StreamEntry[] = []
  465. f.broker.subscribe(c, undefined, e => received.push(e))
  466. f.truncate(); f.disconnect()
  467. await vi.waitFor(() => expect(received.some(e => e.event === 'reset')).toBe(true), { timeout: 2500 })
  468. })
  469. it('never repeats a prompt when the upstream acceptance reply is lost', async () => {
  470. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  471. await f.broker.command(c, 'open', resume)
  472. f.dropPrompt()
  473. const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'do once' } }
  474. const result = await f.broker.command(c, 'lost-ack', prompt)
  475. expect(result.state).toBe('unknown')
  476. expect((await f.broker.command(c, 'lost-ack', prompt)).state).toBe('unknown')
  477. expect(f.commands.filter(f => f.method === 'prompt.submit')).toHaveLength(1)
  478. })
  479. it('invalidates read snapshots before/after mutation and before delivering live events', async () => {
  480. const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
  481. await f.broker.command(c, 'open', resume)
  482. await f.broker.command(c, 'send', { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'once' } })
  483. expect(f.activities.filter(c => c.kind === 'command' && c.name === 'prompt.submit')).toEqual([
  484. { kind: 'command', name: 'prompt.submit', sessionId: 'stored-1' },
  485. { kind: 'command', name: 'prompt.submit', sessionId: 'stored-1' },
  486. ])
  487. const seen: StreamEntry[] = []
  488. f.broker.subscribe(c, undefined, entry => {
  489. if (entry.data.includes('message.delta')) expect(f.activities.at(-1)).toMatchObject({ kind: 'event', name: 'message.delta', sessionId: 'stored-1' })
  490. seen.push(entry)
  491. })
  492. f.emit('message.delta', { text: 'fresh' })
  493. await vi.waitFor(() => expect(seen.some(e => e.data.includes('fresh'))).toBe(true))
  494. })
  495. })