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 按设计排除。
This commit is contained in:
@ -11,19 +11,21 @@
|
||||
* 而不是一个间接的计数(计数在字段被丢掉时依然会给出绿色)。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// ─── 桩 worker ───
|
||||
//
|
||||
// 落到临时目录而不是仓库里:它是测试脚手架,不该被 check-shared-libs 之类的
|
||||
// 一致性脚本看到,也不该让人误以为是第二个真 worker。
|
||||
const STUB_DIR = mkdtempSync(join(tmpdir(), 'pi-pool-test-'));
|
||||
const STUB = join(STUB_DIR, 'stub-worker.mjs');
|
||||
writeFileSync(STUB, `
|
||||
const STUB_DIR = mkdtempSync(join(tmpdir(), "pi-pool-test-"));
|
||||
const STUB = join(STUB_DIR, "stub-worker.mjs");
|
||||
writeFileSync(
|
||||
STUB,
|
||||
`
|
||||
process.on('message', (msg) => {
|
||||
if (msg?.type === 'job') {
|
||||
const hold = msg.data?.__hold ?? 30;
|
||||
@ -80,297 +82,379 @@ process.on('message', (msg) => {
|
||||
}
|
||||
});
|
||||
process.send({ type: 'ready' });
|
||||
`);
|
||||
`,
|
||||
);
|
||||
|
||||
const { createWorkerPool } = await import('../src/pool.mjs');
|
||||
const { createWorkerPool } = await import("../src/pool.mjs");
|
||||
|
||||
/** 建一个用桩 worker 的池。 */
|
||||
function makePool(opts = {}) {
|
||||
const lines = [];
|
||||
const pool = createWorkerPool({
|
||||
log: (...a) => lines.push(a.join(' ')),
|
||||
config: () => ({ turnTimeoutMs: 1000, ...(opts.config || {}) }),
|
||||
onReconfigure: opts.onReconfigure || (() => {}),
|
||||
maxWorkers: opts.maxWorkers ?? 2,
|
||||
workerMaxMs: opts.workerMaxMs ?? 5000,
|
||||
maxAttempts: opts.maxAttempts,
|
||||
workerPath: opts.workerPath || STUB,
|
||||
});
|
||||
return { pool, lines };
|
||||
const lines = [];
|
||||
const pool = createWorkerPool({
|
||||
log: (...a) => lines.push(a.join(" ")),
|
||||
config: () => ({ turnTimeoutMs: 1000, ...(opts.config || {}) }),
|
||||
onReconfigure: opts.onReconfigure || (() => {}),
|
||||
maxWorkers: opts.maxWorkers ?? 2,
|
||||
workerMaxMs: opts.workerMaxMs ?? 5000,
|
||||
maxAttempts: opts.maxAttempts,
|
||||
workerPath: opts.workerPath || STUB,
|
||||
});
|
||||
return { pool, lines };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 轮询到条件成立或超时 —— 比固定 sleep 稳。 */
|
||||
async function until(fn, timeoutMs = 4000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (fn()) return true;
|
||||
await sleep(20);
|
||||
}
|
||||
return false;
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (fn()) return true;
|
||||
await sleep(20);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 从日志行里取出桩 worker 回声的 job 载荷。 */
|
||||
function jobs(lines) {
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
const i = l.indexOf('JOB ');
|
||||
if (i === -1) continue;
|
||||
try { out.push(JSON.parse(l.slice(i + 4))); } catch { /* 不是完整一行 */ }
|
||||
}
|
||||
return out;
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
const i = l.indexOf("JOB ");
|
||||
if (i === -1) continue;
|
||||
try {
|
||||
out.push(JSON.parse(l.slice(i + 4)));
|
||||
} catch {
|
||||
/* 不是完整一行 */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 某个 mailID 的心跳出现过几次。 */
|
||||
const ticks = (lines, id) => lines.filter((l) => l.includes(`TICK ${id}`)).length;
|
||||
const ticks = (lines, id) =>
|
||||
lines.filter((l) => l.includes(`TICK ${id}`)).length;
|
||||
|
||||
test('并发上限被遵守:第三条会话要等前面空出来', async () => {
|
||||
const { pool } = makePool({ maxWorkers: 2 });
|
||||
for (const id of ['a', 'b', 'c']) {
|
||||
pool.submit('mail', { mail_id: id, session_id: `S-${id}`, __hold: 250 });
|
||||
}
|
||||
test("并发上限被遵守:第三条会话要等前面空出来", async () => {
|
||||
const { pool } = makePool({ maxWorkers: 2 });
|
||||
for (const id of ["a", "b", "c"]) {
|
||||
pool.submit("mail", { mail_id: id, session_id: `S-${id}`, __hold: 250 });
|
||||
}
|
||||
|
||||
let peak = 0;
|
||||
const t = setInterval(() => { peak = Math.max(peak, pool.stats().running); }, 15);
|
||||
const sawQueue = await until(() => pool.stats().queued > 0, 1000);
|
||||
await until(() => pool.stats().running === 0 && pool.stats().queued === 0);
|
||||
clearInterval(t);
|
||||
pool.stop();
|
||||
let peak = 0;
|
||||
const t = setInterval(() => {
|
||||
peak = Math.max(peak, pool.stats().running);
|
||||
}, 15);
|
||||
const sawQueue = await until(() => pool.stats().queued > 0, 1000);
|
||||
await until(() => pool.stats().running === 0 && pool.stats().queued === 0);
|
||||
clearInterval(t);
|
||||
pool.stop();
|
||||
|
||||
assert.ok(peak <= 2, `同时跑的 worker 峰值 ${peak},不该超过 maxWorkers=2`);
|
||||
assert.ok(sawQueue, '满载时第三封该进队列而不是被丢掉');
|
||||
assert.ok(peak <= 2, `同时跑的 worker 峰值 ${peak},不该超过 maxWorkers=2`);
|
||||
assert.ok(sawQueue, "满载时第三封该进队列而不是被丢掉");
|
||||
});
|
||||
|
||||
test('同一会话串行:两个 worker 的心跳不得重叠', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||
pool.submit('mail', { mail_id: 'm1', session_id: 'SAME', __hold: 220 });
|
||||
pool.submit('mail', { mail_id: 'm2', session_id: 'SAME', __hold: 60 });
|
||||
test("同一会话串行:两个 worker 的心跳不得重叠", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||
pool.submit("mail", { mail_id: "m1", session_id: "SAME", __hold: 220 });
|
||||
pool.submit("mail", { mail_id: "m2", session_id: "SAME", __hold: 60 });
|
||||
|
||||
// 判据一:任一时刻只有一个 worker 在跑。
|
||||
let everTwo = false;
|
||||
const t = setInterval(() => { if (pool.stats().running > 1) everTwo = true; }, 10);
|
||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0, 5000);
|
||||
clearInterval(t);
|
||||
pool.stop();
|
||||
// 判据一:任一时刻只有一个 worker 在跑。
|
||||
let everTwo = false;
|
||||
const t = setInterval(() => {
|
||||
if (pool.stats().running > 1) everTwo = true;
|
||||
}, 10);
|
||||
await until(
|
||||
() => jobs(lines).length === 2 && pool.stats().running === 0,
|
||||
5000,
|
||||
);
|
||||
clearInterval(t);
|
||||
pool.stop();
|
||||
|
||||
assert.equal(everTwo, false, '同一条会话不得有两个 worker 同时装载会话文件');
|
||||
// 判据二:m2 一次心跳都没能在 m1 结束前发出 —— m1 的心跳数应当远多于 m2。
|
||||
assert.ok(ticks(lines, 'm1') >= 3, `m1 该跑满 220ms,实际心跳 ${ticks(lines, 'm1')} 次`);
|
||||
assert.equal(jobs(lines).length, 2, '两封都要被处理,不能因为串行而丢掉一封');
|
||||
assert.equal(everTwo, false, "同一条会话不得有两个 worker 同时装载会话文件");
|
||||
// 判据二:m2 一次心跳都没能在 m1 结束前发出 —— m1 的心跳数应当远多于 m2。
|
||||
assert.ok(
|
||||
ticks(lines, "m1") >= 3,
|
||||
`m1 该跑满 220ms,实际心跳 ${ticks(lines, "m1")} 次`,
|
||||
);
|
||||
assert.equal(jobs(lines).length, 2, "两封都要被处理,不能因为串行而丢掉一封");
|
||||
});
|
||||
|
||||
test('不同会话真并发:两个进程的心跳在同一段时间里交错', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||
pool.submit('mail', { mail_id: 'p', session_id: 'S-P', __hold: 300 });
|
||||
pool.submit('mail', { mail_id: 'q', session_id: 'S-Q', __hold: 300 });
|
||||
test("不同会话真并发:两个进程的心跳在同一段时间里交错", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||
pool.submit("mail", { mail_id: "p", session_id: "S-P", __hold: 300 });
|
||||
pool.submit("mail", { mail_id: "q", session_id: "S-Q", __hold: 300 });
|
||||
|
||||
const interleaved = await until(() => ticks(lines, 'p') >= 2 && ticks(lines, 'q') >= 2, 2500);
|
||||
pool.stop();
|
||||
assert.ok(interleaved,
|
||||
`两条不同会话应当并发,实际 p=${ticks(lines, 'p')} q=${ticks(lines, 'q')} 次心跳`);
|
||||
const interleaved = await until(
|
||||
() => ticks(lines, "p") >= 2 && ticks(lines, "q") >= 2,
|
||||
2500,
|
||||
);
|
||||
pool.stop();
|
||||
assert.ok(
|
||||
interleaved,
|
||||
`两条不同会话应当并发,实际 p=${ticks(lines, "p")} q=${ticks(lines, "q")} 次心跳`,
|
||||
);
|
||||
});
|
||||
|
||||
test('sessionFile 与 cwd 跨 worker 传下去:第二封接着第一封的会话谈', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', { mail_id: 'first', session_id: 'KEEP', __hold: 30 });
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
test("sessionFile 与 cwd 跨 worker 传下去:第二封接着第一封的会话谈", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", { mail_id: "first", session_id: "KEEP", __hold: 30 });
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
|
||||
pool.submit('mail', { mail_id: 'second', session_id: 'KEEP', __hold: 30 });
|
||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
pool.submit("mail", { mail_id: "second", session_id: "KEEP", __hold: 30 });
|
||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
|
||||
const [j1, j2] = jobs(lines);
|
||||
assert.equal(j1.sessionFile, '', '第一封时还没有会话文件');
|
||||
assert.equal(j2.sessionFile, '/tmp/f-first.jsonl',
|
||||
'第二封必须带上第一封开出来的会话文件,否则每封邮件都从零开始、上下文全丢');
|
||||
assert.equal(j2.cwd, '/tmp', 'cwd 也要传下去');
|
||||
const [j1, j2] = jobs(lines);
|
||||
assert.equal(j1.sessionFile, "", "第一封时还没有会话文件");
|
||||
assert.equal(
|
||||
j2.sessionFile,
|
||||
"/tmp/f-first.jsonl",
|
||||
"第二封必须带上第一封开出来的会话文件,否则每封邮件都从零开始、上下文全丢",
|
||||
);
|
||||
assert.equal(j2.cwd, "/tmp", "cwd 也要传下去");
|
||||
});
|
||||
|
||||
test('「一直同意」与命名指纹跨 worker 存活', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', {
|
||||
mail_id: 'g1', session_id: 'GRANT', __hold: 30,
|
||||
__grant: 'bash', __name: 'platform:某名字|某名字',
|
||||
});
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
test("「一直同意」与命名指纹跨 worker 存活", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", {
|
||||
mail_id: "g1",
|
||||
session_id: "GRANT",
|
||||
__hold: 30,
|
||||
__grant: "bash",
|
||||
__name: "platform:某名字|某名字",
|
||||
});
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
|
||||
pool.submit('mail', { mail_id: 'g2', session_id: 'GRANT', __hold: 30 });
|
||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
pool.submit("mail", { mail_id: "g2", session_id: "GRANT", __hold: 30 });
|
||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
|
||||
const [j1, j2] = jobs(lines);
|
||||
assert.deepEqual(j1.grants, [], '第一封时还没人点过「一直同意」');
|
||||
assert.deepEqual(j2.grants, ['bash'],
|
||||
'「一直同意」不跨 worker 存活的话,下一封邮件又问一遍 —— 那个选项就是在骗人');
|
||||
assert.equal(j2.lastSyncedName, 'platform:某名字|某名字',
|
||||
'命名指纹要传下去,否则每封邮件都重新 sync 一次');
|
||||
const [j1, j2] = jobs(lines);
|
||||
assert.deepEqual(j1.grants, [], "第一封时还没人点过「一直同意」");
|
||||
assert.deepEqual(
|
||||
j2.grants,
|
||||
["bash"],
|
||||
"「一直同意」不跨 worker 存活的话,下一封邮件又问一遍 —— 那个选项就是在骗人",
|
||||
);
|
||||
assert.equal(
|
||||
j2.lastSyncedName,
|
||||
"platform:某名字|某名字",
|
||||
"命名指纹要传下去,否则每封邮件都重新 sync 一次",
|
||||
);
|
||||
});
|
||||
|
||||
test('config() 每次派活时重取:allowedModels 随心跳变,不能用快照', async () => {
|
||||
let turnTimeoutMs = 111;
|
||||
const { pool, lines } = makePool({ maxWorkers: 1, config: {} });
|
||||
// makePool 的 config 是固定值,这里换成动态的
|
||||
pool.stop();
|
||||
test("config() 每次派活时重取:allowedModels 随心跳变,不能用快照", async () => {
|
||||
let turnTimeoutMs = 111;
|
||||
const { pool, lines } = makePool({ maxWorkers: 1, config: {} });
|
||||
// makePool 的 config 是固定值,这里换成动态的
|
||||
pool.stop();
|
||||
|
||||
const lines2 = [];
|
||||
const p2 = createWorkerPool({
|
||||
log: (...a) => lines2.push(a.join(' ')),
|
||||
config: () => ({ turnTimeoutMs }),
|
||||
onReconfigure: () => {},
|
||||
maxWorkers: 1,
|
||||
workerMaxMs: 5000,
|
||||
workerPath: STUB,
|
||||
});
|
||||
p2.submit('mail', { mail_id: 'c1', session_id: 'C1', __hold: 20 });
|
||||
await until(() => jobs(lines2).length === 1 && p2.stats().running === 0);
|
||||
turnTimeoutMs = 222;
|
||||
p2.submit('mail', { mail_id: 'c2', session_id: 'C2', __hold: 20 });
|
||||
await until(() => jobs(lines2).length === 2 && p2.stats().running === 0);
|
||||
p2.stop();
|
||||
const lines2 = [];
|
||||
const p2 = createWorkerPool({
|
||||
log: (...a) => lines2.push(a.join(" ")),
|
||||
config: () => ({ turnTimeoutMs }),
|
||||
onReconfigure: () => {},
|
||||
maxWorkers: 1,
|
||||
workerMaxMs: 5000,
|
||||
workerPath: STUB,
|
||||
});
|
||||
p2.submit("mail", { mail_id: "c1", session_id: "C1", __hold: 20 });
|
||||
await until(() => jobs(lines2).length === 1 && p2.stats().running === 0);
|
||||
turnTimeoutMs = 222;
|
||||
p2.submit("mail", { mail_id: "c2", session_id: "C2", __hold: 20 });
|
||||
await until(() => jobs(lines2).length === 2 && p2.stats().running === 0);
|
||||
p2.stop();
|
||||
|
||||
const [j1, j2] = jobs(lines2);
|
||||
assert.equal(j1.turnTimeoutMs, 111);
|
||||
assert.equal(j2.turnTimeoutMs, 222, 'config() 必须每次重取,否则 worker 用的是上一轮的模型范围');
|
||||
assert.equal(lines.length >= 0, true);
|
||||
const [j1, j2] = jobs(lines2);
|
||||
assert.equal(j1.turnTimeoutMs, 111);
|
||||
assert.equal(
|
||||
j2.turnTimeoutMs,
|
||||
222,
|
||||
"config() 必须每次重取,否则 worker 用的是上一轮的模型范围",
|
||||
);
|
||||
assert.equal(lines.length >= 0, true);
|
||||
});
|
||||
|
||||
test('硬超时回收卡死的 worker,且不堵住同会话后续邮件', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2, workerMaxMs: 400 });
|
||||
pool.submit('mail', { mail_id: 'stuck', session_id: 'STUCK', __hold: -1 });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
test("硬超时回收卡死的 worker,且不堵住同会话后续邮件", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2, workerMaxMs: 400 });
|
||||
pool.submit("mail", { mail_id: "stuck", session_id: "STUCK", __hold: -1 });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
|
||||
const freed = await until(() => pool.stats().running === 0, 3000);
|
||||
assert.ok(freed, '卡死的 worker 必须被硬超时回收,否则那条会话的后续邮件永远排队');
|
||||
assert.ok(lines.some((l) => l.includes('强杀')), `应记下强杀日志,实际:\n${lines.join('\n')}`);
|
||||
const freed = await until(() => pool.stats().running === 0, 3000);
|
||||
assert.ok(
|
||||
freed,
|
||||
"卡死的 worker 必须被硬超时回收,否则那条会话的后续邮件永远排队",
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((l) => l.includes("强杀")),
|
||||
`应记下强杀日志,实际:\n${lines.join("\n")}`,
|
||||
);
|
||||
|
||||
pool.submit('mail', { mail_id: 'after', session_id: 'STUCK', __hold: 30 });
|
||||
const ran = await until(() => jobs(lines).some((j) => j.mailID === 'after'), 2000);
|
||||
await until(() => pool.stats().running === 0);
|
||||
pool.stop();
|
||||
assert.ok(ran, '硬超时后同一会话的后续邮件必须能被处理');
|
||||
pool.submit("mail", { mail_id: "after", session_id: "STUCK", __hold: 30 });
|
||||
const ran = await until(
|
||||
() => jobs(lines).some((j) => j.mailID === "after"),
|
||||
2000,
|
||||
);
|
||||
await until(() => pool.stats().running === 0);
|
||||
pool.stop();
|
||||
assert.ok(ran, "硬超时后同一会话的后续邮件必须能被处理");
|
||||
});
|
||||
|
||||
test('权限决策路由到发起询问的那个 worker', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', { mail_id: 'perm', session_id: 'PERM', __pending: 'rk-1' });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
await sleep(150); // 等 permission_pending 到主进程
|
||||
test("权限决策路由到发起询问的那个 worker", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", {
|
||||
mail_id: "perm",
|
||||
session_id: "PERM",
|
||||
__pending: "rk-1",
|
||||
});
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
await sleep(150); // 等 permission_pending 到主进程
|
||||
|
||||
assert.equal(pool.routePermission('rk-1', '同意'), true, '应当路由成功');
|
||||
await until(() => pool.stats().running === 0, 2000);
|
||||
pool.stop();
|
||||
assert.equal(pool.routePermission("rk-1", "同意"), true, "应当路由成功");
|
||||
await until(() => pool.stats().running === 0, 2000);
|
||||
pool.stop();
|
||||
|
||||
assert.ok(lines.some((l) => l.includes('DECISION rk-1=同意')),
|
||||
`worker 应收到决策原文,实际:\n${lines.join('\n')}`);
|
||||
assert.ok(
|
||||
lines.some((l) => l.includes("DECISION rk-1=同意")),
|
||||
`worker 应收到决策原文,实际:\n${lines.join("\n")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('决策发的是选项原文而不是归一化的 allow/deny', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', { mail_id: 'p2', session_id: 'P2', __pending: 'rk-2' });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
await sleep(150);
|
||||
pool.routePermission('rk-2', '一直同意');
|
||||
await until(() => pool.stats().running === 0, 2000);
|
||||
pool.stop();
|
||||
test("决策发的是选项原文而不是归一化的 allow/deny", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", { mail_id: "p2", session_id: "P2", __pending: "rk-2" });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
await sleep(150);
|
||||
pool.routePermission("rk-2", "一直同意");
|
||||
await until(() => pool.stats().running === 0, 2000);
|
||||
pool.stop();
|
||||
|
||||
// 「同意」与「一直同意」语义不同,归一化会让后者退化成单次授权
|
||||
assert.ok(lines.some((l) => l.includes('DECISION rk-2=一直同意')),
|
||||
`必须原文透传,实际:\n${lines.join('\n')}`);
|
||||
// 「同意」与「一直同意」语义不同,归一化会让后者退化成单次授权
|
||||
assert.ok(
|
||||
lines.some((l) => l.includes("DECISION rk-2=一直同意")),
|
||||
`必须原文透传,实际:\n${lines.join("\n")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('决策找不到 worker 时返回 false(调用方据此走 B-4.2 降级)', async () => {
|
||||
const { pool } = makePool();
|
||||
assert.equal(pool.routePermission('never-seen', '同意'), false);
|
||||
pool.stop();
|
||||
test("决策找不到 worker 时返回 false(调用方据此走 B-4.2 降级)", async () => {
|
||||
const { pool } = makePool();
|
||||
assert.equal(pool.routePermission("never-seen", "同意"), false);
|
||||
pool.stop();
|
||||
});
|
||||
|
||||
test('worker 退出后它的权限路由被清掉,不会误投给下一个 worker', async () => {
|
||||
const { pool } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', { mail_id: 'gone', session_id: 'GONE', __pending: 'rk-gone' });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
await sleep(150);
|
||||
// 不给决策,直接停掉它
|
||||
pool.stop();
|
||||
await until(() => pool.stats().running === 0, 4000);
|
||||
test("worker 退出后它的权限路由被清掉,不会误投给下一个 worker", async () => {
|
||||
const { pool } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", {
|
||||
mail_id: "gone",
|
||||
session_id: "GONE",
|
||||
__pending: "rk-gone",
|
||||
});
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
await sleep(150);
|
||||
// 不给决策,直接停掉它
|
||||
pool.stop();
|
||||
await until(() => pool.stats().running === 0, 4000);
|
||||
|
||||
assert.equal(pool.routePermission('rk-gone', '同意'), false,
|
||||
'worker 已退出,路由必须返回 false 让调用方走降级路径');
|
||||
assert.equal(
|
||||
pool.routePermission("rk-gone", "同意"),
|
||||
false,
|
||||
"worker 已退出,路由必须返回 false 让调用方走降级路径",
|
||||
);
|
||||
});
|
||||
|
||||
test('mailDrivenIDs 报出所有跑过的 pi 会话,且不随 worker 退出而清', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', { mail_id: 'd1', session_id: 'D1', __hold: 30 });
|
||||
pool.submit('mail', { mail_id: 'd2', session_id: 'D2', __hold: 30 });
|
||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
test("mailDrivenIDs 报出所有跑过的 pi 会话,且不随 worker 退出而清", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", { mail_id: "d1", session_id: "D1", __hold: 30 });
|
||||
pool.submit("mail", { mail_id: "d2", session_id: "D2", __hold: 30 });
|
||||
await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
|
||||
const ids = pool.mailDrivenIDs();
|
||||
assert.ok(ids.has('pi-d1'), 'D1 的 pi 会话该被标记为邮件驱动');
|
||||
assert.ok(ids.has('pi-d2'), 'D2 的 pi 会话该被标记为邮件驱动');
|
||||
const ids = pool.mailDrivenIDs();
|
||||
assert.ok(ids.has("pi-d1"), "D1 的 pi 会话该被标记为邮件驱动");
|
||||
assert.ok(ids.has("pi-d2"), "D2 的 pi 会话该被标记为邮件驱动");
|
||||
});
|
||||
|
||||
test('模型降级换掉的旧 pi 会话仍算邮件驱动', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', { mail_id: 'r', session_id: 'RETIRE', __hold: 40, __reopen: 'pi-new' });
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
test("模型降级换掉的旧 pi 会话仍算邮件驱动", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", {
|
||||
mail_id: "r",
|
||||
session_id: "RETIRE",
|
||||
__hold: 40,
|
||||
__reopen: "pi-new",
|
||||
});
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
|
||||
const ids = pool.mailDrivenIDs();
|
||||
assert.ok(ids.has('pi-new'), '新会话要在');
|
||||
assert.ok(ids.has('pi-r'), '被换掉的旧会话也参与过邮件往来,磁盘上的文件还在,快照该报它');
|
||||
const ids = pool.mailDrivenIDs();
|
||||
assert.ok(ids.has("pi-new"), "新会话要在");
|
||||
assert.ok(
|
||||
ids.has("pi-r"),
|
||||
"被换掉的旧会话也参与过邮件往来,磁盘上的文件还在,快照该报它",
|
||||
);
|
||||
});
|
||||
|
||||
test('hasSession 只对跑过的邮件会话为真', async () => {
|
||||
const { pool } = makePool();
|
||||
assert.equal(pool.hasSession('NOPE'), false);
|
||||
pool.submit('mail', { mail_id: 'h1', session_id: 'HAS', __hold: 30 });
|
||||
await until(() => pool.hasSession('HAS'), 2000);
|
||||
await until(() => pool.stats().running === 0);
|
||||
pool.stop();
|
||||
assert.equal(pool.hasSession('HAS'), true, 'worker 退出后仍该记着这条会话');
|
||||
test("hasSession 只对跑过的邮件会话为真", async () => {
|
||||
const { pool } = makePool();
|
||||
assert.equal(pool.hasSession("NOPE"), false);
|
||||
pool.submit("mail", { mail_id: "h1", session_id: "HAS", __hold: 30 });
|
||||
await until(() => pool.hasSession("HAS"), 2000);
|
||||
await until(() => pool.stats().running === 0);
|
||||
pool.stop();
|
||||
assert.equal(pool.hasSession("HAS"), true, "worker 退出后仍该记着这条会话");
|
||||
});
|
||||
|
||||
test('kind 透传:权限通知走 permission 而不是 mail', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('permission', { mail_id: 'k1', session_id: 'K1', __hold: 20 });
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
assert.equal(jobs(lines)[0].kind, 'permission',
|
||||
'kind 决定 worker 用哪套提示词,传错会让模型以为收到一封新邮件');
|
||||
test("kind 透传:权限通知走 permission 而不是 mail", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("permission", { mail_id: "k1", session_id: "K1", __hold: 20 });
|
||||
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
|
||||
pool.stop();
|
||||
assert.equal(
|
||||
jobs(lines)[0].kind,
|
||||
"permission",
|
||||
"kind 决定 worker 用哪套提示词,传错会让模型以为收到一封新邮件",
|
||||
);
|
||||
});
|
||||
|
||||
test('stop 之后不再派活', async () => {
|
||||
const { pool } = makePool();
|
||||
pool.stop();
|
||||
pool.submit('mail', { mail_id: 'late', session_id: 'LATE', __hold: 30 });
|
||||
await sleep(200);
|
||||
assert.equal(pool.stats().running, 0, '关停后不该再起 worker');
|
||||
assert.equal(pool.stats().queued, 0, '关停后队列应为空');
|
||||
test("stop 之后不再派活", async () => {
|
||||
const { pool } = makePool();
|
||||
pool.stop();
|
||||
pool.submit("mail", { mail_id: "late", session_id: "LATE", __hold: 30 });
|
||||
await sleep(200);
|
||||
assert.equal(pool.stats().running, 0, "关停后不该再起 worker");
|
||||
assert.equal(pool.stats().queued, 0, "关停后队列应为空");
|
||||
});
|
||||
|
||||
test('stop 会先给 worker 发 shutdown(让它 fail closed)再杀', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit('mail', { mail_id: 's1', session_id: 'S1', __hold: -1 });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
pool.stop();
|
||||
const gotShutdown = await until(() => lines.some((l) => l.includes('SHUTDOWN')), 2000);
|
||||
assert.ok(gotShutdown,
|
||||
'必须先发 shutdown:直接 SIGKILL 会让 pi 侧那些等权限的 await 永不返回');
|
||||
test("stop 会先给 worker 发 shutdown(让它 fail closed)再杀", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 2 });
|
||||
pool.submit("mail", { mail_id: "s1", session_id: "S1", __hold: -1 });
|
||||
await until(() => pool.stats().running === 1, 1500);
|
||||
pool.stop();
|
||||
const gotShutdown = await until(
|
||||
() => lines.some((l) => l.includes("SHUTDOWN")),
|
||||
2000,
|
||||
);
|
||||
assert.ok(
|
||||
gotShutdown,
|
||||
"必须先发 shutdown:直接 SIGKILL 会让 pi 侧那些等权限的 await 永不返回",
|
||||
);
|
||||
});
|
||||
|
||||
test('没有 session_id 的事件各占一个 key,不会互相串行', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||
pool.submit('mail', { mail_id: 'n1', __hold: 300 });
|
||||
pool.submit('mail', { mail_id: 'n2', __hold: 300 });
|
||||
const both = await until(() => ticks(lines, 'n1') >= 2 && ticks(lines, 'n2') >= 2, 2500);
|
||||
pool.stop();
|
||||
assert.ok(both, '无 session_id 的两封不属于同一条会话,应能并发');
|
||||
test("没有 session_id 的事件各占一个 key,不会互相串行", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 3 });
|
||||
pool.submit("mail", { mail_id: "n1", __hold: 300 });
|
||||
pool.submit("mail", { mail_id: "n2", __hold: 300 });
|
||||
const both = await until(
|
||||
() => ticks(lines, "n1") >= 2 && ticks(lines, "n2") >= 2,
|
||||
2500,
|
||||
);
|
||||
pool.stop();
|
||||
assert.ok(both, "无 session_id 的两封不属于同一条会话,应能并发");
|
||||
});
|
||||
|
||||
test('reconfigure 上报被转达给主进程', async () => {
|
||||
const STUB2 = join(STUB_DIR, 'stub-reconf.mjs');
|
||||
writeFileSync(STUB2, `
|
||||
test("reconfigure 上报被转达给主进程", async () => {
|
||||
const STUB2 = join(STUB_DIR, "stub-reconf.mjs");
|
||||
writeFileSync(
|
||||
STUB2,
|
||||
`
|
||||
process.on('message', (msg) => {
|
||||
if (msg?.type === 'job') {
|
||||
process.send({ type: 'reconfigure', url: 'http://new:9999', agentKey: 'k2' });
|
||||
@ -379,42 +463,57 @@ process.on('message', (msg) => {
|
||||
}
|
||||
});
|
||||
process.send({ type: 'ready' });
|
||||
`);
|
||||
let got = null;
|
||||
const { pool } = makePool({
|
||||
maxWorkers: 1,
|
||||
workerPath: STUB2,
|
||||
onReconfigure: (url, key) => { got = { url, key }; },
|
||||
});
|
||||
pool.submit('mail', { mail_id: 'r1', session_id: 'R1' });
|
||||
await until(() => got !== null, 3000);
|
||||
pool.stop();
|
||||
assert.deepEqual(got, { url: 'http://new:9999', key: 'k2' },
|
||||
'worker 里 connect_to_server 换的坐标必须回到主进程 —— worker 马上就退了,改在它自己身上等于没改');
|
||||
`,
|
||||
);
|
||||
let got = null;
|
||||
const { pool } = makePool({
|
||||
maxWorkers: 1,
|
||||
workerPath: STUB2,
|
||||
onReconfigure: (url, key) => {
|
||||
got = { url, key };
|
||||
},
|
||||
});
|
||||
pool.submit("mail", { mail_id: "r1", session_id: "R1" });
|
||||
await until(() => got !== null, 3000);
|
||||
pool.stop();
|
||||
assert.deepEqual(
|
||||
got,
|
||||
{ url: "http://new:9999", key: "k2" },
|
||||
"worker 里 connect_to_server 换的坐标必须回到主进程 —— worker 马上就退了,改在它自己身上等于没改",
|
||||
);
|
||||
});
|
||||
|
||||
test('worker 未回报 done 就退出:有界重投而不是静默丢信', async () => {
|
||||
// maxAttempts=2:首次 + 一次重投,然后放弃。
|
||||
// 这封邮件必定崩溃,重投就是在验证「有界」——不然它会变成永久活锁。
|
||||
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 2 });
|
||||
pool.submit('mail', { mail_id: 'crashy', session_id: 'CRASH', __crash: true });
|
||||
test("worker 未回报 done 就退出:有界重投而不是静默丢信", async () => {
|
||||
// maxAttempts=2:首次 + 一次重投,然后放弃。
|
||||
// 这封邮件必定崩溃,重投就是在验证「有界」——不然它会变成永久活锁。
|
||||
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 2 });
|
||||
pool.submit("mail", {
|
||||
mail_id: "crashy",
|
||||
session_id: "CRASH",
|
||||
__crash: true,
|
||||
});
|
||||
|
||||
const gaveUp = await until(() => lines.some((l) => l.includes('放弃')), 6000);
|
||||
pool.stop();
|
||||
const gaveUp = await until(() => lines.some((l) => l.includes("放弃")), 6000);
|
||||
pool.stop();
|
||||
|
||||
assert.ok(gaveUp, `重投到上限后应记下「放弃」,实际:\n${lines.join('\n')}`);
|
||||
assert.equal(jobs(lines).length, 2,
|
||||
`应当尝试 2 次(首次 + 1 次重投),实际 ${jobs(lines).length} 次`);
|
||||
assert.ok(lines.some((l) => l.includes('未回报 done 就退出')),
|
||||
'必须明说是「未回报 done 就退出」——否则看到 exit code 会误以为是普通崩溃');
|
||||
assert.ok(gaveUp, `重投到上限后应记下「放弃」,实际:\n${lines.join("\n")}`);
|
||||
assert.equal(
|
||||
jobs(lines).length,
|
||||
2,
|
||||
`应当尝试 2 次(首次 + 1 次重投),实际 ${jobs(lines).length} 次`,
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((l) => l.includes("未回报 done 就退出")),
|
||||
"必须明说是「未回报 done 就退出」——否则看到 exit code 会误以为是普通崩溃",
|
||||
);
|
||||
});
|
||||
|
||||
test('重投上限之下不会无限重投(maxAttempts=1 就是不重投)', async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 1 });
|
||||
pool.submit('mail', { mail_id: 'once', session_id: 'ONCE', __crash: true });
|
||||
await until(() => lines.some((l) => l.includes('放弃')), 4000);
|
||||
await sleep(300); // 再等一会儿,确认没有额外重投
|
||||
pool.stop();
|
||||
test("重投上限之下不会无限重投(maxAttempts=1 就是不重投)", async () => {
|
||||
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 1 });
|
||||
pool.submit("mail", { mail_id: "once", session_id: "ONCE", __crash: true });
|
||||
await until(() => lines.some((l) => l.includes("放弃")), 4000);
|
||||
await sleep(300); // 再等一会儿,确认没有额外重投
|
||||
pool.stop();
|
||||
|
||||
assert.equal(jobs(lines).length, 1, 'maxAttempts=1 时只跑一次');
|
||||
assert.equal(jobs(lines).length, 1, "maxAttempts=1 时只跑一次");
|
||||
});
|
||||
|
||||
@ -9,121 +9,132 @@
|
||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createFrameParser } from '../lib/sse-client.js';
|
||||
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})`);
|
||||
}
|
||||
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("完整帧一次喂入:正常解析", () => {
|
||||
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, [], '半帧不该派发');
|
||||
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');
|
||||
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("帧被切在行中间: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("一个 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("注释/心跳行被忽略,不影响后续帧", () => {
|
||||
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("多行 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("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 只向前推进:重放旧 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("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("只有 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("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');
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user