import { describe, it, expect } from 'vitest'; import { groupMailsBySession, isFlatGroup, isPendingPermission, splitByPermission, countPendingPermissions, groupPermissions } from '../../src/lib/mailGroups'; import type { Mail } from '../../src/types'; /** * 收件箱会话分组 + 授权请求独立分组。 * * 这些用例锁的是生产实测过的形状:一个会话独占 17 封权限邮件, * 把另外两个会话的信挤出视野。修法有两层 —— * 权限请求整体移出收件箱(各归各的导航项),剩下的普通邮件按会话折叠。 */ let seq = 0; function mail(over: Partial = {}): Mail { seq += 1; return { mail_id: `m${String(seq).padStart(3, '0')}`, session_id: 's1', parent_mail_id: null, from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '', cc_list: [], subject: `主题 ${seq}`, body: '正文', mail_type: 'normal', permission_options: null, permission_result: null, status: 'read', created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`, from_human: false, to_human: true, ...over }; } function perm(over: Partial = {}): Mail { return mail({ mail_type: 'permission_request', ...over }); } describe('groupMailsBySession', () => { it('把同一会话的多封邮件折成一组', () => { const groups = groupMailsBySession([ mail({ session_id: 'a' }), mail({ session_id: 'a' }), mail({ session_id: 'a' }), mail({ session_id: 'b' }) ]); expect(groups).toHaveLength(2); expect(groups.find(g => g.sessionId === 'a')!.mails).toHaveLength(3); }); it('组内按时间倒序,最新那封做组头', () => { const old = mail({ session_id: 'a', created_at: '2026-09-03T08:00:00Z', subject: '旧' }); const mid = mail({ session_id: 'a', created_at: '2026-09-03T09:00:00Z', subject: '中' }); const now = mail({ session_id: 'a', created_at: '2026-09-03T10:00:00Z', subject: '新' }); const [g] = groupMailsBySession([mid, old, now]); // 故意乱序传入 expect(g.mails.map(m => m.subject)).toEqual(['新', '中', '旧']); expect(g.latest.subject).toBe('新'); // 组标题取最新一封:会话主题随任务推进被改写,最新的最贴切 expect(g.subject).toBe('新'); }); it('组之间按最新邮件时间倒序', () => { const groups = groupMailsBySession([ mail({ session_id: 'stale', created_at: '2026-09-01T10:00:00Z' }), mail({ session_id: 'fresh', created_at: '2026-09-03T10:00:00Z' }), mail({ session_id: 'mid', created_at: '2026-09-02T10:00:00Z' }) ]); expect(groups.map(g => g.sessionId)).toEqual(['fresh', 'mid', 'stale']); }); it('同一时刻用 mail_id 兜底定序(SQLite 时间戳精度有限)', () => { const ts = '2026-09-03T10:00:00Z'; const a = mail({ mail_id: 'aaa', session_id: 's', created_at: ts }); const z = mail({ mail_id: 'zzz', session_id: 's', created_at: ts }); const [g1] = groupMailsBySession([a, z]); const [g2] = groupMailsBySession([z, a]); // 两种输入顺序必须给出同一结果,否则「刷新一次顺序就变了」 expect(g1.mails.map(m => m.mail_id)).toEqual(g2.mails.map(m => m.mail_id)); expect(g1.mails[0].mail_id).toBe('zzz'); }); it('统计未读数', () => { const [g] = groupMailsBySession([ mail({ session_id: 's', status: 'unread' }), mail({ session_id: 's', status: 'unread' }), mail({ session_id: 's', status: 'read' }) ]); expect(g.unreadCount).toBe(2); }); it('别名取自最新一封;无别名给空串而不是 undefined', () => { const [withAlias] = groupMailsBySession([ mail({ session_id: 's', session_alias: 'deploy-review' }) ]); expect(withAlias.alias).toBe('deploy-review'); const [without] = groupMailsBySession([mail({ session_id: 's' })]); expect(without.alias).toBe(''); }); it('session_id 缺失的脏数据各自成组而不是挤成一堆', () => { const groups = groupMailsBySession([ mail({ mail_id: 'x', session_id: '' }), mail({ mail_id: 'y', session_id: '' }) ]); // 全归到 '' 这一组会把两封无关的信显示成一个会话 expect(groups).toHaveLength(2); }); it('created_at 解析失败不会让顺序变得不确定', () => { const bad = mail({ mail_id: 'bad', session_id: 's', created_at: '不是时间' }); const good = mail({ mail_id: 'good', session_id: 's', created_at: '2026-09-03T10:00:00Z' }); const [g] = groupMailsBySession([bad, good]); // NaN 参与比较恒为 false,会让排序结果取决于原数组顺序 expect(g.mails.map(m => m.mail_id)).toEqual(['good', 'bad']); }); it('不修改入参数组', () => { const input = [ mail({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }), mail({ session_id: 's', created_at: '2026-09-03T10:00:00Z' }) ]; const snapshot = input.map(m => m.mail_id); groupMailsBySession(input); expect(input.map(m => m.mail_id)).toEqual(snapshot); }); it('空数组返回空数组', () => { expect(groupMailsBySession([])).toEqual([]); }); }); describe('isFlatGroup', () => { it('单封邮件的组平铺显示,不套折叠头', () => { const [g] = groupMailsBySession([mail({ session_id: 'solo' })]); expect(isFlatGroup(g)).toBe(true); }); it('两封以上才折叠', () => { const [g] = groupMailsBySession([mail({ session_id: 's' }), mail({ session_id: 's' })]); expect(isFlatGroup(g)).toBe(false); }); }); describe('splitByPermission', () => { it('权限请求与普通邮件分开', () => { const { normal, permissions } = splitByPermission([ mail({ subject: '来信' }), perm({ subject: '要跑 bash' }), mail({ subject: '又一封' }) ]); expect(normal.map(m => m.subject)).toEqual(['来信', '又一封']); expect(permissions.map(m => m.subject)).toEqual(['要跑 bash']); }); it('保持原有顺序(调用方自己排序)', () => { const a = mail({ mail_id: 'a' }); const b = mail({ mail_id: 'b' }); expect(splitByPermission([a, b]).normal.map(m => m.mail_id)).toEqual(['a', 'b']); }); it('空输入给两个空数组而不是 undefined', () => { const r = splitByPermission([]); expect(r.normal).toEqual([]); expect(r.permissions).toEqual([]); }); }); describe('isPendingPermission', () => { it('普通邮件不是权限请求', () => { expect(isPendingPermission(mail())).toBe(false); }); it('permission_result 为空串或 null 都算未决策', () => { // 后端用 COALESCE(permission_result,'') 归一,两种都会出现 expect(isPendingPermission(perm({ permission_result: null }))).toBe(true); expect(isPendingPermission(perm({ permission_result: '' }))).toBe(true); }); it('有决策结果就不再是待决策', () => { expect(isPendingPermission(perm({ permission_result: '拒绝' }))).toBe(false); }); }); describe('countPendingPermissions', () => { it('只数待决策的那些', () => { const n = countPendingPermissions([ perm({ permission_result: null }), perm({ permission_result: '' }), perm({ permission_result: '同意' }), mail() ]); expect(n).toBe(2); }); it('没有待决策时给 0', () => { expect(countPendingPermissions([perm({ permission_result: '同意' })])).toBe(0); }); }); describe('groupPermissions', () => { it('只收权限请求,普通邮件不进来', () => { const groups = groupPermissions([ mail({ session_id: 'a' }), perm({ session_id: 'a' }), perm({ session_id: 'a' }) ]); expect(groups).toHaveLength(1); expect(groups[0].pending.length + groups[0].settled.length).toBe(2); }); it('待决策与已决策分开装', () => { const [g] = groupPermissions([ perm({ session_id: 's', permission_result: null, subject: '在等' }), perm({ session_id: 's', permission_result: '同意', subject: '批过' }), perm({ session_id: 's', permission_result: '拒绝', subject: '拒过' }) ]); expect(g.pending.map(m => m.subject)).toEqual(['在等']); expect(g.settled.map(m => m.subject).sort()).toEqual(['批过', '拒过']); }); it('有待决策的会话排在前面,哪怕它更旧', () => { const groups = groupPermissions([ // 刚刚批完的 perm({ session_id: 'done', permission_result: '同意', created_at: '2026-09-03T12:00:00Z' }), // 三天前就卡着的 —— 那条 Agent 会话正在那儿等 perm({ session_id: 'stuck', permission_result: null, created_at: '2026-08-31T09:00:00Z' }) ]); expect(groups.map(g => g.sessionId)).toEqual(['stuck', 'done']); }); it('都有待决策时,卡住更多请求的会话更靠前', () => { const groups = groupPermissions([ perm({ session_id: 'one', permission_result: null, created_at: '2026-09-03T12:00:00Z' }), perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:00:00Z' }), perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:01:00Z' }), perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:02:00Z' }) ]); expect(groups[0].sessionId).toBe('many'); }); it('都无待决策时按最新时间倒序', () => { const groups = groupPermissions([ perm({ session_id: 'old', permission_result: '同意', created_at: '2026-09-01T10:00:00Z' }), perm({ session_id: 'new', permission_result: '同意', created_at: '2026-09-03T10:00:00Z' }) ]); expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']); }); it('组头带上发起请求的 Agent 与会话工作目录', () => { const [g] = groupPermissions([ perm({ session_id: 's', from_name: 'dsh', // from_workspace 对 Agent 存的是 **Agent 名**而不是路径(历史遗留)。 // 拿它当路径用会在授权页拼出 `dsh@dsh`,而权限请求的 // from_workspace 实测是**空串** —— 于是那一行永远不渲染, // 人根本不知道是哪个目录里的哪条线索在请求权限。 from_workspace: 'dsh', session_workspace: '/home/program/llmsproxy' }) ]); // 同名 Agent 在不同目录是不同的活,光有名字判断不了 expect(g.agentName).toBe('dsh'); // path 必须取 session_workspace(会话的 workspace,权威来源) expect(g.path).toBe('/home/program/llmsproxy'); }); it('组内时间倒序', () => { const [g] = groupPermissions([ perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T08:00:00Z', subject: '早' }), perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T10:00:00Z', subject: '晚' }) ]); expect(g.pending.map(m => m.subject)).toEqual(['晚', '早']); }); it('没有权限请求时返回空数组', () => { expect(groupPermissions([mail(), mail()])).toEqual([]); }); it('不修改入参数组', () => { const input = [ perm({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }), perm({ session_id: 's', created_at: '2026-09-03T10:00:00Z' }) ]; const snapshot = input.map(m => m.mail_id); groupPermissions(input); expect(input.map(m => m.mail_id)).toEqual(snapshot); }); }); describe('生产实测形状:17 封权限邮件的会话', () => { it('权限请求移出收件箱后,剩下的信按会话分组且不再被淹', () => { const flood: Mail[] = []; for (let i = 0; i < 17; i++) { flood.push( perm({ session_id: 'f3ce62b0', permission_result: i === 16 ? null : '同意', created_at: `2026-09-03T09:${String(i).padStart(2, '0')}:00Z` }) ); } const realMail = [ mail({ session_id: 'fa420c33', created_at: '2026-09-03T10:00:00Z', subject: '进展汇报' }), mail({ session_id: 'c2db6b62', created_at: '2026-09-03T11:00:00Z', subject: '需要确认' }) ]; const inbox = [...flood, ...realMail]; // 收件箱侧:权限请求整体不进来,只剩两封真信 const { normal, permissions } = splitByPermission(inbox); expect(normal).toHaveLength(2); expect(permissions).toHaveLength(17); const inboxGroups = groupMailsBySession(normal); expect(inboxGroups.map(g => g.subject)).toEqual(['需要确认', '进展汇报']); // 授权侧:17 封折成 1 组,只有 1 个在等人 const permGroups = groupPermissions(permissions); expect(permGroups).toHaveLength(1); expect(permGroups[0].pending).toHaveLength(1); expect(permGroups[0].settled).toHaveLength(16); expect(countPendingPermissions(permissions)).toBe(1); }); });