pushEvents.test.ts 32 KB

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