Files
MailUI4Agents/plugins/dsh-mail-bridge/test/catchup.test.mjs
JianFeeeee 9e5c557cdf feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段
7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。

## 为什么不做注册中心

它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。

同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。

## 验证方式:一个纯标准库脚本

`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。

「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。

## 缺陷一:SSE 只推连上之后的事件,没人补拉积压

写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。

新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:

- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
  邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
  并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
  那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文

端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。

## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段

脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。

新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:

    {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
    {"error": "JSON 语法错误(第 8 字节处)"}
    {"error": "请求体为空"}

刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。

## 验证

- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
2026-09-02 22:47:31 +08:00

82 lines
2.8 KiB
JavaScript
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 assert from 'node:assert/strict';
import test from 'node:test';
import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js';
const mail = (over = {}) => ({
mail_id: 'm1',
session_id: 's1',
from_name: 'admin',
subject: '主题',
mail_type: 'normal',
to_workspace: '/tmp/ws',
...over,
});
test('mailToEvent 产出与 SSE new_mail 同形的对象', () => {
const ev = mailToEvent(mail());
// 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息
for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) {
assert.ok(k in ev, `缺少 ${k}`);
}
assert.equal(ev.role, 'to');
assert.equal(ev.catchup, true);
});
test('mailToEvent 对缺字段的行给出空串而非 undefined', () => {
const ev = mailToEvent({});
assert.equal(ev.mail_id, '');
assert.equal(ev.to_workspace, '');
assert.equal(ev.mail_type, 'normal');
});
test('已经通过 SSE 投过的不再补投', () => {
const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })];
const got = selectCatchup(mails, new Set(['a']));
assert.deepEqual(got.map(e => e.mail_id), ['b']);
});
test('按时间正序补投(收件箱是倒序返回的)', () => {
// 收件箱:新的在前
const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(
got.map(e => e.mail_id),
['old', 'mid', 'new'],
'先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的',
);
});
test('permission 类邮件不补投', () => {
const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(got.map(e => e.mail_id), ['n']);
});
test('超过上限的部分留在收件箱里', () => {
const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i }));
const got = selectCatchup(mails, new Set());
assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去');
});
test('上限可显式压到 0用于禁用补拉', () => {
const got = selectCatchup([mail()], new Set(), 0);
assert.deepEqual(got, []);
});
test('空输入与非数组不炸', () => {
assert.deepEqual(selectCatchup([], new Set()), []);
assert.deepEqual(selectCatchup(undefined, new Set()), []);
assert.deepEqual(selectCatchup(null, new Set()), []);
});
test('没有 mail_id 的行跳过', () => {
const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set());
assert.deepEqual(got.map(e => e.mail_id), ['ok']);
});
test('seen 传 undefined 时不去重也不报错', () => {
const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
assert.deepEqual(got.map(e => e.mail_id), ['x']);
});