| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457 |
- import { TestBed } from '@angular/core/testing';
- import { ChatService, isAttachmentEcho } from './chat.service';
- import { setServerUploadLimit } from './upload-limits';
- import { FmodeApi } from './api/fmode-api';
- import { LiveService } from './live.service';
- import { MatrixClientService, MxMessage } from './matrix-client.service';
- import { SessionService } from './session.service';
- import { UnreadService } from './unread.service';
- import { ChatSessionService } from './chat-session.service';
- import { Agent, MatrixProvision } from './api/models';
- import { environment } from '../../environments/environment';
- /**
- * ChatService 单测 —— 会话状态机(SPEC FR-04)
- *
- * 覆盖:provision → 连接 → 房间 → 收发 → 分页 的编排与失败分环节报错。
- * 依赖(FmodeApi / LiveService / MatrixClientService)全部用桩替换,
- * **不触网、不加载 matrix-js-sdk**。
- *
- * 🔴 重点回归:
- * ① 切会话时 `seq` 防串扰(过期回包必须被丢弃)
- * ② `close()` **不断开**会话级连接(未读依赖 sync 常驻)
- * ③ 失败时**分环节**报错(FMODE 开通 / Matrix 登录 / 房间创建)
- * ④ `paginate()` 防重入(滚动事件高频触发)
- */
- describe('ChatService', () => {
- let svc: ChatService;
- /** 桩:FmodeApi */
- /**
- * 云端会话目录桩(2026-10-31):会话列表以云端为唯一来源。
- * 默认返回一条已有会话,模拟「进入一个聊过的容器」。
- */
- const CLOUD_THREAD = {
- objectId: 'cloud1',
- agentId: 'agent-node-lj-test',
- roomId: '!room1:fmode.cn',
- threadRoot: '$approot_test:fmode.cn',
- title: '云端会话',
- titleLocked: false,
- messageCount: 1,
- lastMessageAt: Date.now(),
- archived: false,
- lastPreview: '',
- };
- const apiStub = {
- provisionMatrix: jasmine.createSpy('provisionMatrix'),
- fetchChatThreads: jasmine.createSpy('fetchChatThreads'),
- upsertChatThreads: jasmine.createSpy('upsertChatThreads'),
- patchChatThread: jasmine.createSpy('patchChatThread'),
- removeChatThread: jasmine.createSpy('removeChatThread'),
- validateMatrix: jasmine.createSpy('validateMatrix'),
- fetchAgents: jasmine.createSpy('fetchAgents'),
- uploadFile: jasmine.createSpy('uploadFile'),
- requestUploadUrl: jasmine.createSpy('requestUploadUrl'),
- };
- /** 桩:LiveService */
- const liveStub = {
- ensureLoaded: jasmine.createSpy('ensureLoaded').and.returnValue(Promise.resolve()),
- agentById: jasmine.createSpy('agentById'),
- agents: jasmine.createSpy('agents').and.returnValue([] as Agent[]),
- /** 加载状态(chat.open 用它区分「加载中」与「确实不存在」) */
- state: jasmine.createSpy('state').and.returnValue('ready'),
- error: jasmine.createSpy('error').and.returnValue(null),
- };
- /** 桩:MatrixClientService */
- const mxStub = {
- connectSession: jasmine.createSpy('connectSession').and.returnValue(Promise.resolve()),
- disconnect: jasmine.createSpy('disconnect'),
- ensureRoom: jasmine.createSpy('ensureRoom'),
- syncState: jasmine.createSpy('syncState').and.returnValue('connected'),
- hasInitialSync: true,
- paginationToken: jasmine.createSpy('paginationToken').and.returnValue('tok-b'),
- timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]),
- onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined),
- onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined),
- onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined),
- sendText: jasmine.createSpy('sendText').and.returnValue(Promise.resolve({ event_id: '$e' })),
- sendFile: jasmine
- .createSpy('sendFile')
- .and.returnValue(Promise.resolve({ event_id: '$f' })),
- setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
- paginateBack: jasmine.createSpy('paginateBack').and.returnValue(Promise.resolve(true)),
- /** 初次 sync 是否完成(回溯历史前会等它,2026-11-12) */
- synced: jasmine.createSpy('synced').and.returnValue(Promise.resolve(true)),
- /** token 失效自愈时用它定位容器(2026-11-12) */
- setRecoveryAgentId: jasmine.createSpy('setRecoveryAgentId'),
- lastMessageEventId: jasmine.createSpy('lastMessageEventId').and.returnValue(null),
- };
- const PROV: MatrixProvision = {
- cached: true,
- bindingId: 'b1',
- status: 'active',
- userMxId: '@sr2wipsdyq:fmode.cn',
- agentMxId: '@zgdcmgnqcd:fmode.cn',
- homeserver: 'https://matrix.fmode.cn',
- accessToken: 'tok',
- deviceId: 'DEV1',
- roomId: '!room1:fmode.cn',
- };
- const AGENT: Agent = {
- id: 'agent-node-lj-test',
- name: 'lj-test',
- status: 'online' as Agent['status'],
- matrixReady: true,
- unread: 0,
- };
- beforeEach(() => {
- localStorage.clear();
- for (const k of Object.keys(apiStub)) {
- (apiStub as Record<string, jasmine.Spy>)[k].calls.reset();
- }
- liveStub.ensureLoaded.calls.reset();
- liveStub.agentById.calls.reset();
- for (const k of Object.keys(mxStub)) {
- const v = (mxStub as Record<string, unknown>)[k];
- if (v && typeof (v as jasmine.Spy).calls === 'object') (v as jasmine.Spy).calls.reset();
- }
- mxStub.connectSession.and.returnValue(Promise.resolve());
- mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
- mxStub.syncState.and.returnValue('connected');
- mxStub.paginationToken.and.returnValue('tok-b');
- mxStub.timelineToMessages.and.returnValue([]);
- mxStub.onMessage.and.returnValue(() => undefined);
- mxStub.onAnyMessage.and.returnValue(() => undefined);
- mxStub.onTyping.and.returnValue(() => undefined);
- mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$e' }));
- // 每个用例都重置附件发送策略 —— 否则上一用例的 reject 会漏到下一个
- mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$f' }));
- mxStub.paginateBack.and.returnValue(Promise.resolve(true));
- mxStub.synced.and.returnValue(Promise.resolve(true));
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([CLOUD_THREAD]));
- apiStub.upsertChatThreads.and.returnValue(Promise.resolve());
- apiStub.patchChatThread.and.returnValue(Promise.resolve());
- apiStub.removeChatThread.and.returnValue(Promise.resolve());
- liveStub.agentById.and.returnValue(AGENT);
- apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
- TestBed.configureTestingModule({
- providers: [
- ChatService,
- { provide: FmodeApi, useValue: apiStub },
- { provide: LiveService, useValue: liveStub },
- { provide: MatrixClientService, useValue: mxStub },
- { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'r:t' } },
- { provide: UnreadService, useClass: UnreadService },
- /*
- * 显式提供 ChatSessionService:每个用例都会重建注入器,
- * 只有列在 providers 里,`ChatService` 内部 inject 的实例
- * 才与 `TestBed.inject` 拿到的是**同一个**。
- */
- ChatSessionService,
- ],
- });
- svc = TestBed.inject(ChatService);
- /*
- * 两个根单例的状态都会跨用例保留,必须显式重置:
- * - ChatSessionService:内存里的会话列表(localStorage.clear() 只管磁盘)
- * - ChatService:agentId/sessionId/state —— 不重置的话第二个用例调
- * open() 会因为「同一个 agent 且已 ready」直接早退,断言全部落空
- */
- svc.sessions.reset();
- svc.close();
- });
- afterEach(() => {
- localStorage.clear();
- });
- /** 测试统一走真实态(演示态由 MockApi 覆盖,另测) */
- function realMode(): void {
- (environment as { useMockData: boolean }).useMockData = false;
- }
- describe('新建对话(2026-11-01 点击即建)', () => {
- /*
- * 🔴 用户反馈:点「新建对话」之后左侧看不到任何变化。
- *
- * 上一版是「首次发消息才建会话」,点按钮只进入待建态、不落数据 ——
- * 左侧列表当然不会出现条目,用户不知道操作有没有生效。
- * 现在改为点击即建,并复用它归属下已有的空会话(避免连点攒出一排「新对话」)。
- */
- it('🔴 startBlank 立刻建出会话并选中', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- // 云端目录置空:默认桩会返回 CLOUD_THREAD,断言会凭空多一条
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- svc.sessions.reset();
- const created = await svc.startBlank('agent-node-lj-test');
- expect(created).withContext('必须立刻返回会话').toBeTruthy();
- expect(svc.currentSession()?.id).withContext('新会话要处于选中态').toBe(created!.id);
- expect(svc.sessions.listFor('agent-node-lj-test').length).withContext('左侧应出现 1 条').toBe(1);
- });
- it('指定工作区时归属正确', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- const created = await svc.startBlank('agent-node-lj-test', 'ws-test');
- expect(svc.sessions.byId(created!.id)?.workspaceId).toBe('ws-test');
- });
- /*
- * 🔴 用户裁决:整个列表**最多保留一条**「新建对话」。
- *
- * 每次新建都把之前所有没发过消息的空会话清掉 —— 不按归属复用,
- * 也不依赖定时清理,否则会出现「工作区一条、日常一条」并存。
- */
- it('🔴 再次新建时,之前的空会话被清掉,只留一条', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- svc.sessions.reset();
- const a = await svc.startBlank('agent-node-lj-test', 'ws-test');
- const b = await svc.startBlank('agent-node-lj-test', 'ws-test');
- expect(svc.sessions.byId(a!.id)).withContext('上一次的空会话应被清掉').toBeUndefined();
- expect(svc.sessions.byId(b!.id)).toBeDefined();
- expect(svc.sessions.listFor('agent-node-lj-test').length).toBe(1);
- });
- it('🔴 不同归属新建时,旧的空会话同样被清掉(全局只留一条)', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- svc.sessions.reset();
- const a = await svc.startBlank('agent-node-lj-test', 'ws-a');
- const b = await svc.startBlank('agent-node-lj-test', 'ws-b');
- expect(svc.sessions.byId(a!.id)).withContext('工作区 A 的那条应被清掉').toBeUndefined();
- expect(svc.sessions.byId(b!.id)?.workspaceId).toBe('ws-b');
- expect(svc.sessions.listFor('agent-node-lj-test').length).toBe(1);
- });
- it('🔴 发过消息的会话不会被新建操作清掉', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- svc.sessions.reset();
- // 先建一条并发消息(= 已持久化)
- const old = await svc.startBlank('agent-node-lj-test');
- await svc.sendText('这条已经发过消息');
- // 再新建一条
- const fresh = await svc.startBlank('agent-node-lj-test');
- expect(svc.sessions.byId(old!.id)).withContext('有消息的会话不能被清').toBeDefined();
- expect(svc.sessions.byId(fresh!.id)).toBeDefined();
- });
- it('建出会话后发消息,state 正常切到 ready', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- svc.sessions.reset();
- await svc.startBlank('agent-node-lj-test');
- await svc.sendText('第一条消息');
- expect(svc.state()).toBe('ready');
- expect(svc.currentSession()).toBeDefined();
- });
- /*
- * 🔴 2026-11-09 用户要求:「第一次发送消息的时候,才会开始持久化存储
- * 这个对话;存储成功后,才会开始发送消息」。
- *
- * 原实现是 `commit()`(发出去就不管):消息先发进 Matrix,落库在后台跑。
- * 现在首次落库必须 `await`,成功才继续发送。
- */
- it('🔴 首次发消息:先落库成功,才真正发送到 Matrix', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- svc.sessions.reset();
- const order: string[] = [];
- apiStub.upsertChatThreads.and.callFake(() => {
- order.push('upsert');
- return Promise.resolve();
- });
- mxStub.sendText.and.callFake(() => {
- order.push('sendText');
- return Promise.resolve({ event_id: '$evt1' });
- });
- await svc.startBlank('agent-node-lj-test');
- order.length = 0; // 只关心 sendText 期间的顺序
- await svc.sendText('第一条消息');
- /*
- * 期望:落库(upsert) → 发送(sendText)。
- * 允许 upsert 出现多次(标题推送等),但**首次 upsert 必须在 sendText 之前**,
- * 且 sendText 只能出现一次。
- */
- const firstUpsert = order.indexOf('upsert');
- const firstSend = order.indexOf('sendText');
- expect(firstUpsert).withContext('应当发生过落库').toBeGreaterThanOrEqual(0);
- expect(firstSend).withContext('应当发生过发送').toBeGreaterThanOrEqual(0);
- expect(firstUpsert)
- .withContext(`落库必须排在发送之前,实际顺序:${order.join(' → ')}`)
- .toBeLessThan(firstSend);
- expect(order.filter((x) => x === 'sendText').length).toBe(1);
- });
- it('🔴 首次落库失败 → 消息标记失败,且不发送到 Matrix', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- svc.sessions.reset();
- apiStub.upsertChatThreads.and.returnValue(Promise.reject(new Error('网络异常')));
- mxStub.sendText.calls.reset();
- await svc.startBlank('agent-node-lj-test');
- await svc.sendText('这条不该被发出去');
- expect(mxStub.sendText).withContext('落库失败就不能发送').not.toHaveBeenCalled();
- const failed = svc.messages().find((m) => m.state === 'failed');
- expect(failed).withContext('消息要标记为失败,用户可重试').toBeDefined();
- // 复原桩,避免影响后续用例
- apiStub.upsertChatThreads.and.returnValue(Promise.resolve());
- });
- });
- describe('open() 真实态编排', () => {
- it('provision → connectSession → ensureRoom 顺序执行', async () => {
- realMode();
- const order: string[] = [];
- apiStub.provisionMatrix.and.callFake(() => {
- order.push('provision');
- return Promise.resolve(PROV);
- });
- mxStub.connectSession.and.callFake(() => {
- order.push('connect');
- return Promise.resolve();
- });
- mxStub.ensureRoom.and.callFake(() => {
- order.push('room');
- return Promise.resolve('!room1:fmode.cn');
- });
- await svc.open('agent-node-lj-test');
- expect(order).toEqual(['provision', 'connect', 'room']);
- expect(svc.agentId()).toBe('agent-node-lj-test');
- expect(svc.connection()).toBe('connected');
- });
- it('空房间 → state=empty(不是 error)', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- await svc.open('agent-node-lj-test');
- expect(svc.state()).toBe('empty');
- });
- /*
- * 🔴 「重新连接」失效的回归测试(2026-11-01 用户实测)。
- *
- * `open()` 第一行是幂等短路:同 agent + 同会话 + state=ready 就直接 return。
- * 断线时 state 仍是 ready,于是点「重新连接」什么都不会发生。
- * 现在用 `force` 参数绕过短路。
- */
- it('🔴 不带 force 时同状态重复 open 会早退', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- await svc.open('agent-node-lj-test');
- const calls = apiStub.provisionMatrix.calls.count();
- await svc.open('agent-node-lj-test');
- expect(apiStub.provisionMatrix.calls.count()).withContext('应早退,不再调 provision').toBe(calls);
- });
- it('🔴 带 force 时重新执行完整编排(重新连接可用)', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- await svc.open('agent-node-lj-test');
- const calls = apiStub.provisionMatrix.calls.count();
- await svc.open('agent-node-lj-test', undefined, true);
- expect(apiStub.provisionMatrix.calls.count()).withContext('force 必须重新编排').toBe(calls + 1);
- });
- it('有消息 → state=ready 且按 sender 区分 me/agent', async () => {
- realMode();
- // 无 thread 的消息属于「历史对话」语义 —— 给它们当前会话的 threadId 才属于本会话
- const legacyRoomThread = (i: number) => `$t${i}`;
- mxStub.timelineToMessages.and.returnValue([
- { roomId: 'r', eventId: '$1', threadId: legacyRoomThread(1), sender: '@sr2wipsdyq:fmode.cn', body: '我发的', ts: 1, kind: 'text', live: false },
- { roomId: 'r', eventId: '$2', threadId: legacyRoomThread(1), sender: '@zgdcmgnqcd:fmode.cn', body: '它回的', ts: 2, kind: 'text', live: false },
- ]);
- // 指定这条 thread 作为要打开的会话
- await svc.open('agent-node-lj-test', legacyRoomThread(1));
- expect(svc.state()).toBe('ready');
- expect(svc.messages().map((m) => m.sender)).toEqual(['me', 'agent']);
- expect(svc.messages()[0].text).toBe('我发的');
- });
- it('数字生命不存在 → 报明确错误', async () => {
- realMode();
- liveStub.agentById.and.returnValue(undefined);
- await svc.open('nope');
- expect(svc.state()).toBe('error');
- expect(svc.error()).toContain('不存在');
- });
- it('🔴 数据仍在加载 → 不报「不存在」(刷新页面的真实场景)', async () => {
- realMode();
- liveStub.agentById.and.returnValue(undefined);
- liveStub.state.and.returnValue('loading');
- await svc.open('agent-node-lj-test');
- expect(svc.error()).not.toContain('不存在');
- expect(svc.error()).toContain('加载');
- liveStub.state.and.returnValue('ready'); // 复位
- });
- it('🔴 数据加载失败 → 报加载失败而非「不存在」', async () => {
- realMode();
- liveStub.agentById.and.returnValue(undefined);
- liveStub.state.and.returnValue('error');
- liveStub.error.and.returnValue('网络异常');
- await svc.open('agent-node-lj-test');
- expect(svc.error()).toContain('加载失败');
- expect(svc.error()).toContain('网络异常');
- liveStub.state.and.returnValue('ready');
- liveStub.error.and.returnValue(null);
- });
- });
- describe('失败分环节报错(FR-04:不得笼统)', () => {
- it('provision 失败 → 文案含「FMODE 开通失败」', async () => {
- realMode();
- apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('SessionToken 无效')));
- await svc.open('a');
- expect(svc.error()).toContain('FMODE 开通失败');
- expect(svc.error()).toContain('SessionToken 无效');
- });
- it('SDK 登录失败 → 文案含「连接失败」(不暴露技术名词)', async () => {
- realMode();
- mxStub.connectSession.and.returnValue(Promise.reject(new Error('凭据不完整')));
- await svc.open('a');
- expect(svc.error()).toContain('连接失败');
- expect(svc.error()).withContext('界面文案不应出现 Matrix').not.toContain('Matrix');
- });
- it('房间创建失败 → 文案含「房间创建失败」', async () => {
- realMode();
- mxStub.ensureRoom.and.returnValue(Promise.reject(new Error('403')));
- await svc.open('a');
- expect(svc.error()).toContain('房间创建失败');
- });
- it('失败时 connection=disconnected(不假装连着)', async () => {
- realMode();
- apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('x')));
- await svc.open('a');
- expect(svc.connection()).toBe('disconnected');
- });
- });
- describe('seq 防串扰(切会话丢弃过期回包)', () => {
- it('慢的旧请求返回时不覆盖新会话', async () => {
- realMode();
- let resolveSlow: (v: MatrixProvision) => void = () => undefined;
- apiStub.provisionMatrix.and.callFake((id: string) =>
- id === 'slow'
- ? new Promise<MatrixProvision>((res) => (resolveSlow = res))
- : Promise.resolve(PROV),
- );
- mxStub.ensureRoom.and.callFake(() => Promise.resolve('!room1:fmode.cn'));
- const slowOpen = svc.open('slow'); // 悬而未决
- await svc.open('fast'); // 后发先至,成为当前会话
- resolveSlow(PROV); // 旧请求此刻才返回
- await slowOpen;
- // 当前会话仍是 fast,未被 slow 的迟到回包覆盖
- expect(svc.agentId()).toBe('fast');
- });
- });
- describe('close() 必须**不断开**会话级连接', () => {
- it('close 后 sync 仍常驻(未读计数依赖)', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- svc.close();
- // 🔴 关键:close 不得调用 disconnect —— 否则离开会话就收不到消息
- expect(mxStub.disconnect).not.toHaveBeenCalled();
- expect(svc.agentId()).toBeNull();
- expect(svc.messages()).toEqual([]);
- expect(svc.connection()).toBe('disconnected'); // 仅 UI 状态复位
- });
- it('close 会解绑本房间的事件订阅(防串扰)', async () => {
- realMode();
- const unbindMsg = jasmine.createSpy('unbindMsg');
- const unbindTyping = jasmine.createSpy('unbindTyping');
- mxStub.onMessage.and.returnValue(unbindMsg);
- mxStub.onTyping.and.returnValue(unbindTyping);
- await svc.open('a');
- svc.close();
- expect(unbindMsg).toHaveBeenCalled();
- expect(unbindTyping).toHaveBeenCalled();
- });
- });
- describe('群聊支持(2026-11-07)', () => {
- /*
- * 🔴 回归:群聊重建历史要遍历 N 个成员房间。若每个成员都走
- * `connectSession`,token 不同时会 disconnect 重建客户端 →
- * 正在进行的 `/sync` 被打断,timeline 永远是空的
- * (真实账号实测:群历史一条都读不出来)。
- */
- it('读历史时:已连接则不重连;未连接仍必须连(评审 2026-11-07)', async () => {
- realMode();
- apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
- mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
- mxStub.connectSession.calls.reset();
- // 未连接:skipConnect 也不能跳过,否则 ensureRoom 里 requireClient 会抛
- (mxStub as unknown as { connected: boolean }).connected = false;
- await svc.ensureAgentRoomFor('agent-node-lj-test', { skipConnect: true });
- expect(mxStub.connectSession)
- .withContext('客户端不存在时必须建立连接')
- .toHaveBeenCalled();
- // 已连接:跳过重连(重建会打断正在进行的 sync)
- (mxStub as unknown as { connected: boolean }).connected = true;
- mxStub.connectSession.calls.reset();
- await svc.ensureAgentRoomFor('agent-node-lj-test', { skipConnect: true });
- expect(mxStub.connectSession).not.toHaveBeenCalled();
- });
- it('群聊发附件:事件带 dispatch 与 session 标记', async () => {
- realMode();
- apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV));
- mxStub.connectSession.and.returnValue(Promise.resolve());
- mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn'));
- mxStub.lastMessageEventId.and.returnValue('$base');
- mxStub.sendFile.and.returnValue(Promise.resolve({ event_id: '$file' }));
- const file = new File(['x'], 'a.png', { type: 'image/png' });
- const r = await svc.sendFileToAgent('agent-node-lj-test', file, {
- groupId: 'g1',
- turnId: 'me-1',
- targetAgentId: 'agent-node-lj-test',
- });
- expect(r.ok).withContext('发送应成功').toBe(true);
- const args = mxStub.sendFile.calls.mostRecent().args;
- const extra = args[5] as Record<string, unknown>;
- expect((extra['com.fmode.dispatch'] as { groupId: string }).groupId).toBe('g1');
- expect(extra['com.fmode.session']).toBe('me-1');
- });
- });
- describe('sendText(AC-04.2 / AC-04.8)', () => {
- beforeEach(async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- });
- it('乐观插入 → 调用 SDK 发送 → 标记 sent', async () => {
- await svc.sendText(' 你好 ');
- const last = svc.messages().at(-1)!;
- expect(last.text).toBe('你好'); // 已 trim
- expect(last.state).toBe('sent');
- /*
- * 🔴 必须带会话线程根:不带 thread 容器会把每条消息切成新会话(实测)。
- * 🔴 第五个参数是 extra:把本地 id 写进 content,服务端 echo 会原样带回,
- * 回读时据此认领乐观消息(2026-11-09 修「一条你好显示三条」)。
- */
- expect(mxStub.sendText).toHaveBeenCalledWith(
- '!room1:fmode.cn',
- '你好',
- svc.sessionId()!,
- undefined,
- { 'com.fmode.local': last.localId! },
- );
- expect(last.localId).toBeTruthy();
- });
- it('第二次发送带上一条消息的 event_id(m.in_reply_to 降级用)', async () => {
- await svc.sendText('第一条');
- mxStub.sendText.calls.reset();
- await svc.sendText('第二条');
- // 第一条是本地乐观 id(local-*),不是真实 event,故 lastEventId 应为 undefined
- const args = mxStub.sendText.calls.mostRecent().args;
- expect(args[0]).toBe('!room1:fmode.cn');
- expect(args[1]).toBe('第二条');
- expect(args[2]).toBe(svc.sessionId()!);
- expect(args[3]).toBeUndefined();
- expect(args[4]).toEqual({ 'com.fmode.local': jasmine.any(String) });
- });
- it('发送后清空草稿', async () => {
- svc.setDraft('待发');
- await svc.sendText('待发');
- expect(svc.draft()).toBe('');
- });
- it('空文本不发送(也不插气泡)', async () => {
- const before = svc.messages().length;
- await svc.sendText(' ');
- expect(svc.messages().length).toBe(before);
- expect(mxStub.sendText).not.toHaveBeenCalled();
- });
- it('失败 → 留痕 failed 且带错误原因(不丢消息)', async () => {
- mxStub.sendText.and.returnValue(Promise.reject(new Error('网络错误')));
- await svc.sendText('会失败');
- const last = svc.messages().at(-1)!;
- expect(last.state).toBe('failed');
- expect(last.error).toContain('网络错误');
- });
- it('retry 把 failed 消息重发并复位为 sent', async () => {
- mxStub.sendText.and.returnValue(Promise.reject(new Error('临时故障')));
- await svc.sendText('重试我');
- const failed = svc.messages().at(-1)!;
- expect(failed.state).toBe('failed');
- mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$ok' }));
- await svc.retry(failed.id);
- const after = svc.messages().find((m) => m.id === failed.id)!;
- expect(after.state).toBe('sent');
- expect(after.error).toBeUndefined();
- });
- it('非 failed 状态的消息 retry 不做事', async () => {
- await svc.sendText('已成功的');
- const ok = svc.messages().at(-1)!;
- mxStub.sendText.calls.reset();
- await svc.retry(ok.id);
- expect(mxStub.sendText).not.toHaveBeenCalled();
- });
- });
- describe('paginate(AC-04.4 翻历史)', () => {
- beforeEach(async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- });
- it('当前会话内的消息计入', async () => {
- mxStub.timelineToMessages.and.returnValue(
- Array.from({ length: 5 }, (_, i) => ({
- roomId: 'r',
- eventId: `$h${i}`,
- sender: '@zgdcmgnqcd:fmode.cn',
- // 必须带当前会话的 threadId —— 无 thread 的属于「历史对话」,不混入
- threadId: svc.sessionId()!,
- body: `历史${i}`,
- ts: i,
- kind: 'text' as const,
- live: false,
- })),
- );
- const grew = await svc.paginate();
- expect(grew).toBeTrue();
- expect(svc.messages().length).toBe(5);
- expect(svc.hasMoreHistory()).toBeTrue();
- });
- it('其它会话的消息被过滤,不混入当前会话', async () => {
- mxStub.timelineToMessages.and.returnValue(
- Array.from({ length: 5 }, (_, i) => ({
- roomId: 'r',
- eventId: `$x${i}`,
- sender: '@zgdcmgnqcd:fmode.cn',
- threadId: '$approot_other_session:fmode.cn',
- body: `别的会话${i}`,
- ts: i,
- kind: 'text' as const,
- live: false,
- })),
- );
- const grew = await svc.paginate();
- expect(grew).toBeFalse(); // 条数没增长
- expect(svc.messages().length).toBe(0);
- });
- it('条数未增长 → hasMoreHistory=false(到最早了,停止请求)', async () => {
- mxStub.timelineToMessages.and.returnValue([]);
- const grew = await svc.paginate();
- expect(grew).toBeFalse();
- expect(svc.hasMoreHistory()).toBeFalse();
- });
- it('🔴 防重入:并发调用只触发一次 SDK 分页', async () => {
- /*
- * 🔴 调用计数必须从这个用例的基线算起(2026-11-12)。
- * `open()` 现在也会按需回溯历史,上一句 `beforeEach` 里的 open
- * 可能已经调用过若干次 `paginateBack`;绝对计数会被累加污染。
- */
- const base = mxStub.paginateBack.calls.count();
- let resolveIt: (v: boolean) => void = () => undefined;
- mxStub.paginateBack.and.callFake(() => new Promise<boolean>((res) => (resolveIt = res)));
- const a = svc.paginate();
- const b = svc.paginate(); // 立刻重入
- resolveIt(true);
- await Promise.all([a, b]);
- expect(mxStub.paginateBack.calls.count() - base).toBe(1);
- });
- it('paginating 在完成后复位', async () => {
- await svc.paginate();
- expect(svc.paginating()).toBeFalse();
- });
- });
- /*
- * 🔴 历史会话打开为空(2026-11-12 用户实测)。
- *
- * 现象:「我的这些历史记录都无法查看了,大部分都没有正常显示。」
- * 根因:sync 只保留房间最近 50 条,老会话的消息不在本地 timeline,
- * 而打开会话时**从不主动向前翻**,于是渲染成「全新会话」欢迎页。
- */
- describe('打开历史会话时按需回溯 timeline', () => {
- const SID = '$approot_test:fmode.cn';
- /** 构造一条属于该会话的消息 */
- const msgOf = (id: string): MxMessage =>
- ({
- roomId: '!room1:fmode.cn',
- eventId: id,
- sender: '@zgdcmgnqcd:fmode.cn',
- threadId: SID,
- body: '历史内容',
- ts: 1,
- kind: 'text' as const,
- live: false,
- }) as MxMessage;
- afterEach(() => {
- mxStub.timelineToMessages.and.returnValue([]);
- mxStub.paginationToken.and.returnValue('tok-b');
- });
- it('本地没有该会话的消息 → 向前回溯并命中', async () => {
- realMode();
- // 第一轮:只有别的会话;回溯一次后:出现本会话
- let calls = 0;
- mxStub.timelineToMessages.and.callFake(() =>
- calls === 0 ? [] : [msgOf('$h1'), msgOf('$h2')],
- );
- mxStub.paginateBack.and.callFake(() => {
- calls++;
- return Promise.resolve(true);
- });
- await svc.open('agent-node-lj-test', SID);
- expect(mxStub.paginateBack).toHaveBeenCalled();
- expect(svc.messages().length).toBeGreaterThan(0);
- expect(svc.emptyReason()).toBeNull();
- });
- it('命中即停:不把整个房间翻完', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([msgOf('$h1')]);
- await svc.open('agent-node-lj-test', SID);
- expect(mxStub.paginateBack).not.toHaveBeenCalled();
- });
- it('页面已到房间最早(无 token)→ 不回溯', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- mxStub.paginationToken.and.returnValue(null);
- await svc.open('agent-node-lj-test', SID);
- expect(mxStub.paginateBack).not.toHaveBeenCalled();
- });
- it('🔥 回溯耗尽仍取不到 → emptyReason=unloaded,不得伪装成新会话', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]); // 永远取不到
- mxStub.paginateBack.and.returnValue(Promise.resolve(true));
- await svc.open('agent-node-lj-test', SID);
- /*
- * 断言「**有上限**」而不是写死具体轮数(2026-11-12):
- * 上限会随房间规模调整(当前 50 轮,因 lj-test 房间已超 2100 个事件)。
- * 这里守住真正的不变量:确实尝试过、且**一定会停**。
- */
- const n = mxStub.paginateBack.calls.count();
- expect(n).toBeGreaterThan(0);
- expect(n).toBeLessThanOrEqual(100);
- /*
- * 房间**没翻到最早**(`paginationToken` 仍返回 token)→ 不能断定「这条会话没有消息」,
- * 只能诚实说「暂时取不到」。
- */
- expect(svc.emptyReason()).toBe('unloaded');
- });
- it('翻到房间最早 + 目录里有记录 → true empty(fresh),不吓唬用户', async () => {
- realMode();
- mxStub.timelineToMessages.and.returnValue([]);
- // 一轮就翻到顶:翻完 token 变 null
- mxStub.paginateBack.and.callFake(() => {
- mxStub.paginationToken.and.returnValue(null);
- return Promise.resolve(true);
- });
- await svc.open('agent-node-lj-test', SID);
- /*
- * 🔴 已翻到房间最早 + 目录里有这条 + 0 条消息 = **空壳会话**(2026-11-12)。
- * 不能报 `fresh`(会被渲染成「全新会话」欢迎语,诱导用户以为内容丢了),
- * 也不能报 `unloaded`(会让用户一直等「暂时取不到」)。
- * 实测样本:「测试9680」在 Matrix 里确实一条消息都没有。
- */
- expect(svc.emptyReason()).toBe('blank');
- });
- });
- describe('setDraft 触发 typing 通知', () => { it('有内容 → typing=true,清空 → typing=false', async () => {
- realMode();
- await svc.open('a');
- svc.setDraft('在输入');
- expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', true);
- svc.setDraft('');
- expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', false);
- });
- });
- describe('附件暂存 → 发送时上传(AC-04.3)', () => {
- const file = (name = 'doc.txt') => new File(['x'], name, { type: 'text/plain' });
- it('stageFiles 只暂存,**不上传、不发消息**', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- apiStub.uploadFile.calls.reset();
- mxStub.sendText.calls.reset();
- svc.stageFiles([file()]);
- expect(svc.pendingFiles().length).toBe(1);
- expect(apiStub.uploadFile).not.toHaveBeenCalled();
- expect(mxStub.sendText).not.toHaveBeenCalled();
- });
- it('可以移除暂存附件', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- svc.stageFiles([file()]);
- const id = svc.pendingFiles()[0].id;
- svc.unstageFile(id);
- expect(svc.pendingFiles()).toEqual([]);
- });
- it('发送时上传附件(走 Matrix 原生事件)+ 本地先插文件气泡', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- // 2026-11-10:改为「先上云、再发 Matrix」,云盘是发送的前置条件
- apiStub.uploadFile.and.returnValue(
- Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
- );
- svc.stageFiles([file()]);
- await svc.sendText('看看这个');
- /*
- * 本地先插一条文件气泡(用户裁决 2026-11-11:立即可见)——
- * 回读的 `m.image` 会再落一条,靠回合聚合的 `USER_MERGE_MS` 并成同一回合,
- * 见 turn-aggregator 的「带附件的 me 消息并入上一回合」用例。
- */
- expect(svc.messages().some((m) => m.id.startsWith('local-file-'))).toBeTrue();
- // 走 Matrix 原生附件,不再依赖 OBS 预签名地址
- expect(mxStub.sendFile).toHaveBeenCalled();
- expect(svc.uploading()).toBeFalse();
- // 上传成功后暂存清空
- expect(svc.pendingFiles()).toEqual([]);
- });
- // 🔴 原实现只更新本地消息、从不发到房间 —— 容器永远收不到
- it('🔴 附件必须以 Matrix 原生事件真的发到房间(sendFile)', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- apiStub.uploadFile.and.returnValue(
- Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
- );
- mxStub.sendFile.calls.reset();
- svc.stageFiles([file()]);
- await svc.sendText('附上');
- expect(mxStub.sendFile).toHaveBeenCalled();
- });
- it('上传失败 → 记录错误且不上传中,附件保留待重试', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- mxStub.sendFile.and.returnValue(Promise.reject(new Error('上传失败')));
- svc.stageFiles([file()]);
- await svc.sendText('附上');
- expect(svc.error()).toContain('失败');
- expect(svc.uploading()).toBeFalse();
- expect(svc.pendingFiles().length).withContext('失败后应保留待重试').toBe(1);
- });
- /*
- * 2026-11-10:上云顺序调整为「先 S3、再 Matrix」。
- * 目的:不发出去「聊天记录里有、云盘里没有」的半成品文件。
- */
- it('🔴 云盘失败 → 不发 Matrix、附件保留待重试', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- apiStub.uploadFile.and.returnValue(Promise.reject(new Error('云盘不可用')));
- mxStub.sendFile.calls.reset();
- svc.stageFiles([file()]);
- await svc.sendText('附上');
- expect(mxStub.sendFile).not.toHaveBeenCalled();
- expect(svc.error()).toContain('云盘不可用');
- expect(svc.pendingFiles().length).withContext('未上云的文件不得被清掉').toBe(1);
- });
- it('🔴 顺序为先云盘后 Matrix(避免半成功)', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- const order: string[] = [];
- apiStub.uploadFile.and.callFake(() => {
- order.push('s3');
- return Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' });
- });
- mxStub.sendFile.and.callFake(() => {
- order.push('matrix');
- return Promise.resolve();
- });
- svc.stageFiles([file()]);
- await svc.sendText('附上');
- expect(order).toEqual(['s3', 'matrix']);
- });
- /* 大小限制(2026-11-10):选文件时就拦,超限项不进暂存区 */
- it('🔴 超过服务端上限的文件在 stageFiles 阶段被拒并提示', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- /*
- * 🔴 2026-11-13:上限不再写死 20MB,而是由服务端 `m.upload.size` 决定。
- * 这里显式设成 20MB 来验证拦截逻辑本身(真实环境实测为 32MB)。
- */
- setServerUploadLimit(20 * 1024 * 1024);
- const big = new File([new Uint8Array(8)], 'big.zip');
- Object.defineProperty(big, 'size', { value: 21 * 1024 * 1024 });
- svc.stageFiles([big]);
- expect(svc.pendingFiles().length).toBe(0);
- expect(svc.error()).toContain('超过平台单文件上限');
- });
- it('🔴 未探测到服务端上限 → 不拦截(用户裁决:不要凭空拦住)', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- setServerUploadLimit(null);
- const big = new File([new Uint8Array(8)], 'huge.zip');
- Object.defineProperty(big, 'size', { value: 500 * 1024 * 1024 });
- svc.stageFiles([big]);
- expect(svc.pendingFiles().length).toBe(1);
- });
- it('未超限的文件正常进入暂存区', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- const ok = new File([new Uint8Array(8)], 'ok.txt');
- Object.defineProperty(ok, 'size', { value: 20 * 1024 * 1024 });
- svc.stageFiles([ok]);
- expect(svc.pendingFiles().length).toBe(1);
- });
- it('多选时只挡掉超限的,合格的照常暂存', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- setServerUploadLimit(20 * 1024 * 1024); // 显式设上限,见上方说明
- const small = file();
- const big = new File([new Uint8Array(8)], 'big.zip');
- Object.defineProperty(big, 'size', { value: 30 * 1024 * 1024 });
- svc.stageFiles([small, big]);
- expect(svc.pendingFiles().length).toBe(1);
- expect(svc.pendingFiles()[0].file.name).toBe('doc.txt');
- expect(svc.error()).toContain('big.zip');
- });
- it('只有附件没有文字也能发送', async () => {
- realMode();
- await svc.open('agent-node-lj-test');
- apiStub.uploadFile.and.returnValue(
- Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
- );
- mxStub.sendFile.calls.reset();
- mxStub.sendText.calls.reset();
- svc.stageFiles([file()]);
- await svc.sendText('');
- // 纯附件:附件本身照常发到房间(本地不伪造文件消息)
- expect(mxStub.sendFile).toHaveBeenCalled();
- // 纯附件不该发一条空文字
- expect(mxStub.sendText).not.toHaveBeenCalled();
- });
- });
- describe('进入会话即已读(清未读计数)', () => {
- it('open 成功后该 agent 未读归零', async () => {
- realMode();
- const unread = TestBed.inject(UnreadService);
- // 先造出未读
- unread.onIncoming('agent-node-lj-test', 1000, true);
- unread.onIncoming('agent-node-lj-test', 2000, false);
- expect(unread.countOf('agent-node-lj-test')).toBe(1);
- await svc.open('agent-node-lj-test');
- expect(unread.countOf('agent-node-lj-test')).toBe(0);
- });
- });
- /**
- * 多会话(线程)隔离 —— 2026-10-28 新增。
- *
- * 容器按 `room + thread` 划分会话(实测),App 侧必须:
- * ① 每条消息带上当前会话的线程根
- * ② 只显示属于当前会话的消息
- */
- describe('多会话隔离', () => {
- const msg = (id: string, threadId?: string, body = 'x'): MxMessage => ({
- roomId: '!room1:fmode.cn',
- eventId: id,
- sender: '@zgdcmgnqcd:fmode.cn',
- threadId,
- body,
- ts: Date.now(),
- kind: 'text',
- live: true,
- });
- beforeEach(() => realMode());
- it('首次打开**不**自动建空会话(2026-10-31 新语义)', async () => {
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
- await svc.open('agent-node-lj-test');
- expect(svc.sessionId()).toBe('');
- expect(svc.state()).toBe('empty');
- });
- it('云端有会话时直接打开最近一条', async () => {
- await svc.open('agent-node-lj-test');
- expect(svc.sessionId()).toBe(CLOUD_THREAD.threadRoot);
- expect(svc.currentSession()).toBeDefined();
- });
- /*
- * 🔴 回归(2026-11-06,渲染测试复现):
- * 刷新页面后先发消息,`_sessionId` 已指向线程根,但云端目录里还没有这条
- * (刷新后目录回来得更晚)—— `currentSession()` 返回 undefined,
- * 聊天页据此隐藏页头、右栏和**整个输入区**,只剩一条告警。
- */
- it('🔴 会话目录里查不到当前会话时,currentSession 仍给出占位', async () => {
- await svc.open('agent-node-lj-test');
- const sid = svc.sessionId();
- // 模拟目录尚未包含这条(换设备 / 刚建还没落云)
- svc.sessions.reset();
- await svc.sendText('刷新后第一条');
- expect(svc.sessionId()).toBe(sid);
- expect(svc.currentSession())
- .withContext('查不到也必须给出占位,否则输入区会消失')
- .toBeDefined();
- expect(svc.currentSession()!.id).toBe(sid as string);
- expect(svc.currentSession()!.agentId).toBe('agent-node-lj-test');
- });
- it('会话目录里有当前会话时,用的是目录里的真身而不是占位', async () => {
- await svc.open('agent-node-lj-test');
- const s = svc.currentSession();
- expect(s!.title).toBe(CLOUD_THREAD.title);
- expect(svc.sessions.byId(CLOUD_THREAD.threadRoot)?.title).toBe(s!.title);
- });
- it('再次打开同一 agent 复用同一会话(不会每次都新建)', async () => {
- await svc.open('agent-node-lj-test');
- const first = svc.sessionId();
- svc.close();
- await svc.open('agent-node-lj-test');
- expect(svc.sessionId()).toBe(first);
- });
- it('新建会话后 sessionId 改变,且消息列表清空', async () => {
- await svc.open('agent-node-lj-test');
- const first = svc.sessionId();
- const sessions = svc.sessions;
- const created = sessions.create('!room1:fmode.cn', 'agent-node-lj-test');
- await svc.open('agent-node-lj-test', created.id);
- expect(svc.sessionId()).toBe(created.id);
- expect(svc.sessionId()).not.toBe(first);
- });
- it('实时消息只进入所属会话,不串线', async () => {
- await svc.open('agent-node-lj-test');
- const mine = svc.sessionId()!;
- let sink: ((m: MxMessage) => void) | null = null;
- mxStub.onMessage.and.callFake((_r: string, cb: (m: MxMessage) => void) => {
- sink = cb;
- return () => undefined;
- });
- svc.close();
- await svc.open('agent-node-lj-test');
- sink!(msg('$m1', mine, '我的会话'));
- sink!(msg('$m2', '$approot_someone_else:fmode.cn', '别人的会话'));
- sink!(msg('$m3', undefined, '无 thread 的历史'));
- const texts = svc.messages().map((m) => m.text);
- expect(texts).toContain('我的会话');
- expect(texts).not.toContain('别人的会话');
- /*
- * 🔴 2026-11-09 用户裁决变更:房间级消息(无 thread)**一律丢弃**。
- *
- * 原规则(2026-11-03)把它们归入当前会话,当时是为了让「用户直接发的图」
- * (m.image 不带 thread)能看见。但实测发现更大的问题:
- * 外部工具/脚本往房间发的消息没有 thread,会被**新建会话全部认领**,
- * 表现为「新建一个会话,上面立刻出现一堆不相干的历史」。
- *
- * 用户明确裁决:宁可丢弃,也不要把别处的消息错认成本会话的。
- * 代价:容器历史上无 thread 的旧回复不再显示
- * (容器现已改为带 thread 回复,影响有限)。
- */
- expect(texts).not.toContain('无 thread 的历史');
- });
- /*
- * 🔴 容器自己开的子线程**属于当前会话**(2026-10-29 实测)。
- * 容器回复时会另开 m.thread 跑内部任务,那些消息也是本次对话的一部分,
- * 交付物(html/截图)就在里面 —— 必须显示,否则内容丢失。
- */
- it('容器自开的子线程归入当前会话', async () => {
- await svc.open('agent-node-lj-test');
- let sink: ((m: MxMessage) => void) | null = null;
- mxStub.onMessage.and.callFake((_r: string, cb: (m: MxMessage) => void) => {
- sink = cb;
- return () => undefined;
- });
- svc.close();
- await svc.open('agent-node-lj-test');
- // 不是 approot 前缀 → 容器内部产生的线程
- sink!(msg('$c1', '$kfv5cDP_ZR3jK4VK2WxWBCNBA:fmode.cn', '容器的子任务输出'));
- expect(svc.messages().map((m) => m.text)).toContain('容器的子任务输出');
- });
- /*
- * 「历史对话」承接无 thread 旧消息的**完整链路验证在端到端**
- * (Playwright 真实浏览器 + 真实 Matrix 房间,2026-10-28 已通过:
- * 房间里 20 条无 thread 旧消息 → 自动出现「历史对话」并显示它们)。
- *
- * 这里只做**否定断言**:没有旧消息时不得凭空多出一个「历史对话」。
- * 不用 spy 驱动的正值用例 —— `timelineToMessages` 在 `openReal` 的
- * 异步间隙会被 beforeEach 的桩覆盖,断言会变成测测试桩本身。
- */
- it('房间里没有旧消息时,不凭空建「历史对话」', async () => {
- mxStub.timelineToMessages.and.returnValue([]);
- await svc.open('agent-node-lj-test');
- expect(svc.sessions.listFor('agent-node-lj-test').some((s) => s.legacy)).toBeFalse();
- });
- it('收到消息后会话预览与更新时间推进', async () => {
- await svc.open('agent-node-lj-test');
- const sid = svc.sessionId()!;
- let sink: ((m: MxMessage) => void) | null = null;
- mxStub.onMessage.and.callFake((_r: string, cb: (m: MxMessage) => void) => {
- sink = cb;
- return () => undefined;
- });
- svc.close();
- await svc.open('agent-node-lj-test');
- sink!(msg('$p1', sid, '这是一条预览内容'));
- const s = TestBed.inject(ChatSessionService).byId(sid)!;
- expect(s.lastPreview).toBe('这是一条预览内容');
- expect(s.messageCount).toBeGreaterThan(0);
- });
- });
- });
- /*
- * 新建会话的「空白保护」窗口(2026-11-08 用户实测:发一句「你好」仍带出历史)。
- *
- * 根因是**清除时机**:`_blankSessionId` 曾在 commit(本地记账)时就清掉,
- * 而那时 `sendReal()` 的网络请求还在路上(约 1 秒)。此间 `/sync` 落地触发
- * 历史回填,两道保护(新建态标记 + state==='empty')同时失效,
- * `rootsOfSession()` 把该房间所有旧容器子线程根都算成「本会话的」,
- * 于是旧对话被灌进新会话。
- *
- * 这里断言的是**行为契约**:发送期间(网络请求未返回)绝不能解除新建态保护。
- */
- describe('ChatService · 新建会话期间不得回填历史(2026-11-08)', () => {
- let svc: ChatService;
- const apiStub = {
- provisionMatrix: jasmine.createSpy('provisionMatrix').and.returnValue(
- Promise.resolve({ accessToken: 'tok', userMxId: '@me:fmode.cn', deviceId: 'd1', agentMxId: '@agent:fmode.cn', roomId: '!room1:fmode.cn' }),
- ),
- fetchChatThreads: jasmine.createSpy('fetchChatThreads').and.returnValue(Promise.resolve([])),
- upsertChatThreads: jasmine.createSpy('upsertChatThreads').and.returnValue(Promise.resolve()),
- patchChatThread: jasmine.createSpy('patchChatThread').and.returnValue(Promise.resolve()),
- removeChatThread: jasmine.createSpy('removeChatThread').and.returnValue(Promise.resolve()),
- validateMatrix: jasmine.createSpy('validateMatrix').and.returnValue(Promise.resolve(true)),
- fetchAgents: jasmine.createSpy('fetchAgents').and.returnValue(Promise.resolve([])),
- };
- const AGENT: Agent = {
- id: 'agent-node-lj-test',
- name: 'lj-test',
- status: 'online' as Agent['status'],
- matrixReady: true,
- unread: 0,
- };
- const liveStub = {
- ensureLoaded: () => Promise.resolve(),
- agentById: () => AGENT,
- agents: () => [AGENT],
- state: () => 'ready',
- error: () => null,
- /** TaskPulseService 会调它做快照对比 */
- tick: () => 0,
- };
- /** 可以手动放行的 sendText —— 模拟「网络请求还没返回」 */
- let releaseSend: () => void = () => undefined;
- const mxStub = {
- connectSession: () => Promise.resolve(),
- disconnect: () => undefined,
- ensureRoom: jasmine.createSpy('ensureRoom').and.returnValue(Promise.resolve('!room1:fmode.cn')),
- syncState: () => 'connected',
- hasInitialSync: true,
- paginationToken: () => null,
- timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]),
- onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined),
- onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined),
- onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined),
- setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
- sendText: jasmine.createSpy('sendText').and.callFake(
- () => new Promise<{ event_id?: string }>((res) => { releaseSend = () => res({ event_id: '$new' }); }),
- ),
- storeVersion: () => 0,
- timelineIndex: () => new Map<string, number>(),
- /* 2026-11-12:openReal 现在会等初次 sync、并登记自愈用的 agentId */
- synced: jasmine.createSpy('synced').and.returnValue(Promise.resolve(true)),
- setRecoveryAgentId: jasmine.createSpy('setRecoveryAgentId'),
- };
- beforeEach(() => {
- TestBed.resetTestingModule();
- localStorage.clear();
- releaseSend = () => undefined;
- (environment as { useMockData: boolean }).useMockData = false;
- TestBed.configureTestingModule({
- providers: [
- ChatService,
- { provide: FmodeApi, useValue: apiStub },
- { provide: LiveService, useValue: liveStub },
- { provide: MatrixClientService, useValue: mxStub },
- { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'tok' } },
- ],
- });
- svc = TestBed.inject(ChatService);
- });
- it('🔴 发送请求未返回前,实时消息不得混入新会话', async () => {
- await svc.open('agent-node-lj-test');
- const created = await svc.startBlank('agent-node-lj-test');
- expect(created).withContext('startBlank 应建出会话,实际为 null').toBeTruthy();
- const sid = created!.id;
- // 开始发送,但请求挂起(网络还没返回)
- const pending = svc.sendText('你好');
- /*
- * 🔴 2026-11-09:首次发消息会先 `await commitNow()`(落库),
- * 多了一轮微任务。只 `await Promise.resolve()` 一次,此刻还停在
- * 落库阶段,`sendText` 根本没走到发送。
- * 这里连推几轮,确保已经进入「发送请求挂起」的状态。
- */
- for (let k = 0; k < 5; k++) await Promise.resolve();
- await new Promise((r) => setTimeout(r, 0));
- // 此刻新会话里应该只有用户自己那条乐观消息
- const during = svc.messages();
- expect(during.length).withContext('发送期间只应有本地乐观消息').toBe(1);
- releaseSend();
- await pending;
- /*
- * 🔴 原来这里只 await 不检查 —— 而「发送成功之后」才是问题真正爆发的地方(2026-11-09)。
- * 用户实测:发一条「你好」,`messages()` 返回 3 条。
- */
- expect(svc.messages().filter((m) => m.sender === 'me').length)
- .withContext('发送完成后仍应恰好 1 条')
- .toBe(1);
- });
- /*
- * 🔴 乐观消息 ↔ 服务端 echo 去重(2026-11-09)。
- *
- * 病根:乐观消息 id 是 `local-*`,echo id 是 `$eventId`,按 id 做并集永远对不上,
- * 于是同一条消息「本地一条 + 回读一条」并存。现在靠 content 里的
- * `com.fmode.local` 认领。这些用例守住这条链路。
- */
- describe('🔴 乐观消息与 echo 去重', () => {
- /** 取 openReal 注册的实时回调 —— 直接走生产链路(appendIncoming),不硬造信号 */
- function emitter(): (m: MxMessage) => void {
- const calls = mxStub.onMessage.calls.allArgs();
- return calls[calls.length - 1][1] as (m: MxMessage) => void;
- }
- it('echo 带回同一个 localId → 只留服务端那条,不重复', async () => {
- await svc.open('agent-node-lj-test');
- const pending = svc.sendText('你好');
- /*
- * 🔴 2026-11-09:首次发消息会先 `await commitNow()`(落库),
- * 比原来多了一轮 await。必须连推几轮微任务,确保乐观消息已插入、
- * 且发送已进入「等待 sendText 返回」的状态,否则下面 `releaseSend()`
- * 时 `sendText` 还没被调用,Promise 永远挂着(超时)。
- */
- for (let k = 0; k < 5; k++) await Promise.resolve();
- await new Promise((r) => setTimeout(r, 0));
- const localId = svc.messages()[0].localId!;
- expect(localId).toBeTruthy();
- releaseSend();
- await pending;
- expect(svc.messages().filter((m) => m.sender === 'me').length).toBe(1);
- // 服务端 echo 实时到达:id 是 eventId,但带回 localId
- emitter()({
- roomId: '!room1:fmode.cn',
- eventId: '$e1',
- sender: '@me:fmode.cn',
- body: '你好',
- ts: Date.now(),
- kind: 'text',
- live: true,
- localId,
- threadId: svc.sessionId() ?? undefined,
- });
- const mine = svc.messages().filter((m) => m.sender === 'me');
- expect(mine.length).withContext('echo 认领后应恰好 1 条').toBe(1);
- expect(mine[0].id).toBe('$e1');
- });
- it('连点两次发送 → 两条,且 id 不冲突', async () => {
- await svc.open('agent-node-lj-test');
- // 两次发送各挂一个 resolver(第一次的会先被放行,第二次的会被新赋值覆盖)
- mxStub.sendText.and.callFake(
- () => new Promise<{ event_id?: string }>((res) => setTimeout(() => res({ event_id: '$x' }), 0)),
- );
- await Promise.all([svc.sendText('第一条'), svc.sendText('第二条')]);
- const mine = svc.messages().filter((m) => m.sender === 'me');
- expect(mine.length).toBe(2);
- expect(new Set(mine.map((m) => m.id)).size).withContext('id 必须唯一').toBe(2);
- });
- it('失败消息在实时流到达后仍保留(可重试,AC-04.8)', async () => {
- await svc.open('agent-node-lj-test');
- mxStub.sendText.and.returnValue(Promise.reject(new Error('网络断了')));
- await svc.sendText('会失败');
- const failed = svc.messages().filter((m) => m.state === 'failed');
- expect(failed.length).toBe(1);
- // 容器回复到达,不能把失败消息冲掉
- emitter()({
- roomId: '!room1:fmode.cn',
- eventId: '$r1',
- sender: '@agent:fmode.cn',
- body: '收到',
- ts: Date.now(),
- kind: 'text',
- live: true,
- threadId: svc.sessionId() ?? undefined,
- });
- expect(svc.messages().filter((m) => m.state === 'failed').length).toBe(1);
- });
- /*
- * 🔴 2026-11-09 评审 B3:合并时不分会话 → 切会话后旧会话的乐观消息残留。
- */
- it('🔴 新建会话后,上一条会话的发送中消息不会残留', async () => {
- await svc.open('agent-node-lj-test');
- // 第一条:挂起不返回(发送中)
- void svc.sendText('上一条会话的消息');
- await Promise.resolve();
- expect(svc.messages().filter((m) => m.state === 'sending').length).toBe(1);
- // 新建会话 —— 应当干净
- await svc.startBlank('agent-node-lj-test');
- expect(svc.messages().length).withContext('新建会话必须空白').toBe(0);
- });
- });
- });
- /**
- * 附件事件的 body 是文件名(Matrix 协议),不能当正文再显示一遍。
- * 2026-11-10 实测截图:发一个文件后界面出现「卡片 + 同名纯文字」两条。
- */
- describe('isAttachmentEcho —— 附件 body 判定', () => {
- it('body 等于文件名 → 视为回声,不显示正文', () => {
- expect(isAttachmentEcho('report.md', 'report.md')).toBeTrue();
- });
- it('前后空白也认', () => {
- expect(isAttachmentEcho(' report.md \n', 'report.md')).toBeTrue();
- });
- it('body 有额外说明 → 保留正文(容器常在附件同一条里写说明)', () => {
- expect(isAttachmentEcho('这是本周的周报,请查收', 'report.md')).toBeFalse();
- });
- it('空 body 不当回声(交给上层按 undefined 处理)', () => {
- expect(isAttachmentEcho('', 'report.md')).toBeFalse();
- expect(isAttachmentEcho(undefined, 'report.md')).toBeFalse();
- });
- });
|