chat.service.spec.ts 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457
  1. import { TestBed } from '@angular/core/testing';
  2. import { ChatService, isAttachmentEcho } from './chat.service';
  3. import { setServerUploadLimit } from './upload-limits';
  4. import { FmodeApi } from './api/fmode-api';
  5. import { LiveService } from './live.service';
  6. import { MatrixClientService, MxMessage } from './matrix-client.service';
  7. import { SessionService } from './session.service';
  8. import { UnreadService } from './unread.service';
  9. import { ChatSessionService } from './chat-session.service';
  10. import { Agent, MatrixProvision } from './api/models';
  11. import { environment } from '../../environments/environment';
  12. /**
  13. * ChatService 单测 —— 会话状态机(SPEC FR-04)
  14. *
  15. * 覆盖:provision → 连接 → 房间 → 收发 → 分页 的编排与失败分环节报错。
  16. * 依赖(FmodeApi / LiveService / MatrixClientService)全部用桩替换,
  17. * **不触网、不加载 matrix-js-sdk**。
  18. *
  19. * 🔴 重点回归:
  20. * ① 切会话时 `seq` 防串扰(过期回包必须被丢弃)
  21. * ② `close()` **不断开**会话级连接(未读依赖 sync 常驻)
  22. * ③ 失败时**分环节**报错(FMODE 开通 / Matrix 登录 / 房间创建)
  23. * ④ `paginate()` 防重入(滚动事件高频触发)
  24. */
  25. describe('ChatService', () => {
  26. let svc: ChatService;
  27. /** 桩:FmodeApi */
  28. /**
  29. * 云端会话目录桩(2026-10-31):会话列表以云端为唯一来源。
  30. * 默认返回一条已有会话,模拟「进入一个聊过的容器」。
  31. */
  32. const CLOUD_THREAD = {
  33. objectId: 'cloud1',
  34. agentId: 'agent-node-lj-test',
  35. roomId: '!room1:fmode.cn',
  36. threadRoot: '$approot_test:fmode.cn',
  37. title: '云端会话',
  38. titleLocked: false,
  39. messageCount: 1,
  40. lastMessageAt: Date.now(),
  41. archived: false,
  42. lastPreview: '',
  43. };
  44. const apiStub = {
  45. provisionMatrix: jasmine.createSpy('provisionMatrix'),
  46. fetchChatThreads: jasmine.createSpy('fetchChatThreads'),
  47. upsertChatThreads: jasmine.createSpy('upsertChatThreads'),
  48. patchChatThread: jasmine.createSpy('patchChatThread'),
  49. removeChatThread: jasmine.createSpy('removeChatThread'),
  50. validateMatrix: jasmine.createSpy('validateMatrix'),
  51. fetchAgents: jasmine.createSpy('fetchAgents'),
  52. uploadFile: jasmine.createSpy('uploadFile'),
  53. requestUploadUrl: jasmine.createSpy('requestUploadUrl'),
  54. };
  55. /** 桩:LiveService */
  56. const liveStub = {
  57. ensureLoaded: jasmine.createSpy('ensureLoaded').and.returnValue(Promise.resolve()),
  58. agentById: jasmine.createSpy('agentById'),
  59. agents: jasmine.createSpy('agents').and.returnValue([] as Agent[]),
  60. /** 加载状态(chat.open 用它区分「加载中」与「确实不存在」) */
  61. state: jasmine.createSpy('state').and.returnValue('ready'),
  62. error: jasmine.createSpy('error').and.returnValue(null),
  63. };
  64. /** 桩:MatrixClientService */
  65. const mxStub = {
  66. connectSession: jasmine.createSpy('connectSession').and.returnValue(Promise.resolve()),
  67. disconnect: jasmine.createSpy('disconnect'),
  68. ensureRoom: jasmine.createSpy('ensureRoom'),
  69. syncState: jasmine.createSpy('syncState').and.returnValue('connected'),
  70. hasInitialSync: true,
  71. paginationToken: jasmine.createSpy('paginationToken').and.returnValue('tok-b'),
  72. timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]),
  73. onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined),
  74. onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined),
  75. onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined),
  76. sendText: jasmine.createSpy('sendText').and.returnValue(Promise.resolve({ event_id: '$e' })),
  77. sendFile: jasmine
  78. .createSpy('sendFile')
  79. .and.returnValue(Promise.resolve({ event_id: '$f' })),
  80. setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
  81. paginateBack: jasmine.createSpy('paginateBack').and.returnValue(Promise.resolve(true)),
  82. /** 初次 sync 是否完成(回溯历史前会等它,2026-11-12) */
  83. synced: jasmine.createSpy('synced').and.returnValue(Promise.resolve(true)),
  84. /** token 失效自愈时用它定位容器(2026-11-12) */
  85. setRecoveryAgentId: jasmine.createSpy('setRecoveryAgentId'),
  86. lastMessageEventId: jasmine.createSpy('lastMessageEventId').and.returnValue(null),
  87. };
  88. const PROV: MatrixProvision = {
  89. cached: true,
  90. bindingId: 'b1',
  91. status: 'active',
  92. userMxId: '@sr2wipsdyq:fmode.cn',
  93. agentMxId: '@zgdcmgnqcd:fmode.cn',
  94. homeserver: 'https://matrix.fmode.cn',
  95. accessToken: 'tok',
  96. deviceId: 'DEV1',
  97. roomId: '!room1:fmode.cn',
  98. };
  99. const AGENT: Agent = {
  100. id: 'agent-node-lj-test',
  101. name: 'lj-test',
  102. status: 'online' as Agent['status'],
  103. matrixReady: true,
  104. unread: 0,
  105. };
  106. beforeEach(() => {
  107. localStorage.clear();
  108. for (const k of Object.keys(apiStub)) {
  109. (apiStub as Record<string, jasmine.Spy>)[k].calls.reset();
  110. }
  111. liveStub.ensureLoaded.calls.reset();
  112. liveStub.agentById.calls.reset();
  113. for (const k of Object.keys(mxStub)) {
  114. const v = (mxStub as Record<string, unknown>)[k];
  115. if (v && typeof (v as jasmine.Spy).calls === 'object') (v as jasmine.Spy).calls.reset();
  116. }
  117. mxStub.connectSession.and.returnValue(Promise.resolve());
  118. mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
  119. mxStub.syncState.and.returnValue('connected');
  120. mxStub.paginationToken.and.returnValue('tok-b');
  121. mxStub.timelineToMessages.and.returnValue([]);
  122. mxStub.onMessage.and.returnValue(() => undefined);
  123. mxStub.onAnyMessage.and.returnValue(() => undefined);
  124. mxStub.onTyping.and.returnValue(() => undefined);
  125. mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$e' }));
  126. // 每个用例都重置附件发送策略 —— 否则上一用例的 reject 会漏到下一个
  127. mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$f' }));
  128. mxStub.paginateBack.and.returnValue(Promise.resolve(true));
  129. mxStub.synced.and.returnValue(Promise.resolve(true));
  130. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([CLOUD_THREAD]));
  131. apiStub.upsertChatThreads.and.returnValue(Promise.resolve());
  132. apiStub.patchChatThread.and.returnValue(Promise.resolve());
  133. apiStub.removeChatThread.and.returnValue(Promise.resolve());
  134. liveStub.agentById.and.returnValue(AGENT);
  135. apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
  136. TestBed.configureTestingModule({
  137. providers: [
  138. ChatService,
  139. { provide: FmodeApi, useValue: apiStub },
  140. { provide: LiveService, useValue: liveStub },
  141. { provide: MatrixClientService, useValue: mxStub },
  142. { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'r:t' } },
  143. { provide: UnreadService, useClass: UnreadService },
  144. /*
  145. * 显式提供 ChatSessionService:每个用例都会重建注入器,
  146. * 只有列在 providers 里,`ChatService` 内部 inject 的实例
  147. * 才与 `TestBed.inject` 拿到的是**同一个**。
  148. */
  149. ChatSessionService,
  150. ],
  151. });
  152. svc = TestBed.inject(ChatService);
  153. /*
  154. * 两个根单例的状态都会跨用例保留,必须显式重置:
  155. * - ChatSessionService:内存里的会话列表(localStorage.clear() 只管磁盘)
  156. * - ChatService:agentId/sessionId/state —— 不重置的话第二个用例调
  157. * open() 会因为「同一个 agent 且已 ready」直接早退,断言全部落空
  158. */
  159. svc.sessions.reset();
  160. svc.close();
  161. });
  162. afterEach(() => {
  163. localStorage.clear();
  164. });
  165. /** 测试统一走真实态(演示态由 MockApi 覆盖,另测) */
  166. function realMode(): void {
  167. (environment as { useMockData: boolean }).useMockData = false;
  168. }
  169. describe('新建对话(2026-11-01 点击即建)', () => {
  170. /*
  171. * 🔴 用户反馈:点「新建对话」之后左侧看不到任何变化。
  172. *
  173. * 上一版是「首次发消息才建会话」,点按钮只进入待建态、不落数据 ——
  174. * 左侧列表当然不会出现条目,用户不知道操作有没有生效。
  175. * 现在改为点击即建,并复用它归属下已有的空会话(避免连点攒出一排「新对话」)。
  176. */
  177. it('🔴 startBlank 立刻建出会话并选中', async () => {
  178. realMode();
  179. mxStub.timelineToMessages.and.returnValue([]);
  180. // 云端目录置空:默认桩会返回 CLOUD_THREAD,断言会凭空多一条
  181. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  182. svc.sessions.reset();
  183. const created = await svc.startBlank('agent-node-lj-test');
  184. expect(created).withContext('必须立刻返回会话').toBeTruthy();
  185. expect(svc.currentSession()?.id).withContext('新会话要处于选中态').toBe(created!.id);
  186. expect(svc.sessions.listFor('agent-node-lj-test').length).withContext('左侧应出现 1 条').toBe(1);
  187. });
  188. it('指定工作区时归属正确', async () => {
  189. realMode();
  190. mxStub.timelineToMessages.and.returnValue([]);
  191. const created = await svc.startBlank('agent-node-lj-test', 'ws-test');
  192. expect(svc.sessions.byId(created!.id)?.workspaceId).toBe('ws-test');
  193. });
  194. /*
  195. * 🔴 用户裁决:整个列表**最多保留一条**「新建对话」。
  196. *
  197. * 每次新建都把之前所有没发过消息的空会话清掉 —— 不按归属复用,
  198. * 也不依赖定时清理,否则会出现「工作区一条、日常一条」并存。
  199. */
  200. it('🔴 再次新建时,之前的空会话被清掉,只留一条', async () => {
  201. realMode();
  202. mxStub.timelineToMessages.and.returnValue([]);
  203. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  204. svc.sessions.reset();
  205. const a = await svc.startBlank('agent-node-lj-test', 'ws-test');
  206. const b = await svc.startBlank('agent-node-lj-test', 'ws-test');
  207. expect(svc.sessions.byId(a!.id)).withContext('上一次的空会话应被清掉').toBeUndefined();
  208. expect(svc.sessions.byId(b!.id)).toBeDefined();
  209. expect(svc.sessions.listFor('agent-node-lj-test').length).toBe(1);
  210. });
  211. it('🔴 不同归属新建时,旧的空会话同样被清掉(全局只留一条)', async () => {
  212. realMode();
  213. mxStub.timelineToMessages.and.returnValue([]);
  214. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  215. svc.sessions.reset();
  216. const a = await svc.startBlank('agent-node-lj-test', 'ws-a');
  217. const b = await svc.startBlank('agent-node-lj-test', 'ws-b');
  218. expect(svc.sessions.byId(a!.id)).withContext('工作区 A 的那条应被清掉').toBeUndefined();
  219. expect(svc.sessions.byId(b!.id)?.workspaceId).toBe('ws-b');
  220. expect(svc.sessions.listFor('agent-node-lj-test').length).toBe(1);
  221. });
  222. it('🔴 发过消息的会话不会被新建操作清掉', async () => {
  223. realMode();
  224. mxStub.timelineToMessages.and.returnValue([]);
  225. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  226. svc.sessions.reset();
  227. // 先建一条并发消息(= 已持久化)
  228. const old = await svc.startBlank('agent-node-lj-test');
  229. await svc.sendText('这条已经发过消息');
  230. // 再新建一条
  231. const fresh = await svc.startBlank('agent-node-lj-test');
  232. expect(svc.sessions.byId(old!.id)).withContext('有消息的会话不能被清').toBeDefined();
  233. expect(svc.sessions.byId(fresh!.id)).toBeDefined();
  234. });
  235. it('建出会话后发消息,state 正常切到 ready', async () => {
  236. realMode();
  237. mxStub.timelineToMessages.and.returnValue([]);
  238. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  239. svc.sessions.reset();
  240. await svc.startBlank('agent-node-lj-test');
  241. await svc.sendText('第一条消息');
  242. expect(svc.state()).toBe('ready');
  243. expect(svc.currentSession()).toBeDefined();
  244. });
  245. /*
  246. * 🔴 2026-11-09 用户要求:「第一次发送消息的时候,才会开始持久化存储
  247. * 这个对话;存储成功后,才会开始发送消息」。
  248. *
  249. * 原实现是 `commit()`(发出去就不管):消息先发进 Matrix,落库在后台跑。
  250. * 现在首次落库必须 `await`,成功才继续发送。
  251. */
  252. it('🔴 首次发消息:先落库成功,才真正发送到 Matrix', async () => {
  253. realMode();
  254. mxStub.timelineToMessages.and.returnValue([]);
  255. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  256. svc.sessions.reset();
  257. const order: string[] = [];
  258. apiStub.upsertChatThreads.and.callFake(() => {
  259. order.push('upsert');
  260. return Promise.resolve();
  261. });
  262. mxStub.sendText.and.callFake(() => {
  263. order.push('sendText');
  264. return Promise.resolve({ event_id: '$evt1' });
  265. });
  266. await svc.startBlank('agent-node-lj-test');
  267. order.length = 0; // 只关心 sendText 期间的顺序
  268. await svc.sendText('第一条消息');
  269. /*
  270. * 期望:落库(upsert) → 发送(sendText)。
  271. * 允许 upsert 出现多次(标题推送等),但**首次 upsert 必须在 sendText 之前**,
  272. * 且 sendText 只能出现一次。
  273. */
  274. const firstUpsert = order.indexOf('upsert');
  275. const firstSend = order.indexOf('sendText');
  276. expect(firstUpsert).withContext('应当发生过落库').toBeGreaterThanOrEqual(0);
  277. expect(firstSend).withContext('应当发生过发送').toBeGreaterThanOrEqual(0);
  278. expect(firstUpsert)
  279. .withContext(`落库必须排在发送之前,实际顺序:${order.join(' → ')}`)
  280. .toBeLessThan(firstSend);
  281. expect(order.filter((x) => x === 'sendText').length).toBe(1);
  282. });
  283. it('🔴 首次落库失败 → 消息标记失败,且不发送到 Matrix', async () => {
  284. realMode();
  285. mxStub.timelineToMessages.and.returnValue([]);
  286. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  287. svc.sessions.reset();
  288. apiStub.upsertChatThreads.and.returnValue(Promise.reject(new Error('网络异常')));
  289. mxStub.sendText.calls.reset();
  290. await svc.startBlank('agent-node-lj-test');
  291. await svc.sendText('这条不该被发出去');
  292. expect(mxStub.sendText).withContext('落库失败就不能发送').not.toHaveBeenCalled();
  293. const failed = svc.messages().find((m) => m.state === 'failed');
  294. expect(failed).withContext('消息要标记为失败,用户可重试').toBeDefined();
  295. // 复原桩,避免影响后续用例
  296. apiStub.upsertChatThreads.and.returnValue(Promise.resolve());
  297. });
  298. });
  299. describe('open() 真实态编排', () => {
  300. it('provision → connectSession → ensureRoom 顺序执行', async () => {
  301. realMode();
  302. const order: string[] = [];
  303. apiStub.provisionMatrix.and.callFake(() => {
  304. order.push('provision');
  305. return Promise.resolve(PROV);
  306. });
  307. mxStub.connectSession.and.callFake(() => {
  308. order.push('connect');
  309. return Promise.resolve();
  310. });
  311. mxStub.ensureRoom.and.callFake(() => {
  312. order.push('room');
  313. return Promise.resolve('!room1:fmode.cn');
  314. });
  315. await svc.open('agent-node-lj-test');
  316. expect(order).toEqual(['provision', 'connect', 'room']);
  317. expect(svc.agentId()).toBe('agent-node-lj-test');
  318. expect(svc.connection()).toBe('connected');
  319. });
  320. it('空房间 → state=empty(不是 error)', async () => {
  321. realMode();
  322. mxStub.timelineToMessages.and.returnValue([]);
  323. await svc.open('agent-node-lj-test');
  324. expect(svc.state()).toBe('empty');
  325. });
  326. /*
  327. * 🔴 「重新连接」失效的回归测试(2026-11-01 用户实测)。
  328. *
  329. * `open()` 第一行是幂等短路:同 agent + 同会话 + state=ready 就直接 return。
  330. * 断线时 state 仍是 ready,于是点「重新连接」什么都不会发生。
  331. * 现在用 `force` 参数绕过短路。
  332. */
  333. it('🔴 不带 force 时同状态重复 open 会早退', async () => {
  334. realMode();
  335. mxStub.timelineToMessages.and.returnValue([]);
  336. await svc.open('agent-node-lj-test');
  337. const calls = apiStub.provisionMatrix.calls.count();
  338. await svc.open('agent-node-lj-test');
  339. expect(apiStub.provisionMatrix.calls.count()).withContext('应早退,不再调 provision').toBe(calls);
  340. });
  341. it('🔴 带 force 时重新执行完整编排(重新连接可用)', async () => {
  342. realMode();
  343. mxStub.timelineToMessages.and.returnValue([]);
  344. await svc.open('agent-node-lj-test');
  345. const calls = apiStub.provisionMatrix.calls.count();
  346. await svc.open('agent-node-lj-test', undefined, true);
  347. expect(apiStub.provisionMatrix.calls.count()).withContext('force 必须重新编排').toBe(calls + 1);
  348. });
  349. it('有消息 → state=ready 且按 sender 区分 me/agent', async () => {
  350. realMode();
  351. // 无 thread 的消息属于「历史对话」语义 —— 给它们当前会话的 threadId 才属于本会话
  352. const legacyRoomThread = (i: number) => `$t${i}`;
  353. mxStub.timelineToMessages.and.returnValue([
  354. { roomId: 'r', eventId: '$1', threadId: legacyRoomThread(1), sender: '@sr2wipsdyq:fmode.cn', body: '我发的', ts: 1, kind: 'text', live: false },
  355. { roomId: 'r', eventId: '$2', threadId: legacyRoomThread(1), sender: '@zgdcmgnqcd:fmode.cn', body: '它回的', ts: 2, kind: 'text', live: false },
  356. ]);
  357. // 指定这条 thread 作为要打开的会话
  358. await svc.open('agent-node-lj-test', legacyRoomThread(1));
  359. expect(svc.state()).toBe('ready');
  360. expect(svc.messages().map((m) => m.sender)).toEqual(['me', 'agent']);
  361. expect(svc.messages()[0].text).toBe('我发的');
  362. });
  363. it('数字生命不存在 → 报明确错误', async () => {
  364. realMode();
  365. liveStub.agentById.and.returnValue(undefined);
  366. await svc.open('nope');
  367. expect(svc.state()).toBe('error');
  368. expect(svc.error()).toContain('不存在');
  369. });
  370. it('🔴 数据仍在加载 → 不报「不存在」(刷新页面的真实场景)', async () => {
  371. realMode();
  372. liveStub.agentById.and.returnValue(undefined);
  373. liveStub.state.and.returnValue('loading');
  374. await svc.open('agent-node-lj-test');
  375. expect(svc.error()).not.toContain('不存在');
  376. expect(svc.error()).toContain('加载');
  377. liveStub.state.and.returnValue('ready'); // 复位
  378. });
  379. it('🔴 数据加载失败 → 报加载失败而非「不存在」', async () => {
  380. realMode();
  381. liveStub.agentById.and.returnValue(undefined);
  382. liveStub.state.and.returnValue('error');
  383. liveStub.error.and.returnValue('网络异常');
  384. await svc.open('agent-node-lj-test');
  385. expect(svc.error()).toContain('加载失败');
  386. expect(svc.error()).toContain('网络异常');
  387. liveStub.state.and.returnValue('ready');
  388. liveStub.error.and.returnValue(null);
  389. });
  390. });
  391. describe('失败分环节报错(FR-04:不得笼统)', () => {
  392. it('provision 失败 → 文案含「FMODE 开通失败」', async () => {
  393. realMode();
  394. apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('SessionToken 无效')));
  395. await svc.open('a');
  396. expect(svc.error()).toContain('FMODE 开通失败');
  397. expect(svc.error()).toContain('SessionToken 无效');
  398. });
  399. it('SDK 登录失败 → 文案含「连接失败」(不暴露技术名词)', async () => {
  400. realMode();
  401. mxStub.connectSession.and.returnValue(Promise.reject(new Error('凭据不完整')));
  402. await svc.open('a');
  403. expect(svc.error()).toContain('连接失败');
  404. expect(svc.error()).withContext('界面文案不应出现 Matrix').not.toContain('Matrix');
  405. });
  406. it('房间创建失败 → 文案含「房间创建失败」', async () => {
  407. realMode();
  408. mxStub.ensureRoom.and.returnValue(Promise.reject(new Error('403')));
  409. await svc.open('a');
  410. expect(svc.error()).toContain('房间创建失败');
  411. });
  412. it('失败时 connection=disconnected(不假装连着)', async () => {
  413. realMode();
  414. apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('x')));
  415. await svc.open('a');
  416. expect(svc.connection()).toBe('disconnected');
  417. });
  418. });
  419. describe('seq 防串扰(切会话丢弃过期回包)', () => {
  420. it('慢的旧请求返回时不覆盖新会话', async () => {
  421. realMode();
  422. let resolveSlow: (v: MatrixProvision) => void = () => undefined;
  423. apiStub.provisionMatrix.and.callFake((id: string) =>
  424. id === 'slow'
  425. ? new Promise<MatrixProvision>((res) => (resolveSlow = res))
  426. : Promise.resolve(PROV),
  427. );
  428. mxStub.ensureRoom.and.callFake(() => Promise.resolve('!room1:fmode.cn'));
  429. const slowOpen = svc.open('slow'); // 悬而未决
  430. await svc.open('fast'); // 后发先至,成为当前会话
  431. resolveSlow(PROV); // 旧请求此刻才返回
  432. await slowOpen;
  433. // 当前会话仍是 fast,未被 slow 的迟到回包覆盖
  434. expect(svc.agentId()).toBe('fast');
  435. });
  436. });
  437. describe('close() 必须**不断开**会话级连接', () => {
  438. it('close 后 sync 仍常驻(未读计数依赖)', async () => {
  439. realMode();
  440. await svc.open('agent-node-lj-test');
  441. svc.close();
  442. // 🔴 关键:close 不得调用 disconnect —— 否则离开会话就收不到消息
  443. expect(mxStub.disconnect).not.toHaveBeenCalled();
  444. expect(svc.agentId()).toBeNull();
  445. expect(svc.messages()).toEqual([]);
  446. expect(svc.connection()).toBe('disconnected'); // 仅 UI 状态复位
  447. });
  448. it('close 会解绑本房间的事件订阅(防串扰)', async () => {
  449. realMode();
  450. const unbindMsg = jasmine.createSpy('unbindMsg');
  451. const unbindTyping = jasmine.createSpy('unbindTyping');
  452. mxStub.onMessage.and.returnValue(unbindMsg);
  453. mxStub.onTyping.and.returnValue(unbindTyping);
  454. await svc.open('a');
  455. svc.close();
  456. expect(unbindMsg).toHaveBeenCalled();
  457. expect(unbindTyping).toHaveBeenCalled();
  458. });
  459. });
  460. describe('群聊支持(2026-11-07)', () => {
  461. /*
  462. * 🔴 回归:群聊重建历史要遍历 N 个成员房间。若每个成员都走
  463. * `connectSession`,token 不同时会 disconnect 重建客户端 →
  464. * 正在进行的 `/sync` 被打断,timeline 永远是空的
  465. * (真实账号实测:群历史一条都读不出来)。
  466. */
  467. it('读历史时:已连接则不重连;未连接仍必须连(评审 2026-11-07)', async () => {
  468. realMode();
  469. apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
  470. mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
  471. mxStub.connectSession.calls.reset();
  472. // 未连接:skipConnect 也不能跳过,否则 ensureRoom 里 requireClient 会抛
  473. (mxStub as unknown as { connected: boolean }).connected = false;
  474. await svc.ensureAgentRoomFor('agent-node-lj-test', { skipConnect: true });
  475. expect(mxStub.connectSession)
  476. .withContext('客户端不存在时必须建立连接')
  477. .toHaveBeenCalled();
  478. // 已连接:跳过重连(重建会打断正在进行的 sync)
  479. (mxStub as unknown as { connected: boolean }).connected = true;
  480. mxStub.connectSession.calls.reset();
  481. await svc.ensureAgentRoomFor('agent-node-lj-test', { skipConnect: true });
  482. expect(mxStub.connectSession).not.toHaveBeenCalled();
  483. });
  484. it('群聊发附件:事件带 dispatch 与 session 标记', async () => {
  485. realMode();
  486. apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
  487. mxStub.connectSession.and.returnValue(Promise.resolve());
  488. mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
  489. mxStub.lastMessageEventId.and.returnValue('$base');
  490. mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$file' }));
  491. const file = new File(['x'], 'a.png', { type: 'image/png' });
  492. const r = await svc.sendFileToAgent('agent-node-lj-test', file, {
  493. groupId: 'g1',
  494. turnId: 'me-1',
  495. targetAgentId: 'agent-node-lj-test',
  496. });
  497. expect(r.ok).withContext('发送应成功').toBe(true);
  498. const args = mxStub.sendFile.calls.mostRecent().args;
  499. const extra = args[5] as Record<string, unknown>;
  500. expect((extra['com.fmode.dispatch'] as { groupId: string }).groupId).toBe('g1');
  501. expect(extra['com.fmode.session']).toBe('me-1');
  502. });
  503. });
  504. describe('sendText(AC-04.2 / AC-04.8)', () => {
  505. beforeEach(async () => {
  506. realMode();
  507. await svc.open('agent-node-lj-test');
  508. });
  509. it('乐观插入 → 调用 SDK 发送 → 标记 sent', async () => {
  510. await svc.sendText(' 你好 ');
  511. const last = svc.messages().at(-1)!;
  512. expect(last.text).toBe('你好'); // 已 trim
  513. expect(last.state).toBe('sent');
  514. /*
  515. * 🔴 必须带会话线程根:不带 thread 容器会把每条消息切成新会话(实测)。
  516. * 🔴 第五个参数是 extra:把本地 id 写进 content,服务端 echo 会原样带回,
  517. * 回读时据此认领乐观消息(2026-11-09 修「一条你好显示三条」)。
  518. */
  519. expect(mxStub.sendText).toHaveBeenCalledWith(
  520. '!room1:fmode.cn',
  521. '你好',
  522. svc.sessionId()!,
  523. undefined,
  524. { 'com.fmode.local': last.localId! },
  525. );
  526. expect(last.localId).toBeTruthy();
  527. });
  528. it('第二次发送带上一条消息的 event_id(m.in_reply_to 降级用)', async () => {
  529. await svc.sendText('第一条');
  530. mxStub.sendText.calls.reset();
  531. await svc.sendText('第二条');
  532. // 第一条是本地乐观 id(local-*),不是真实 event,故 lastEventId 应为 undefined
  533. const args = mxStub.sendText.calls.mostRecent().args;
  534. expect(args[0]).toBe('!room1:fmode.cn');
  535. expect(args[1]).toBe('第二条');
  536. expect(args[2]).toBe(svc.sessionId()!);
  537. expect(args[3]).toBeUndefined();
  538. expect(args[4]).toEqual({ 'com.fmode.local': jasmine.any(String) });
  539. });
  540. it('发送后清空草稿', async () => {
  541. svc.setDraft('待发');
  542. await svc.sendText('待发');
  543. expect(svc.draft()).toBe('');
  544. });
  545. it('空文本不发送(也不插气泡)', async () => {
  546. const before = svc.messages().length;
  547. await svc.sendText(' ');
  548. expect(svc.messages().length).toBe(before);
  549. expect(mxStub.sendText).not.toHaveBeenCalled();
  550. });
  551. it('失败 → 留痕 failed 且带错误原因(不丢消息)', async () => {
  552. mxStub.sendText.and.returnValue(Promise.reject(new Error('网络错误')));
  553. await svc.sendText('会失败');
  554. const last = svc.messages().at(-1)!;
  555. expect(last.state).toBe('failed');
  556. expect(last.error).toContain('网络错误');
  557. });
  558. it('retry 把 failed 消息重发并复位为 sent', async () => {
  559. mxStub.sendText.and.returnValue(Promise.reject(new Error('临时故障')));
  560. await svc.sendText('重试我');
  561. const failed = svc.messages().at(-1)!;
  562. expect(failed.state).toBe('failed');
  563. mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$ok' }));
  564. await svc.retry(failed.id);
  565. const after = svc.messages().find((m) => m.id === failed.id)!;
  566. expect(after.state).toBe('sent');
  567. expect(after.error).toBeUndefined();
  568. });
  569. it('非 failed 状态的消息 retry 不做事', async () => {
  570. await svc.sendText('已成功的');
  571. const ok = svc.messages().at(-1)!;
  572. mxStub.sendText.calls.reset();
  573. await svc.retry(ok.id);
  574. expect(mxStub.sendText).not.toHaveBeenCalled();
  575. });
  576. });
  577. describe('paginate(AC-04.4 翻历史)', () => {
  578. beforeEach(async () => {
  579. realMode();
  580. await svc.open('agent-node-lj-test');
  581. });
  582. it('当前会话内的消息计入', async () => {
  583. mxStub.timelineToMessages.and.returnValue(
  584. Array.from({ length: 5 }, (_, i) => ({
  585. roomId: 'r',
  586. eventId: `$h${i}`,
  587. sender: '@zgdcmgnqcd:fmode.cn',
  588. // 必须带当前会话的 threadId —— 无 thread 的属于「历史对话」,不混入
  589. threadId: svc.sessionId()!,
  590. body: `历史${i}`,
  591. ts: i,
  592. kind: 'text' as const,
  593. live: false,
  594. })),
  595. );
  596. const grew = await svc.paginate();
  597. expect(grew).toBeTrue();
  598. expect(svc.messages().length).toBe(5);
  599. expect(svc.hasMoreHistory()).toBeTrue();
  600. });
  601. it('其它会话的消息被过滤,不混入当前会话', async () => {
  602. mxStub.timelineToMessages.and.returnValue(
  603. Array.from({ length: 5 }, (_, i) => ({
  604. roomId: 'r',
  605. eventId: `$x${i}`,
  606. sender: '@zgdcmgnqcd:fmode.cn',
  607. threadId: '$approot_other_session:fmode.cn',
  608. body: `别的会话${i}`,
  609. ts: i,
  610. kind: 'text' as const,
  611. live: false,
  612. })),
  613. );
  614. const grew = await svc.paginate();
  615. expect(grew).toBeFalse(); // 条数没增长
  616. expect(svc.messages().length).toBe(0);
  617. });
  618. it('条数未增长 → hasMoreHistory=false(到最早了,停止请求)', async () => {
  619. mxStub.timelineToMessages.and.returnValue([]);
  620. const grew = await svc.paginate();
  621. expect(grew).toBeFalse();
  622. expect(svc.hasMoreHistory()).toBeFalse();
  623. });
  624. it('🔴 防重入:并发调用只触发一次 SDK 分页', async () => {
  625. /*
  626. * 🔴 调用计数必须从这个用例的基线算起(2026-11-12)。
  627. * `open()` 现在也会按需回溯历史,上一句 `beforeEach` 里的 open
  628. * 可能已经调用过若干次 `paginateBack`;绝对计数会被累加污染。
  629. */
  630. const base = mxStub.paginateBack.calls.count();
  631. let resolveIt: (v: boolean) => void = () => undefined;
  632. mxStub.paginateBack.and.callFake(() => new Promise<boolean>((res) => (resolveIt = res)));
  633. const a = svc.paginate();
  634. const b = svc.paginate(); // 立刻重入
  635. resolveIt(true);
  636. await Promise.all([a, b]);
  637. expect(mxStub.paginateBack.calls.count() - base).toBe(1);
  638. });
  639. it('paginating 在完成后复位', async () => {
  640. await svc.paginate();
  641. expect(svc.paginating()).toBeFalse();
  642. });
  643. });
  644. /*
  645. * 🔴 历史会话打开为空(2026-11-12 用户实测)。
  646. *
  647. * 现象:「我的这些历史记录都无法查看了,大部分都没有正常显示。」
  648. * 根因:sync 只保留房间最近 50 条,老会话的消息不在本地 timeline,
  649. * 而打开会话时**从不主动向前翻**,于是渲染成「全新会话」欢迎页。
  650. */
  651. describe('打开历史会话时按需回溯 timeline', () => {
  652. const SID = '$approot_test:fmode.cn';
  653. /** 构造一条属于该会话的消息 */
  654. const msgOf = (id: string): MxMessage =>
  655. ({
  656. roomId: '!room1:fmode.cn',
  657. eventId: id,
  658. sender: '@zgdcmgnqcd:fmode.cn',
  659. threadId: SID,
  660. body: '历史内容',
  661. ts: 1,
  662. kind: 'text' as const,
  663. live: false,
  664. }) as MxMessage;
  665. afterEach(() => {
  666. mxStub.timelineToMessages.and.returnValue([]);
  667. mxStub.paginationToken.and.returnValue('tok-b');
  668. });
  669. it('本地没有该会话的消息 → 向前回溯并命中', async () => {
  670. realMode();
  671. // 第一轮:只有别的会话;回溯一次后:出现本会话
  672. let calls = 0;
  673. mxStub.timelineToMessages.and.callFake(() =>
  674. calls === 0 ? [] : [msgOf('$h1'), msgOf('$h2')],
  675. );
  676. mxStub.paginateBack.and.callFake(() => {
  677. calls++;
  678. return Promise.resolve(true);
  679. });
  680. await svc.open('agent-node-lj-test', SID);
  681. expect(mxStub.paginateBack).toHaveBeenCalled();
  682. expect(svc.messages().length).toBeGreaterThan(0);
  683. expect(svc.emptyReason()).toBeNull();
  684. });
  685. it('命中即停:不把整个房间翻完', async () => {
  686. realMode();
  687. mxStub.timelineToMessages.and.returnValue([msgOf('$h1')]);
  688. await svc.open('agent-node-lj-test', SID);
  689. expect(mxStub.paginateBack).not.toHaveBeenCalled();
  690. });
  691. it('页面已到房间最早(无 token)→ 不回溯', async () => {
  692. realMode();
  693. mxStub.timelineToMessages.and.returnValue([]);
  694. mxStub.paginationToken.and.returnValue(null);
  695. await svc.open('agent-node-lj-test', SID);
  696. expect(mxStub.paginateBack).not.toHaveBeenCalled();
  697. });
  698. it('🔥 回溯耗尽仍取不到 → emptyReason=unloaded,不得伪装成新会话', async () => {
  699. realMode();
  700. mxStub.timelineToMessages.and.returnValue([]); // 永远取不到
  701. mxStub.paginateBack.and.returnValue(Promise.resolve(true));
  702. await svc.open('agent-node-lj-test', SID);
  703. /*
  704. * 断言「**有上限**」而不是写死具体轮数(2026-11-12):
  705. * 上限会随房间规模调整(当前 50 轮,因 lj-test 房间已超 2100 个事件)。
  706. * 这里守住真正的不变量:确实尝试过、且**一定会停**。
  707. */
  708. const n = mxStub.paginateBack.calls.count();
  709. expect(n).toBeGreaterThan(0);
  710. expect(n).toBeLessThanOrEqual(100);
  711. /*
  712. * 房间**没翻到最早**(`paginationToken` 仍返回 token)→ 不能断定「这条会话没有消息」,
  713. * 只能诚实说「暂时取不到」。
  714. */
  715. expect(svc.emptyReason()).toBe('unloaded');
  716. });
  717. it('翻到房间最早 + 目录里有记录 → true empty(fresh),不吓唬用户', async () => {
  718. realMode();
  719. mxStub.timelineToMessages.and.returnValue([]);
  720. // 一轮就翻到顶:翻完 token 变 null
  721. mxStub.paginateBack.and.callFake(() => {
  722. mxStub.paginationToken.and.returnValue(null);
  723. return Promise.resolve(true);
  724. });
  725. await svc.open('agent-node-lj-test', SID);
  726. /*
  727. * 🔴 已翻到房间最早 + 目录里有这条 + 0 条消息 = **空壳会话**(2026-11-12)。
  728. * 不能报 `fresh`(会被渲染成「全新会话」欢迎语,诱导用户以为内容丢了),
  729. * 也不能报 `unloaded`(会让用户一直等「暂时取不到」)。
  730. * 实测样本:「测试9680」在 Matrix 里确实一条消息都没有。
  731. */
  732. expect(svc.emptyReason()).toBe('blank');
  733. });
  734. });
  735. describe('setDraft 触发 typing 通知', () => { it('有内容 → typing=true,清空 → typing=false', async () => {
  736. realMode();
  737. await svc.open('a');
  738. svc.setDraft('在输入');
  739. expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', true);
  740. svc.setDraft('');
  741. expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', false);
  742. });
  743. });
  744. describe('附件暂存 → 发送时上传(AC-04.3)', () => {
  745. const file = (name = 'doc.txt') => new File(['x'], name, { type: 'text/plain' });
  746. it('stageFiles 只暂存,**不上传、不发消息**', async () => {
  747. realMode();
  748. await svc.open('agent-node-lj-test');
  749. apiStub.uploadFile.calls.reset();
  750. mxStub.sendText.calls.reset();
  751. svc.stageFiles([file()]);
  752. expect(svc.pendingFiles().length).toBe(1);
  753. expect(apiStub.uploadFile).not.toHaveBeenCalled();
  754. expect(mxStub.sendText).not.toHaveBeenCalled();
  755. });
  756. it('可以移除暂存附件', async () => {
  757. realMode();
  758. await svc.open('agent-node-lj-test');
  759. svc.stageFiles([file()]);
  760. const id = svc.pendingFiles()[0].id;
  761. svc.unstageFile(id);
  762. expect(svc.pendingFiles()).toEqual([]);
  763. });
  764. it('发送时上传附件(走 Matrix 原生事件)+ 本地先插文件气泡', async () => {
  765. realMode();
  766. await svc.open('agent-node-lj-test');
  767. // 2026-11-10:改为「先上云、再发 Matrix」,云盘是发送的前置条件
  768. apiStub.uploadFile.and.returnValue(
  769. Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
  770. );
  771. svc.stageFiles([file()]);
  772. await svc.sendText('看看这个');
  773. /*
  774. * 本地先插一条文件气泡(用户裁决 2026-11-11:立即可见)——
  775. * 回读的 `m.image` 会再落一条,靠回合聚合的 `USER_MERGE_MS` 并成同一回合,
  776. * 见 turn-aggregator 的「带附件的 me 消息并入上一回合」用例。
  777. */
  778. expect(svc.messages().some((m) => m.id.startsWith('local-file-'))).toBeTrue();
  779. // 走 Matrix 原生附件,不再依赖 OBS 预签名地址
  780. expect(mxStub.sendFile).toHaveBeenCalled();
  781. expect(svc.uploading()).toBeFalse();
  782. // 上传成功后暂存清空
  783. expect(svc.pendingFiles()).toEqual([]);
  784. });
  785. // 🔴 原实现只更新本地消息、从不发到房间 —— 容器永远收不到
  786. it('🔴 附件必须以 Matrix 原生事件真的发到房间(sendFile)', async () => {
  787. realMode();
  788. await svc.open('agent-node-lj-test');
  789. apiStub.uploadFile.and.returnValue(
  790. Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
  791. );
  792. mxStub.sendFile.calls.reset();
  793. svc.stageFiles([file()]);
  794. await svc.sendText('附上');
  795. expect(mxStub.sendFile).toHaveBeenCalled();
  796. });
  797. it('上传失败 → 记录错误且不上传中,附件保留待重试', async () => {
  798. realMode();
  799. await svc.open('agent-node-lj-test');
  800. mxStub.sendFile.and.returnValue(Promise.reject(new Error('上传失败')));
  801. svc.stageFiles([file()]);
  802. await svc.sendText('附上');
  803. expect(svc.error()).toContain('失败');
  804. expect(svc.uploading()).toBeFalse();
  805. expect(svc.pendingFiles().length).withContext('失败后应保留待重试').toBe(1);
  806. });
  807. /*
  808. * 2026-11-10:上云顺序调整为「先 S3、再 Matrix」。
  809. * 目的:不发出去「聊天记录里有、云盘里没有」的半成品文件。
  810. */
  811. it('🔴 云盘失败 → 不发 Matrix、附件保留待重试', async () => {
  812. realMode();
  813. await svc.open('agent-node-lj-test');
  814. apiStub.uploadFile.and.returnValue(Promise.reject(new Error('云盘不可用')));
  815. mxStub.sendFile.calls.reset();
  816. svc.stageFiles([file()]);
  817. await svc.sendText('附上');
  818. expect(mxStub.sendFile).not.toHaveBeenCalled();
  819. expect(svc.error()).toContain('云盘不可用');
  820. expect(svc.pendingFiles().length).withContext('未上云的文件不得被清掉').toBe(1);
  821. });
  822. it('🔴 顺序为先云盘后 Matrix(避免半成功)', async () => {
  823. realMode();
  824. await svc.open('agent-node-lj-test');
  825. const order: string[] = [];
  826. apiStub.uploadFile.and.callFake(() => {
  827. order.push('s3');
  828. return Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' });
  829. });
  830. mxStub.sendFile.and.callFake(() => {
  831. order.push('matrix');
  832. return Promise.resolve();
  833. });
  834. svc.stageFiles([file()]);
  835. await svc.sendText('附上');
  836. expect(order).toEqual(['s3', 'matrix']);
  837. });
  838. /* 大小限制(2026-11-10):选文件时就拦,超限项不进暂存区 */
  839. it('🔴 超过服务端上限的文件在 stageFiles 阶段被拒并提示', async () => {
  840. realMode();
  841. await svc.open('agent-node-lj-test');
  842. /*
  843. * 🔴 2026-11-13:上限不再写死 20MB,而是由服务端 `m.upload.size` 决定。
  844. * 这里显式设成 20MB 来验证拦截逻辑本身(真实环境实测为 32MB)。
  845. */
  846. setServerUploadLimit(20 * 1024 * 1024);
  847. const big = new File([new Uint8Array(8)], 'big.zip');
  848. Object.defineProperty(big, 'size', { value: 21 * 1024 * 1024 });
  849. svc.stageFiles([big]);
  850. expect(svc.pendingFiles().length).toBe(0);
  851. expect(svc.error()).toContain('超过平台单文件上限');
  852. });
  853. it('🔴 未探测到服务端上限 → 不拦截(用户裁决:不要凭空拦住)', async () => {
  854. realMode();
  855. await svc.open('agent-node-lj-test');
  856. setServerUploadLimit(null);
  857. const big = new File([new Uint8Array(8)], 'huge.zip');
  858. Object.defineProperty(big, 'size', { value: 500 * 1024 * 1024 });
  859. svc.stageFiles([big]);
  860. expect(svc.pendingFiles().length).toBe(1);
  861. });
  862. it('未超限的文件正常进入暂存区', async () => {
  863. realMode();
  864. await svc.open('agent-node-lj-test');
  865. const ok = new File([new Uint8Array(8)], 'ok.txt');
  866. Object.defineProperty(ok, 'size', { value: 20 * 1024 * 1024 });
  867. svc.stageFiles([ok]);
  868. expect(svc.pendingFiles().length).toBe(1);
  869. });
  870. it('多选时只挡掉超限的,合格的照常暂存', async () => {
  871. realMode();
  872. await svc.open('agent-node-lj-test');
  873. setServerUploadLimit(20 * 1024 * 1024); // 显式设上限,见上方说明
  874. const small = file();
  875. const big = new File([new Uint8Array(8)], 'big.zip');
  876. Object.defineProperty(big, 'size', { value: 30 * 1024 * 1024 });
  877. svc.stageFiles([small, big]);
  878. expect(svc.pendingFiles().length).toBe(1);
  879. expect(svc.pendingFiles()[0].file.name).toBe('doc.txt');
  880. expect(svc.error()).toContain('big.zip');
  881. });
  882. it('只有附件没有文字也能发送', async () => {
  883. realMode();
  884. await svc.open('agent-node-lj-test');
  885. apiStub.uploadFile.and.returnValue(
  886. Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
  887. );
  888. mxStub.sendFile.calls.reset();
  889. mxStub.sendText.calls.reset();
  890. svc.stageFiles([file()]);
  891. await svc.sendText('');
  892. // 纯附件:附件本身照常发到房间(本地不伪造文件消息)
  893. expect(mxStub.sendFile).toHaveBeenCalled();
  894. // 纯附件不该发一条空文字
  895. expect(mxStub.sendText).not.toHaveBeenCalled();
  896. });
  897. });
  898. describe('进入会话即已读(清未读计数)', () => {
  899. it('open 成功后该 agent 未读归零', async () => {
  900. realMode();
  901. const unread = TestBed.inject(UnreadService);
  902. // 先造出未读
  903. unread.onIncoming('agent-node-lj-test', 1000, true);
  904. unread.onIncoming('agent-node-lj-test', 2000, false);
  905. expect(unread.countOf('agent-node-lj-test')).toBe(1);
  906. await svc.open('agent-node-lj-test');
  907. expect(unread.countOf('agent-node-lj-test')).toBe(0);
  908. });
  909. });
  910. /**
  911. * 多会话(线程)隔离 —— 2026-10-28 新增。
  912. *
  913. * 容器按 `room + thread` 划分会话(实测),App 侧必须:
  914. * ① 每条消息带上当前会话的线程根
  915. * ② 只显示属于当前会话的消息
  916. */
  917. describe('多会话隔离', () => {
  918. const msg = (id: string, threadId?: string, body = 'x'): MxMessage => ({
  919. roomId: '!room1:fmode.cn',
  920. eventId: id,
  921. sender: '@zgdcmgnqcd:fmode.cn',
  922. threadId,
  923. body,
  924. ts: Date.now(),
  925. kind: 'text',
  926. live: true,
  927. });
  928. beforeEach(() => realMode());
  929. it('首次打开**不**自动建空会话(2026-10-31 新语义)', async () => {
  930. apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
  931. await svc.open('agent-node-lj-test');
  932. expect(svc.sessionId()).toBe('');
  933. expect(svc.state()).toBe('empty');
  934. });
  935. it('云端有会话时直接打开最近一条', async () => {
  936. await svc.open('agent-node-lj-test');
  937. expect(svc.sessionId()).toBe(CLOUD_THREAD.threadRoot);
  938. expect(svc.currentSession()).toBeDefined();
  939. });
  940. /*
  941. * 🔴 回归(2026-11-06,渲染测试复现):
  942. * 刷新页面后先发消息,`_sessionId` 已指向线程根,但云端目录里还没有这条
  943. * (刷新后目录回来得更晚)—— `currentSession()` 返回 undefined,
  944. * 聊天页据此隐藏页头、右栏和**整个输入区**,只剩一条告警。
  945. */
  946. it('🔴 会话目录里查不到当前会话时,currentSession 仍给出占位', async () => {
  947. await svc.open('agent-node-lj-test');
  948. const sid = svc.sessionId();
  949. // 模拟目录尚未包含这条(换设备 / 刚建还没落云)
  950. svc.sessions.reset();
  951. await svc.sendText('刷新后第一条');
  952. expect(svc.sessionId()).toBe(sid);
  953. expect(svc.currentSession())
  954. .withContext('查不到也必须给出占位,否则输入区会消失')
  955. .toBeDefined();
  956. expect(svc.currentSession()!.id).toBe(sid as string);
  957. expect(svc.currentSession()!.agentId).toBe('agent-node-lj-test');
  958. });
  959. it('会话目录里有当前会话时,用的是目录里的真身而不是占位', async () => {
  960. await svc.open('agent-node-lj-test');
  961. const s = svc.currentSession();
  962. expect(s!.title).toBe(CLOUD_THREAD.title);
  963. expect(svc.sessions.byId(CLOUD_THREAD.threadRoot)?.title).toBe(s!.title);
  964. });
  965. it('再次打开同一 agent 复用同一会话(不会每次都新建)', async () => {
  966. await svc.open('agent-node-lj-test');
  967. const first = svc.sessionId();
  968. svc.close();
  969. await svc.open('agent-node-lj-test');
  970. expect(svc.sessionId()).toBe(first);
  971. });
  972. it('新建会话后 sessionId 改变,且消息列表清空', async () => {
  973. await svc.open('agent-node-lj-test');
  974. const first = svc.sessionId();
  975. const sessions = svc.sessions;
  976. const created = sessions.create('!room1:fmode.cn', 'agent-node-lj-test');
  977. await svc.open('agent-node-lj-test', created.id);
  978. expect(svc.sessionId()).toBe(created.id);
  979. expect(svc.sessionId()).not.toBe(first);
  980. });
  981. it('实时消息只进入所属会话,不串线', async () => {
  982. await svc.open('agent-node-lj-test');
  983. const mine = svc.sessionId()!;
  984. let sink: ((m: MxMessage) => void) | null = null;
  985. mxStub.onMessage.and.callFake((_r: string, cb: (m: MxMessage) => void) => {
  986. sink = cb;
  987. return () => undefined;
  988. });
  989. svc.close();
  990. await svc.open('agent-node-lj-test');
  991. sink!(msg('$m1', mine, '我的会话'));
  992. sink!(msg('$m2', '$approot_someone_else:fmode.cn', '别人的会话'));
  993. sink!(msg('$m3', undefined, '无 thread 的历史'));
  994. const texts = svc.messages().map((m) => m.text);
  995. expect(texts).toContain('我的会话');
  996. expect(texts).not.toContain('别人的会话');
  997. /*
  998. * 🔴 2026-11-09 用户裁决变更:房间级消息(无 thread)**一律丢弃**。
  999. *
  1000. * 原规则(2026-11-03)把它们归入当前会话,当时是为了让「用户直接发的图」
  1001. * (m.image 不带 thread)能看见。但实测发现更大的问题:
  1002. * 外部工具/脚本往房间发的消息没有 thread,会被**新建会话全部认领**,
  1003. * 表现为「新建一个会话,上面立刻出现一堆不相干的历史」。
  1004. *
  1005. * 用户明确裁决:宁可丢弃,也不要把别处的消息错认成本会话的。
  1006. * 代价:容器历史上无 thread 的旧回复不再显示
  1007. * (容器现已改为带 thread 回复,影响有限)。
  1008. */
  1009. expect(texts).not.toContain('无 thread 的历史');
  1010. });
  1011. /*
  1012. * 🔴 容器自己开的子线程**属于当前会话**(2026-10-29 实测)。
  1013. * 容器回复时会另开 m.thread 跑内部任务,那些消息也是本次对话的一部分,
  1014. * 交付物(html/截图)就在里面 —— 必须显示,否则内容丢失。
  1015. */
  1016. it('容器自开的子线程归入当前会话', async () => {
  1017. await svc.open('agent-node-lj-test');
  1018. let sink: ((m: MxMessage) => void) | null = null;
  1019. mxStub.onMessage.and.callFake((_r: string, cb: (m: MxMessage) => void) => {
  1020. sink = cb;
  1021. return () => undefined;
  1022. });
  1023. svc.close();
  1024. await svc.open('agent-node-lj-test');
  1025. // 不是 approot 前缀 → 容器内部产生的线程
  1026. sink!(msg('$c1', '$kfv5cDP_ZR3jK4VK2WxWBCNBA:fmode.cn', '容器的子任务输出'));
  1027. expect(svc.messages().map((m) => m.text)).toContain('容器的子任务输出');
  1028. });
  1029. /*
  1030. * 「历史对话」承接无 thread 旧消息的**完整链路验证在端到端**
  1031. * (Playwright 真实浏览器 + 真实 Matrix 房间,2026-10-28 已通过:
  1032. * 房间里 20 条无 thread 旧消息 → 自动出现「历史对话」并显示它们)。
  1033. *
  1034. * 这里只做**否定断言**:没有旧消息时不得凭空多出一个「历史对话」。
  1035. * 不用 spy 驱动的正值用例 —— `timelineToMessages` 在 `openReal` 的
  1036. * 异步间隙会被 beforeEach 的桩覆盖,断言会变成测测试桩本身。
  1037. */
  1038. it('房间里没有旧消息时,不凭空建「历史对话」', async () => {
  1039. mxStub.timelineToMessages.and.returnValue([]);
  1040. await svc.open('agent-node-lj-test');
  1041. expect(svc.sessions.listFor('agent-node-lj-test').some((s) => s.legacy)).toBeFalse();
  1042. });
  1043. it('收到消息后会话预览与更新时间推进', async () => {
  1044. await svc.open('agent-node-lj-test');
  1045. const sid = svc.sessionId()!;
  1046. let sink: ((m: MxMessage) => void) | null = null;
  1047. mxStub.onMessage.and.callFake((_r: string, cb: (m: MxMessage) => void) => {
  1048. sink = cb;
  1049. return () => undefined;
  1050. });
  1051. svc.close();
  1052. await svc.open('agent-node-lj-test');
  1053. sink!(msg('$p1', sid, '这是一条预览内容'));
  1054. const s = TestBed.inject(ChatSessionService).byId(sid)!;
  1055. expect(s.lastPreview).toBe('这是一条预览内容');
  1056. expect(s.messageCount).toBeGreaterThan(0);
  1057. });
  1058. });
  1059. });
  1060. /*
  1061. * 新建会话的「空白保护」窗口(2026-11-08 用户实测:发一句「你好」仍带出历史)。
  1062. *
  1063. * 根因是**清除时机**:`_blankSessionId` 曾在 commit(本地记账)时就清掉,
  1064. * 而那时 `sendReal()` 的网络请求还在路上(约 1 秒)。此间 `/sync` 落地触发
  1065. * 历史回填,两道保护(新建态标记 + state==='empty')同时失效,
  1066. * `rootsOfSession()` 把该房间所有旧容器子线程根都算成「本会话的」,
  1067. * 于是旧对话被灌进新会话。
  1068. *
  1069. * 这里断言的是**行为契约**:发送期间(网络请求未返回)绝不能解除新建态保护。
  1070. */
  1071. describe('ChatService · 新建会话期间不得回填历史(2026-11-08)', () => {
  1072. let svc: ChatService;
  1073. const apiStub = {
  1074. provisionMatrix: jasmine.createSpy('provisionMatrix').and.returnValue(
  1075. Promise.resolve({ accessToken: 'tok', userMxId: '@me:fmode.cn', deviceId: 'd1', agentMxId: '@agent:fmode.cn', roomId: '!room1:fmode.cn' }),
  1076. ),
  1077. fetchChatThreads: jasmine.createSpy('fetchChatThreads').and.returnValue(Promise.resolve([])),
  1078. upsertChatThreads: jasmine.createSpy('upsertChatThreads').and.returnValue(Promise.resolve()),
  1079. patchChatThread: jasmine.createSpy('patchChatThread').and.returnValue(Promise.resolve()),
  1080. removeChatThread: jasmine.createSpy('removeChatThread').and.returnValue(Promise.resolve()),
  1081. validateMatrix: jasmine.createSpy('validateMatrix').and.returnValue(Promise.resolve(true)),
  1082. fetchAgents: jasmine.createSpy('fetchAgents').and.returnValue(Promise.resolve([])),
  1083. };
  1084. const AGENT: Agent = {
  1085. id: 'agent-node-lj-test',
  1086. name: 'lj-test',
  1087. status: 'online' as Agent['status'],
  1088. matrixReady: true,
  1089. unread: 0,
  1090. };
  1091. const liveStub = {
  1092. ensureLoaded: () => Promise.resolve(),
  1093. agentById: () => AGENT,
  1094. agents: () => [AGENT],
  1095. state: () => 'ready',
  1096. error: () => null,
  1097. /** TaskPulseService 会调它做快照对比 */
  1098. tick: () => 0,
  1099. };
  1100. /** 可以手动放行的 sendText —— 模拟「网络请求还没返回」 */
  1101. let releaseSend: () => void = () => undefined;
  1102. const mxStub = {
  1103. connectSession: () => Promise.resolve(),
  1104. disconnect: () => undefined,
  1105. ensureRoom: jasmine.createSpy('ensureRoom').and.returnValue(Promise.resolve('!room1:fmode.cn')),
  1106. syncState: () => 'connected',
  1107. hasInitialSync: true,
  1108. paginationToken: () => null,
  1109. timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]),
  1110. onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined),
  1111. onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined),
  1112. onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined),
  1113. setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
  1114. sendText: jasmine.createSpy('sendText').and.callFake(
  1115. () => new Promise<{ event_id?: string }>((res) => { releaseSend = () => res({ event_id: '$new' }); }),
  1116. ),
  1117. storeVersion: () => 0,
  1118. timelineIndex: () => new Map<string, number>(),
  1119. /* 2026-11-12:openReal 现在会等初次 sync、并登记自愈用的 agentId */
  1120. synced: jasmine.createSpy('synced').and.returnValue(Promise.resolve(true)),
  1121. setRecoveryAgentId: jasmine.createSpy('setRecoveryAgentId'),
  1122. };
  1123. beforeEach(() => {
  1124. TestBed.resetTestingModule();
  1125. localStorage.clear();
  1126. releaseSend = () => undefined;
  1127. (environment as { useMockData: boolean }).useMockData = false;
  1128. TestBed.configureTestingModule({
  1129. providers: [
  1130. ChatService,
  1131. { provide: FmodeApi, useValue: apiStub },
  1132. { provide: LiveService, useValue: liveStub },
  1133. { provide: MatrixClientService, useValue: mxStub },
  1134. { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'tok' } },
  1135. ],
  1136. });
  1137. svc = TestBed.inject(ChatService);
  1138. });
  1139. it('🔴 发送请求未返回前,实时消息不得混入新会话', async () => {
  1140. await svc.open('agent-node-lj-test');
  1141. const created = await svc.startBlank('agent-node-lj-test');
  1142. expect(created).withContext('startBlank 应建出会话,实际为 null').toBeTruthy();
  1143. const sid = created!.id;
  1144. // 开始发送,但请求挂起(网络还没返回)
  1145. const pending = svc.sendText('你好');
  1146. /*
  1147. * 🔴 2026-11-09:首次发消息会先 `await commitNow()`(落库),
  1148. * 多了一轮微任务。只 `await Promise.resolve()` 一次,此刻还停在
  1149. * 落库阶段,`sendText` 根本没走到发送。
  1150. * 这里连推几轮,确保已经进入「发送请求挂起」的状态。
  1151. */
  1152. for (let k = 0; k < 5; k++) await Promise.resolve();
  1153. await new Promise((r) => setTimeout(r, 0));
  1154. // 此刻新会话里应该只有用户自己那条乐观消息
  1155. const during = svc.messages();
  1156. expect(during.length).withContext('发送期间只应有本地乐观消息').toBe(1);
  1157. releaseSend();
  1158. await pending;
  1159. /*
  1160. * 🔴 原来这里只 await 不检查 —— 而「发送成功之后」才是问题真正爆发的地方(2026-11-09)。
  1161. * 用户实测:发一条「你好」,`messages()` 返回 3 条。
  1162. */
  1163. expect(svc.messages().filter((m) => m.sender === 'me').length)
  1164. .withContext('发送完成后仍应恰好 1 条')
  1165. .toBe(1);
  1166. });
  1167. /*
  1168. * 🔴 乐观消息 ↔ 服务端 echo 去重(2026-11-09)。
  1169. *
  1170. * 病根:乐观消息 id 是 `local-*`,echo id 是 `$eventId`,按 id 做并集永远对不上,
  1171. * 于是同一条消息「本地一条 + 回读一条」并存。现在靠 content 里的
  1172. * `com.fmode.local` 认领。这些用例守住这条链路。
  1173. */
  1174. describe('🔴 乐观消息与 echo 去重', () => {
  1175. /** 取 openReal 注册的实时回调 —— 直接走生产链路(appendIncoming),不硬造信号 */
  1176. function emitter(): (m: MxMessage) => void {
  1177. const calls = mxStub.onMessage.calls.allArgs();
  1178. return calls[calls.length - 1][1] as (m: MxMessage) => void;
  1179. }
  1180. it('echo 带回同一个 localId → 只留服务端那条,不重复', async () => {
  1181. await svc.open('agent-node-lj-test');
  1182. const pending = svc.sendText('你好');
  1183. /*
  1184. * 🔴 2026-11-09:首次发消息会先 `await commitNow()`(落库),
  1185. * 比原来多了一轮 await。必须连推几轮微任务,确保乐观消息已插入、
  1186. * 且发送已进入「等待 sendText 返回」的状态,否则下面 `releaseSend()`
  1187. * 时 `sendText` 还没被调用,Promise 永远挂着(超时)。
  1188. */
  1189. for (let k = 0; k < 5; k++) await Promise.resolve();
  1190. await new Promise((r) => setTimeout(r, 0));
  1191. const localId = svc.messages()[0].localId!;
  1192. expect(localId).toBeTruthy();
  1193. releaseSend();
  1194. await pending;
  1195. expect(svc.messages().filter((m) => m.sender === 'me').length).toBe(1);
  1196. // 服务端 echo 实时到达:id 是 eventId,但带回 localId
  1197. emitter()({
  1198. roomId: '!room1:fmode.cn',
  1199. eventId: '$e1',
  1200. sender: '@me:fmode.cn',
  1201. body: '你好',
  1202. ts: Date.now(),
  1203. kind: 'text',
  1204. live: true,
  1205. localId,
  1206. threadId: svc.sessionId() ?? undefined,
  1207. });
  1208. const mine = svc.messages().filter((m) => m.sender === 'me');
  1209. expect(mine.length).withContext('echo 认领后应恰好 1 条').toBe(1);
  1210. expect(mine[0].id).toBe('$e1');
  1211. });
  1212. it('连点两次发送 → 两条,且 id 不冲突', async () => {
  1213. await svc.open('agent-node-lj-test');
  1214. // 两次发送各挂一个 resolver(第一次的会先被放行,第二次的会被新赋值覆盖)
  1215. mxStub.sendText.and.callFake(
  1216. () => new Promise<{ event_id?: string }>((res) => setTimeout(() => res({ event_id: '$x' }), 0)),
  1217. );
  1218. await Promise.all([svc.sendText('第一条'), svc.sendText('第二条')]);
  1219. const mine = svc.messages().filter((m) => m.sender === 'me');
  1220. expect(mine.length).toBe(2);
  1221. expect(new Set(mine.map((m) => m.id)).size).withContext('id 必须唯一').toBe(2);
  1222. });
  1223. it('失败消息在实时流到达后仍保留(可重试,AC-04.8)', async () => {
  1224. await svc.open('agent-node-lj-test');
  1225. mxStub.sendText.and.returnValue(Promise.reject(new Error('网络断了')));
  1226. await svc.sendText('会失败');
  1227. const failed = svc.messages().filter((m) => m.state === 'failed');
  1228. expect(failed.length).toBe(1);
  1229. // 容器回复到达,不能把失败消息冲掉
  1230. emitter()({
  1231. roomId: '!room1:fmode.cn',
  1232. eventId: '$r1',
  1233. sender: '@agent:fmode.cn',
  1234. body: '收到',
  1235. ts: Date.now(),
  1236. kind: 'text',
  1237. live: true,
  1238. threadId: svc.sessionId() ?? undefined,
  1239. });
  1240. expect(svc.messages().filter((m) => m.state === 'failed').length).toBe(1);
  1241. });
  1242. /*
  1243. * 🔴 2026-11-09 评审 B3:合并时不分会话 → 切会话后旧会话的乐观消息残留。
  1244. */
  1245. it('🔴 新建会话后,上一条会话的发送中消息不会残留', async () => {
  1246. await svc.open('agent-node-lj-test');
  1247. // 第一条:挂起不返回(发送中)
  1248. void svc.sendText('上一条会话的消息');
  1249. await Promise.resolve();
  1250. expect(svc.messages().filter((m) => m.state === 'sending').length).toBe(1);
  1251. // 新建会话 —— 应当干净
  1252. await svc.startBlank('agent-node-lj-test');
  1253. expect(svc.messages().length).withContext('新建会话必须空白').toBe(0);
  1254. });
  1255. });
  1256. });
  1257. /**
  1258. * 附件事件的 body 是文件名(Matrix 协议),不能当正文再显示一遍。
  1259. * 2026-11-10 实测截图:发一个文件后界面出现「卡片 + 同名纯文字」两条。
  1260. */
  1261. describe('isAttachmentEcho —— 附件 body 判定', () => {
  1262. it('body 等于文件名 → 视为回声,不显示正文', () => {
  1263. expect(isAttachmentEcho('report.md', 'report.md')).toBeTrue();
  1264. });
  1265. it('前后空白也认', () => {
  1266. expect(isAttachmentEcho(' report.md \n', 'report.md')).toBeTrue();
  1267. });
  1268. it('body 有额外说明 → 保留正文(容器常在附件同一条里写说明)', () => {
  1269. expect(isAttachmentEcho('这是本周的周报,请查收', 'report.md')).toBeFalse();
  1270. });
  1271. it('空 body 不当回声(交给上层按 undefined 处理)', () => {
  1272. expect(isAttachmentEcho('', 'report.md')).toBeFalse();
  1273. expect(isAttachmentEcho(undefined, 'report.md')).toBeFalse();
  1274. });
  1275. });