|
|
@@ -0,0 +1,291 @@
|
|
|
+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<MxMessage> & { 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<string, number>([
|
|
|
+ [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();
|
|
|
+ });
|
|
|
+});
|