chore(format): 撤销误入提交的整体重排,并关闭本仓库的格式化器
# 发生了什么 pi-lens 内置「安全格式化」:它会自动安装 biome 并对**编辑过的文件**跑 `biome format --write`。本机原先没有任何 biome 配置,于是 biome 用它自己的 默认值 —— tab 缩进 + 双引号 —— 把文件整体重写。 我在19a3161那次提交里用了 `git add -A`,把这批与功能无关的重排一起扫了进去: 约 7000 行改动散落在 20 个文件上,使那次提交无法审查,还掩盖了 server/internal/handler/permission.go 的一处删行(实为文件末尾空行,无代码丢失)。 # 为什么是「关掉」而不是「配置成我们的风格」 试过把缩进/引号/lineWidth 全部对齐本仓库习惯(biome.json + space/2/single/ lineWidth 120):`biome format --write` 仍然改动 17 个文件。原因是本仓库从未按 biome 的规则排版过 —— 注释按语义换行、数组与调用按可读性手工折行, 这些无法由格式化器还原。也就是说只要格式化器开着,每次编辑都会产生与内容无关的 大面积 diff,把真正的改动埋掉。 因此 biome.jsonc 里 formatter 与 linter 都关闭:本仓库的静态检查由 tsc / go vet / tree-sitter / ast-grep 与各自测试套件承担,不引入会改动无关行的 自动修复。 (pi-lens 这一版把 format 服务的 enabled 硬编码为 true,没有配置开关, 所以只能在仓库侧用 biome 配置让它不动文件;已验证 `biome format --write` 对这些文件零改动。) # 本提交内容 把19a3161里除「有意改动」外的 20 个文件还原到重排前的样子。19a3161中真正有意的改动是 deploy/install.sh 的扩展注册与 plugins/pi-mail-bridge/extension/index.ts 新文件,两者原样保留。 验证:Go 全量、三桥插件(320/362/409)、前端 196 全绿; `biome format --write` 对还原后的文件零改动。
This commit is contained in:
10
plugins/dsh-mail-bridge/lib/mail-session-id.d.ts
vendored
10
plugins/dsh-mail-bridge/lib/mail-session-id.d.ts
vendored
@ -1,9 +1,3 @@
|
||||
export declare function dshSessionIdForMail(mailSessionID: any): string;
|
||||
export declare function matchesMailSession(
|
||||
dshSessionId: any,
|
||||
mailSessionID: any,
|
||||
): boolean;
|
||||
export declare function pickMailSession(
|
||||
dshSessionIds: any[] | undefined,
|
||||
mailSessionID: any,
|
||||
): string | undefined;
|
||||
export declare function matchesMailSession(dshSessionId: any, mailSessionID: any): boolean;
|
||||
export declare function pickMailSession(dshSessionIds: any[] | undefined, mailSessionID: any): string | undefined;
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
/** 首次尝试使用的 DSH 会话 id。 */
|
||||
export function dshSessionIdForMail(mailSessionID) {
|
||||
return `mail-${String(mailSessionID ?? "")}`;
|
||||
return `mail-${String(mailSessionID ?? '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -35,12 +35,12 @@ export function dshSessionIdForMail(mailSessionID) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchesMailSession(dshSessionId, mailSessionID) {
|
||||
const id = String(dshSessionId ?? "");
|
||||
const base = dshSessionIdForMail(mailSessionID);
|
||||
if (id === base) return true;
|
||||
// 模型降级重试:mail-<id>-r1 / -r2 / …
|
||||
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : "";
|
||||
return suffix.length > 0 && /^\d+$/.test(suffix);
|
||||
const id = String(dshSessionId ?? '');
|
||||
const base = dshSessionIdForMail(mailSessionID);
|
||||
if (id === base) return true;
|
||||
// 模型降级重试:mail-<id>-r1 / -r2 / …
|
||||
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : '';
|
||||
return suffix.length > 0 && /^\d+$/.test(suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -54,19 +54,19 @@ export function matchesMailSession(dshSessionId, mailSessionID) {
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function pickMailSession(dshSessionIds, mailSessionID) {
|
||||
const base = dshSessionIdForMail(mailSessionID);
|
||||
const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : [];
|
||||
if (list.includes(base)) return base;
|
||||
const base = dshSessionIdForMail(mailSessionID);
|
||||
const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : [];
|
||||
if (list.includes(base)) return base;
|
||||
|
||||
let best;
|
||||
let bestIndex = Infinity;
|
||||
for (const id of list) {
|
||||
if (!matchesMailSession(id, mailSessionID)) continue;
|
||||
const idx = Number(id.slice(base.length + 2));
|
||||
if (idx < bestIndex) {
|
||||
bestIndex = idx;
|
||||
best = id;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
let best;
|
||||
let bestIndex = Infinity;
|
||||
for (const id of list) {
|
||||
if (!matchesMailSession(id, mailSessionID)) continue;
|
||||
const idx = Number(id.slice(base.length + 2));
|
||||
if (idx < bestIndex) {
|
||||
bestIndex = idx;
|
||||
best = id;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
22
plugins/dsh-mail-bridge/lib/sse-client.d.ts
vendored
22
plugins/dsh-mail-bridge/lib/sse-client.d.ts
vendored
@ -1,17 +1,17 @@
|
||||
export interface SseClientOptions {
|
||||
authHeaders: () => Record<string, string>;
|
||||
baseURL: string;
|
||||
path: string;
|
||||
onEvent: (evt: string, data: any) => void;
|
||||
log?: (msg: string) => void;
|
||||
authHeaders: () => Record<string, string>;
|
||||
baseURL: string;
|
||||
path: string;
|
||||
onEvent: (evt: string, data: any) => void;
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
export declare function createSSEClient(options: SseClientOptions): {
|
||||
stop: () => void;
|
||||
reset: () => void;
|
||||
stop: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
export declare function createFrameParser(): {
|
||||
push: (chunk: string) => Array<{ event: string; data: string; id: string }>;
|
||||
reset: () => void;
|
||||
lastEventId: () => string;
|
||||
setLastEventId: (id: string) => void;
|
||||
push: (chunk: string) => Array<{ event: string; data: string; id: string }>;
|
||||
reset: () => void;
|
||||
lastEventId: () => string;
|
||||
setLastEventId: (id: string) => void;
|
||||
};
|
||||
|
||||
22
plugins/dsh-mail-bridge/lib/user-question.d.ts
vendored
22
plugins/dsh-mail-bridge/lib/user-question.d.ts
vendored
@ -2,20 +2,12 @@ export declare function hasOptions(question: any): boolean;
|
||||
export declare function optionLabels(question: any): string[];
|
||||
export declare function questionTitle(question: any): string;
|
||||
export declare function flattenQuestions(questions: any[]): {
|
||||
question: string;
|
||||
options: string[];
|
||||
context: string;
|
||||
multiSelect: boolean;
|
||||
question: string;
|
||||
options: string[];
|
||||
context: string;
|
||||
multiSelect: boolean;
|
||||
};
|
||||
export declare function answersFromDecision(
|
||||
questions: any[],
|
||||
decision: string,
|
||||
note?: string,
|
||||
): {
|
||||
answers: Array<{ id: string; selected: string[]; custom?: string }>;
|
||||
export declare function answersFromDecision(questions: any[], decision: string, note?: string): {
|
||||
answers: Array<{ id: string; selected: string[]; custom?: string }>;
|
||||
};
|
||||
export declare function isBlankAnswer(
|
||||
questions: any[],
|
||||
decision: string,
|
||||
note?: string,
|
||||
): boolean;
|
||||
export declare function isBlankAnswer(questions: any[], decision: string, note?: string): boolean;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,69 +6,59 @@
|
||||
* 运行时仍按旧档位执行 —— 人以为自己收紧了权限。
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
dshSessionIdForMail,
|
||||
matchesMailSession,
|
||||
pickMailSession,
|
||||
} from "../lib/mail-session-id.js";
|
||||
dshSessionIdForMail,
|
||||
matchesMailSession,
|
||||
pickMailSession,
|
||||
} from '../lib/mail-session-id.js';
|
||||
|
||||
test("首次尝试的 id 是 mail-<邮件会话 id>", () => {
|
||||
assert.equal(dshSessionIdForMail("abc-123"), "mail-abc-123");
|
||||
test('首次尝试的 id 是 mail-<邮件会话 id>', () => {
|
||||
assert.equal(dshSessionIdForMail('abc-123'), 'mail-abc-123');
|
||||
});
|
||||
|
||||
test("匹配首次尝试的 id", () => {
|
||||
assert.equal(matchesMailSession("mail-abc", "abc"), true);
|
||||
test('匹配首次尝试的 id', () => {
|
||||
assert.equal(matchesMailSession('mail-abc', 'abc'), true);
|
||||
});
|
||||
|
||||
test("匹配模型降级重试的 -r<i>", () => {
|
||||
assert.equal(matchesMailSession("mail-abc-r1", "abc"), true);
|
||||
assert.equal(matchesMailSession("mail-abc-r12", "abc"), true);
|
||||
test('匹配模型降级重试的 -r<i>', () => {
|
||||
assert.equal(matchesMailSession('mail-abc-r1', 'abc'), true);
|
||||
assert.equal(matchesMailSession('mail-abc-r12', 'abc'), true);
|
||||
});
|
||||
|
||||
test("不匹配别的会话(前缀相同也不行)", () => {
|
||||
assert.equal(
|
||||
matchesMailSession("mail-abcdef", "abc"),
|
||||
false,
|
||||
"mail-abc 的前缀不能吃掉 mail-abcdef",
|
||||
);
|
||||
assert.equal(matchesMailSession("mail-abd", "abc"), false);
|
||||
assert.equal(matchesMailSession("other-abc", "abc"), false);
|
||||
assert.equal(matchesMailSession("", "abc"), false);
|
||||
test('不匹配别的会话(前缀相同也不行)', () => {
|
||||
assert.equal(matchesMailSession('mail-abcdef', 'abc'), false, 'mail-abc 的前缀不能吃掉 mail-abcdef');
|
||||
assert.equal(matchesMailSession('mail-abd', 'abc'), false);
|
||||
assert.equal(matchesMailSession('other-abc', 'abc'), false);
|
||||
assert.equal(matchesMailSession('', 'abc'), false);
|
||||
});
|
||||
|
||||
test("不匹配非数字后缀(避免误吞其它会话)", () => {
|
||||
assert.equal(matchesMailSession("mail-abc-rx", "abc"), false);
|
||||
assert.equal(matchesMailSession("mail-abc-r", "abc"), false);
|
||||
assert.equal(matchesMailSession("mail-abc-retry", "abc"), false);
|
||||
test('不匹配非数字后缀(避免误吞其它会话)', () => {
|
||||
assert.equal(matchesMailSession('mail-abc-rx', 'abc'), false);
|
||||
assert.equal(matchesMailSession('mail-abc-r', 'abc'), false);
|
||||
assert.equal(matchesMailSession('mail-abc-retry', 'abc'), false);
|
||||
});
|
||||
|
||||
test("pickMailSession 优先首次尝试,而不是数组顺序", () => {
|
||||
// 数组顺序可能来自 ctx.agents.list(),与尝试顺序无关
|
||||
assert.equal(
|
||||
pickMailSession(["mail-abc-r2", "mail-abc-r1", "mail-abc"], "abc"),
|
||||
"mail-abc",
|
||||
);
|
||||
test('pickMailSession 优先首次尝试,而不是数组顺序', () => {
|
||||
// 数组顺序可能来自 ctx.agents.list(),与尝试顺序无关
|
||||
assert.equal(pickMailSession(['mail-abc-r2', 'mail-abc-r1', 'mail-abc'], 'abc'), 'mail-abc');
|
||||
});
|
||||
|
||||
test("pickMailSession 没有首次尝试时取序号最小的重试", () => {
|
||||
assert.equal(
|
||||
pickMailSession(["mail-abc-r3", "mail-abc-r1", "mail-abc-r2"], "abc"),
|
||||
"mail-abc-r1",
|
||||
);
|
||||
test('pickMailSession 没有首次尝试时取序号最小的重试', () => {
|
||||
assert.equal(pickMailSession(['mail-abc-r3', 'mail-abc-r1', 'mail-abc-r2'], 'abc'), 'mail-abc-r1');
|
||||
});
|
||||
|
||||
test("pickMailSession 找不到时返回 undefined(调用方据此跳过)", () => {
|
||||
assert.equal(pickMailSession(["other-1", "mail-def"], "abc"), undefined);
|
||||
assert.equal(pickMailSession([], "abc"), undefined);
|
||||
assert.equal(pickMailSession(undefined, "abc"), undefined);
|
||||
test('pickMailSession 找不到时返回 undefined(调用方据此跳过)', () => {
|
||||
assert.equal(pickMailSession(['other-1', 'mail-def'], 'abc'), undefined);
|
||||
assert.equal(pickMailSession([], 'abc'), undefined);
|
||||
assert.equal(pickMailSession(undefined, 'abc'), undefined);
|
||||
});
|
||||
|
||||
test("接管的平台会话推不出 id:不能被误判成邮件会话", () => {
|
||||
// 平台自己生成的 id(如 DSH 界面里开的会话)与邮件会话无关,
|
||||
// 匹配函数必须说「不是」,否则会给错误的会话改档位。
|
||||
assert.equal(matchesMailSession("session-7f3a91", "abc"), false);
|
||||
assert.equal(pickMailSession(["session-7f3a91"], "abc"), undefined);
|
||||
test('接管的平台会话推不出 id:不能被误判成邮件会话', () => {
|
||||
// 平台自己生成的 id(如 DSH 界面里开的会话)与邮件会话无关,
|
||||
// 匹配函数必须说「不是」,否则会给错误的会话改档位。
|
||||
assert.equal(matchesMailSession('session-7f3a91', 'abc'), false);
|
||||
assert.equal(pickMailSession(['session-7f3a91'], 'abc'), undefined);
|
||||
});
|
||||
|
||||
@ -9,132 +9,121 @@
|
||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||
*/
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -34,61 +34,59 @@
|
||||
* setLastEventId: (id: string) => void}}
|
||||
*/
|
||||
export function createFrameParser() {
|
||||
let buffer = "";
|
||||
let lastEventId = "";
|
||||
let curEvent = "";
|
||||
let curData = "";
|
||||
let curId = "";
|
||||
let buffer = '';
|
||||
let lastEventId = '';
|
||||
let curEvent = '';
|
||||
let curData = '';
|
||||
let curId = '';
|
||||
|
||||
function push(chunk) {
|
||||
buffer += chunk;
|
||||
const events = [];
|
||||
const lines = buffer.split("\n");
|
||||
// 最后一段可能是被切断的半行,留到下一个 chunk
|
||||
buffer = lines.pop() ?? "";
|
||||
function push(chunk) {
|
||||
buffer += chunk;
|
||||
const events = [];
|
||||
const lines = buffer.split('\n');
|
||||
// 最后一段可能是被切断的半行,留到下一个 chunk
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (let line of lines) {
|
||||
if (line.length > 0 && line.charAt(line.length - 1) === "\r") {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
if (line.startsWith(":")) continue;
|
||||
for (let line of lines) {
|
||||
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
if (line.startsWith(':')) continue;
|
||||
|
||||
if (line.startsWith("id:")) {
|
||||
curId = line.slice(3).trim();
|
||||
} else if (line.startsWith("event:")) {
|
||||
curEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
let value = line.slice(5);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
curData = curData.length > 0 ? `${curData}\n${value}` : value;
|
||||
} else if (line === "") {
|
||||
if (curEvent.length > 0 && curData.length > 0) {
|
||||
if (curId.length > 0) lastEventId = curId;
|
||||
events.push({ event: curEvent, data: curData, id: curId });
|
||||
}
|
||||
curEvent = "";
|
||||
curData = "";
|
||||
curId = "";
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if (line.startsWith('id:')) {
|
||||
curId = line.slice(3).trim();
|
||||
} else if (line.startsWith('event:')) {
|
||||
curEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
let value = line.slice(5);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
curData = curData.length > 0 ? `${curData}\n${value}` : value;
|
||||
} else if (line === '') {
|
||||
if (curEvent.length > 0 && curData.length > 0) {
|
||||
if (curId.length > 0) lastEventId = curId;
|
||||
events.push({ event: curEvent, data: curData, id: curId });
|
||||
}
|
||||
curEvent = '';
|
||||
curData = '';
|
||||
curId = '';
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
buffer = "";
|
||||
curEvent = "";
|
||||
curData = "";
|
||||
curId = "";
|
||||
}
|
||||
function reset() {
|
||||
buffer = '';
|
||||
curEvent = '';
|
||||
curData = '';
|
||||
curId = '';
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
reset,
|
||||
lastEventId: () => lastEventId,
|
||||
setLastEventId: (id) => {
|
||||
lastEventId = id || "";
|
||||
},
|
||||
};
|
||||
return {
|
||||
push,
|
||||
reset,
|
||||
lastEventId: () => lastEventId,
|
||||
setLastEventId: (id) => { lastEventId = id || ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,93 +98,82 @@ export function createFrameParser() {
|
||||
* @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error)
|
||||
* @returns {{stop: () => void}} stop() 终止重连与在途请求
|
||||
*/
|
||||
export function createSSEClient({
|
||||
authHeaders,
|
||||
baseURL,
|
||||
path,
|
||||
onEvent,
|
||||
log = console.error,
|
||||
}) {
|
||||
const controller = new AbortController();
|
||||
const parser = createFrameParser();
|
||||
export function createSSEClient({ authHeaders, baseURL, path, onEvent, log = console.error }) {
|
||||
const controller = new AbortController();
|
||||
const parser = createFrameParser();
|
||||
|
||||
function stop() {
|
||||
controller.abort();
|
||||
}
|
||||
function stop() {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
function reconnect(delay) {
|
||||
if (controller.signal.aborted) return;
|
||||
setTimeout(() => connect(), delay);
|
||||
}
|
||||
function reconnect(delay) {
|
||||
if (controller.signal.aborted) return;
|
||||
setTimeout(() => connect(), delay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (controller.signal.aborted) return;
|
||||
function connect() {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const headers = { ...authHeaders(), Accept: "text/event-stream" };
|
||||
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
|
||||
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||
const lastEventID = parser.lastEventId();
|
||||
if (lastEventID) {
|
||||
headers["Last-Event-ID"] = lastEventID;
|
||||
log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
|
||||
}
|
||||
const headers = { ...authHeaders(), Accept: 'text/event-stream' };
|
||||
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
|
||||
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||
const lastEventID = parser.lastEventId();
|
||||
if (lastEventID) {
|
||||
headers['Last-Event-ID'] = lastEventID;
|
||||
log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
|
||||
}
|
||||
|
||||
fetch(`${baseURL}${path}`, { headers, signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
log(`SSE 建连失败: HTTP ${res.status}`);
|
||||
return reconnect(5000);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
fetch(`${baseURL}${path}`, { headers, signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
log(`SSE 建连失败: HTTP ${res.status}`);
|
||||
return reconnect(5000);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function read() {
|
||||
reader
|
||||
.read()
|
||||
.then(({ done, value }) => {
|
||||
if (done) {
|
||||
parser.reset();
|
||||
return reconnect(3000);
|
||||
}
|
||||
for (const ev of parser.push(
|
||||
decoder.decode(value, { stream: true }),
|
||||
)) {
|
||||
try {
|
||||
onEvent(ev.event, JSON.parse(ev.data));
|
||||
} catch (e) {
|
||||
log(`SSE 事件处理失败: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 读取中断: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 连接错误: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
function read() {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
parser.reset();
|
||||
return reconnect(3000);
|
||||
}
|
||||
for (const ev of parser.push(decoder.decode(value, { stream: true }))) {
|
||||
try {
|
||||
onEvent(ev.event, JSON.parse(ev.data));
|
||||
} catch (e) {
|
||||
log(`SSE 事件处理失败: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 读取中断: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
log(`SSE 连接错误: ${e?.message || e}`);
|
||||
parser.reset();
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
connect();
|
||||
|
||||
return {
|
||||
stop,
|
||||
/**
|
||||
* 清掉断点(不终止连接)。
|
||||
*
|
||||
* 换 Gateway 地址时必须调:lastEventID 是**旧** Gateway 环形缓冲里的序号,
|
||||
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
|
||||
* 得到的事件属于别人的会话。
|
||||
*/
|
||||
reset: () => parser.setLastEventId(""),
|
||||
};
|
||||
return {
|
||||
stop,
|
||||
/**
|
||||
* 清掉断点(不终止连接)。
|
||||
*
|
||||
* 换 Gateway 地址时必须调:lastEventID 是**旧** Gateway 环形缓冲里的序号,
|
||||
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
|
||||
* 得到的事件属于别人的会话。
|
||||
*/
|
||||
reset: () => parser.setLastEventId(''),
|
||||
};
|
||||
}
|
||||
|
||||
@ -32,25 +32,23 @@
|
||||
|
||||
/** 问题是否带可选项。 */
|
||||
export function hasOptions(question) {
|
||||
return Array.isArray(question?.options) && question.options.length > 0;
|
||||
return Array.isArray(question?.options) && question.options.length > 0;
|
||||
}
|
||||
|
||||
/** 一个 DSH 问题的可选项 label 列表(保序)。 */
|
||||
export function optionLabels(question) {
|
||||
if (!hasOptions(question)) return [];
|
||||
return question.options
|
||||
.map((o) => (typeof o === "string" ? o : o?.label))
|
||||
.filter((l) => typeof l === "string" && l.length > 0);
|
||||
if (!hasOptions(question)) return [];
|
||||
return question.options
|
||||
.map((o) => (typeof o === 'string' ? o : o?.label))
|
||||
.filter((l) => typeof l === 'string' && l.length > 0);
|
||||
}
|
||||
|
||||
/** 一个问题的展示标题:header 有就用它做前缀,否则只用 question。 */
|
||||
export function questionTitle(question) {
|
||||
const header =
|
||||
typeof question?.header === "string" ? question.header.trim() : "";
|
||||
const text =
|
||||
typeof question?.question === "string" ? question.question.trim() : "";
|
||||
if (header && text) return `${header}: ${text}`;
|
||||
return header || text || "(未提供问题)";
|
||||
const header = typeof question?.header === 'string' ? question.header.trim() : '';
|
||||
const text = typeof question?.question === 'string' ? question.question.trim() : '';
|
||||
if (header && text) return `${header}: ${text}`;
|
||||
return header || text || '(未提供问题)';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -60,56 +58,51 @@ export function questionTitle(question) {
|
||||
* @returns {{ question: string, options: string[], context: string, multiSelect: boolean }}
|
||||
*/
|
||||
export function flattenQuestions(questions) {
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
throw new Error("ask_user_question 至少需要一个 question");
|
||||
}
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
throw new Error('ask_user_question 至少需要一个 question');
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const options = [];
|
||||
const seen = new Set();
|
||||
let anyMulti = false;
|
||||
const lines = [];
|
||||
const options = [];
|
||||
const seen = new Set();
|
||||
let anyMulti = false;
|
||||
|
||||
list.forEach((q, i) => {
|
||||
const title = questionTitle(q);
|
||||
lines.push(`${i + 1}. ${title}`);
|
||||
const detail = typeof q?.detail === "string" ? q.detail.trim() : "";
|
||||
if (detail) lines.push(` ${detail}`);
|
||||
list.forEach((q, i) => {
|
||||
const title = questionTitle(q);
|
||||
lines.push(`${i + 1}. ${title}`);
|
||||
const detail = typeof q?.detail === 'string' ? q.detail.trim() : '';
|
||||
if (detail) lines.push(` ${detail}`);
|
||||
|
||||
const labels = optionLabels(q);
|
||||
if (labels.length > 0) {
|
||||
lines.push(
|
||||
` 可选项:${labels.join(" / ")}${q.multiSelect ? "(可多选)" : ""}`,
|
||||
);
|
||||
for (const label of labels) {
|
||||
if (!seen.has(label)) {
|
||||
seen.add(label);
|
||||
options.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(" (请直接填写回答)");
|
||||
}
|
||||
if (q?.multiSelect === true) anyMulti = true;
|
||||
});
|
||||
const labels = optionLabels(q);
|
||||
if (labels.length > 0) {
|
||||
lines.push(` 可选项:${labels.join(' / ')}${q.multiSelect ? '(可多选)' : ''}`);
|
||||
for (const label of labels) {
|
||||
if (!seen.has(label)) {
|
||||
seen.add(label);
|
||||
options.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(' (请直接填写回答)');
|
||||
}
|
||||
if (q?.multiSelect === true) anyMulti = true;
|
||||
});
|
||||
|
||||
// 多问题时必须允许多选:不同问题的选项要能一起勾选。
|
||||
const multiSelect = list.length > 1 ? options.length > 0 : anyMulti;
|
||||
// 多问题时必须允许多选:不同问题的选项要能一起勾选。
|
||||
const multiSelect = list.length > 1 ? options.length > 0 : anyMulti;
|
||||
|
||||
const question =
|
||||
list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`;
|
||||
const context = [
|
||||
list.length === 1 ? "" : "模型提出了多个问题,请在「回复」里一并回答:",
|
||||
...lines,
|
||||
"",
|
||||
options.length > 0
|
||||
? "可直接勾选下方的选项;补充说明写在备注里。"
|
||||
: "这题没有预设选项,请把回答写在备注里。",
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
const question = list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`;
|
||||
const context = [
|
||||
list.length === 1 ? '' : '模型提出了多个问题,请在「回复」里一并回答:',
|
||||
...lines,
|
||||
'',
|
||||
options.length > 0
|
||||
? '可直接勾选下方的选项;补充说明写在备注里。'
|
||||
: '这题没有预设选项,请把回答写在备注里。',
|
||||
].filter((l) => l !== '').join('\n');
|
||||
|
||||
return { question, options, context, multiSelect };
|
||||
return { question, options, context, multiSelect };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -121,55 +114,51 @@ export function flattenQuestions(questions) {
|
||||
* @returns {{ answers: Array<{id: string, selected: string[], custom?: string}> }}
|
||||
*/
|
||||
export function answersFromDecision(questions, decision, note) {
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
const labels = String(decision || "")
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const custom = typeof note === "string" ? note.trim() : "";
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
const labels = String(decision || '')
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const custom = typeof note === 'string' ? note.trim() : '';
|
||||
|
||||
// 单问题:忠实映射(选项 → selected,备注 → custom)。
|
||||
if (list.length === 1) {
|
||||
const q = list[0];
|
||||
const id = String(q?.id ?? "0");
|
||||
if (!hasOptions(q)) {
|
||||
// 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。
|
||||
const text = custom || labels.join("\n");
|
||||
return {
|
||||
answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
answers: [
|
||||
{
|
||||
id,
|
||||
selected: labels,
|
||||
...(custom ? { custom } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// 单问题:忠实映射(选项 → selected,备注 → custom)。
|
||||
if (list.length === 1) {
|
||||
const q = list[0];
|
||||
const id = String(q?.id ?? '0');
|
||||
if (!hasOptions(q)) {
|
||||
// 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。
|
||||
const text = custom || labels.join('\n');
|
||||
return { answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }] };
|
||||
}
|
||||
return {
|
||||
answers: [{
|
||||
id,
|
||||
selected: labels,
|
||||
...(custom ? { custom } : {}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// 多问题:按 label 归属把选择分配给各自的问题;备注归给第一个问题。
|
||||
let customUsed = false;
|
||||
const answers = list.map((q, i) => {
|
||||
const id = String(q?.id ?? String(i));
|
||||
const labels_q = optionLabels(q);
|
||||
const selected = labels.filter((l) => labels_q.includes(l));
|
||||
let qCustom;
|
||||
if (custom && !customUsed) {
|
||||
qCustom = custom;
|
||||
customUsed = true;
|
||||
}
|
||||
// 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。
|
||||
if (qCustom === undefined && !hasOptions(q) && note === undefined) {
|
||||
const text = labels.join("\n");
|
||||
if (text) qCustom = text;
|
||||
}
|
||||
return { id, selected, ...(qCustom ? { custom: qCustom } : {}) };
|
||||
});
|
||||
// 多问题:按 label 归属把选择分配给各自的问题;备注归给第一个问题。
|
||||
let customUsed = false;
|
||||
const answers = list.map((q, i) => {
|
||||
const id = String(q?.id ?? String(i));
|
||||
const labels_q = optionLabels(q);
|
||||
const selected = labels.filter((l) => labels_q.includes(l));
|
||||
let qCustom;
|
||||
if (custom && !customUsed) {
|
||||
qCustom = custom;
|
||||
customUsed = true;
|
||||
}
|
||||
// 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。
|
||||
if (qCustom === undefined && !hasOptions(q) && note === undefined) {
|
||||
const text = labels.join('\n');
|
||||
if (text) qCustom = text;
|
||||
}
|
||||
return { id, selected, ...(qCustom ? { custom: qCustom } : {}) };
|
||||
});
|
||||
|
||||
return { answers };
|
||||
return { answers };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,13 +167,10 @@ export function answersFromDecision(questions, decision, note) {
|
||||
* 允许多选时空 selected 但有 custom 也算答了;两者都空才算没答。
|
||||
*/
|
||||
export function isBlankAnswer(questions, decision, note) {
|
||||
const labels = String(decision || "")
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const custom = typeof note === "string" ? note.trim() : "";
|
||||
if (labels.length > 0 || custom) return false;
|
||||
// 全部问题都没有选项、人也没写字 → 确实什么都没答
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
return list.some((q) => hasOptions(q)) || list.length === 0;
|
||||
const labels = String(decision || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const custom = typeof note === 'string' ? note.trim() : '';
|
||||
if (labels.length > 0 || custom) return false;
|
||||
// 全部问题都没有选项、人也没写字 → 确实什么都没答
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
return list.some((q) => hasOptions(q)) || list.length === 0;
|
||||
}
|
||||
|
||||
@ -9,132 +9,121 @@
|
||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||
*/
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@ -10,179 +10,127 @@
|
||||
* - 空回答不能被当成「答了」
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
flattenQuestions,
|
||||
answersFromDecision,
|
||||
isBlankAnswer,
|
||||
hasOptions,
|
||||
optionLabels,
|
||||
questionTitle,
|
||||
} from "../lib/user-question.js";
|
||||
flattenQuestions,
|
||||
answersFromDecision,
|
||||
isBlankAnswer,
|
||||
hasOptions,
|
||||
optionLabels,
|
||||
questionTitle,
|
||||
} from '../lib/user-question.js';
|
||||
|
||||
test("单问题单选项:忠实映射问题与选项", () => {
|
||||
const flat = flattenQuestions([
|
||||
{
|
||||
id: "q1",
|
||||
question: "用哪种方案?",
|
||||
header: "选择",
|
||||
options: [{ label: "方案 A" }, { label: "方案 B" }],
|
||||
},
|
||||
]);
|
||||
assert.equal(flat.question, "选择: 用哪种方案?");
|
||||
assert.deepEqual(flat.options, ["方案 A", "方案 B"]);
|
||||
assert.equal(flat.multiSelect, false);
|
||||
test('单问题单选项:忠实映射问题与选项', () => {
|
||||
const flat = flattenQuestions([{
|
||||
id: 'q1',
|
||||
question: '用哪种方案?',
|
||||
header: '选择',
|
||||
options: [{ label: '方案 A' }, { label: '方案 B' }],
|
||||
}]);
|
||||
assert.equal(flat.question, '选择: 用哪种方案?');
|
||||
assert.deepEqual(flat.options, ['方案 A', '方案 B']);
|
||||
assert.equal(flat.multiSelect, false);
|
||||
});
|
||||
|
||||
test("单问题多选:multiSelect 透传", () => {
|
||||
const flat = flattenQuestions([
|
||||
{
|
||||
id: "q1",
|
||||
question: "要哪些?",
|
||||
options: [{ label: "a" }, { label: "b" }],
|
||||
multiSelect: true,
|
||||
},
|
||||
]);
|
||||
assert.equal(flat.multiSelect, true);
|
||||
test('单问题多选:multiSelect 透传', () => {
|
||||
const flat = flattenQuestions([{
|
||||
id: 'q1',
|
||||
question: '要哪些?',
|
||||
options: [{ label: 'a' }, { label: 'b' }],
|
||||
multiSelect: true,
|
||||
}]);
|
||||
assert.equal(flat.multiSelect, true);
|
||||
});
|
||||
|
||||
test("多问题:选项取并集且去重保序,multiSelect 置真", () => {
|
||||
const flat = flattenQuestions([
|
||||
{
|
||||
id: "q1",
|
||||
question: "前端?",
|
||||
options: [{ label: "React" }, { label: "Vue" }],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
question: "后端?",
|
||||
options: [{ label: "Vue" }, { label: "Go" }],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
flat.options,
|
||||
["React", "Vue", "Go"],
|
||||
"重复 label 只出现一次",
|
||||
);
|
||||
assert.equal(
|
||||
flat.multiSelect,
|
||||
true,
|
||||
"多问题必须允许多选,否则无法同时回答两题",
|
||||
);
|
||||
assert.match(flat.context, /前端?/);
|
||||
assert.match(flat.context, /后端?/);
|
||||
test('多问题:选项取并集且去重保序,multiSelect 置真', () => {
|
||||
const flat = flattenQuestions([
|
||||
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
|
||||
{ id: 'q2', question: '后端?', options: [{ label: 'Vue' }, { label: 'Go' }] },
|
||||
]);
|
||||
assert.deepEqual(flat.options, ['React', 'Vue', 'Go'], '重复 label 只出现一次');
|
||||
assert.equal(flat.multiSelect, true, '多问题必须允许多选,否则无法同时回答两题');
|
||||
assert.match(flat.context, /前端?/);
|
||||
assert.match(flat.context, /后端?/);
|
||||
});
|
||||
|
||||
test("无选项题:options 为空,正文提示直接填写", () => {
|
||||
const flat = flattenQuestions([{ id: "q1", question: "你的名字?" }]);
|
||||
assert.deepEqual(flat.options, []);
|
||||
assert.match(flat.context, /直接填写|没有预设选项/);
|
||||
test('无选项题:options 为空,正文提示直接填写', () => {
|
||||
const flat = flattenQuestions([{ id: 'q1', question: '你的名字?' }]);
|
||||
assert.deepEqual(flat.options, []);
|
||||
assert.match(flat.context, /直接填写|没有预设选项/);
|
||||
});
|
||||
|
||||
test("空问题列表:抛错而不是造一封没有内容的信", () => {
|
||||
assert.throws(() => flattenQuestions([]), /至少需要一个/);
|
||||
assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
|
||||
test('空问题列表:抛错而不是造一封没有内容的信', () => {
|
||||
assert.throws(() => flattenQuestions([]), /至少需要一个/);
|
||||
assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
|
||||
});
|
||||
|
||||
test("单问题回写:选项进 selected,备注进 custom", () => {
|
||||
const qs = [
|
||||
{ id: "q1", question: "选哪个", options: [{ label: "A" }, { label: "B" }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, "A", "再确认下");
|
||||
assert.deepEqual(ans.answers, [
|
||||
{ id: "q1", selected: ["A"], custom: "再确认下" },
|
||||
]);
|
||||
test('单问题回写:选项进 selected,备注进 custom', () => {
|
||||
const qs = [{ id: 'q1', question: '选哪个', options: [{ label: 'A' }, { label: 'B' }] }];
|
||||
const ans = answersFromDecision(qs, 'A', '再确认下');
|
||||
assert.deepEqual(ans.answers, [{ id: 'q1', selected: ['A'], custom: '再确认下' }]);
|
||||
});
|
||||
|
||||
test("单问题多选回写:多行决策拆成多个 selected", () => {
|
||||
const qs = [
|
||||
{
|
||||
id: "q1",
|
||||
question: "选哪些",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
multiSelect: true,
|
||||
},
|
||||
];
|
||||
const ans = answersFromDecision(qs, "A\nB", "");
|
||||
assert.deepEqual(ans.answers[0].selected, ["A", "B"]);
|
||||
assert.equal(ans.answers[0].custom, undefined, "空备注不该变成空 custom");
|
||||
test('单问题多选回写:多行决策拆成多个 selected', () => {
|
||||
const qs = [{ id: 'q1', question: '选哪些', options: [{ label: 'A' }, { label: 'B' }], multiSelect: true }];
|
||||
const ans = answersFromDecision(qs, 'A\nB', '');
|
||||
assert.deepEqual(ans.answers[0].selected, ['A', 'B']);
|
||||
assert.equal(ans.answers[0].custom, undefined, '空备注不该变成空 custom');
|
||||
});
|
||||
|
||||
test("无选项题回写:答案进 custom,selected 为空", () => {
|
||||
const qs = [{ id: "q1", question: "名字?" }];
|
||||
const ans = answersFromDecision(qs, "", "张三");
|
||||
assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "张三" }]);
|
||||
test('无选项题回写:答案进 custom,selected 为空', () => {
|
||||
const qs = [{ id: 'q1', question: '名字?' }];
|
||||
const ans = answersFromDecision(qs, '', '张三');
|
||||
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '张三' }]);
|
||||
});
|
||||
|
||||
test("无选项题只有决策文本时:文本进 custom(否则答案永远为空)", () => {
|
||||
const qs = [{ id: "q1", question: "名字?" }];
|
||||
const ans = answersFromDecision(qs, "李四", undefined);
|
||||
assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "李四" }]);
|
||||
test('无选项题只有决策文本时:文本进 custom(否则答案永远为空)', () => {
|
||||
const qs = [{ id: 'q1', question: '名字?' }];
|
||||
const ans = answersFromDecision(qs, '李四', undefined);
|
||||
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '李四' }]);
|
||||
});
|
||||
|
||||
test("多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)", () => {
|
||||
const qs = [
|
||||
{
|
||||
id: "q1",
|
||||
question: "前端?",
|
||||
options: [{ label: "React" }, { label: "Vue" }],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
question: "后端?",
|
||||
options: [{ label: "Go" }, { label: "Rust" }],
|
||||
},
|
||||
];
|
||||
const ans = answersFromDecision(qs, "Vue\nGo", "都行");
|
||||
assert.deepEqual(ans.answers[0].selected, ["Vue"], "q1 只拿前端的选择");
|
||||
assert.deepEqual(ans.answers[1].selected, ["Go"], "q2 只拿后端的选择");
|
||||
assert.equal(ans.answers[0].custom, "都行", "备注归第一个问题");
|
||||
assert.equal(ans.answers[1].custom, undefined, "备注不重复分发");
|
||||
test('多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)', () => {
|
||||
const qs = [
|
||||
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
|
||||
{ id: 'q2', question: '后端?', options: [{ label: 'Go' }, { label: 'Rust' }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, 'Vue\nGo', '都行');
|
||||
assert.deepEqual(ans.answers[0].selected, ['Vue'], 'q1 只拿前端的选择');
|
||||
assert.deepEqual(ans.answers[1].selected, ['Go'], 'q2 只拿后端的选择');
|
||||
assert.equal(ans.answers[0].custom, '都行', '备注归第一个问题');
|
||||
assert.equal(ans.answers[1].custom, undefined, '备注不重复分发');
|
||||
});
|
||||
|
||||
test("多问题里认不出的 label:不匹配任何问题,不猜测放行", () => {
|
||||
const qs = [
|
||||
{ id: "q1", question: "a", options: [{ label: "X" }] },
|
||||
{ id: "q2", question: "b", options: [{ label: "Y" }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, "Z", "");
|
||||
assert.deepEqual(
|
||||
ans.answers[0].selected,
|
||||
[],
|
||||
"认不出的 label 不得被塞进任意问题",
|
||||
);
|
||||
assert.deepEqual(ans.answers[1].selected, []);
|
||||
test('多问题里认不出的 label:不匹配任何问题,不猜测放行', () => {
|
||||
const qs = [
|
||||
{ id: 'q1', question: 'a', options: [{ label: 'X' }] },
|
||||
{ id: 'q2', question: 'b', options: [{ label: 'Y' }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, 'Z', '');
|
||||
assert.deepEqual(ans.answers[0].selected, [], '认不出的 label 不得被塞进任意问题');
|
||||
assert.deepEqual(ans.answers[1].selected, []);
|
||||
});
|
||||
|
||||
test("answers 的 id 与问题一一对应", () => {
|
||||
const qs = [
|
||||
{ id: "alpha", question: "a", options: [{ label: "X" }] },
|
||||
{ id: "beta", question: "b" },
|
||||
];
|
||||
const ans = answersFromDecision(qs, "X", "note");
|
||||
assert.deepEqual(
|
||||
ans.answers.map((a) => a.id),
|
||||
["alpha", "beta"],
|
||||
);
|
||||
test('answers 的 id 与问题一一对应', () => {
|
||||
const qs = [{ id: 'alpha', question: 'a', options: [{ label: 'X' }] }, { id: 'beta', question: 'b' }];
|
||||
const ans = answersFromDecision(qs, 'X', 'note');
|
||||
assert.deepEqual(ans.answers.map((a) => a.id), ['alpha', 'beta']);
|
||||
});
|
||||
|
||||
test("空回答判定:有选项的问题什么都没选 = 空", () => {
|
||||
const qs = [{ id: "q1", question: "a", options: [{ label: "X" }] }];
|
||||
assert.equal(isBlankAnswer(qs, "", ""), true);
|
||||
assert.equal(isBlankAnswer(qs, "X", ""), false);
|
||||
assert.equal(isBlankAnswer(qs, "", "自由文本"), false, "自由文本也算答了");
|
||||
test('空回答判定:有选项的问题什么都没选 = 空', () => {
|
||||
const qs = [{ id: 'q1', question: 'a', options: [{ label: 'X' }] }];
|
||||
assert.equal(isBlankAnswer(qs, '', ''), true);
|
||||
assert.equal(isBlankAnswer(qs, 'X', ''), false);
|
||||
assert.equal(isBlankAnswer(qs, '', '自由文本'), false, '自由文本也算答了');
|
||||
});
|
||||
|
||||
test("hasOptions / optionLabels / questionTitle 的边界", () => {
|
||||
assert.equal(hasOptions({}), false);
|
||||
assert.equal(hasOptions({ options: [] }), false);
|
||||
assert.equal(hasOptions({ options: [{ label: "a" }] }), true);
|
||||
assert.deepEqual(
|
||||
optionLabels({ options: ["a", { label: "b" }, { description: "x" }] }),
|
||||
["a", "b"],
|
||||
);
|
||||
assert.equal(questionTitle({ question: "只问一句" }), "只问一句");
|
||||
assert.equal(questionTitle({}), "(未提供问题)");
|
||||
test('hasOptions / optionLabels / questionTitle 的边界', () => {
|
||||
assert.equal(hasOptions({}), false);
|
||||
assert.equal(hasOptions({ options: [] }), false);
|
||||
assert.equal(hasOptions({ options: [{ label: 'a' }] }), true);
|
||||
assert.deepEqual(optionLabels({ options: ['a', { label: 'b' }, { description: 'x' }] }), ['a', 'b']);
|
||||
assert.equal(questionTitle({ question: '只问一句' }), '只问一句');
|
||||
assert.equal(questionTitle({}), '(未提供问题)');
|
||||
});
|
||||
|
||||
@ -5,17 +5,16 @@
|
||||
* 本文件只管认证头、密钥解析与坐标变更。
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createSSEClient } from "../lib/sse-client.js";
|
||||
import { createSSEClient } from '../lib/sse-client.js';
|
||||
|
||||
const CONFIG_DIR =
|
||||
process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail");
|
||||
export const KEY_FILE = join(CONFIG_DIR, "agent.key");
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||||
export const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||
|
||||
/**
|
||||
* 把管理员给的密钥落盘(0600)。
|
||||
@ -24,29 +23,23 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||
* 否则重启后又回到无法连接的状态 —— 而那正是这个工具要解决的问题。
|
||||
*/
|
||||
export function saveLocalKey(token) {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify(
|
||||
{ key_token: token, created_at: new Date().toISOString() },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
|
||||
export function readLocalKey() {
|
||||
try {
|
||||
if (!existsSync(KEY_FILE)) return null;
|
||||
const raw = JSON.parse(readFileSync(KEY_FILE, "utf8"));
|
||||
return typeof raw?.key_token === "string" && raw.key_token
|
||||
? raw.key_token
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!existsSync(KEY_FILE)) return null;
|
||||
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
|
||||
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,187 +49,164 @@ export function readLocalKey() {
|
||||
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。
|
||||
*/
|
||||
export function generateLocalKey(log = console.error) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify(
|
||||
{ key_token: token, created_at: new Date().toISOString() },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
|
||||
log(`已在 ${KEY_FILE} 生成本地密钥。`);
|
||||
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
|
||||
log(` ${token}`);
|
||||
return token;
|
||||
const token = randomBytes(32).toString('hex');
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
|
||||
log(`已在 ${KEY_FILE} 生成本地密钥。`);
|
||||
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
|
||||
log(` ${token}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 把 gateway 地址与身份记到 config.json,便于换机时人工核对。 */
|
||||
export function saveConfig(extra) {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
let cur = {};
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
||||
} catch {
|
||||
/* 损坏就重写 */
|
||||
}
|
||||
}
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[pi-mail-bridge] 写 config.json 失败:", e?.message || e);
|
||||
}
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
let cur = {};
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ }
|
||||
}
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 });
|
||||
} catch (e) {
|
||||
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
/**
|
||||
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
|
||||
*/
|
||||
constructor({ url, agentName, agentKey, agentSecret }) {
|
||||
this.baseURL = String(url || "http://127.0.0.1:8180").replace(/\/+$/, "");
|
||||
this.agentName = agentName;
|
||||
this.agentKey = agentKey || "";
|
||||
this.agentSecret = agentSecret || "";
|
||||
this.sseClient = null;
|
||||
}
|
||||
/**
|
||||
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
|
||||
*/
|
||||
constructor({ url, agentName, agentKey, agentSecret }) {
|
||||
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, '');
|
||||
this.agentName = agentName;
|
||||
this.agentKey = agentKey || '';
|
||||
this.agentSecret = agentSecret || '';
|
||||
this.sseClient = null;
|
||||
}
|
||||
|
||||
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
||||
authHeaders() {
|
||||
if (this.agentKey) {
|
||||
return {
|
||||
Authorization: `Bearer ${this.agentKey}`,
|
||||
"X-Agent-Name": this.agentName,
|
||||
};
|
||||
}
|
||||
return {
|
||||
"X-Agent-Name": this.agentName,
|
||||
"X-Agent-Secret": this.agentSecret,
|
||||
};
|
||||
}
|
||||
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
||||
authHeaders() {
|
||||
if (this.agentKey) {
|
||||
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
|
||||
}
|
||||
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
async get(path) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async post(path, body) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...this.authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(
|
||||
data?.error || `POST ${path} 失败: HTTP ${res.status}`,
|
||||
);
|
||||
err.status = res.status;
|
||||
// 响应体也带上:服务端对 409 会给 detail/suggestion,
|
||||
// 那些文字要原文转给模型(它据此决定换什么做法)。
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
async post(path, body) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
// 响应体也带上:服务端对 409 会给 detail/suggestion,
|
||||
// 那些文字要原文转给模型(它据此决定换什么做法)。
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
|
||||
async register() {
|
||||
return this.post("/agent/register", {
|
||||
name: this.agentName,
|
||||
secret: this.agentSecret || "",
|
||||
workspaces: [],
|
||||
platform: "pi",
|
||||
});
|
||||
}
|
||||
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
|
||||
async register() {
|
||||
return this.post('/agent/register', {
|
||||
name: this.agentName,
|
||||
secret: this.agentSecret || '',
|
||||
workspaces: [],
|
||||
platform: 'pi',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传附件。
|
||||
*
|
||||
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`,
|
||||
* 且**不认 `X-Filename` 头**(grep 过 handler/attachments.go,没有这个分支)。
|
||||
* 直接 POST 二进制体会得到 400「缺少 file 字段」。
|
||||
*
|
||||
* 不手动设 Content-Type:让 undici 按 FormData 自己生成 boundary。
|
||||
*/
|
||||
async uploadFile(buf, filename) {
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([buf]), filename);
|
||||
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
|
||||
method: "POST",
|
||||
headers: this.authHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
|
||||
return data.attachment;
|
||||
}
|
||||
/**
|
||||
* 上传附件。
|
||||
*
|
||||
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`,
|
||||
* 且**不认 `X-Filename` 头**(grep 过 handler/attachments.go,没有这个分支)。
|
||||
* 直接 POST 二进制体会得到 400「缺少 file 字段」。
|
||||
*
|
||||
* 不手动设 Content-Type:让 undici 按 FormData 自己生成 boundary。
|
||||
*/
|
||||
async uploadFile(buf, filename) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([buf]), filename);
|
||||
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
|
||||
return data.attachment;
|
||||
}
|
||||
|
||||
async downloadFile(attachmentID) {
|
||||
const res = await fetch(
|
||||
`${this.baseURL}/api/v1/attachments/${attachmentID}`,
|
||||
{
|
||||
headers: this.authHeaders(),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
async downloadFile(attachmentID) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 SSE 长连并自动重连。
|
||||
*
|
||||
* 实现委托给共用模块 `lib/sse-client.js`(三桥逐字节同源,由
|
||||
* deploy/check-shared-libs.sh 校验)—— 那里把「跨 TCP 分片保帧状态」与
|
||||
* 「Last-Event-ID 断点续传」两件事写对了一次,不必每个平台各抄一遍。
|
||||
*
|
||||
* 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲,
|
||||
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
|
||||
* 首次连接**不带**(N-11):那会让服务端把缓冲区里的旧事件全回放一遍。
|
||||
*/
|
||||
startSSE(onEvent, log = console.error) {
|
||||
this.sseClient?.stop?.();
|
||||
this.sseClient = createSSEClient({
|
||||
authHeaders: () => this.authHeaders(),
|
||||
baseURL: this.baseURL,
|
||||
path: "/api/v1/events/stream",
|
||||
onEvent,
|
||||
log,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 建立 SSE 长连并自动重连。
|
||||
*
|
||||
* 实现委托给共用模块 `lib/sse-client.js`(三桥逐字节同源,由
|
||||
* deploy/check-shared-libs.sh 校验)—— 那里把「跨 TCP 分片保帧状态」与
|
||||
* 「Last-Event-ID 断点续传」两件事写对了一次,不必每个平台各抄一遍。
|
||||
*
|
||||
* 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲,
|
||||
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
|
||||
* 首次连接**不带**(N-11):那会让服务端把缓冲区里的旧事件全回放一遍。
|
||||
*/
|
||||
startSSE(onEvent, log = console.error) {
|
||||
this.sseClient?.stop?.();
|
||||
this.sseClient = createSSEClient({
|
||||
authHeaders: () => this.authHeaders(),
|
||||
baseURL: this.baseURL,
|
||||
path: '/api/v1/events/stream',
|
||||
onEvent,
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 换 Gateway 地址或换密钥。
|
||||
*
|
||||
* 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中**
|
||||
* 调的,它期望调完就能收信。所以这里除了改字段还要重置断点:
|
||||
* `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会
|
||||
* 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。
|
||||
*
|
||||
* @returns {boolean} 是否真的变了(没变就不必重连 SSE,省一次断流)
|
||||
*/
|
||||
reconfigure({ url, agentKey }) {
|
||||
const nextURL = url ? String(url).replace(/\/+$/, "") : this.baseURL;
|
||||
const nextKey = agentKey || this.agentKey;
|
||||
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
|
||||
if (!changed) return false;
|
||||
/**
|
||||
* 换 Gateway 地址或换密钥。
|
||||
*
|
||||
* 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中**
|
||||
* 调的,它期望调完就能收信。所以这里除了改字段还要重置断点:
|
||||
* `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会
|
||||
* 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。
|
||||
*
|
||||
* @returns {boolean} 是否真的变了(没变就不必重连 SSE,省一次断流)
|
||||
*/
|
||||
reconfigure({ url, agentKey }) {
|
||||
const nextURL = url ? String(url).replace(/\/+$/, '') : this.baseURL;
|
||||
const nextKey = agentKey || this.agentKey;
|
||||
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
|
||||
if (!changed) return false;
|
||||
|
||||
if (nextURL !== this.baseURL) this.sseClient?.reset?.();
|
||||
this.baseURL = nextURL;
|
||||
this.agentKey = nextKey;
|
||||
return true;
|
||||
}
|
||||
if (nextURL !== this.baseURL) this.sseClient?.reset?.();
|
||||
this.baseURL = nextURL;
|
||||
this.agentKey = nextKey;
|
||||
return true;
|
||||
}
|
||||
|
||||
stopSSE() {
|
||||
this.sseClient?.stop?.();
|
||||
this.sseClient = null;
|
||||
}
|
||||
stopSSE() {
|
||||
this.sseClient?.stop?.();
|
||||
this.sseClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,16 +56,12 @@
|
||||
* 一处只增不减的结构。
|
||||
*/
|
||||
|
||||
import { fork } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fork } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
BoundedMap,
|
||||
BoundedSet,
|
||||
MAX_TRACKED_SESSIONS,
|
||||
} from "../lib/bounded.js";
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
|
||||
const WORKER_PATH = fileURLToPath(new URL("./worker.mjs", import.meta.url));
|
||||
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
@ -81,328 +77,277 @@ const WORKER_PATH = fileURLToPath(new URL("./worker.mjs", import.meta.url));
|
||||
* 让调度不变量(并发上限、同会话串行、硬超时)能在毫秒级验证。
|
||||
*/
|
||||
export function createWorkerPool({
|
||||
log,
|
||||
config,
|
||||
onReconfigure,
|
||||
maxWorkers = 3,
|
||||
workerMaxMs = 600_000,
|
||||
maxAttempts = 3,
|
||||
workerPath = WORKER_PATH,
|
||||
log, config, onReconfigure,
|
||||
maxWorkers = 3, workerMaxMs = 600_000, maxAttempts = 3, workerPath = WORKER_PATH,
|
||||
}) {
|
||||
/** 正在跑的 worker:mailSessionKey -> {child, mailID, startedAt, timer} */
|
||||
const running = new Map();
|
||||
/** 等着派的活,先进先出。 */
|
||||
const queue = [];
|
||||
/** relay_key -> mailSessionKey,把决策路由回发起询问的那个 worker。 */
|
||||
const permissionRoutes = new Map();
|
||||
/**
|
||||
* 跨 worker 存活的会话状态:mailSessionKey -> {sessionFile, cwd, piSessionId,
|
||||
* grants:Set, lastSyncedName}。
|
||||
*
|
||||
* 这是 worker 一封一进程之后仍需在主进程留存的全部东西 —— 下一封邮件靠
|
||||
* sessionFile 接着谈,靠 grants 不重复问已经「一直同意」过的工具。
|
||||
*/
|
||||
const sessionState = new BoundedMap(MAX_TRACKED_SESSIONS);
|
||||
/**
|
||||
* 被模型降级换掉的旧 pi 会话 id。
|
||||
*
|
||||
* 仍要计入 mail_driven:它们已经参与过邮件往来,而磁盘上的会话文件
|
||||
* 不会因为换模型而消失 —— 心跳快照仍会上报它们。
|
||||
*/
|
||||
const retired = new BoundedSet(MAX_TRACKED_SESSIONS);
|
||||
let stopped = false;
|
||||
/** 正在跑的 worker:mailSessionKey -> {child, mailID, startedAt, timer} */
|
||||
const running = new Map();
|
||||
/** 等着派的活,先进先出。 */
|
||||
const queue = [];
|
||||
/** relay_key -> mailSessionKey,把决策路由回发起询问的那个 worker。 */
|
||||
const permissionRoutes = new Map();
|
||||
/**
|
||||
* 跨 worker 存活的会话状态:mailSessionKey -> {sessionFile, cwd, piSessionId,
|
||||
* grants:Set, lastSyncedName}。
|
||||
*
|
||||
* 这是 worker 一封一进程之后仍需在主进程留存的全部东西 —— 下一封邮件靠
|
||||
* sessionFile 接着谈,靠 grants 不重复问已经「一直同意」过的工具。
|
||||
*/
|
||||
const sessionState = new BoundedMap(MAX_TRACKED_SESSIONS);
|
||||
/**
|
||||
* 被模型降级换掉的旧 pi 会话 id。
|
||||
*
|
||||
* 仍要计入 mail_driven:它们已经参与过邮件往来,而磁盘上的会话文件
|
||||
* 不会因为换模型而消失 —— 心跳快照仍会上报它们。
|
||||
*/
|
||||
const retired = new BoundedSet(MAX_TRACKED_SESSIONS);
|
||||
let stopped = false;
|
||||
|
||||
/**
|
||||
* 邮件会话 id 作为串行化的键。
|
||||
*
|
||||
* 没有 session_id 的事件(理论上不该有)退回 mail_id:那样每封各占一个
|
||||
* worker,不会串行 —— 但它们本来也不属于同一条会话。
|
||||
*/
|
||||
const keyOf = (data) =>
|
||||
data?.session_id || `mail:${data?.mail_id || Math.random()}`;
|
||||
/**
|
||||
* 邮件会话 id 作为串行化的键。
|
||||
*
|
||||
* 没有 session_id 的事件(理论上不该有)退回 mail_id:那样每封各占一个
|
||||
* worker,不会串行 —— 但它们本来也不属于同一条会话。
|
||||
*/
|
||||
const keyOf = (data) => data?.session_id || `mail:${data?.mail_id || Math.random()}`;
|
||||
|
||||
function submit(kind, data, attempt = 1) {
|
||||
if (stopped) return;
|
||||
queue.push({ kind, data, key: keyOf(data), attempt });
|
||||
pump();
|
||||
}
|
||||
function submit(kind, data, attempt = 1) {
|
||||
if (stopped) return;
|
||||
queue.push({ kind, data, key: keyOf(data), attempt });
|
||||
pump();
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (stopped) return;
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const job = queue[i];
|
||||
// 同一会话已有 worker 在跑 → 跳过它,看后面有没有别的会话可以先跑。
|
||||
// 不能 break:那会让一条慢会话把所有别的会话都堵住(正是要修的病)。
|
||||
if (running.has(job.key)) continue;
|
||||
if (running.size >= maxWorkers) return;
|
||||
queue.splice(i, 1);
|
||||
i--;
|
||||
spawn(job);
|
||||
}
|
||||
}
|
||||
function pump() {
|
||||
if (stopped) return;
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const job = queue[i];
|
||||
// 同一会话已有 worker 在跑 → 跳过它,看后面有没有别的会话可以先跑。
|
||||
// 不能 break:那会让一条慢会话把所有别的会话都堵住(正是要修的病)。
|
||||
if (running.has(job.key)) continue;
|
||||
if (running.size >= maxWorkers) return;
|
||||
queue.splice(i, 1);
|
||||
i--;
|
||||
spawn(job);
|
||||
}
|
||||
}
|
||||
|
||||
function spawn(job) {
|
||||
const state = sessionState.get(job.key) || {
|
||||
grants: new Set(),
|
||||
lastSyncedName: "",
|
||||
};
|
||||
const child = fork(workerPath, [], {
|
||||
// stdio 继承:worker 里 pi SDK 自己打的东西直接进 journalctl。
|
||||
// 'ipc' 必须显式列出,否则 process.send 不存在。
|
||||
stdio: ["ignore", "inherit", "inherit", "ipc"],
|
||||
});
|
||||
function spawn(job) {
|
||||
const state = sessionState.get(job.key) || { grants: new Set(), lastSyncedName: '' };
|
||||
const child = fork(workerPath, [], {
|
||||
// stdio 继承:worker 里 pi SDK 自己打的东西直接进 journalctl。
|
||||
// 'ipc' 必须显式列出,否则 process.send 不存在。
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
});
|
||||
|
||||
// 硬超时:worker 卡死(模型不返回、权限等不到决策而主进程也没收到事件)
|
||||
// 时必须能回收,否则那条会话的后续邮件永远排队。
|
||||
const timer = setTimeout(() => {
|
||||
log(
|
||||
`worker ${child.pid} 处理 ${job.data?.mail_id} 超过 ${workerMaxMs / 1000}s,强杀`,
|
||||
);
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* 已经死了 */
|
||||
}
|
||||
}, workerMaxMs);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
// 硬超时:worker 卡死(模型不返回、权限等不到决策而主进程也没收到事件)
|
||||
// 时必须能回收,否则那条会话的后续邮件永远排队。
|
||||
const timer = setTimeout(() => {
|
||||
log(`worker ${child.pid} 处理 ${job.data?.mail_id} 超过 ${workerMaxMs / 1000}s,强杀`);
|
||||
try { child.kill('SIGKILL'); } catch { /* 已经死了 */ }
|
||||
}, workerMaxMs);
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
|
||||
const entry = {
|
||||
child,
|
||||
mailID: job.data?.mail_id || "",
|
||||
key: job.key,
|
||||
startedAt: Date.now(),
|
||||
timer,
|
||||
settled: false,
|
||||
};
|
||||
running.set(job.key, entry);
|
||||
const entry = {
|
||||
child, mailID: job.data?.mail_id || '', key: job.key,
|
||||
startedAt: Date.now(), timer, settled: false,
|
||||
};
|
||||
running.set(job.key, entry);
|
||||
|
||||
child.on("message", (msg) => onWorkerMessage(entry, msg));
|
||||
child.on('message', (msg) => onWorkerMessage(entry, msg));
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
running.delete(job.key);
|
||||
for (const [rk, k] of permissionRoutes)
|
||||
if (k === job.key) permissionRoutes.delete(rk);
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
running.delete(job.key);
|
||||
for (const [rk, k] of permissionRoutes) if (k === job.key) permissionRoutes.delete(rk);
|
||||
|
||||
// 没收到 `done` 就退出 = 这封邮件**从未处理完**。
|
||||
//
|
||||
// 这是生产上真实存在的静默丢信路径:worker 被 SIGKILL(硬超时)、
|
||||
// OOM、或自己崩溃时,`done` 永远不会到达,主进程只看到 exit code。
|
||||
// 原来这里只记一行日志就 pump() —— 发件人看到信发出去了,
|
||||
// 而那条会话再也不会有人回。
|
||||
//
|
||||
// 重投而不是直接由主进程回信:worker 崩溃可能是内存/上游瞬时故障,
|
||||
// 重启一个进程真能跑通。有界(maxAttempts)是因为「必定失败」的邮件
|
||||
// 无界重投会变成永久活锁,而日志里只有一行看不出是同一封在原地打转。
|
||||
if (!entry.settled && !stopped) {
|
||||
const attempt = job.attempt || 1;
|
||||
if (attempt < maxAttempts) {
|
||||
const delay = attempt * 1000;
|
||||
log(
|
||||
`worker ${child.pid}(mail ${entry.mailID})未回报 done 就退出` +
|
||||
`(code=${code} signal=${signal || "-"}),${delay / 1000}s 后` +
|
||||
`第 ${attempt + 1}/${maxAttempts} 次重投`,
|
||||
);
|
||||
const retry = setTimeout(() => {
|
||||
if (stopped) return;
|
||||
queue.push({ ...job, attempt: attempt + 1 });
|
||||
pump();
|
||||
}, delay);
|
||||
if (typeof retry.unref === "function") retry.unref();
|
||||
// 退避期间不 pump:否则同一会话会被立刻重投,退避形同虚设
|
||||
return;
|
||||
}
|
||||
log(
|
||||
`worker ${child.pid}(mail ${entry.mailID})重投 ${maxAttempts} 次仍未完成,放弃` +
|
||||
`(code=${code} signal=${signal || "-"})`,
|
||||
);
|
||||
} else if (code !== 0) {
|
||||
log(
|
||||
`worker ${child.pid}(mail ${entry.mailID})异常退出 code=${code} signal=${signal || "-"}`,
|
||||
);
|
||||
}
|
||||
pump();
|
||||
});
|
||||
// 没收到 `done` 就退出 = 这封邮件**从未处理完**。
|
||||
//
|
||||
// 这是生产上真实存在的静默丢信路径:worker 被 SIGKILL(硬超时)、
|
||||
// OOM、或自己崩溃时,`done` 永远不会到达,主进程只看到 exit code。
|
||||
// 原来这里只记一行日志就 pump() —— 发件人看到信发出去了,
|
||||
// 而那条会话再也不会有人回。
|
||||
//
|
||||
// 重投而不是直接由主进程回信:worker 崩溃可能是内存/上游瞬时故障,
|
||||
// 重启一个进程真能跑通。有界(maxAttempts)是因为「必定失败」的邮件
|
||||
// 无界重投会变成永久活锁,而日志里只有一行看不出是同一封在原地打转。
|
||||
if (!entry.settled && !stopped) {
|
||||
const attempt = job.attempt || 1;
|
||||
if (attempt < maxAttempts) {
|
||||
const delay = attempt * 1000;
|
||||
log(`worker ${child.pid}(mail ${entry.mailID})未回报 done 就退出`
|
||||
+ `(code=${code} signal=${signal || '-'}),${delay / 1000}s 后`
|
||||
+ `第 ${attempt + 1}/${maxAttempts} 次重投`);
|
||||
const retry = setTimeout(() => {
|
||||
if (stopped) return;
|
||||
queue.push({ ...job, attempt: attempt + 1 });
|
||||
pump();
|
||||
}, delay);
|
||||
if (typeof retry.unref === 'function') retry.unref();
|
||||
// 退避期间不 pump:否则同一会话会被立刻重投,退避形同虚设
|
||||
return;
|
||||
}
|
||||
log(`worker ${child.pid}(mail ${entry.mailID})重投 ${maxAttempts} 次仍未完成,放弃`
|
||||
+ `(code=${code} signal=${signal || '-'})`);
|
||||
} else if (code !== 0) {
|
||||
log(`worker ${child.pid}(mail ${entry.mailID})异常退出 code=${code} signal=${signal || '-'}`);
|
||||
}
|
||||
pump();
|
||||
});
|
||||
|
||||
child.on("error", (e) =>
|
||||
log(`worker ${child.pid} 出错: ${e?.message || e}`),
|
||||
);
|
||||
child.on('error', (e) => log(`worker ${child.pid} 出错: ${e?.message || e}`));
|
||||
|
||||
// 等 worker 说 ready 再派活:fork 返回时子进程的 import 还没跑完,
|
||||
// 此时 send 的消息会排在 IPC 队列里(能收到,但 ready 让顺序确定)。
|
||||
child.once("message", function first(msg) {
|
||||
if (msg?.type !== "ready") return;
|
||||
child.send({
|
||||
type: "job",
|
||||
kind: job.kind,
|
||||
data: job.data,
|
||||
session: {
|
||||
sessionFile: state.sessionFile || "",
|
||||
cwd: state.cwd || "",
|
||||
},
|
||||
grants: [...state.grants],
|
||||
lastSyncedName: state.lastSyncedName || "",
|
||||
config: config(),
|
||||
});
|
||||
});
|
||||
}
|
||||
// 等 worker 说 ready 再派活:fork 返回时子进程的 import 还没跑完,
|
||||
// 此时 send 的消息会排在 IPC 队列里(能收到,但 ready 让顺序确定)。
|
||||
child.once('message', function first(msg) {
|
||||
if (msg?.type !== 'ready') return;
|
||||
child.send({
|
||||
type: 'job',
|
||||
kind: job.kind,
|
||||
data: job.data,
|
||||
session: {
|
||||
sessionFile: state.sessionFile || '',
|
||||
cwd: state.cwd || '',
|
||||
},
|
||||
grants: [...state.grants],
|
||||
lastSyncedName: state.lastSyncedName || '',
|
||||
config: config(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onWorkerMessage(entry, msg) {
|
||||
const state = sessionState.get(entry.key) || {
|
||||
grants: new Set(),
|
||||
lastSyncedName: "",
|
||||
};
|
||||
switch (msg?.type) {
|
||||
case "log":
|
||||
log(`[w${entry.child.pid}] ${msg.line}`);
|
||||
return;
|
||||
case "session_opened":
|
||||
// 一条会话可能先后用过多个 pi 会话 id(模型降级会换会话)。
|
||||
// 旧 id 仍计入 mail_driven,理由见 retired 的注释。
|
||||
if (state.piSessionId && state.piSessionId !== msg.piSessionId) {
|
||||
retired.add(state.piSessionId);
|
||||
}
|
||||
state.piSessionId = msg.piSessionId;
|
||||
state.sessionFile = msg.sessionFile;
|
||||
state.cwd = msg.cwd;
|
||||
sessionState.set(entry.key, state);
|
||||
return;
|
||||
case "permission_pending":
|
||||
permissionRoutes.set(msg.relayKey, entry.key);
|
||||
return;
|
||||
case "permission_grant":
|
||||
// 「一直同意」必须跨 worker 活着:worker 一封一进程,不存的话下一封
|
||||
// 邮件又问一遍,那个选项就是在骗人。
|
||||
state.grants.add(msg.toolName);
|
||||
sessionState.set(entry.key, state);
|
||||
return;
|
||||
case "name_synced":
|
||||
state.lastSyncedName = msg.signature;
|
||||
sessionState.set(entry.key, state);
|
||||
return;
|
||||
case "reconfigure":
|
||||
onReconfigure?.(msg.url, msg.agentKey);
|
||||
return;
|
||||
case "done":
|
||||
// 标记「这封真的处理完了」:exit 处理器据此区分「正常收尾」
|
||||
// 与「未回报就崩溃」(后者要重投)。
|
||||
entry.settled = true;
|
||||
if (!msg.ok) log(`投递 ${entry.mailID} 失败: ${msg.error}`);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
function onWorkerMessage(entry, msg) {
|
||||
const state = sessionState.get(entry.key) || { grants: new Set(), lastSyncedName: '' };
|
||||
switch (msg?.type) {
|
||||
case 'log':
|
||||
log(`[w${entry.child.pid}] ${msg.line}`);
|
||||
return;
|
||||
case 'session_opened':
|
||||
// 一条会话可能先后用过多个 pi 会话 id(模型降级会换会话)。
|
||||
// 旧 id 仍计入 mail_driven,理由见 retired 的注释。
|
||||
if (state.piSessionId && state.piSessionId !== msg.piSessionId) {
|
||||
retired.add(state.piSessionId);
|
||||
}
|
||||
state.piSessionId = msg.piSessionId;
|
||||
state.sessionFile = msg.sessionFile;
|
||||
state.cwd = msg.cwd;
|
||||
sessionState.set(entry.key, state);
|
||||
return;
|
||||
case 'permission_pending':
|
||||
permissionRoutes.set(msg.relayKey, entry.key);
|
||||
return;
|
||||
case 'permission_grant':
|
||||
// 「一直同意」必须跨 worker 活着:worker 一封一进程,不存的话下一封
|
||||
// 邮件又问一遍,那个选项就是在骗人。
|
||||
state.grants.add(msg.toolName);
|
||||
sessionState.set(entry.key, state);
|
||||
return;
|
||||
case 'name_synced':
|
||||
state.lastSyncedName = msg.signature;
|
||||
sessionState.set(entry.key, state);
|
||||
return;
|
||||
case 'reconfigure':
|
||||
onReconfigure?.(msg.url, msg.agentKey);
|
||||
return;
|
||||
case 'done':
|
||||
// 标记「这封真的处理完了」:exit 处理器据此区分「正常收尾」
|
||||
// 与「未回报就崩溃」(后者要重投)。
|
||||
entry.settled = true;
|
||||
if (!msg.ok) log(`投递 ${entry.mailID} 失败: ${msg.error}`);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把权限决策路由到发起询问的那个 worker。
|
||||
*
|
||||
* @returns {boolean} 有没有找到对应的 worker。找不到说明那个 worker 已经退了
|
||||
* (桥重启、硬超时被杀、或者处理已经结束)—— 调用方据此走 B-4.2 的
|
||||
* 降级路径(把决策当一封通知投进原会话)。
|
||||
*/
|
||||
function routePermission(relayKey, decision) {
|
||||
const key = permissionRoutes.get(relayKey);
|
||||
if (!key) return false;
|
||||
const entry = running.get(key);
|
||||
if (!entry) {
|
||||
permissionRoutes.delete(relayKey);
|
||||
return false;
|
||||
}
|
||||
permissionRoutes.delete(relayKey);
|
||||
entry.child.send({ type: "permission_decision", relayKey, decision });
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 把权限决策路由到发起询问的那个 worker。
|
||||
*
|
||||
* @returns {boolean} 有没有找到对应的 worker。找不到说明那个 worker 已经退了
|
||||
* (桥重启、硬超时被杀、或者处理已经结束)—— 调用方据此走 B-4.2 的
|
||||
* 降级路径(把决策当一封通知投进原会话)。
|
||||
*/
|
||||
function routePermission(relayKey, decision) {
|
||||
const key = permissionRoutes.get(relayKey);
|
||||
if (!key) return false;
|
||||
const entry = running.get(key);
|
||||
if (!entry) {
|
||||
permissionRoutes.delete(relayKey);
|
||||
return false;
|
||||
}
|
||||
permissionRoutes.delete(relayKey);
|
||||
entry.child.send({ type: 'permission_decision', relayKey, decision });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 这条邮件会话有 worker 在跑吗(B-4.2 判断降级路径用)。 */
|
||||
const hasSession = (mailSessionID) => sessionState.has(mailSessionID);
|
||||
/** 这条邮件会话有 worker 在跑吗(B-4.2 判断降级路径用)。 */
|
||||
const hasSession = (mailSessionID) => sessionState.has(mailSessionID);
|
||||
|
||||
/**
|
||||
* 忘掉一条已归档会话的全部状态。
|
||||
*
|
||||
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有
|
||||
* 新邮件投进来。把它的 sessionState 留着只是占内存,而上限淘汰是「猜」——
|
||||
* 能确切知道该删的时候就不该依赖猜。
|
||||
*
|
||||
* 正在跑的 worker **不杀**:归档不是中止指令,模型可能正在写文件;它自己跑完
|
||||
* 就退,只是那一轮的回信会因为会话已归档而被服务端拦下。
|
||||
*
|
||||
* @param {string} mailSessionID
|
||||
* @returns {boolean} 是否真的删掉了东西
|
||||
*/
|
||||
function forget(mailSessionID) {
|
||||
if (!mailSessionID) return false;
|
||||
// peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。
|
||||
const state = sessionState.peek(mailSessionID);
|
||||
// 已归档会话的 pi 会话 id 也不必再报 mail_driven:那个标记的用途是让人在
|
||||
// 补全里看到「这条在跑邮件」,而已归档的会话不在补全候选里。
|
||||
if (state?.piSessionId) retired.delete(state.piSessionId);
|
||||
return sessionState.delete(mailSessionID);
|
||||
}
|
||||
/**
|
||||
* 忘掉一条已归档会话的全部状态。
|
||||
*
|
||||
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有
|
||||
* 新邮件投进来。把它的 sessionState 留着只是占内存,而上限淘汰是「猜」——
|
||||
* 能确切知道该删的时候就不该依赖猜。
|
||||
*
|
||||
* 正在跑的 worker **不杀**:归档不是中止指令,模型可能正在写文件;它自己跑完
|
||||
* 就退,只是那一轮的回信会因为会话已归档而被服务端拦下。
|
||||
*
|
||||
* @param {string} mailSessionID
|
||||
* @returns {boolean} 是否真的删掉了东西
|
||||
*/
|
||||
function forget(mailSessionID) {
|
||||
if (!mailSessionID) return false;
|
||||
// peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。
|
||||
const state = sessionState.peek(mailSessionID);
|
||||
// 已归档会话的 pi 会话 id 也不必再报 mail_driven:那个标记的用途是让人在
|
||||
// 补全里看到「这条在跑邮件」,而已归档的会话不在补全候选里。
|
||||
if (state?.piSessionId) retired.delete(state.piSessionId);
|
||||
return sessionState.delete(mailSessionID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件驱动过的 pi 会话 id,喂给心跳快照的 `mail_driven` 标记。
|
||||
*
|
||||
* 不随 worker 退出而清:worker 退了不代表那条会话不再参与邮件往来 ——
|
||||
* 下一封邮件还会接着谈,而人在补全里需要看到它带着这个标记。
|
||||
* 重启丢是已知取舍(契约第六节);确定性的清理时机是归档(见 forget)。
|
||||
*
|
||||
* 返回普通 Set 而不是 BoundedSet:调用方只拿它做一轮 has 查询就丢,
|
||||
* 没有长期持有,不需要上界。
|
||||
*/
|
||||
const mailDrivenIDs = () => {
|
||||
const out = new Set(retired);
|
||||
for (const st of sessionState.values()) {
|
||||
if (st.piSessionId) out.add(st.piSessionId);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* 邮件驱动过的 pi 会话 id,喂给心跳快照的 `mail_driven` 标记。
|
||||
*
|
||||
* 不随 worker 退出而清:worker 退了不代表那条会话不再参与邮件往来 ——
|
||||
* 下一封邮件还会接着谈,而人在补全里需要看到它带着这个标记。
|
||||
* 重启丢是已知取舍(契约第六节);确定性的清理时机是归档(见 forget)。
|
||||
*
|
||||
* 返回普通 Set 而不是 BoundedSet:调用方只拿它做一轮 has 查询就丢,
|
||||
* 没有长期持有,不需要上界。
|
||||
*/
|
||||
const mailDrivenIDs = () => {
|
||||
const out = new Set(retired);
|
||||
for (const st of sessionState.values()) {
|
||||
if (st.piSessionId) out.add(st.piSessionId);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
queue.length = 0;
|
||||
for (const { child, timer } of running.values()) {
|
||||
clearTimeout(timer);
|
||||
// 先 shutdown 让 worker 把未决权限 fail closed(B-9.2),再给它一点
|
||||
// 时间自己退。不直接 SIGKILL:那样 pi 侧的 await 不会返回,而 worker
|
||||
// 里可能正握着会话文件。
|
||||
try {
|
||||
child.send({ type: "shutdown" });
|
||||
} catch {
|
||||
/* 通道已断 */
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* 已经死了 */
|
||||
}
|
||||
}, 2000).unref?.();
|
||||
}
|
||||
}
|
||||
function stop() {
|
||||
stopped = true;
|
||||
queue.length = 0;
|
||||
for (const { child, timer } of running.values()) {
|
||||
clearTimeout(timer);
|
||||
// 先 shutdown 让 worker 把未决权限 fail closed(B-9.2),再给它一点
|
||||
// 时间自己退。不直接 SIGKILL:那样 pi 侧的 await 不会返回,而 worker
|
||||
// 里可能正握着会话文件。
|
||||
try { child.send({ type: 'shutdown' }); } catch { /* 通道已断 */ }
|
||||
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* 已经死了 */ } }, 2000).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 观测用:现在跑着几个、排了几个。 */
|
||||
const stats = () => ({
|
||||
running: running.size,
|
||||
queued: queue.length,
|
||||
sessions: sessionState.size,
|
||||
// 淘汰计数持续增长说明上限设得太小 —— 那意味着会话上下文在被白白丢掉,
|
||||
// 而症状是「这条会话怎么突然不记得前面说过什么了」。
|
||||
evictedSessions: sessionState.evicted,
|
||||
workers: [...running.values()].map((e) => ({
|
||||
pid: e.child.pid,
|
||||
mailID: e.mailID,
|
||||
ageMs: Date.now() - e.startedAt,
|
||||
})),
|
||||
});
|
||||
/** 观测用:现在跑着几个、排了几个。 */
|
||||
const stats = () => ({
|
||||
running: running.size,
|
||||
queued: queue.length,
|
||||
sessions: sessionState.size,
|
||||
// 淘汰计数持续增长说明上限设得太小 —— 那意味着会话上下文在被白白丢掉,
|
||||
// 而症状是「这条会话怎么突然不记得前面说过什么了」。
|
||||
evictedSessions: sessionState.evicted,
|
||||
workers: [...running.values()].map((e) => ({
|
||||
pid: e.child.pid, mailID: e.mailID, ageMs: Date.now() - e.startedAt,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
submit,
|
||||
routePermission,
|
||||
hasSession,
|
||||
forget,
|
||||
mailDrivenIDs,
|
||||
stop,
|
||||
stats,
|
||||
};
|
||||
return { submit, routePermission, hasSession, forget, mailDrivenIDs, stop, stats };
|
||||
}
|
||||
|
||||
@ -11,21 +11,19 @@
|
||||
* 而不是一个间接的计数(计数在字段被丢掉时依然会给出绿色)。
|
||||
*/
|
||||
|
||||
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;
|
||||
@ -82,379 +80,297 @@ 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' });
|
||||
@ -463,57 +379,42 @@ 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,132 +9,121 @@
|
||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||
*/
|
||||
|
||||
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