pushEvents.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. // @vitest-environment node
  2. import { createServer, type Server } from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import WebSocket, { WebSocketServer } from 'ws'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import type { ServerConfig } from '../../src/server/config.js'
  7. import { UpstreamServiceSession } from '../../src/server/localAuth.js'
  8. import {
  9. ChatPushJobManager,
  10. ChatPushRelayObserver,
  11. GroupPushEventProcessor,
  12. HermesChatNotificationResolver,
  13. HermesChatPushJobWatcher,
  14. type ChatPushJob,
  15. type ChatPushObservation,
  16. type GroupPushCandidate,
  17. type GroupWatchAnchor,
  18. type GroupWatchReset,
  19. type PushEventCoordinator,
  20. type PushNotificationCandidate,
  21. } from '../../src/server/pushEvents.js'
  22. import { UpstreamClient } from '../../src/server/upstream.js'
  23. const epoch = '123e4567-e89b-42d3-a456-426614174000'
  24. const closers: Array<() => Promise<void>> = []
  25. afterEach(async () => {
  26. for (const close of closers.splice(0).reverse()) await close()
  27. })
  28. function listen(server: Server): Promise<number> {
  29. return new Promise((resolve, reject) => {
  30. server.once('error', reject)
  31. server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
  32. })
  33. }
  34. function closeServer(server: Server): Promise<void> {
  35. return new Promise(resolve => server.close(() => resolve()))
  36. }
  37. class FakeCoordinator implements PushEventCoordinator {
  38. observations: ChatPushObservation[] = []
  39. jobs = new Map<string, ChatPushJob>()
  40. notifications: PushNotificationCandidate[] = []
  41. anchor?: GroupWatchAnchor
  42. subscribers = new Map<string, string[]>()
  43. failEnqueue = false
  44. promptDigest(localUserID: string, prompt: string): string { return `${localUserID}:${prompt}` }
  45. canRecoverChatJob(job: ChatPushJob): boolean {
  46. const owners = new Set([...this.jobs.values()]
  47. .filter(candidate => candidate.storedSessionID === job.storedSessionID)
  48. .map(candidate => candidate.localUserID))
  49. return owners.size === 1 && owners.has(job.localUserID)
  50. }
  51. observeChat(observation: ChatPushObservation): void { this.observations.push(observation) }
  52. saveChatJob(job: ChatPushJob): void { this.jobs.set(job.id, { ...job }) }
  53. completeChatJob(jobID: string): void { this.jobs.delete(jobID) }
  54. pendingChatJobs(): readonly ChatPushJob[] { return [...this.jobs.values()] }
  55. enqueueNotification(candidate: PushNotificationCandidate): 'enqueued' | 'duplicate' {
  56. if (this.failEnqueue) throw new Error('outbox unavailable')
  57. if (this.notifications.some(item => item.localUserID === candidate.localUserID && item.eventID === candidate.eventID)) {
  58. return 'duplicate'
  59. }
  60. this.notifications.push(candidate)
  61. return 'enqueued'
  62. }
  63. groupWatchAnchor(): GroupWatchAnchor | undefined { return this.anchor && { ...this.anchor } }
  64. groupSubscribers(roomID: string): readonly string[] { return this.subscribers.get(roomID) ?? [] }
  65. advanceGroupCursor(anchor: GroupWatchAnchor): void { this.anchor = { ...anchor } }
  66. resetGroupCursor(reset: GroupWatchReset): GroupWatchAnchor {
  67. this.anchor = { epoch: reset.epoch, cursor: reset.cursor }
  68. return this.anchor
  69. }
  70. }
  71. describe('push event bridge', () => {
  72. it('persists an owned chat job and enqueues a terminal notification', async () => {
  73. const coordinator = new FakeCoordinator()
  74. const observer = new ChatPushRelayObserver(
  75. coordinator,
  76. { localUserID: 'user-a', accountKey: 'account-a', source: 'web', connectionID: 'connection-a' },
  77. () => 1_000,
  78. { resolveTerminal: async () => ({ title: '权威会话标题', body: '权威最终正文', confirmed: true }) },
  79. )
  80. await observer.observeClientFrame(JSON.stringify({
  81. jsonrpc: '2.0', id: 'resume', method: 'session.resume',
  82. params: { profile: 'default', session_id: 'stored-1' },
  83. }))
  84. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
  85. jsonrpc: '2.0', id: 'resume', result: { session_id: 'runtime-1', stored_session_id: 'stored-1' },
  86. })), false)
  87. await observer.observeClientFrame(JSON.stringify({
  88. jsonrpc: '2.0', id: 'prompt', method: 'prompt.submit',
  89. params: { session_id: 'runtime-1', text: '执行任务' },
  90. }))
  91. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
  92. jsonrpc: '2.0', id: 'prompt', result: { status: 'accepted' },
  93. })), false)
  94. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
  95. jsonrpc: '2.0', method: 'event', params: {
  96. type: 'message.complete', session_id: 'runtime-1', profile: 'default',
  97. payload: { text: '中继正文', status: 'complete' },
  98. },
  99. })), false)
  100. await observer.flush()
  101. expect(coordinator.jobs.size).toBe(0)
  102. expect(coordinator.notifications).toEqual([
  103. expect.objectContaining({
  104. localUserID: 'user-a', kind: 'chat.completed', title: '权威会话标题', body: '权威最终正文',
  105. sessionID: 'stored-1', profile: 'default',
  106. }),
  107. ])
  108. expect(coordinator.observations.map(item => item.type)).toEqual([
  109. 'chat.session_opened', 'chat.prompt', 'chat.prompt', 'chat.rpc_event',
  110. ])
  111. })
  112. it('keeps a chat job after disconnect and emits approval only once through durable dedupe', async () => {
  113. const coordinator = new FakeCoordinator()
  114. const observer = new ChatPushRelayObserver(
  115. coordinator,
  116. { localUserID: 'user-a', source: 'gateway' },
  117. () => 2_000,
  118. { resolveTerminal: async () => ({ correlated: true }) },
  119. )
  120. await observer.observeClientFrame(JSON.stringify({
  121. id: 'resume', method: 'session.resume', params: { profile: 'default', session_id: 'stored-2' },
  122. }))
  123. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
  124. id: 'resume', result: { session_id: 'runtime-2', stored_session_id: 'stored-2' },
  125. })), false)
  126. await observer.observeClientFrame(JSON.stringify({
  127. id: 'prompt', method: 'prompt.submit', params: { session_id: 'runtime-2', text: '执行' },
  128. }))
  129. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({ id: 'prompt', result: { status: 'accepted' } })), false)
  130. const approval = Buffer.from(JSON.stringify({
  131. method: 'event', params: {
  132. type: 'approval.requested', session_id: 'runtime-2',
  133. payload: { request_id: 'approval-1', message: '允许执行?' },
  134. },
  135. }))
  136. observer.observeUpstreamFrame(approval, false)
  137. observer.observeUpstreamFrame(approval, false)
  138. observer.disconnected()
  139. await observer.flush()
  140. expect(coordinator.jobs.size).toBe(1)
  141. expect(coordinator.notifications).toHaveLength(1)
  142. expect(coordinator.notifications[0]).toMatchObject({
  143. kind: 'chat.approval.requested', requestID: 'approval-1', body: '允许执行?',
  144. })
  145. expect(coordinator.observations.at(-1)).toMatchObject({ type: 'chat.disconnected' })
  146. })
  147. it('persists a submitted unknown-receipt job with an authoritative pre-submit baseline', async () => {
  148. const coordinator = new FakeCoordinator()
  149. const observer = new ChatPushRelayObserver(
  150. coordinator,
  151. { localUserID: 'user-a', source: 'gateway' },
  152. () => 5_000,
  153. {
  154. captureBaseline: async () => ({
  155. messageID: 'assistant-before', sequence: 8, total: 9, assistantCount: 4, lastRowSequence: 8,
  156. }),
  157. resolveTerminal: async () => ({}),
  158. },
  159. )
  160. await observer.observeClientFrame(JSON.stringify({
  161. id: 'resume', method: 'session.resume', params: { profile: 'default', session_id: 'stored-3' },
  162. }))
  163. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
  164. id: 'resume', result: { session_id: 'runtime-3', stored_session_id: 'stored-3' },
  165. })), false)
  166. await observer.observeClientFrame(JSON.stringify({
  167. id: 'prompt-unknown', method: 'prompt.submit', params: { session_id: 'runtime-3', text: '可能已收到' },
  168. }))
  169. observer.disconnected()
  170. await observer.flush()
  171. expect([...coordinator.jobs.values()]).toEqual([
  172. expect.objectContaining({
  173. phase: 'submitted', storedSessionID: 'stored-3',
  174. metadata: expect.objectContaining({
  175. baselineCaptured: true, baselineMessageID: 'assistant-before', baselineSequence: 8,
  176. baselineTotal: 9, baselineAssistantCount: 4, baselineRowSequence: 8,
  177. promptDigest: 'user-a:可能已收到',
  178. }),
  179. }),
  180. ])
  181. })
  182. it('does not publish live terminal content when two local users own the same stored session', async () => {
  183. const coordinator = new FakeCoordinator()
  184. const makeObserver = async (user: string, runtime: string, prompt: string) => {
  185. const observer = new ChatPushRelayObserver(
  186. coordinator,
  187. { localUserID: user, source: 'gateway' },
  188. () => 10_000,
  189. { resolveTerminal: async () => ({ confirmed: false, correlated: false }) },
  190. )
  191. await observer.observeClientFrame(JSON.stringify({
  192. id: `resume-${user}`, method: 'session.resume', params: { profile: 'default', session_id: 'shared' },
  193. }))
  194. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
  195. id: `resume-${user}`, result: { session_id: runtime, stored_session_id: 'shared' },
  196. })), false)
  197. await observer.observeClientFrame(JSON.stringify({
  198. id: `prompt-${user}`, method: 'prompt.submit', params: { session_id: runtime, text: prompt },
  199. }))
  200. observer.observeUpstreamFrame(Buffer.from(JSON.stringify({
  201. id: `prompt-${user}`, result: { status: 'accepted' },
  202. })), false)
  203. return observer
  204. }
  205. const first = await makeObserver('user-a', 'runtime-a', '任务 A')
  206. const second = await makeObserver('user-b', 'runtime-b', '任务 B')
  207. await first.flush()
  208. await second.flush()
  209. second.observeUpstreamFrame(Buffer.from(JSON.stringify({
  210. method: 'event', params: {
  211. type: 'message.complete', session_id: 'runtime-b', payload: { text: '不应泄漏的正文' },
  212. },
  213. })), false)
  214. await second.flush()
  215. expect(coordinator.notifications).toHaveLength(0)
  216. })
  217. it('filters group events by terminal agent state, subscription, and stable event id', async () => {
  218. const coordinator = new FakeCoordinator()
  219. coordinator.anchor = { epoch, cursor: 5 }
  220. coordinator.subscribers.set('room-1', ['user-a', 'user-a', 'user-b'])
  221. const processor = new GroupPushEventProcessor({ epoch, cursor: 5 }, coordinator)
  222. await expect(processor.accept({ type: 'group.ready', epoch, cursor: 5 })).resolves.toBe('ready')
  223. await processor.accept({
  224. type: 'group.event', epoch, cursor: 6, roomId: 'room-1', event: 'message.upsert',
  225. payload: { id: 'message-1', roomId: 'room-1', senderKind: 'agent', status: 'streaming', content: '半成品' },
  226. })
  227. expect(coordinator.notifications).toHaveLength(0)
  228. await processor.accept({
  229. type: 'group.event', epoch, cursor: 7, roomId: 'room-1', event: 'message.upsert',
  230. payload: {
  231. id: 'message-1', roomId: 'room-1', topicId: 'topic-1', senderKind: 'agent', senderName: '夭夭',
  232. status: 'completed', content: '团队最终回复',
  233. },
  234. })
  235. expect(coordinator.notifications).toHaveLength(2)
  236. expect(coordinator.notifications.every(item => item.kind === 'group.message.completed')).toBe(true)
  237. expect((coordinator.notifications[0] as GroupPushCandidate).roomID).toBe('room-1')
  238. await processor.accept({
  239. type: 'group.event', epoch, cursor: 8, roomId: 'room-1', event: 'message.upsert',
  240. payload: { id: 'message-1', roomId: 'room-1', senderKind: 'agent', status: 'completed', content: '重复终态' },
  241. })
  242. expect(coordinator.notifications).toHaveLength(2)
  243. await processor.accept({
  244. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'interaction.requested',
  245. payload: {
  246. id: 'interaction-1', roomId: 'room-1', kind: 'clarification', status: 'pending',
  247. payload: { question: '选择哪个?' },
  248. },
  249. })
  250. expect(coordinator.notifications.filter(item => item.kind === 'group.interaction.requested')).toHaveLength(2)
  251. expect(coordinator.anchor).toEqual({ epoch, cursor: 9 })
  252. })
  253. it('does not advance the group cursor when durable enqueue fails', async () => {
  254. const coordinator = new FakeCoordinator()
  255. coordinator.anchor = { epoch, cursor: 1 }
  256. coordinator.subscribers.set('room-1', ['user-a'])
  257. coordinator.failEnqueue = true
  258. const processor = new GroupPushEventProcessor({ epoch, cursor: 1 }, coordinator)
  259. await expect(processor.accept({
  260. type: 'group.event', epoch, cursor: 2, roomId: 'room-1', event: 'message.upsert',
  261. payload: { id: 'message-1', roomId: 'room-1', senderKind: 'agent', status: 'failed', error: '失败' },
  262. })).rejects.toThrow('outbox unavailable')
  263. expect(coordinator.anchor).toEqual({ epoch, cursor: 1 })
  264. })
  265. it('resolves the authoritative latest assistant body from Hermes REST', async () => {
  266. let requested = ''
  267. const upstreamSession = {
  268. request: async (path: string, options?: { search?: URLSearchParams }) => {
  269. requested = `${path}?${options?.search?.toString()}`
  270. return {
  271. status: 200,
  272. body: Buffer.from(JSON.stringify({
  273. session: { title: '会话标题' },
  274. messages: [
  275. { id: 'assistant-1', run_id: 'run-1', role: 'assistant', content: '较早回复', sequence: 1, created_at: 1 },
  276. { id: 'user-1', role: 'user', content: '问题', sequence: 2, created_at: 2 },
  277. { id: 'assistant-2', run_id: 'run-2', role: 'assistant', content: '最终权威回复', sequence: 3, created_at: 3 },
  278. ],
  279. })),
  280. }
  281. },
  282. } as unknown as UpstreamServiceSession
  283. const resolver = new HermesChatNotificationResolver(upstreamSession)
  284. const result = await resolver.resolveTerminal({
  285. id: 'job-1', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
  286. storedSessionID: 'stored/a', requestID: 'prompt-1', queued: false, phase: 'accepted',
  287. submittedAt: 1, expiresAt: 2,
  288. metadata: {
  289. baselineCaptured: true, baselineMessageID: 'assistant-1', baselineSequence: 1,
  290. baselineAssistantCount: 1, baselineTotal: 2, runID: 'run-2',
  291. },
  292. }, 'run.completed', {})
  293. expect(result).toEqual({
  294. title: '会话标题', body: '最终权威回复', messageID: 'assistant-2', timestamp: 3_000,
  295. confirmed: true, correlated: true,
  296. })
  297. expect(requested).toContain('/api/sessions/stored%2Fa/messages?')
  298. expect(requested).toContain('order=latest')
  299. expect(requested).toContain('include_compacted=true')
  300. expect(requested).toContain('profile=default')
  301. })
  302. it('loads the title from the real Hermes session detail endpoint', async () => {
  303. const paths: string[] = []
  304. const upstream = {
  305. request: async (path: string) => {
  306. paths.push(path)
  307. return path.endsWith('/messages')
  308. ? {
  309. status: 200,
  310. body: Buffer.from(JSON.stringify({
  311. session_id: 'stored-real',
  312. messages: [{ id: 2, role: 'assistant', content: '真实正文', run_id: 'run-real' }],
  313. pagination: { total: 2 },
  314. })),
  315. }
  316. : { status: 200, body: Buffer.from('{"id":"stored-real","title":"真实会话标题"}') }
  317. },
  318. } as unknown as UpstreamServiceSession
  319. const resolved = await new HermesChatNotificationResolver(upstream).resolveTerminal({
  320. id: 'real-title', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  321. storedSessionID: 'stored-real', requestID: 'prompt', queued: false, phase: 'accepted',
  322. submittedAt: 1, expiresAt: 60_000,
  323. metadata: {
  324. baselineCaptured: true, baselineSequence: 0, baselineMessageID: 'old',
  325. baselineAssistantCount: 0, baselineTotal: 1, runID: 'run-real',
  326. },
  327. }, 'run.completed', {})
  328. expect(resolved).toMatchObject({ title: '真实会话标题', body: '真实正文', confirmed: true })
  329. expect(paths).toEqual([
  330. '/api/sessions/stored-real/messages',
  331. '/api/sessions/stored-real',
  332. ])
  333. })
  334. it('does not confirm an old assistant when only a newer user message exists', async () => {
  335. const upstreamSession = {
  336. request: async () => ({
  337. status: 200,
  338. body: Buffer.from(JSON.stringify({
  339. pagination: { total: 2 },
  340. messages: [
  341. { id: 'assistant-old', run_id: 'run-old', role: 'assistant', content: '旧回复', sequence: 1, timestamp: 1 },
  342. { id: 'user-new', role: 'user', content: '新问题', sequence: 2, timestamp: 2 },
  343. ],
  344. })),
  345. }),
  346. } as unknown as UpstreamServiceSession
  347. const resolver = new HermesChatNotificationResolver(upstreamSession)
  348. const resolved = await resolver.resolveTerminal({
  349. id: 'job-old-only', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
  350. storedSessionID: 'stored-1', requestID: 'prompt-1', queued: false, phase: 'submitted',
  351. submittedAt: 2_000, expiresAt: 60_000,
  352. metadata: {
  353. baselineCaptured: true, baselineMessageID: 'assistant-old', baselineSequence: 1,
  354. baselineTotal: 1, baselineAssistantCount: 1, runID: 'run-new',
  355. },
  356. }, 'run.completed', {})
  357. expect(resolved.confirmed).toBe(false)
  358. expect(resolved.body).toBeUndefined()
  359. })
  360. it('does not attribute another user run in the same stored session', async () => {
  361. const upstreamSession = {
  362. request: async () => ({
  363. status: 200,
  364. body: Buffer.from(JSON.stringify({
  365. messages: [
  366. { id: 'assistant-old', run_id: 'run-old', role: 'assistant', content: '旧回复', sequence: 1 },
  367. { id: 'assistant-a', run_id: 'run-user-a', role: 'assistant', content: '用户 A 的回复', sequence: 2 },
  368. { id: 'assistant-b', run_id: 'run-user-b', role: 'assistant', content: '用户 B 的回复', sequence: 3 },
  369. ],
  370. })),
  371. }),
  372. } as unknown as UpstreamServiceSession
  373. const resolver = new HermesChatNotificationResolver(upstreamSession)
  374. const common = {
  375. profile: 'default', runtimeSessionID: 'runtime', storedSessionID: 'shared-session',
  376. requestID: 'prompt', queued: false, phase: 'accepted' as const, submittedAt: 1, expiresAt: 60_000,
  377. metadata: {
  378. baselineCaptured: true, baselineMessageID: 'assistant-old', baselineSequence: 1,
  379. baselineAssistantCount: 1, baselineTotal: 1,
  380. },
  381. }
  382. const userA = await resolver.resolveTerminal({
  383. ...common, id: 'job-a', localUserID: 'user-a', metadata: { ...common.metadata, runID: 'run-user-a' },
  384. }, 'run.completed', {})
  385. const userB = await resolver.resolveTerminal({
  386. ...common, id: 'job-b', localUserID: 'user-b', metadata: { ...common.metadata, runID: 'run-user-b' },
  387. }, 'run.completed', {})
  388. const missing = await resolver.resolveTerminal({
  389. ...common, id: 'job-c', localUserID: 'user-c', metadata: { ...common.metadata, runID: 'run-user-c' },
  390. }, 'run.completed', {})
  391. expect(userA).toMatchObject({ confirmed: true, body: '用户 A 的回复' })
  392. expect(userB).toMatchObject({ confirmed: true, body: '用户 B 的回复' })
  393. expect(missing.confirmed).toBe(false)
  394. })
  395. it('recognizes an authoritative failed assistant record during recovery', async () => {
  396. const upstreamSession = {
  397. request: async () => ({
  398. status: 200,
  399. body: Buffer.from(JSON.stringify({
  400. messages: [{
  401. id: 'assistant-failed', run_id: 'run-failed', role: 'assistant', status: 'failed',
  402. error: 'permission denied', sequence: 2,
  403. }],
  404. })),
  405. }),
  406. } as unknown as UpstreamServiceSession
  407. const resolved = await new HermesChatNotificationResolver(upstreamSession).resolveTerminal({
  408. id: 'job-failed', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  409. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'accepted',
  410. submittedAt: 1, expiresAt: 60_000,
  411. metadata: {
  412. baselineCaptured: true, baselineAssistantCount: 0, baselineTotal: 0,
  413. baselineRowSequence: 0, runID: 'run-failed',
  414. },
  415. }, 'run.completed', {})
  416. expect(resolved).toMatchObject({
  417. confirmed: true, failed: true, error: 'permission denied', body: 'permission denied',
  418. })
  419. })
  420. it('correlates the real Hermes history shape by a private prompt digest without run IDs', async () => {
  421. const digest = async (userID: string, prompt: string) => `${userID}:${prompt}`
  422. let repeated = false
  423. const upstreamSession = {
  424. request: async () => ({
  425. status: 200,
  426. body: Buffer.from(JSON.stringify({
  427. messages: [
  428. { id: 10, role: 'assistant', content: '旧回复' },
  429. { id: 11, role: 'user', content: '生成报告' },
  430. ...(repeated ? [{ id: 12, role: 'user', content: '生成报告' }] : []),
  431. { id: 13, role: 'assistant', content: '新报告' },
  432. ],
  433. })),
  434. }),
  435. } as unknown as UpstreamServiceSession
  436. const resolver = new HermesChatNotificationResolver(upstreamSession, digest)
  437. const job: ChatPushJob = {
  438. id: 'job-real-shape', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  439. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'submitted',
  440. submittedAt: 1, expiresAt: 60_000,
  441. metadata: {
  442. baselineCaptured: true, baselineRowSequence: 10,
  443. promptDigest: await digest('user-a', '生成报告'),
  444. },
  445. }
  446. await expect(resolver.resolveTerminal(job, 'run.completed', {})).resolves.toMatchObject({
  447. confirmed: true, correlated: true, body: '新报告', messageID: '13',
  448. })
  449. repeated = true
  450. await expect(resolver.resolveTerminal(job, 'run.completed', {})).resolves.toMatchObject({
  451. confirmed: false, correlated: false,
  452. })
  453. })
  454. it('uses row zero as the recovery baseline for a new empty session', async () => {
  455. const upstream = {
  456. request: async () => ({ status: 200, body: Buffer.from('{"messages":[],"pagination":{"total":0}}') }),
  457. } as unknown as UpstreamServiceSession
  458. const baseline = await new HermesChatNotificationResolver(upstream).captureBaseline({
  459. id: 'empty', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  460. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'submitted',
  461. submittedAt: 1, expiresAt: 60_000,
  462. })
  463. expect(baseline).toMatchObject({ total: 0, assistantCount: 0, lastRowSequence: 0 })
  464. })
  465. it('starts pending chat recovery immediately and enforces the socket cap', async () => {
  466. const coordinator = new FakeCoordinator()
  467. const now = Date.now()
  468. for (let index = 1; index <= 3; index += 1) {
  469. coordinator.saveChatJob({
  470. id: `job-${index}`, localUserID: 'user-a', profile: 'default',
  471. runtimeSessionID: `runtime-${index}`, storedSessionID: `stored-${index}`,
  472. requestID: `prompt-${index}`, queued: false, phase: index === 1 ? 'submitted' : 'accepted',
  473. metadata: {
  474. baselineCaptured: true, baselineTotal: 0, baselineAssistantCount: 0,
  475. runID: `run-${index}`, disconnectedAt: now,
  476. },
  477. submittedAt: now + index, expiresAt: now + 60_000,
  478. })
  479. }
  480. coordinator.saveChatJob({
  481. id: 'job-online', localUserID: 'user-a', profile: 'default',
  482. runtimeSessionID: 'runtime-online', storedSessionID: 'stored-online',
  483. requestID: 'prompt-online', queued: false, phase: 'accepted',
  484. submittedAt: now + 4, expiresAt: now + 60_000,
  485. metadata: { baselineCaptured: true, baselineRowSequence: 0, promptDigest: 'digest-online' },
  486. })
  487. coordinator.saveChatJob({
  488. id: 'job-submitted', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-submitted',
  489. storedSessionID: 'stored-submitted', requestID: 'prompt-submitted', queued: false, phase: 'submitted',
  490. submittedAt: now + 4, expiresAt: now + 60_000,
  491. })
  492. coordinator.saveChatJob({
  493. id: 'job-expired', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-expired',
  494. storedSessionID: 'stored-expired', requestID: 'prompt-expired', queued: false, phase: 'submitted',
  495. submittedAt: now - 60_000, expiresAt: now - 1,
  496. })
  497. let ticketRequests = 0
  498. const session = {
  499. webSocketCredential: () => {
  500. ticketRequests += 1
  501. return new Promise<never>(() => undefined)
  502. },
  503. } as unknown as UpstreamServiceSession
  504. const config: ServerConfig = {
  505. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  506. allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
  507. imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  508. }
  509. const manager = new ChatPushJobManager(config, session, coordinator, {
  510. maximumConcurrent: 2, reconcileMilliseconds: 60_000,
  511. })
  512. manager.start()
  513. await manager.reconcile()
  514. expect(ticketRequests).toBe(2)
  515. expect(coordinator.jobs.has('job-submitted')).toBe(true)
  516. expect(coordinator.jobs.has('job-expired')).toBe(false)
  517. coordinator.completeChatJob('job-1')
  518. await manager.reconcile()
  519. expect(ticketRequests).toBe(3)
  520. manager.stop()
  521. })
  522. it('does not push an uncorrelated terminal frame seen by a recovery watcher', async () => {
  523. const upstream = createServer((request, response) => {
  524. response.setHeader('Content-Type', 'application/json')
  525. const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
  526. if (path === '/api/status') response.end('{"auth_required":true}')
  527. else if (path === '/api/auth/ws-ticket') response.end('{"ticket":"ownership-ticket"}')
  528. else response.end('{}')
  529. })
  530. const sockets = new WebSocketServer({ noServer: true })
  531. upstream.on('upgrade', (_request, socket, head) => {
  532. sockets.handleUpgrade(_request, socket, head, client => {
  533. client.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: {} } }))
  534. client.on('message', raw => {
  535. const frame = JSON.parse(raw.toString()) as Record<string, unknown>
  536. client.send(JSON.stringify({ id: frame.id, result: { session_id: 'runtime', running: true } }))
  537. setTimeout(() => client.send(JSON.stringify({
  538. method: 'event', params: {
  539. type: 'message.complete', session_id: 'runtime', payload: { text: '另一位用户的回复' },
  540. },
  541. })), 10)
  542. })
  543. })
  544. })
  545. const port = await listen(upstream)
  546. closers.push(async () => {
  547. for (const client of sockets.clients) client.terminate()
  548. sockets.close()
  549. await closeServer(upstream)
  550. })
  551. const config: ServerConfig = {
  552. host: '127.0.0.1', port: 8800, upstream: new URL(`http://127.0.0.1:${port}`),
  553. allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
  554. imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  555. }
  556. const session = new UpstreamServiceSession(new UpstreamClient(config.upstream), () => undefined)
  557. const coordinator = new FakeCoordinator()
  558. const job: ChatPushJob = {
  559. id: 'job-owned', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'old-runtime',
  560. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'accepted',
  561. submittedAt: Date.now(), expiresAt: Date.now() + 60_000,
  562. metadata: { baselineCaptured: true, baselineRowSequence: 1, promptDigest: 'user-a:我的任务' },
  563. }
  564. coordinator.saveChatJob(job)
  565. const watcher = new HermesChatPushJobWatcher(
  566. job,
  567. config,
  568. session,
  569. coordinator,
  570. { resolveTerminal: async () => ({ confirmed: false, correlated: false }) },
  571. )
  572. watcher.start()
  573. await new Promise(resolve => setTimeout(resolve, 100))
  574. watcher.stop()
  575. expect(coordinator.notifications).toHaveLength(0)
  576. expect(coordinator.jobs.has(job.id)).toBe(true)
  577. })
  578. it('recovers a persisted chat job through ticket, resume, and authoritative REST', async () => {
  579. const rpcFrames: Array<Record<string, unknown>> = []
  580. const submittedAt = Date.now()
  581. let messageReads = 0
  582. const upstream = createServer((request, response) => {
  583. response.setHeader('Content-Type', 'application/json')
  584. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  585. if (url.pathname === '/api/status') response.end(JSON.stringify({ auth_required: true }))
  586. else if (url.pathname === '/api/auth/me') response.end(JSON.stringify({ user_id: 'service' }))
  587. else if (url.pathname === '/api/auth/ws-ticket') response.end(JSON.stringify({ ticket: 'push-ticket' }))
  588. else if (url.pathname === '/api/sessions/stored-1/messages') {
  589. messageReads += 1
  590. response.end(JSON.stringify({
  591. session: { title: '恢复后的会话' },
  592. messages: [{
  593. id: messageReads === 1 ? 'old-assistant' : 'new-assistant',
  594. run_id: messageReads === 1 ? 'run-old' : 'run-recovery',
  595. role: 'assistant',
  596. content: messageReads === 1 ? '提交任务前的旧回复' : '后台恢复取得的最终正文',
  597. sequence: messageReads === 1 ? 2 : 3,
  598. created_at: (submittedAt + (messageReads === 1 ? -10_000 : 1_000)) / 1_000,
  599. }],
  600. }))
  601. } else {
  602. response.statusCode = 404
  603. response.end('{}')
  604. }
  605. })
  606. const sockets = new WebSocketServer({ noServer: true })
  607. upstream.on('upgrade', (request, socket, head) => {
  608. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  609. if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'push-ticket') return socket.destroy()
  610. sockets.handleUpgrade(request, socket, head, client => {
  611. client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } }))
  612. client.on('message', raw => {
  613. const frame = JSON.parse(raw.toString()) as Record<string, unknown>
  614. rpcFrames.push(frame)
  615. client.send(JSON.stringify({
  616. jsonrpc: '2.0', id: frame.id,
  617. result: { session_id: 'runtime-recovered', stored_session_id: 'stored-1', run_id: 'run-recovery', running: false },
  618. }))
  619. })
  620. })
  621. })
  622. const port = await listen(upstream)
  623. closers.push(async () => {
  624. for (const client of sockets.clients) client.terminate()
  625. sockets.close()
  626. await closeServer(upstream)
  627. })
  628. const config: ServerConfig = {
  629. host: '127.0.0.1', port: 8800, upstream: new URL(`http://127.0.0.1:${port}`),
  630. allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
  631. imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  632. }
  633. const session = new UpstreamServiceSession(new UpstreamClient(config.upstream), () => undefined)
  634. const coordinator = new FakeCoordinator()
  635. const job: ChatPushJob = {
  636. id: 'job-recovery', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-old',
  637. storedSessionID: 'stored-1', requestID: 'prompt-1', queued: false, phase: 'accepted',
  638. metadata: {
  639. baselineCaptured: true, baselineTotal: 2, baselineAssistantCount: 1,
  640. baselineMessageID: 'old-assistant', baselineSequence: 2, runID: 'run-recovery',
  641. },
  642. submittedAt, expiresAt: submittedAt + 60_000,
  643. }
  644. coordinator.saveChatJob(job)
  645. const watcher = new HermesChatPushJobWatcher(job, config, session, coordinator)
  646. watcher.start()
  647. await new Promise(resolve => setTimeout(resolve, 150))
  648. expect(coordinator.notifications).toHaveLength(0)
  649. expect(coordinator.jobs.has(job.id)).toBe(true)
  650. await new Promise<void>((resolve, reject) => {
  651. const timeout = setTimeout(() => reject(new Error('notification timeout')), 3_000)
  652. const poll = setInterval(() => {
  653. if (!coordinator.notifications.length) return
  654. clearTimeout(timeout)
  655. clearInterval(poll)
  656. resolve()
  657. }, 10)
  658. })
  659. watcher.stop()
  660. expect(rpcFrames.length).toBeGreaterThanOrEqual(2)
  661. expect(rpcFrames.every(frame => frame.method === 'session.resume')).toBe(true)
  662. expect(rpcFrames[0]).toMatchObject({
  663. params: { session_id: 'stored-1', profile: 'default', close_on_disconnect: false, omit_messages: true },
  664. })
  665. expect(coordinator.notifications[0]).toMatchObject({
  666. kind: 'chat.completed', title: '恢复后的会话', body: '后台恢复取得的最终正文', sessionID: 'stored-1',
  667. })
  668. expect(messageReads).toBeGreaterThanOrEqual(2)
  669. expect(coordinator.jobs.size).toBe(0)
  670. })
  671. })