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 负向对照项
334 lines
13 KiB
TypeScript
334 lines
13 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`,
|
||
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);
|
||
});
|
||
});
|