pushEvents.test.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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. 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('resolves the authoritative latest assistant body from Hermes REST', async () => {
  217. let requested = ''
  218. const upstreamSession = {
  219. request: async (path: string, options?: { search?: URLSearchParams }) => {
  220. requested = `${path}?${options?.search?.toString()}`
  221. return {
  222. status: 200,
  223. body: Buffer.from(JSON.stringify({
  224. session: { title: '会话标题' },
  225. messages: [
  226. { id: 'assistant-1', run_id: 'run-1', role: 'assistant', content: '较早回复', sequence: 1, created_at: 1 },
  227. { id: 'user-1', role: 'user', content: '问题', sequence: 2, created_at: 2 },
  228. { id: 'assistant-2', run_id: 'run-2', role: 'assistant', content: '最终权威回复', sequence: 3, created_at: 3 },
  229. ],
  230. })),
  231. }
  232. },
  233. } as unknown as UpstreamServiceSession
  234. const resolver = new HermesChatNotificationResolver(upstreamSession)
  235. const result = await resolver.resolveTerminal({
  236. id: 'job-1', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
  237. storedSessionID: 'stored/a', requestID: 'prompt-1', queued: false, phase: 'accepted',
  238. submittedAt: 1, expiresAt: 2,
  239. metadata: {
  240. baselineCaptured: true, baselineMessageID: 'assistant-1', baselineSequence: 1,
  241. baselineAssistantCount: 1, baselineTotal: 2, runID: 'run-2',
  242. },
  243. }, 'run.completed', {})
  244. expect(result).toEqual({
  245. title: '会话标题', body: '最终权威回复', messageID: 'assistant-2', timestamp: 3_000,
  246. confirmed: true, correlated: true,
  247. })
  248. expect(requested).toContain('/api/sessions/stored%2Fa/messages?')
  249. expect(requested).toContain('order=latest')
  250. expect(requested).toContain('include_compacted=true')
  251. expect(requested).toContain('profile=default')
  252. })
  253. it('loads the title from the real Hermes session detail endpoint', async () => {
  254. const paths: string[] = []
  255. const upstream = {
  256. request: async (path: string) => {
  257. paths.push(path)
  258. return path.endsWith('/messages')
  259. ? {
  260. status: 200,
  261. body: Buffer.from(JSON.stringify({
  262. session_id: 'stored-real',
  263. messages: [{ id: 2, role: 'assistant', content: '真实正文', run_id: 'run-real' }],
  264. pagination: { total: 2 },
  265. })),
  266. }
  267. : { status: 200, body: Buffer.from('{"id":"stored-real","title":"真实会话标题"}') }
  268. },
  269. } as unknown as UpstreamServiceSession
  270. const resolved = await new HermesChatNotificationResolver(upstream).resolveTerminal({
  271. id: 'real-title', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  272. storedSessionID: 'stored-real', requestID: 'prompt', queued: false, phase: 'accepted',
  273. submittedAt: 1, expiresAt: 60_000,
  274. metadata: {
  275. baselineCaptured: true, baselineSequence: 0, baselineMessageID: 'old',
  276. baselineAssistantCount: 0, baselineTotal: 1, runID: 'run-real',
  277. },
  278. }, 'run.completed', {})
  279. expect(resolved).toMatchObject({ title: '真实会话标题', body: '真实正文', confirmed: true })
  280. expect(paths).toEqual([
  281. '/api/sessions/stored-real/messages',
  282. '/api/sessions/stored-real',
  283. ])
  284. })
  285. it('does not confirm an old assistant when only a newer user message exists', async () => {
  286. const upstreamSession = {
  287. request: async () => ({
  288. status: 200,
  289. body: Buffer.from(JSON.stringify({
  290. pagination: { total: 2 },
  291. messages: [
  292. { id: 'assistant-old', run_id: 'run-old', role: 'assistant', content: '旧回复', sequence: 1, timestamp: 1 },
  293. { id: 'user-new', role: 'user', content: '新问题', sequence: 2, timestamp: 2 },
  294. ],
  295. })),
  296. }),
  297. } as unknown as UpstreamServiceSession
  298. const resolver = new HermesChatNotificationResolver(upstreamSession)
  299. const resolved = await resolver.resolveTerminal({
  300. id: 'job-old-only', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
  301. storedSessionID: 'stored-1', requestID: 'prompt-1', queued: false, phase: 'submitted',
  302. submittedAt: 2_000, expiresAt: 60_000,
  303. metadata: {
  304. baselineCaptured: true, baselineMessageID: 'assistant-old', baselineSequence: 1,
  305. baselineTotal: 1, baselineAssistantCount: 1, runID: 'run-new',
  306. },
  307. }, 'run.completed', {})
  308. expect(resolved.confirmed).toBe(false)
  309. expect(resolved.body).toBeUndefined()
  310. })
  311. it('does not attribute another user run in the same stored session', async () => {
  312. const upstreamSession = {
  313. request: async () => ({
  314. status: 200,
  315. body: Buffer.from(JSON.stringify({
  316. messages: [
  317. { id: 'assistant-old', run_id: 'run-old', role: 'assistant', content: '旧回复', sequence: 1 },
  318. { id: 'assistant-a', run_id: 'run-user-a', role: 'assistant', content: '用户 A 的回复', sequence: 2 },
  319. { id: 'assistant-b', run_id: 'run-user-b', role: 'assistant', content: '用户 B 的回复', sequence: 3 },
  320. ],
  321. })),
  322. }),
  323. } as unknown as UpstreamServiceSession
  324. const resolver = new HermesChatNotificationResolver(upstreamSession)
  325. const common = {
  326. profile: 'default', runtimeSessionID: 'runtime', storedSessionID: 'shared-session',
  327. requestID: 'prompt', queued: false, phase: 'accepted' as const, submittedAt: 1, expiresAt: 60_000,
  328. metadata: {
  329. baselineCaptured: true, baselineMessageID: 'assistant-old', baselineSequence: 1,
  330. baselineAssistantCount: 1, baselineTotal: 1,
  331. },
  332. }
  333. const userA = await resolver.resolveTerminal({
  334. ...common, id: 'job-a', localUserID: 'user-a', metadata: { ...common.metadata, runID: 'run-user-a' },
  335. }, 'run.completed', {})
  336. const userB = await resolver.resolveTerminal({
  337. ...common, id: 'job-b', localUserID: 'user-b', metadata: { ...common.metadata, runID: 'run-user-b' },
  338. }, 'run.completed', {})
  339. const missing = await resolver.resolveTerminal({
  340. ...common, id: 'job-c', localUserID: 'user-c', metadata: { ...common.metadata, runID: 'run-user-c' },
  341. }, 'run.completed', {})
  342. expect(userA).toMatchObject({ confirmed: true, body: '用户 A 的回复' })
  343. expect(userB).toMatchObject({ confirmed: true, body: '用户 B 的回复' })
  344. expect(missing.confirmed).toBe(false)
  345. })
  346. it('recognizes an authoritative failed assistant record during recovery', async () => {
  347. const upstreamSession = {
  348. request: async () => ({
  349. status: 200,
  350. body: Buffer.from(JSON.stringify({
  351. messages: [{
  352. id: 'assistant-failed', run_id: 'run-failed', role: 'assistant', status: 'failed',
  353. error: 'permission denied', sequence: 2,
  354. }],
  355. })),
  356. }),
  357. } as unknown as UpstreamServiceSession
  358. const resolved = await new HermesChatNotificationResolver(upstreamSession).resolveTerminal({
  359. id: 'job-failed', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  360. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'accepted',
  361. submittedAt: 1, expiresAt: 60_000,
  362. metadata: {
  363. baselineCaptured: true, baselineAssistantCount: 0, baselineTotal: 0,
  364. baselineRowSequence: 0, runID: 'run-failed',
  365. },
  366. }, 'run.completed', {})
  367. expect(resolved).toMatchObject({
  368. confirmed: true, failed: true, error: 'permission denied', body: 'permission denied',
  369. })
  370. })
  371. it('correlates the real Hermes history shape by a private prompt digest without run IDs', async () => {
  372. const digest = async (userID: string, prompt: string) => `${userID}:${prompt}`
  373. let repeated = false
  374. const upstreamSession = {
  375. request: async () => ({
  376. status: 200,
  377. body: Buffer.from(JSON.stringify({
  378. messages: [
  379. { id: 10, role: 'assistant', content: '旧回复' },
  380. { id: 11, role: 'user', content: '生成报告' },
  381. ...(repeated ? [{ id: 12, role: 'user', content: '生成报告' }] : []),
  382. { id: 13, role: 'assistant', content: '新报告' },
  383. ],
  384. })),
  385. }),
  386. } as unknown as UpstreamServiceSession
  387. const resolver = new HermesChatNotificationResolver(upstreamSession, digest)
  388. const job: ChatPushJob = {
  389. id: 'job-real-shape', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  390. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'submitted',
  391. submittedAt: 1, expiresAt: 60_000,
  392. metadata: {
  393. baselineCaptured: true, baselineRowSequence: 10,
  394. promptDigest: await digest('user-a', '生成报告'),
  395. },
  396. }
  397. await expect(resolver.resolveTerminal(job, 'run.completed', {})).resolves.toMatchObject({
  398. confirmed: true, correlated: true, body: '新报告', messageID: '13',
  399. })
  400. repeated = true
  401. await expect(resolver.resolveTerminal(job, 'run.completed', {})).resolves.toMatchObject({
  402. confirmed: false, correlated: false,
  403. })
  404. })
  405. it('uses row zero as the recovery baseline for a new empty session', async () => {
  406. const upstream = {
  407. request: async () => ({ status: 200, body: Buffer.from('{"messages":[],"pagination":{"total":0}}') }),
  408. } as unknown as UpstreamServiceSession
  409. const baseline = await new HermesChatNotificationResolver(upstream).captureBaseline({
  410. id: 'empty', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime',
  411. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'submitted',
  412. submittedAt: 1, expiresAt: 60_000,
  413. })
  414. expect(baseline).toMatchObject({ total: 0, assistantCount: 0, lastRowSequence: 0 })
  415. })
  416. it('starts pending chat recovery immediately and enforces the socket cap', async () => {
  417. const coordinator = new FakeCoordinator()
  418. const now = Date.now()
  419. for (let index = 1; index <= 3; index += 1) {
  420. coordinator.saveChatJob({
  421. id: `job-${index}`, localUserID: 'user-a', profile: 'default',
  422. runtimeSessionID: `runtime-${index}`, storedSessionID: `stored-${index}`,
  423. requestID: `prompt-${index}`, queued: false, phase: index === 1 ? 'submitted' : 'accepted',
  424. metadata: {
  425. baselineCaptured: true, baselineTotal: 0, baselineAssistantCount: 0,
  426. runID: `run-${index}`, disconnectedAt: now,
  427. },
  428. submittedAt: now + index, expiresAt: now + 60_000,
  429. })
  430. }
  431. coordinator.saveChatJob({
  432. id: 'job-online', localUserID: 'user-a', profile: 'default',
  433. runtimeSessionID: 'runtime-online', storedSessionID: 'stored-online',
  434. requestID: 'prompt-online', queued: false, phase: 'accepted',
  435. submittedAt: now + 4, expiresAt: now + 60_000,
  436. metadata: { baselineCaptured: true, baselineRowSequence: 0, promptDigest: 'digest-online' },
  437. })
  438. coordinator.saveChatJob({
  439. id: 'job-submitted', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-submitted',
  440. storedSessionID: 'stored-submitted', requestID: 'prompt-submitted', queued: false, phase: 'submitted',
  441. submittedAt: now + 4, expiresAt: now + 60_000,
  442. })
  443. coordinator.saveChatJob({
  444. id: 'job-expired', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-expired',
  445. storedSessionID: 'stored-expired', requestID: 'prompt-expired', queued: false, phase: 'submitted',
  446. submittedAt: now - 60_000, expiresAt: now - 1,
  447. })
  448. let ticketRequests = 0
  449. const session = {
  450. webSocketCredential: () => {
  451. ticketRequests += 1
  452. return new Promise<never>(() => undefined)
  453. },
  454. } as unknown as UpstreamServiceSession
  455. const config: ServerConfig = {
  456. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  457. allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
  458. imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  459. }
  460. const manager = new ChatPushJobManager(config, session, coordinator, {
  461. maximumConcurrent: 2, reconcileMilliseconds: 60_000,
  462. })
  463. manager.start()
  464. await manager.reconcile()
  465. expect(ticketRequests).toBe(2)
  466. expect(coordinator.jobs.has('job-submitted')).toBe(true)
  467. expect(coordinator.jobs.has('job-expired')).toBe(false)
  468. coordinator.completeChatJob('job-1')
  469. await manager.reconcile()
  470. expect(ticketRequests).toBe(3)
  471. manager.stop()
  472. })
  473. it('does not push an uncorrelated terminal frame seen by a recovery watcher', async () => {
  474. const upstream = createServer((request, response) => {
  475. response.setHeader('Content-Type', 'application/json')
  476. const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
  477. if (path === '/api/status') response.end('{"auth_required":true}')
  478. else if (path === '/api/auth/ws-ticket') response.end('{"ticket":"ownership-ticket"}')
  479. else response.end('{}')
  480. })
  481. const sockets = new WebSocketServer({ noServer: true })
  482. upstream.on('upgrade', (_request, socket, head) => {
  483. sockets.handleUpgrade(_request, socket, head, client => {
  484. client.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: {} } }))
  485. client.on('message', raw => {
  486. const frame = JSON.parse(raw.toString()) as Record<string, unknown>
  487. client.send(JSON.stringify({ id: frame.id, result: { session_id: 'runtime', running: true } }))
  488. setTimeout(() => client.send(JSON.stringify({
  489. method: 'event', params: {
  490. type: 'message.complete', session_id: 'runtime', payload: { text: '另一位用户的回复' },
  491. },
  492. })), 10)
  493. })
  494. })
  495. })
  496. const port = await listen(upstream)
  497. closers.push(async () => {
  498. for (const client of sockets.clients) client.terminate()
  499. sockets.close()
  500. await closeServer(upstream)
  501. })
  502. const config: ServerConfig = {
  503. host: '127.0.0.1', port: 15300, upstream: new URL(`http://127.0.0.1:${port}`),
  504. allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
  505. imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  506. }
  507. const session = new UpstreamServiceSession(new UpstreamClient(config.upstream), () => undefined)
  508. const coordinator = new FakeCoordinator()
  509. const job: ChatPushJob = {
  510. id: 'job-owned', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'old-runtime',
  511. storedSessionID: 'stored', requestID: 'prompt', queued: false, phase: 'accepted',
  512. submittedAt: Date.now(), expiresAt: Date.now() + 60_000,
  513. metadata: { baselineCaptured: true, baselineRowSequence: 1, promptDigest: 'user-a:我的任务' },
  514. }
  515. coordinator.saveChatJob(job)
  516. const watcher = new HermesChatPushJobWatcher(
  517. job,
  518. config,
  519. session,
  520. coordinator,
  521. { resolveTerminal: async () => ({ confirmed: false, correlated: false }) },
  522. )
  523. watcher.start()
  524. await new Promise(resolve => setTimeout(resolve, 100))
  525. watcher.stop()
  526. expect(coordinator.notifications).toHaveLength(0)
  527. expect(coordinator.jobs.has(job.id)).toBe(true)
  528. })
  529. it('recovers a persisted chat job through ticket, resume, and authoritative REST', async () => {
  530. const rpcFrames: Array<Record<string, unknown>> = []
  531. const submittedAt = Date.now()
  532. let messageReads = 0
  533. const upstream = createServer((request, response) => {
  534. response.setHeader('Content-Type', 'application/json')
  535. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  536. if (url.pathname === '/api/status') response.end(JSON.stringify({ auth_required: true }))
  537. else if (url.pathname === '/api/auth/me') response.end(JSON.stringify({ user_id: 'service' }))
  538. else if (url.pathname === '/api/auth/ws-ticket') response.end(JSON.stringify({ ticket: 'push-ticket' }))
  539. else if (url.pathname === '/api/sessions/stored-1/messages') {
  540. messageReads += 1
  541. response.end(JSON.stringify({
  542. session: { title: '恢复后的会话' },
  543. messages: [{
  544. id: messageReads === 1 ? 'old-assistant' : 'new-assistant',
  545. run_id: messageReads === 1 ? 'run-old' : 'run-recovery',
  546. role: 'assistant',
  547. content: messageReads === 1 ? '提交任务前的旧回复' : '后台恢复取得的最终正文',
  548. sequence: messageReads === 1 ? 2 : 3,
  549. created_at: (submittedAt + (messageReads === 1 ? -10_000 : 1_000)) / 1_000,
  550. }],
  551. }))
  552. } else {
  553. response.statusCode = 404
  554. response.end('{}')
  555. }
  556. })
  557. const sockets = new WebSocketServer({ noServer: true })
  558. upstream.on('upgrade', (request, socket, head) => {
  559. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  560. if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'push-ticket') return socket.destroy()
  561. sockets.handleUpgrade(request, socket, head, client => {
  562. client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } }))
  563. client.on('message', raw => {
  564. const frame = JSON.parse(raw.toString()) as Record<string, unknown>
  565. rpcFrames.push(frame)
  566. client.send(JSON.stringify({
  567. jsonrpc: '2.0', id: frame.id,
  568. result: { session_id: 'runtime-recovered', stored_session_id: 'stored-1', run_id: 'run-recovery', running: false },
  569. }))
  570. })
  571. })
  572. })
  573. const port = await listen(upstream)
  574. closers.push(async () => {
  575. for (const client of sockets.clients) client.terminate()
  576. sockets.close()
  577. await closeServer(upstream)
  578. })
  579. const config: ServerConfig = {
  580. host: '127.0.0.1', port: 15300, upstream: new URL(`http://127.0.0.1:${port}`),
  581. allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
  582. imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  583. }
  584. const session = new UpstreamServiceSession(new UpstreamClient(config.upstream), () => undefined)
  585. const coordinator = new FakeCoordinator()
  586. const job: ChatPushJob = {
  587. id: 'job-recovery', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-old',
  588. storedSessionID: 'stored-1', requestID: 'prompt-1', queued: false, phase: 'accepted',
  589. metadata: {
  590. baselineCaptured: true, baselineTotal: 2, baselineAssistantCount: 1,
  591. baselineMessageID: 'old-assistant', baselineSequence: 2, runID: 'run-recovery',
  592. },
  593. submittedAt, expiresAt: submittedAt + 60_000,
  594. }
  595. coordinator.saveChatJob(job)
  596. const watcher = new HermesChatPushJobWatcher(job, config, session, coordinator)
  597. watcher.start()
  598. await new Promise(resolve => setTimeout(resolve, 150))
  599. expect(coordinator.notifications).toHaveLength(0)
  600. expect(coordinator.jobs.has(job.id)).toBe(true)
  601. await new Promise<void>((resolve, reject) => {
  602. const timeout = setTimeout(() => reject(new Error('notification timeout')), 3_000)
  603. const poll = setInterval(() => {
  604. if (!coordinator.notifications.length) return
  605. clearTimeout(timeout)
  606. clearInterval(poll)
  607. resolve()
  608. }, 10)
  609. })
  610. watcher.stop()
  611. expect(rpcFrames.length).toBeGreaterThanOrEqual(2)
  612. expect(rpcFrames.every(frame => frame.method === 'session.resume')).toBe(true)
  613. expect(rpcFrames[0]).toMatchObject({
  614. params: { session_id: 'stored-1', profile: 'default', close_on_disconnect: false, omit_messages: true },
  615. })
  616. expect(coordinator.notifications[0]).toMatchObject({
  617. kind: 'chat.completed', title: '恢复后的会话', body: '后台恢复取得的最终正文', sessionID: 'stored-1',
  618. })
  619. expect(messageReads).toBeGreaterThanOrEqual(2)
  620. expect(coordinator.jobs.size).toBe(0)
  621. })
  622. })