import { MxMessage } from './matrix-client.service'; import { atFromTurnId, deriveGroupEntries, matchLocalTurn, mergeGroupEntries, turnIdAt, } from './group-matrix-transcript'; import { GroupEntry } from './group-turns'; const GROUP = 'group-1'; const ME = '@xhvrjtmwqh:fmode.cn'; const A = '@k0nrevcfik:fmode.cn'; // agent-lj const B = '@axmojmfphr:fmode.cn'; // app-lj-test-v1 let seq = 0; const msg = (o: Partial & { ts: number }): MxMessage => ({ roomId: '!r', eventId: o.eventId ?? `$e${++seq}`, sender: o.sender ?? A, body: o.body ?? '', kind: 'text', live: false, ...o, }); const dispatch = (turnId: string, targetAgentId: string) => ({ groupId: GROUP, turnId, targetAgentId, }); describe('group-matrix-transcript —— 群聊历史重建', () => { beforeEach(() => { seq = 0; }); it('单成员:派活 → 回复 → 新派活 → 回复,切成两个回合', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: '第一句', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1100, body: '回一' }), msg({ ts: 2000, sender: ME, body: '第二句', dispatch: dispatch('me-2000', 'lj') }), msg({ ts: 2100, body: '回二' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); expect(d.entries.filter((e) => e.fromAgentId === null).length).toBe(2); expect(d.entries.filter((e) => e.fromAgentId === 'lj').length).toBe(2); expect(d.targets['me-1000']).toEqual(['lj']); }); it('两成员同一回合:用户消息只出现一次,targets 是并集', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: '帮我看下', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1200, body: '好的(lj)' }), ], }, { agentId: 'app-v1', messages: [ msg({ ts: 1004, sender: ME, body: '帮我看下', dispatch: dispatch('me-1000', 'app-v1') }), msg({ ts: 1300, sender: B, body: '好的(v1)' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); expect(d.entries.filter((e) => e.fromAgentId === null).length).toBe(1); expect(d.targets['me-1000']).toEqual(['lj', 'app-v1']); expect(d.entries.filter((e) => e.fromAgentId).map((e) => e.fromAgentId)).toEqual(['lj', 'app-v1']); }); it('只有部分房间有该回合的派活:不丢回合,targets 以实际为准', () => { const d = deriveGroupEntries( [{ agentId: 'lj', messages: [msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') })] }], { groupId: GROUP, myUserId: ME }, ); expect(d.entries.length).toBe(1); expect(d.targets['me-1000']).toEqual(['lj']); }); it('同一房间重复派活:以最后一条切段,且不重复回复', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1100, body: '第一次回复' }), msg({ ts: 1200, sender: ME, body: 'y', dispatch: dispatch('me-1200', 'lj') }), msg({ ts: 1300, body: '第二次回复' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); const reply = d.entries.filter((e) => e.fromAgentId); expect(reply.map((e) => e.turnId)).toEqual(['me-1000', 'me-1200']); }); it('别的群的派活会把段切断:之后的回复不归到本群', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1100, sender: ME, body: '别的群', dispatch: { groupId: 'group-2', turnId: 'me-1100', targetAgentId: 'lj' }, }), msg({ ts: 1200, body: '别的群的回复' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); expect(d.entries.filter((e) => e.fromAgentId)).toEqual([]); }); it('单聊附件(带 sessionId)会切断段', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1100, sender: ME, body: '图', sessionId: '$approot_other' }), msg({ ts: 1200, body: '单聊里的回复' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); expect(d.entries.filter((e) => e.fromAgentId)).toEqual([]); }); it('附件回复生成文件卡(text 为空)', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: '出个表', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1100, body: 'table.html', media: { name: 'table.html', url: 'https://x/t.html', mimeType: 'text/html', size: 1234 }, }), ], }, ], { groupId: GROUP, myUserId: ME }, ); const reply = d.entries.find((e) => e.fromAgentId)!; expect(reply.message.file?.name).toBe('table.html'); expect(reply.message.text).toBeUndefined(); }); it('用户发附件:成为该回合的用户消息,多个房间的同名附件只记一次', () => { const media = { name: 'a.png', url: 'https://x/a.png', mimeType: 'image/png', size: 10 }; const d = deriveGroupEntries( [ { agentId: 'lj', messages: [msg({ ts: 1000, sender: ME, body: '', media, dispatch: dispatch('me-1000', 'lj') })], }, { agentId: 'app-v1', messages: [msg({ ts: 1002, sender: ME, body: '', media, dispatch: dispatch('me-1000', 'app-v1') })], }, ], { groupId: GROUP, myUserId: ME }, ); const users = d.entries.filter((e) => e.fromAgentId === null); expect(users.length).toBe(1); expect(users[0].message.file?.name).toBe('a.png'); }); it('本人发的非派活消息不进入群聊', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1050, sender: ME, body: '我自己又补了一句' }), msg({ ts: 1100, body: '回复' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); expect(d.entries.filter((e) => e.fromAgentId === null).length).toBe(1); expect(d.entries.filter((e) => e.fromAgentId).length).toBe(1); }); it('位置校验:回复排在派活之前时(离线补发错位)不算本回合', () => { const dispMsg = msg({ ts: 2000, sender: ME, body: 'x', dispatch: dispatch('me-2000', 'lj') }); const early = msg({ ts: 2500, body: '时间更晚但下标更早' }); const index = new Map([ [dispMsg.eventId, 5], [early.eventId, 3], ]); const d = deriveGroupEntries([{ agentId: 'lj', messages: [dispMsg, early], index }], { groupId: GROUP, myUserId: ME, }); expect(d.entries.filter((e) => e.fromAgentId)).toEqual([]); }); it('🔴 文字 + 附件同一回合:文件不覆盖文字(评审 2026-11-07)', () => { const media = { name: 'a.png', url: 'https://x/a.png', mimeType: 'image/png', size: 10 }; const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ msg({ ts: 1000, sender: ME, body: '看图', dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1001, sender: ME, body: '', media, dispatch: dispatch('me-1000', 'lj') }), msg({ ts: 1100, body: '看到了' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); const user = d.entries.find((e) => e.fromAgentId === null)!; expect(user.message.text).withContext('文字不能被文件事件覆盖').toBe('看图'); expect(d.entries.filter((e) => e.fromAgentId).length).toBe(1); }); it('🔴 派活的 targetAgentId 与房间不一致时不认该段(评审 2026-11-07)', () => { const d = deriveGroupEntries( [ { agentId: 'lj', messages: [ // 发给 app-v1 的派活误出现在 lj 的房间 msg({ ts: 1000, sender: ME, body: 'x', dispatch: dispatch('me-1000', 'app-v1') }), msg({ ts: 1100, body: '不该归到群里' }), ], }, ], { groupId: GROUP, myUserId: ME }, ); expect(d.entries).toEqual([]); }); it('mergeGroupEntries:同 id 本地优先,其余由 Matrix 补齐', () => { const local: GroupEntry[] = [ { id: 'me-1000', turnId: 'me-1000', fromAgentId: null, message: { text: '本地版本' } as never, at: 1000 }, ]; const fromMatrix: GroupEntry[] = [ { id: 'me-1000', turnId: 'me-1000', fromAgentId: null, message: { text: 'Matrix 版本' } as never, at: 1000 }, { id: 'me-2000', turnId: 'me-2000', fromAgentId: null, message: { text: '只有 Matrix' } as never, at: 2000 }, ]; const merged = mergeGroupEntries(local, fromMatrix); expect(merged.length).toBe(2); expect(merged[0].message.text).toBe('本地版本'); }); it('matchLocalTurn:窗口内同文本命中,窗口外不命中', () => { const local: GroupEntry[] = [ { id: 'me-1000-1', turnId: 'me-1000-1', fromAgentId: null, message: { text: '你好' } as never, at: 1000 }, ]; expect(matchLocalTurn(local, '你好', 1500)).toBe('me-1000-1'); expect(matchLocalTurn(local, '你好', 9000)).toBeNull(); expect(matchLocalTurn(local, '别的话', 1000)).toBeNull(); }); it('turnIdAt:同毫秒冲突时追加序号;atFromTurnId 能反解', () => { const a = turnIdAt(1000); expect(a).toBe('me-1000'); expect(turnIdAt(1000, new Set([a]))).toBe('me-1000-2'); expect(atFromTurnId('me-1000')).toBe(1000); expect(atFromTurnId('me-1000-3')).toBe(1000); expect(atFromTurnId('$approot_x')).toBeNull(); }); });