chat-session.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import {
  2. ChatSession,
  3. createSession,
  4. deriveTitle,
  5. deriveTaskTitle,
  6. groupKey,
  7. groupOf,
  8. isAppSessionId,
  9. newSessionId,
  10. sessionsOf,
  11. sortSessions,
  12. TITLE_MAX,
  13. touchSession,
  14. } from './chat-session';
  15. /**
  16. * 会话纯函数层。
  17. *
  18. * 这一层的每个函数都直接影响「会话是否串线 / 标题是否会乱」,
  19. * 所以边界按实际会遇到的脏数据处理。
  20. */
  21. describe('newSessionId', () => {
  22. it('生成的 id 以 $ 开头、带 :fmode.cn 后缀(符合 Matrix event_id 形态)', () => {
  23. const id = newSessionId();
  24. expect(id.startsWith('$')).toBeTrue();
  25. expect(id.endsWith(':fmode.cn')).toBeTrue();
  26. });
  27. it('能被 isAppSessionId 识别为 App 自造', () => {
  28. expect(isAppSessionId(newSessionId())).toBeTrue();
  29. });
  30. it('容器产生的真实 event_id 不被误判为 App 自造', () => {
  31. expect(isAppSessionId('$YrgWy5Io0N_dDP0-xIqMgrTKxBZ3uJDtbJfKQU0LCuA')).toBeFalse();
  32. });
  33. it('连续生成不重复', () => {
  34. const ids = new Set(Array.from({ length: 200 }, () => newSessionId()));
  35. expect(ids.size).toBe(200);
  36. });
  37. it('同一毫秒内生成也不重复(随机段兜底)', () => {
  38. const now = 1700000000000;
  39. const ids = new Set(Array.from({ length: 50 }, () => newSessionId(now)));
  40. expect(ids.size).toBe(50);
  41. });
  42. });
  43. describe('deriveTitle', () => {
  44. it('短文本原样作为标题', () => {
  45. expect(deriveTitle('帮我做个登录页')).toBe('帮我做个登录页');
  46. });
  47. it('压缩换行与多余空白', () => {
  48. expect(deriveTitle(' 第一行\n\n第二行 内容 ')).toBe('第一行 第二行 内容');
  49. });
  50. it('超长截断并加省略号', () => {
  51. const long = '一'.repeat(TITLE_MAX + 10);
  52. const t = deriveTitle(long)!;
  53. expect(Array.from(t).length).toBe(TITLE_MAX + 1); // +1 是省略号
  54. expect(t.endsWith('…')).toBeTrue();
  55. });
  56. it('恰好等于上限时不截断', () => {
  57. const exact = '一'.repeat(TITLE_MAX);
  58. expect(deriveTitle(exact)).toBe(exact);
  59. });
  60. // 按码点截断,不会把 emoji 切成半个
  61. it('emoji 不被截半', () => {
  62. const s = '😀'.repeat(TITLE_MAX + 5);
  63. const t = deriveTitle(s)!;
  64. expect(t.endsWith('…')).toBeTrue();
  65. // 截断后除省略号外应全是完整 emoji
  66. const body = t.slice(0, -1);
  67. expect(Array.from(body).every((c) => c === '😀')).toBeTrue();
  68. });
  69. it('空 / 纯空白 / null 返回 undefined', () => {
  70. expect(deriveTitle('')).toBeUndefined();
  71. expect(deriveTitle(' \n\t ')).toBeUndefined();
  72. expect(deriveTitle(null)).toBeUndefined();
  73. expect(deriveTitle(undefined)).toBeUndefined();
  74. });
  75. });
  76. describe('createSession', () => {
  77. it('默认标题为「新建对话」,未归档,计数 0', () => {
  78. const s = createSession({ roomId: '!r:a', agentId: 'a1', now: 1000 });
  79. expect(s.title).toBe('新建对话');
  80. expect(s.archived).toBeFalse();
  81. expect(s.messageCount).toBe(0);
  82. expect(s.createdAt).toBe(1000);
  83. expect(s.updatedAt).toBe(1000);
  84. });
  85. it('可指定标题', () => {
  86. expect(createSession({ roomId: '!r:a', agentId: 'a1', title: '登录页' }).title).toBe('登录页');
  87. });
  88. it('id 是 App 自造的线程根', () => {
  89. const s = createSession({ roomId: '!r:a', agentId: 'a1' });
  90. expect(isAppSessionId(s.id)).toBeTrue();
  91. });
  92. });
  93. const base = (): ChatSession => ({
  94. id: '$s1',
  95. roomId: '!r:a',
  96. agentId: 'a1',
  97. title: '新对话',
  98. createdAt: 1000,
  99. updatedAt: 1000,
  100. archived: false,
  101. messageCount: 0,
  102. });
  103. describe('touchSession', () => {
  104. it('用户首条消息决定标题(已剥掉口语前缀)', () => {
  105. const s = touchSession(base(), { text: '帮我做个登录页', ts: 2000, fromMe: true });
  106. // 2026-10-29 起用 deriveTaskTitle 清洗,标题是任务本身而不是原话
  107. expect(s.title).toBe('做个登录页');
  108. expect(s.messageCount).toBe(1);
  109. expect(s.updatedAt).toBe(2000);
  110. });
  111. // agent 先开口时不该拿它的话当会话名
  112. it('agent 的首条消息不定标题', () => {
  113. const s = touchSession(base(), { text: '你好,我是 lj', ts: 2000, fromMe: false });
  114. expect(s.title).toBe('新对话');
  115. expect(s.messageCount).toBe(1);
  116. });
  117. it('agent 先说话后,用户的第一条消息才定标题', () => {
  118. let s = touchSession(base(), { text: '你好', ts: 2000, fromMe: false });
  119. s = touchSession(s, { text: '做登录页', ts: 3000, fromMe: true });
  120. // messageCount 已非 0 —— 标题只在会话最开始定时取
  121. expect(s.title).toBe('新对话');
  122. });
  123. it('手动改过标题后不再自动覆盖', () => {
  124. const locked: ChatSession = { ...base(), titleLocked: true };
  125. expect(touchSession(locked, { text: '别的内容', ts: 2000, fromMe: true }).title).toBe('新对话');
  126. });
  127. it('更新最后一条预览', () => {
  128. const s = touchSession(base(), { text: '好的', ts: 2000, fromMe: false });
  129. expect(s.lastPreview).toBe('好的');
  130. });
  131. it('超长预览截断', () => {
  132. const s = touchSession(base(), { text: '字'.repeat(100), ts: 2000, fromMe: false });
  133. expect(s.lastPreview!.endsWith('…')).toBeTrue();
  134. expect(Array.from(s.lastPreview!).length).toBe(61);
  135. });
  136. it('updatedAt 不会被更早的消息回退', () => {
  137. const later = { ...base(), updatedAt: 9999 };
  138. expect(touchSession(later, { text: 'x', ts: 100, fromMe: true }).updatedAt).toBe(9999);
  139. });
  140. it('不改动原对象(纯函数)', () => {
  141. const s = base();
  142. touchSession(s, { text: 'x', ts: 2000, fromMe: true });
  143. expect(s.title).toBe('新对话');
  144. expect(s.messageCount).toBe(0);
  145. });
  146. it('纯空白消息不产生标题', () => {
  147. const s = touchSession(base(), { text: ' ', ts: 2000, fromMe: true });
  148. expect(s.title).toBe('新对话');
  149. });
  150. });
  151. describe('sortSessions', () => {
  152. const mk = (id: string, updatedAt: number, archived = false): ChatSession => ({
  153. ...base(),
  154. id,
  155. updatedAt,
  156. archived,
  157. });
  158. it('按更新时间倒序(最近聊的在最前)', () => {
  159. const r = sortSessions([mk('a', 100), mk('b', 300), mk('c', 200)]);
  160. expect(r.map((s) => s.id)).toEqual(['b', 'c', 'a']);
  161. });
  162. // 归档会话永远排在后面,即使它更近
  163. it('归档的排后面', () => {
  164. const r = sortSessions([mk('old-archived', 9999, true), mk('active', 100)]);
  165. expect(r.map((s) => s.id)).toEqual(['active', 'old-archived']);
  166. });
  167. it('不改动原数组', () => {
  168. const list = [mk('a', 100), mk('b', 300)];
  169. const snapshot = list.map((s) => s.id);
  170. sortSessions(list);
  171. expect(list.map((s) => s.id)).toEqual(snapshot);
  172. });
  173. });
  174. describe('sessionsOf', () => {
  175. const list: ChatSession[] = [
  176. { ...base(), id: 's1', agentId: 'a1', updatedAt: 100 },
  177. { ...base(), id: 's2', agentId: 'a1', updatedAt: 300 },
  178. { ...base(), id: 's3', agentId: 'a2', updatedAt: 200 },
  179. { ...base(), id: 's4', agentId: 'a1', updatedAt: 400, archived: true },
  180. ];
  181. it('只取指定 agent 的未归档会话', () => {
  182. expect(sessionsOf(list, 'a1').map((s) => s.id)).toEqual(['s2', 's1']);
  183. });
  184. // 包含归档时,归档的仍排在活跃会话之后(即使它更新时间更近)
  185. it('可选择包含归档', () => {
  186. expect(sessionsOf(list, 'a1', { includeArchived: true }).map((s) => s.id)).toEqual([
  187. 's2',
  188. 's1',
  189. 's4',
  190. ]);
  191. });
  192. it('无会话时返回空数组', () => {
  193. expect(sessionsOf(list, 'nobody')).toEqual([]);
  194. });
  195. });
  196. describe('deriveTaskTitle(会话名提炼)', () => {
  197. it('剥掉请求前缀,留下任务本身', () => {
  198. expect(deriveTaskTitle('帮我做个登录页')).toBe('做个登录页');
  199. expect(deriveTaskTitle('请分析一下这份数据')).toBe('分析一下这份数据');
  200. });
  201. it('剥掉句末的交付方式与语气词', () => {
  202. expect(deriveTaskTitle('直接做成html然后发给我')).toBe('做成html');
  203. expect(deriveTaskTitle('把接口文档整理一下发给我')).toBe('把接口文档整理一下');
  204. });
  205. it('前后夹击也能清干净', () => {
  206. expect(deriveTaskTitle('麻烦你把登录页做成html然后发给我')).toBe('把登录页做成html');
  207. });
  208. // 剥没了就回落原文,不能给出空标题
  209. it('清洗后太短时回落到原文截断', () => {
  210. expect(deriveTaskTitle('帮我')).toBe('帮我');
  211. expect(deriveTaskTitle('直接')).toBe('直接');
  212. });
  213. it('空输入返回 undefined', () => {
  214. expect(deriveTaskTitle('')).toBeUndefined();
  215. expect(deriveTaskTitle(' ')).toBeUndefined();
  216. expect(deriveTaskTitle(null)).toBeUndefined();
  217. });
  218. it('结果仍然遵守标题长度上限', () => {
  219. const long = '请' + '一'.repeat(60) + '发给我';
  220. const t = deriveTaskTitle(long)!;
  221. expect(Array.from(t).length).toBeLessThanOrEqual(25);
  222. });
  223. });
  224. /*
  225. * 会话分组归属(2026-11-08 用户反馈)。
  226. *
  227. * 问题:群聊归档后掉进「日常对话 → 已归档」,而不是留在「群聊 → 已归档」。
  228. * 根因:归属只看 `workspaceId`,而群聊的 `workspaceId` 恒为 null。
  229. */
  230. describe('groupOf / groupKey(归档回到原分组)', () => {
  231. it('群聊:没指定工作区 → 归群聊分组', () => {
  232. expect(groupOf({ kind: 'group', workspaceId: null })).toEqual({ kind: 'group' });
  233. });
  234. /*
  235. * 2026-11-11 修正:原来 `kind === 'group'` 无条件归群聊,
  236. * 导致「从工作区新建群聊」时写进去的 workspaceId 被静默丢弃 ——
  237. * 用户在工作区点的「+」,会话却跑到「群聊」分组。
  238. */
  239. it('🔴 群聊指定了工作区 → 归工作区(用户放哪就在哪)', () => {
  240. expect(groupOf({ kind: 'group', workspaceId: 'ws1' })).withContext('工作区优先于群聊').toEqual({
  241. kind: 'workspace',
  242. id: 'ws1',
  243. });
  244. });
  245. it('有工作区归属的单聊归该工作区', () => {
  246. expect(groupOf({ kind: undefined, workspaceId: 'ws1' })).toEqual({
  247. kind: 'workspace',
  248. id: 'ws1',
  249. });
  250. });
  251. it('无归属的单聊归日常对话', () => {
  252. expect(groupOf({ kind: undefined, workspaceId: null })).toEqual({ kind: 'daily' });
  253. });
  254. it('分组键稳定且互不冲突', () => {
  255. expect(groupKey({ kind: 'group' })).toBe('group');
  256. expect(groupKey({ kind: 'daily' })).toBe('daily');
  257. expect(groupKey({ kind: 'workspace', id: 'ws1' })).toBe('workspace:ws1');
  258. });
  259. });