Files
MailUI4Agents/web/test/components/mailGroups.test.tsx
JianFeeeee 79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00

334 lines
13 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`,
from_human: false,
to_human: true,
...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 对 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);
});
});