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)[k].calls.reset(); } liveStub.ensureLoaded.calls.reset(); liveStub.agentById.calls.reset(); for (const k of Object.keys(mxStub)) { const v = (mxStub as Record)[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((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; 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((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(), /* 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(); }); });