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 负向对照项
This commit is contained in:
2026-09-06 15:18:06 +08:00
parent a44fd6949b
commit 79c4171c9d
40 changed files with 3369 additions and 116 deletions

View File

@ -36,6 +36,8 @@ function mail(over: Partial<Mail> = {}): Mail {
permission_result: null,
status: 'read',
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
from_human: false,
to_human: true,
...over
};
}

View File

@ -41,6 +41,11 @@ function mail(over: Partial<Mail> = {}): Mail {
permission_result: null,
status: 'read',
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
// 默认值:会话工作目录、发件方是 Agent、收件方是人 ——
// 对应「pi 在 /home/program/llmsproxy 干活 → jianf」这条最常见的信
session_workspace: '/home/program/llmsproxy',
from_human: false,
to_human: true,
...over
};
}
@ -79,18 +84,20 @@ describe('formatAddress', () => {
describe('mailCounterpart', () => {
it('别人发来的 → 回给发件人', () => {
const m = mail({ from_name: 'pi', to_name: 'jianf' });
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
});
it('我发出的 → 回给收件人(不是回给自己)', () => {
const m = mail({
from_name: 'jianf',
from_human: true,
from_workspace: '',
to_name: 'pi',
to_human: false,
to_workspace: '/home/program/llmsproxy'
});
// 这一条就是生产 bug原代码在这里返回 jianf
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
});
it('登录名未知时退化为回给发件人,不会回给自己', () => {
@ -104,22 +111,37 @@ describe('mailCounterpart', () => {
expect(mailCounterpart(m, 'jianf').name).toBe('human');
expect(mailCounterpart(m, 'human').name).toBe('pi');
});
it('path 从 session_workspace 取,不用 from_workspace后者存的是 Agent 名)', () => {
// from_workspace 存的是 Agent 名而不是路径(历史遗留),
// 从 session_workspace 取路径才不会拼出 dsh@dsh
const m = mail({
from_name: 'dsh',
from_workspace: 'dsh', // Agent 名,不是路径
to_name: 'jianf',
to_human: true,
session_workspace: '/home/program/webui4frpc'
});
const peer = mailCounterpart(m, 'jianf');
expect(peer.path).toBe('/home/program/webui4frpc');
expect(peer.path).not.toBe('dsh');
});
});
describe('sessionCounterpart', () => {
it('按会话定对端,不受最后一封是谁发的影响', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:00:00Z' })
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:00:00Z' })
];
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
});
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
];
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
@ -127,27 +149,28 @@ describe('sessionCounterpart', () => {
it('补上首次出现时缺失的 path', () => {
const mails = [
// 首封的 to_workspace 是空的
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
// 首封的 session_workspace 是空的
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
// 后一封才带上目录
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', session_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T09:30:00Z' })
];
// 同名 Agent 在不同目录是不同的活path 不能丢
expect(sessionCounterpart(mails, 'jianf')).toEqual({
name: 'pi',
path: '/home/program/llmsproxy'
path: '/home/program/llmsproxy',
isHuman: false
});
});
it('同刻邮件用 mail_id 定序,结果稳定', () => {
const ts = '2026-09-03T09:00:00Z';
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: ts });
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: ts });
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: ts });
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: ts });
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
});
it('全是自己的会话返回 null 而不是自己', () => {
const mails = [mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf' })];
const mails = [mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true })];
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
});
@ -156,8 +179,8 @@ describe('sessionCounterpart', () => {
});
it('created_at 解析失败不影响确定性', () => {
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '不是时间' });
const good = mail({ mail_id: 'good', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: '2026-09-03T09:00:00Z' });
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '不是时间' });
const good = mail({ mail_id: 'good', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: '2026-09-03T09:00:00Z' });
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
sessionCounterpart([good, bad], 'jianf')?.name
);
@ -167,7 +190,7 @@ describe('sessionCounterpart', () => {
describe('sessionReplyTarget', () => {
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy' })
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy' })
];
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
@ -176,7 +199,7 @@ describe('sessionReplyTarget', () => {
it('会话未命名时省略会话段', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home' })
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '/home' })
];
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
});
@ -184,9 +207,9 @@ describe('sessionReplyTarget', () => {
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
const mails = [
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:56:34Z' })
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:56:34Z' })
];
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
expect(target.startsWith('pi@')).toBe(true);
@ -239,7 +262,9 @@ describe('replyAllCC', () => {
from_name: 'pi',
from_workspace: '/home',
to_name: 'dsh',
to_human: false,
to_workspace: '/opt',
session_workspace: '/opt',
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
});
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
@ -302,33 +327,36 @@ describe('participantAddress', () => {
* 人只要名字(没有工作目录,也不需要指定会话)
*/
it('Agent 带完整三段 —— 少任何一段都不是可投递地址', () => {
expect(participantAddress('pi', '/home/program/agentmail', '我的任务'))
expect(participantAddress('pi', false, '/home/program/agentmail', '我的任务'))
.toBe('pi@/home/program/agentmail.我的任务');
});
it('Agent 无会话别名时退到 name@path默认会话', () => {
expect(participantAddress('pi', '/home/program/agentmail', null))
expect(participantAddress('pi', false, '/home/program/agentmail', null))
.toBe('pi@/home/program/agentmail');
expect(participantAddress('pi', '/home/program/agentmail'))
expect(participantAddress('pi', false, '/home/program/agentmail'))
.toBe('pi@/home/program/agentmail');
});
it('人只显示名字,即便传了会话别名也不拼', () => {
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
expect(participantAddress('jianf', '', '某会话')).toBe('jianf');
expect(participantAddress('jianf', null, '某会话')).toBe('jianf');
expect(participantAddress('jianf')).toBe('jianf');
expect(participantAddress('jianf', true, '', '某会话')).toBe('jianf');
expect(participantAddress('jianf', true, null, '某会话')).toBe('jianf');
expect(participantAddress('jianf', true)).toBe('jianf');
});
it('人的地址里不含 @ 也不含 .', () => {
const addr = participantAddress('jianf', '', '邮件驱动·多智能体协作平台-完整设计文档');
const addr = participantAddress('jianf', true, '', '邮件驱动·多智能体协作平台-完整设计文档');
expect(addr).toBe('jianf');
expect(addr).not.toContain('@');
expect(addr).not.toContain('.');
});
it('workspace 只有空白时按人处理', () => {
expect(participantAddress('jianf', ' ', '某会话')).toBe('jianf');
it('人是 Agent 时 workspace 为空也不会拼出裸 @', () => {
// 边界Agent 名在库里但 session_workspace 为空(数据不完整),
// 仍然要留 @ 而不是裸名字 —— 否则按最后一个 . 切分会错
expect(participantAddress('pi', false, '', 'sess'))
.toBe('pi@.sess');
});
});
@ -378,8 +406,10 @@ describe('replyAllCC 保留 cc_list 的原始意图', () => {
it('人类发件人(无 workspace在抄送里是裸名字不带会话位', () => {
const m = mail({
from_name: 'jianf',
from_human: true,
from_workspace: '',
to_name: 'pi',
to_human: false,
to_workspace: '/home',
session_alias: '某个很长的会话别名'
});