Files
MailUI4Agents/web/test/components/mailGroups.test.tsx
JianFeeeee e504eccf3a fix(web): 授权独立成导航项 + 收件箱按会话分组 + 回复对端判定
**授权请求不是「一封信」而是「一件待办」。**

生产取证:一个 pi 会话独占 17 封权限邮件(后涨到 39),另外两个会话各
3 封 / 1 封 —— 收件箱被一件事塞满,失去了它唯一的作用(让人知道有哪几件
事在等我)。

第一版做成会话折叠,用户纠正后重做:权限请求的生命周期是「等人点头 →
决策完就作废」,与普通邮件混在一个箱子里两者互相伤害。收件箱只放要读的,
授权项只放要批的。

`groupPermissions` 的排序判据是「要不要我动手」而非时间:三天前发起、
至今还卡着的授权比十分钟前刚批完的重要得多。纯按时间排会把它压到底部,
而 Agent 那条会话正在等 —— 那正是权限死锁在 UI 上的样子。

未读徽标用红色、待决策用橙色,且两个数字**互不重复计数**:未读是
「有内容没看」,待决策是「有 Agent 卡着等我」。

---

**回复发给自己的 bug(用户报)。**

根因:ReplyBar 的对端判定写死 `from_name === 'human'` —— 单用户时代遗留
(当时人类只有 human@ 一个身份)。登录名是 jianf 时判据恒为假,
于是取 from_name(自己)。

生产链条:`27c22900 jianf→pi` 对(锚点是 pi 发来的),
`8e519925 jianf→jianf` 错(锚点是自己发的)。

两处修正:
1. **判据必须是当前登录用户名**,不是字面量 'human'。同一遗留判据在三处:
   对端解析 / replyAll 去自己 / ThreadCard 图标。
2. **会话视图的回复对端是会话的属性,不看任何单封邮件。**
   人在那儿打字就是「给这次任务的对方追加一句」;用「最后一封」当锚点时,
   自己刚发过信就会把自己算成对端。sessionCounterpart 扫全会话取首个
   非我参与方(收件人优先于发件人,同刻用 mail_id 定序)。

ThreadCard 顺带显示真实发件人名而不是统一渲染成 'human' —— 会话里可能有
多个人类参与方。

测试:mailGroups 30 例(含「生产实测形状:17 封权限邮件」)、
replyTarget 24 例(含生产链条重现:断言 target 以 pi@ 开头而非 jianf@)。
手工验收脚本 inbox-group-verify.mjs 六项全过。
2026-09-04 06:29:21 +08:00

322 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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> = {}): 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`,
...over
};
}
function perm(over: Partial<Mail> = {}): 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: '/home/program/llmsproxy' })
]);
// 同名 Agent 在不同目录是不同的活,光有名字判断不了
expect(g.agentName).toBe('dsh');
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);
});
});