**授权请求不是「一封信」而是「一件待办」。** 生产取证:一个 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 六项全过。
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|