Files
MailUI4Agents/plugins/opencode-mail-bridge/test/sse-client.test.mjs
JianFeeeee 19a3161ee4 feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。

新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。

密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
  1. 进程环境
  2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
     因此身份一致
  3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
     (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
      只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。

不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。

为什么不会重复注册(读 SDK 实现确认,并用探针实测):
  resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
  settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
  命令行 -e 传入的扩展。
  探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。

deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。

验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
2026-09-11 11:32:47 +08:00

141 lines
4.9 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.

/**
* 共用 SSE 帧解析器的行为约定。
*
* 三个平台桥共用同一份deploy/check-shared-libs.sh 校验逐字节相同)。
* 这里钉住的是**曾经真实丢帧**的两个场景,以及凭据在重连时的正确用法。
*
* 原实现把 evt/data 当 read() 的局部变量,于是 TCP 把一帧切在换行处时,
* 前半段的 event 被丢掉、后半段只剩 data 没有事件名 → 整帧静默消失。
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { createFrameParser } from "../lib/sse-client.js";
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
function parse(s) {
try {
return JSON.parse(s);
} catch (e) {
assert.fail(`不是合法 JSON: ${s}${e.message}`);
}
}
test("完整帧一次喂入:正常解析", () => {
const p = createFrameParser();
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, "new_mail");
assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
assert.equal(events[0].id, "7");
assert.equal(p.lastEventId(), "7");
});
test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
const p = createFrameParser();
// chunk1 恰好停在 event 行之后、data 行之前
const first = p.push("id: 12\nevent: content_delta\n");
assert.deepEqual(first, [], "半帧不该派发");
const second = p.push('data: {"x":1}\n\n');
assert.equal(
second.length,
1,
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
);
assert.equal(second[0].event, "content_delta");
assert.equal(p.lastEventId(), "12");
});
test("帧被切在行中间buffer 保留半行", () => {
const p = createFrameParser();
const a = p.push("event: new_ma");
assert.deepEqual(a, []);
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
assert.equal(b.length, 1);
assert.equal(b[0].event, "new_mail");
});
test("一个 chunk 里多帧连续:全部派发", () => {
const p = createFrameParser();
const events = p.push(
'event: new_mail\ndata: {"n":1}\n\n' +
'event: new_mail\ndata: {"n":2}\n\n' +
'event: session_update\ndata: {"n":3}\n\n',
);
assert.equal(events.length, 3);
assert.deepEqual(
events.map((e) => e.event),
["new_mail", "new_mail", "session_update"],
);
});
test("注释/心跳行被忽略,不影响后续帧", () => {
const p = createFrameParser();
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, "new_mail");
});
test("多行 data 用换行拼接", () => {
const p = createFrameParser();
const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
assert.equal(events[0].data, "line1\nline2");
});
test("CRLF 不被当成事件名或 JSON 的一部分", () => {
const p = createFrameParser();
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, "new_mail");
assert.equal(events[0].id, "3");
assert.deepEqual(parse(events[0].data), { n: 1 });
});
test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
const p = createFrameParser();
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), "10");
// 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
assert.equal(
p.lastEventId(),
"5",
"解析器如实记录当前 id是否回退由使用方决定",
);
});
test("id 在派发前记录:回调抛异常也不丢断点", () => {
const p = createFrameParser();
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), "42");
});
test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
const p = createFrameParser();
const events = p.push('data: {"orphan":true}\n\n');
assert.deepEqual(events, []);
});
test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
const p = createFrameParser();
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
p.push("event: partial"); // 半帧
p.reset();
assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
// reset 后半帧不该复活
const after = p.push('data: {"n":2}\n\n');
assert.deepEqual(after, []);
});
test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
const p = createFrameParser();
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), "123");
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
p.setLastEventId("");
assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
});