问题(⑧):守护进程用 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 按设计排除。
141 lines
4.9 KiB
JavaScript
141 lines
4.9 KiB
JavaScript
/**
|
||
* 共用 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");
|
||
});
|