import { ChatSession, createSession, deriveTitle, deriveTaskTitle, groupKey, groupOf, isAppSessionId, newSessionId, sessionsOf, sortSessions, TITLE_MAX, touchSession, } from './chat-session'; /** * 会话纯函数层。 * * 这一层的每个函数都直接影响「会话是否串线 / 标题是否会乱」, * 所以边界按实际会遇到的脏数据处理。 */ describe('newSessionId', () => { it('生成的 id 以 $ 开头、带 :fmode.cn 后缀(符合 Matrix event_id 形态)', () => { const id = newSessionId(); expect(id.startsWith('$')).toBeTrue(); expect(id.endsWith(':fmode.cn')).toBeTrue(); }); it('能被 isAppSessionId 识别为 App 自造', () => { expect(isAppSessionId(newSessionId())).toBeTrue(); }); it('容器产生的真实 event_id 不被误判为 App 自造', () => { expect(isAppSessionId('$YrgWy5Io0N_dDP0-xIqMgrTKxBZ3uJDtbJfKQU0LCuA')).toBeFalse(); }); it('连续生成不重复', () => { const ids = new Set(Array.from({ length: 200 }, () => newSessionId())); expect(ids.size).toBe(200); }); it('同一毫秒内生成也不重复(随机段兜底)', () => { const now = 1700000000000; const ids = new Set(Array.from({ length: 50 }, () => newSessionId(now))); expect(ids.size).toBe(50); }); }); describe('deriveTitle', () => { it('短文本原样作为标题', () => { expect(deriveTitle('帮我做个登录页')).toBe('帮我做个登录页'); }); it('压缩换行与多余空白', () => { expect(deriveTitle(' 第一行\n\n第二行 内容 ')).toBe('第一行 第二行 内容'); }); it('超长截断并加省略号', () => { const long = '一'.repeat(TITLE_MAX + 10); const t = deriveTitle(long)!; expect(Array.from(t).length).toBe(TITLE_MAX + 1); // +1 是省略号 expect(t.endsWith('…')).toBeTrue(); }); it('恰好等于上限时不截断', () => { const exact = '一'.repeat(TITLE_MAX); expect(deriveTitle(exact)).toBe(exact); }); // 按码点截断,不会把 emoji 切成半个 it('emoji 不被截半', () => { const s = '😀'.repeat(TITLE_MAX + 5); const t = deriveTitle(s)!; expect(t.endsWith('…')).toBeTrue(); // 截断后除省略号外应全是完整 emoji const body = t.slice(0, -1); expect(Array.from(body).every((c) => c === '😀')).toBeTrue(); }); it('空 / 纯空白 / null 返回 undefined', () => { expect(deriveTitle('')).toBeUndefined(); expect(deriveTitle(' \n\t ')).toBeUndefined(); expect(deriveTitle(null)).toBeUndefined(); expect(deriveTitle(undefined)).toBeUndefined(); }); }); describe('createSession', () => { it('默认标题为「新建对话」,未归档,计数 0', () => { const s = createSession({ roomId: '!r:a', agentId: 'a1', now: 1000 }); expect(s.title).toBe('新建对话'); expect(s.archived).toBeFalse(); expect(s.messageCount).toBe(0); expect(s.createdAt).toBe(1000); expect(s.updatedAt).toBe(1000); }); it('可指定标题', () => { expect(createSession({ roomId: '!r:a', agentId: 'a1', title: '登录页' }).title).toBe('登录页'); }); it('id 是 App 自造的线程根', () => { const s = createSession({ roomId: '!r:a', agentId: 'a1' }); expect(isAppSessionId(s.id)).toBeTrue(); }); }); const base = (): ChatSession => ({ id: '$s1', roomId: '!r:a', agentId: 'a1', title: '新对话', createdAt: 1000, updatedAt: 1000, archived: false, messageCount: 0, }); describe('touchSession', () => { it('用户首条消息决定标题(已剥掉口语前缀)', () => { const s = touchSession(base(), { text: '帮我做个登录页', ts: 2000, fromMe: true }); // 2026-10-29 起用 deriveTaskTitle 清洗,标题是任务本身而不是原话 expect(s.title).toBe('做个登录页'); expect(s.messageCount).toBe(1); expect(s.updatedAt).toBe(2000); }); // agent 先开口时不该拿它的话当会话名 it('agent 的首条消息不定标题', () => { const s = touchSession(base(), { text: '你好,我是 lj', ts: 2000, fromMe: false }); expect(s.title).toBe('新对话'); expect(s.messageCount).toBe(1); }); it('agent 先说话后,用户的第一条消息才定标题', () => { let s = touchSession(base(), { text: '你好', ts: 2000, fromMe: false }); s = touchSession(s, { text: '做登录页', ts: 3000, fromMe: true }); // messageCount 已非 0 —— 标题只在会话最开始定时取 expect(s.title).toBe('新对话'); }); it('手动改过标题后不再自动覆盖', () => { const locked: ChatSession = { ...base(), titleLocked: true }; expect(touchSession(locked, { text: '别的内容', ts: 2000, fromMe: true }).title).toBe('新对话'); }); it('更新最后一条预览', () => { const s = touchSession(base(), { text: '好的', ts: 2000, fromMe: false }); expect(s.lastPreview).toBe('好的'); }); it('超长预览截断', () => { const s = touchSession(base(), { text: '字'.repeat(100), ts: 2000, fromMe: false }); expect(s.lastPreview!.endsWith('…')).toBeTrue(); expect(Array.from(s.lastPreview!).length).toBe(61); }); it('updatedAt 不会被更早的消息回退', () => { const later = { ...base(), updatedAt: 9999 }; expect(touchSession(later, { text: 'x', ts: 100, fromMe: true }).updatedAt).toBe(9999); }); it('不改动原对象(纯函数)', () => { const s = base(); touchSession(s, { text: 'x', ts: 2000, fromMe: true }); expect(s.title).toBe('新对话'); expect(s.messageCount).toBe(0); }); it('纯空白消息不产生标题', () => { const s = touchSession(base(), { text: ' ', ts: 2000, fromMe: true }); expect(s.title).toBe('新对话'); }); }); describe('sortSessions', () => { const mk = (id: string, updatedAt: number, archived = false): ChatSession => ({ ...base(), id, updatedAt, archived, }); it('按更新时间倒序(最近聊的在最前)', () => { const r = sortSessions([mk('a', 100), mk('b', 300), mk('c', 200)]); expect(r.map((s) => s.id)).toEqual(['b', 'c', 'a']); }); // 归档会话永远排在后面,即使它更近 it('归档的排后面', () => { const r = sortSessions([mk('old-archived', 9999, true), mk('active', 100)]); expect(r.map((s) => s.id)).toEqual(['active', 'old-archived']); }); it('不改动原数组', () => { const list = [mk('a', 100), mk('b', 300)]; const snapshot = list.map((s) => s.id); sortSessions(list); expect(list.map((s) => s.id)).toEqual(snapshot); }); }); describe('sessionsOf', () => { const list: ChatSession[] = [ { ...base(), id: 's1', agentId: 'a1', updatedAt: 100 }, { ...base(), id: 's2', agentId: 'a1', updatedAt: 300 }, { ...base(), id: 's3', agentId: 'a2', updatedAt: 200 }, { ...base(), id: 's4', agentId: 'a1', updatedAt: 400, archived: true }, ]; it('只取指定 agent 的未归档会话', () => { expect(sessionsOf(list, 'a1').map((s) => s.id)).toEqual(['s2', 's1']); }); // 包含归档时,归档的仍排在活跃会话之后(即使它更新时间更近) it('可选择包含归档', () => { expect(sessionsOf(list, 'a1', { includeArchived: true }).map((s) => s.id)).toEqual([ 's2', 's1', 's4', ]); }); it('无会话时返回空数组', () => { expect(sessionsOf(list, 'nobody')).toEqual([]); }); }); describe('deriveTaskTitle(会话名提炼)', () => { it('剥掉请求前缀,留下任务本身', () => { expect(deriveTaskTitle('帮我做个登录页')).toBe('做个登录页'); expect(deriveTaskTitle('请分析一下这份数据')).toBe('分析一下这份数据'); }); it('剥掉句末的交付方式与语气词', () => { expect(deriveTaskTitle('直接做成html然后发给我')).toBe('做成html'); expect(deriveTaskTitle('把接口文档整理一下发给我')).toBe('把接口文档整理一下'); }); it('前后夹击也能清干净', () => { expect(deriveTaskTitle('麻烦你把登录页做成html然后发给我')).toBe('把登录页做成html'); }); // 剥没了就回落原文,不能给出空标题 it('清洗后太短时回落到原文截断', () => { expect(deriveTaskTitle('帮我')).toBe('帮我'); expect(deriveTaskTitle('直接')).toBe('直接'); }); it('空输入返回 undefined', () => { expect(deriveTaskTitle('')).toBeUndefined(); expect(deriveTaskTitle(' ')).toBeUndefined(); expect(deriveTaskTitle(null)).toBeUndefined(); }); it('结果仍然遵守标题长度上限', () => { const long = '请' + '一'.repeat(60) + '发给我'; const t = deriveTaskTitle(long)!; expect(Array.from(t).length).toBeLessThanOrEqual(25); }); }); /* * 会话分组归属(2026-11-08 用户反馈)。 * * 问题:群聊归档后掉进「日常对话 → 已归档」,而不是留在「群聊 → 已归档」。 * 根因:归属只看 `workspaceId`,而群聊的 `workspaceId` 恒为 null。 */ describe('groupOf / groupKey(归档回到原分组)', () => { it('群聊:没指定工作区 → 归群聊分组', () => { expect(groupOf({ kind: 'group', workspaceId: null })).toEqual({ kind: 'group' }); }); /* * 2026-11-11 修正:原来 `kind === 'group'` 无条件归群聊, * 导致「从工作区新建群聊」时写进去的 workspaceId 被静默丢弃 —— * 用户在工作区点的「+」,会话却跑到「群聊」分组。 */ it('🔴 群聊指定了工作区 → 归工作区(用户放哪就在哪)', () => { expect(groupOf({ kind: 'group', workspaceId: 'ws1' })).withContext('工作区优先于群聊').toEqual({ kind: 'workspace', id: 'ws1', }); }); it('有工作区归属的单聊归该工作区', () => { expect(groupOf({ kind: undefined, workspaceId: 'ws1' })).toEqual({ kind: 'workspace', id: 'ws1', }); }); it('无归属的单聊归日常对话', () => { expect(groupOf({ kind: undefined, workspaceId: null })).toEqual({ kind: 'daily' }); }); it('分组键稳定且互不冲突', () => { expect(groupKey({ kind: 'group' })).toBe('group'); expect(groupKey({ kind: 'daily' })).toBe('daily'); expect(groupKey({ kind: 'workspace', id: 'ws1' })).toBe('workspace:ws1'); }); });