@pi-flash-plugins/pf-wxbot 0.0.0-stage → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/wx.svg +5 -0
- package/package.json +9 -4
- package/pf-plugin.toml +18 -0
- package/pi/config.mjs +26 -0
- package/pi/design-002.test.mjs +323 -0
- package/pi/fixtures/ec-golden.json +1 -0
- package/pi/fixtures/getupdates-real.json +37 -0
- package/pi/fixtures/qr-golden.json +1 -0
- package/pi/hook.mjs +231 -0
- package/pi/hook.test.mjs +351 -0
- package/pi/index.mjs +658 -0
- package/pi/index.test.mjs +696 -0
- package/pi/inject.mjs +41 -0
- package/pi/inject.test.mjs +55 -0
- package/pi/jsonx.mjs +64 -0
- package/pi/lock.mjs +171 -0
- package/pi/messages.mjs +160 -0
- package/pi/poller.mjs +364 -0
- package/pi/poller.test.mjs +191 -0
- package/pi/qr.mjs +454 -0
- package/pi/qr.test.mjs +101 -0
- package/pi/register.mjs +236 -0
- package/pi/register.test.mjs +232 -0
- package/pi/reply.mjs +49 -0
- package/pi/state.mjs +248 -0
- package/pi/state.test.mjs +138 -0
- package/pi/transport.mjs +335 -0
- package/pi/transport.test.mjs +327 -0
- package/pi/wire.mjs +219 -0
- package/pi/wire.test.mjs +29 -0
- package/plugin.wasm +0 -0
- package/README.md +0 -3
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// index 入口串接单测 —— v4 全局装载 + 泵内门控(004 §5.2)的行为面。
|
|
2
|
+
// 全部依赖注入(假 pi / 假 ctx / 假 Transport / 内存状态盘),不碰真实盘。
|
|
3
|
+
// 跑法:node --test packages/pf-wxbot/pi/index.test.mjs
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
|
|
8
|
+
import { create_entry, resolve_session_file, session_file_from_argv } from "./index.mjs";
|
|
9
|
+
import { WX_LINK_ON, WX_LINK_OFF } from "./hook.mjs";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------- 假件
|
|
12
|
+
|
|
13
|
+
/** 内存状态盘(state.mjs 同面:load/save/get_str/set/base_dir/dump_dir)。 */
|
|
14
|
+
function mem_state(initial = {}) {
|
|
15
|
+
let store = { ...initial };
|
|
16
|
+
return {
|
|
17
|
+
store: () => store,
|
|
18
|
+
load: () => ({ ...store }),
|
|
19
|
+
save(next) {
|
|
20
|
+
store = { ...next };
|
|
21
|
+
},
|
|
22
|
+
get_str: (st, k) => {
|
|
23
|
+
const v = st?.[k];
|
|
24
|
+
if (typeof v !== "string") return undefined;
|
|
25
|
+
const s = v.trim();
|
|
26
|
+
return s === "" ? undefined : s;
|
|
27
|
+
},
|
|
28
|
+
set: (st, k, v) => {
|
|
29
|
+
if (v === null || v === undefined) delete st[k];
|
|
30
|
+
else st[k] = v;
|
|
31
|
+
},
|
|
32
|
+
base_dir: () => "<mem>/data",
|
|
33
|
+
dump_dir: () => "<mem>/dump",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 假渠道:spawn 即成,try_recv 由测试喂数。 */
|
|
38
|
+
function fake_transport_class() {
|
|
39
|
+
const spawned = [];
|
|
40
|
+
class FakeTransport {
|
|
41
|
+
static spawn(cfg) {
|
|
42
|
+
const t = new FakeTransport(cfg);
|
|
43
|
+
spawned.push(t);
|
|
44
|
+
return Promise.resolve(t);
|
|
45
|
+
}
|
|
46
|
+
constructor(cfg) {
|
|
47
|
+
this.cfg = cfg;
|
|
48
|
+
this.stopped = false;
|
|
49
|
+
this.queue = []; // 待收批
|
|
50
|
+
this.sends = [];
|
|
51
|
+
this.dead = false;
|
|
52
|
+
}
|
|
53
|
+
try_recv() {
|
|
54
|
+
return this.queue.shift() ?? null;
|
|
55
|
+
}
|
|
56
|
+
stop() {
|
|
57
|
+
this.stopped = true;
|
|
58
|
+
}
|
|
59
|
+
is_closed() {
|
|
60
|
+
return this.stopped;
|
|
61
|
+
}
|
|
62
|
+
session_dead() {
|
|
63
|
+
return this.dead;
|
|
64
|
+
}
|
|
65
|
+
send(text) {
|
|
66
|
+
this.sends.push(text);
|
|
67
|
+
}
|
|
68
|
+
ack_routing() {}
|
|
69
|
+
ack_cursor() {}
|
|
70
|
+
}
|
|
71
|
+
return { cls: FakeTransport, spawned };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 假 pi:只记录 on 注册与 sendUserMessage 调用。 */
|
|
75
|
+
function fake_pi(ctxObj) {
|
|
76
|
+
const handlers = {};
|
|
77
|
+
const calls = { prompt: [] };
|
|
78
|
+
return {
|
|
79
|
+
pi: {
|
|
80
|
+
on(event, handler) {
|
|
81
|
+
(handlers[event] ??= []).push(handler);
|
|
82
|
+
},
|
|
83
|
+
sendUserMessage(text, opts) {
|
|
84
|
+
calls.prompt.push({ text, opts });
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
handlers,
|
|
88
|
+
calls,
|
|
89
|
+
async fire(event, arg) {
|
|
90
|
+
for (const h of handlers[event] ?? []) await h(arg, ctxObj ?? ctx_stub());
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 假 session 域上下文:带 sessionManager(自身身份,004 §7-1)。 */
|
|
96
|
+
function ctx_stub(over = {}) {
|
|
97
|
+
return {
|
|
98
|
+
isIdle: over.isIdle ?? (() => true),
|
|
99
|
+
abort: over.abort ?? (() => {}),
|
|
100
|
+
cwd: "D:/proj/pi-flash",
|
|
101
|
+
model: over.model ?? undefined,
|
|
102
|
+
scopedModels: over.scopedModels ?? [],
|
|
103
|
+
sessionManager: {
|
|
104
|
+
getSessionId: () => over.ownKey ?? "sA",
|
|
105
|
+
getSessionName: () => over.ownName ?? "wx session",
|
|
106
|
+
},
|
|
107
|
+
...over,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const TICK = 15;
|
|
112
|
+
const settle = (ms = 80) => new Promise((r) => setTimeout(r, ms));
|
|
113
|
+
/** 条件等待(并发跑测试时定时器会被拖慢,用轮询代替固定 sleep)。 */
|
|
114
|
+
async function wait_for(pred, ms = 800) {
|
|
115
|
+
const t0 = Date.now();
|
|
116
|
+
while (Date.now() - t0 < ms) {
|
|
117
|
+
if (pred()) return true;
|
|
118
|
+
await settle(10);
|
|
119
|
+
}
|
|
120
|
+
return pred();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 假 Wire:get_qr 出固定码,get_qrcode_status 按脚本走(不碰网络)。 */
|
|
124
|
+
function fake_wire_class(script = () => ({ status: "pending" })) {
|
|
125
|
+
return class FakeWire {
|
|
126
|
+
constructor() {}
|
|
127
|
+
async get_qr(_tag, path) {
|
|
128
|
+
if (path.includes("get_bot_qrcode")) {
|
|
129
|
+
return { qrcode: "ENC-1", qrcode_url: "https://qr/ENC-1", expires_in: 120 };
|
|
130
|
+
}
|
|
131
|
+
return script();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 标准装配:ownKey = 本进程会话身份;镜像 initial 由用例给。 */
|
|
137
|
+
function mk_entry({ ownKey = "sA", state: st = {}, ctx, wire, sessionExists, deps: extra = {} } = {}) {
|
|
138
|
+
const S = mem_state({ bot_token: "T1", bot_id: "bot@im", bot_user_id: "bot@im", ...st });
|
|
139
|
+
const { cls, spawned } = fake_transport_class();
|
|
140
|
+
const f = fake_pi(ctx ? ctx_stub({ ownKey, ...ctx }) : ctx_stub({ ownKey }));
|
|
141
|
+
const directs = []; // 降沿直发记录器(生产 = hook.mjs 真直发;测试绝不碰真盘/真网)
|
|
142
|
+
const factory = create_entry({
|
|
143
|
+
state: S,
|
|
144
|
+
Transport: cls,
|
|
145
|
+
Wire: wire ?? fake_wire_class(),
|
|
146
|
+
sessionExists,
|
|
147
|
+
sessionCheckTicks: 2, // 测试用快周期
|
|
148
|
+
sleep: () => new Promise(() => {}), // QR 轮询挂起(不堆计时器)
|
|
149
|
+
tickMs: TICK,
|
|
150
|
+
stat: () => null, // Snapshot 每拍重读(内存盘无 mtime)
|
|
151
|
+
directSend: async (t) => {
|
|
152
|
+
directs.push(t);
|
|
153
|
+
},
|
|
154
|
+
...extra,
|
|
155
|
+
});
|
|
156
|
+
factory(f.pi);
|
|
157
|
+
return { S, spawned, directs, ...f, factory };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------- 工厂(禁起资源)
|
|
161
|
+
|
|
162
|
+
test("factory_registers_handlers_and_starts_nothing", () => {
|
|
163
|
+
const { handlers, spawned } = mk_entry();
|
|
164
|
+
assert.ok(handlers.session_start?.length === 1);
|
|
165
|
+
assert.ok(handlers.message_end?.length === 1);
|
|
166
|
+
assert.ok(handlers.agent_settled?.length === 1, "异常停止回执的接线注册");
|
|
167
|
+
assert.ok(handlers.session_shutdown?.length === 1);
|
|
168
|
+
assert.equal(spawned.length, 0, "工厂只注册,不起资源(生命周期铁律)");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------- 门控(004 §5.2)
|
|
172
|
+
|
|
173
|
+
/** 小工具:起一个 entry 并 fire session_start,返回句柄。 */
|
|
174
|
+
async function start({ ownKey, state: st, ctx, wire, sessionExists, deps } = {}) {
|
|
175
|
+
const h = mk_entry({ ownKey, state: st, ctx, wire, sessionExists, deps });
|
|
176
|
+
await h.fire("session_start", {}, ctx ? ctx_stub({ ownKey, ...ctx }) : ctx_stub({ ownKey }));
|
|
177
|
+
return h;
|
|
178
|
+
}
|
|
179
|
+
test("unbound_session_never_spawns_transport", async () => {
|
|
180
|
+
const h = await start({ ownKey: "sA", state: {} }); // 镜像无 session_id
|
|
181
|
+
await settle();
|
|
182
|
+
assert.equal(h.spawned.length, 0, "门控不过 → 永不起渠道");
|
|
183
|
+
h.fire("session_shutdown");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("bound_session_spawns_and_pumps", async () => {
|
|
187
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
188
|
+
await settle();
|
|
189
|
+
assert.equal(h.spawned.length, 1, "被绑定会话 → 起渠道");
|
|
190
|
+
assert.equal(h.spawned[0].cfg.token, "T1");
|
|
191
|
+
h.fire("session_shutdown");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("foreign_bound_session_stays_idle", async () => {
|
|
195
|
+
// 本进程 sA,镜像绑定的是 sB(另一会话抢走指针)
|
|
196
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sB" } });
|
|
197
|
+
await settle();
|
|
198
|
+
assert.equal(h.spawned.length, 0, "指针属于别的会话 → 空转");
|
|
199
|
+
h.fire("session_shutdown");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("gate_release_binds_late_pointer", async () => {
|
|
203
|
+
// 先空转,壳写指针后泵下一拍拾起(004 §5.2:壳写完 session_id 下一拍即被拾起)
|
|
204
|
+
const h = await start({ ownKey: "sA", state: {} });
|
|
205
|
+
await settle();
|
|
206
|
+
assert.equal(h.spawned.length, 0);
|
|
207
|
+
h.S.save({ ...h.S.store(), session_id: "sA" });
|
|
208
|
+
await settle();
|
|
209
|
+
assert.equal(h.spawned.length, 1, "指针落到本会话 → 起渠道");
|
|
210
|
+
h.fire("session_shutdown");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("gate_loss_stops_transport_silently", async () => {
|
|
214
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
215
|
+
await settle();
|
|
216
|
+
assert.equal(h.spawned.length, 1);
|
|
217
|
+
h.S.save({ ...h.S.store(), session_id: "sB" }); // 指针换人
|
|
218
|
+
await settle();
|
|
219
|
+
assert.equal(h.spawned[0].stopped, true, "失配 → 渠道停(静默,非用户关停)");
|
|
220
|
+
assert.deepEqual(h.directs, [], "失门停机不宣告失联(stop_notice:非用户关停)");
|
|
221
|
+
h.fire("session_shutdown");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------- off 态(002 主动断开)
|
|
225
|
+
|
|
226
|
+
test("enabled_off_never_starts_and_stops_running", async () => {
|
|
227
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA", enabled: "0" } });
|
|
228
|
+
await settle();
|
|
229
|
+
assert.equal(h.spawned.length, 0, "off:不起渠道");
|
|
230
|
+
// 运行中被关:停
|
|
231
|
+
h.S.save({ ...h.S.store(), enabled: undefined, session_id: "sA" });
|
|
232
|
+
await settle();
|
|
233
|
+
assert.equal(h.spawned.length, 1);
|
|
234
|
+
h.S.save({ ...h.S.store(), enabled: "0" });
|
|
235
|
+
await settle();
|
|
236
|
+
assert.equal(h.spawned[0].stopped, true, "运行中 switch off → 停服");
|
|
237
|
+
h.fire("session_shutdown");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------- 会话删除校验(off 态 case-1)
|
|
241
|
+
|
|
242
|
+
test("session_deleted_check_clears_binding", async () => {
|
|
243
|
+
// 快周期(deps.sessionCheckTicks=2,mk_entry 已注):门控通过 → 校验被周期调用;
|
|
244
|
+
// PF 说会话已删 → 自动关服务 + 删除持有的 sessionId(002 主动断开 case-1)
|
|
245
|
+
let calls = 0;
|
|
246
|
+
let exists = true;
|
|
247
|
+
const h = mk_entry({
|
|
248
|
+
ownKey: "sA",
|
|
249
|
+
state: { session_id: "sA" },
|
|
250
|
+
sessionExists: async () => {
|
|
251
|
+
calls += 1;
|
|
252
|
+
return exists;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
await h.fire("session_start", {}, ctx_stub({ ownKey: "sA" }));
|
|
256
|
+
await settle();
|
|
257
|
+
assert.equal(h.spawned.length, 1);
|
|
258
|
+
assert.ok(calls >= 1, "校验通道被周期调用");
|
|
259
|
+
exists = false;
|
|
260
|
+
await settle();
|
|
261
|
+
assert.equal(h.spawned[0].stopped, true, "会话不存在 → 自动关服务");
|
|
262
|
+
assert.equal(h.S.store().session_id, undefined, "删除持有的 sessionId");
|
|
263
|
+
h.fire("session_shutdown");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------- 入站路由(inject 最小面)
|
|
267
|
+
|
|
268
|
+
test("inbound_text_passes_through_to_pi", async () => {
|
|
269
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
270
|
+
await settle();
|
|
271
|
+
h.spawned[0].queue.push({ messages: [{ text: "帮我跑下测试" }] });
|
|
272
|
+
await settle();
|
|
273
|
+
assert.deepEqual(
|
|
274
|
+
h.calls.prompt.map((p) => p.text),
|
|
275
|
+
["帮我跑下测试"],
|
|
276
|
+
"纯文本透传 sendUserMessage",
|
|
277
|
+
);
|
|
278
|
+
h.fire("session_shutdown");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("stop_command_aborts_current_run", async () => {
|
|
282
|
+
let aborted = 0;
|
|
283
|
+
const h = await start({
|
|
284
|
+
ownKey: "sA",
|
|
285
|
+
state: { session_id: "sA" },
|
|
286
|
+
ctx: { abort: () => (aborted += 1) },
|
|
287
|
+
});
|
|
288
|
+
await settle();
|
|
289
|
+
h.spawned[0].queue.push({ messages: [{ text: "/stop" }] });
|
|
290
|
+
await settle();
|
|
291
|
+
assert.equal(aborted, 1, "/stop → abort");
|
|
292
|
+
h.fire("session_shutdown");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("status_command_replies_via_transport", async () => {
|
|
296
|
+
const h = await start({
|
|
297
|
+
ownKey: "sA",
|
|
298
|
+
state: { session_id: "sA" },
|
|
299
|
+
ctx: { model: { provider: "zhipu", id: "glm-5" } },
|
|
300
|
+
});
|
|
301
|
+
await settle();
|
|
302
|
+
h.spawned[0].queue.push({ messages: [{ text: "/status" }] });
|
|
303
|
+
await settle();
|
|
304
|
+
assert.equal(h.spawned[0].sends.length, 1, "/status → 渠道回执一条");
|
|
305
|
+
assert.ok(h.spawned[0].sends[0].includes("glm-5"), "回执含模型信息");
|
|
306
|
+
h.fire("session_shutdown");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("unknown_slash_command_passes_through", async () => {
|
|
310
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
311
|
+
await settle();
|
|
312
|
+
h.spawned[0].queue.push({ messages: [{ text: "/model glm-5" }] });
|
|
313
|
+
await settle();
|
|
314
|
+
assert.deepEqual(h.calls.prompt.map((p) => p.text), ["/model glm-5"], "未知 /命令不吞,原文透传");
|
|
315
|
+
h.fire("session_shutdown");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------- 异常停止回执(2026-11-01 buglist#2)
|
|
319
|
+
|
|
320
|
+
/** 出错助手消息(pi agent-loop error 分支同形)。 */
|
|
321
|
+
const error_msg = (errorMessage) => ({
|
|
322
|
+
role: "assistant",
|
|
323
|
+
content: [],
|
|
324
|
+
stopReason: "error",
|
|
325
|
+
errorMessage,
|
|
326
|
+
usage: {},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("agent_error_settled_sends_notice_to_wechat", async () => {
|
|
330
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
331
|
+
await settle();
|
|
332
|
+
assert.equal(h.spawned.length, 1);
|
|
333
|
+
// 运行中出错:message_end 只记账,微信不响
|
|
334
|
+
await h.fire("message_end", { message: error_msg("provider 5xx: upstream overloaded") });
|
|
335
|
+
assert.deepEqual(h.spawned[0].sends, [], "错误不即发");
|
|
336
|
+
// 运行彻底落定(重试链全停):错误补发微信
|
|
337
|
+
await h.fire("agent_settled", { aborted: false });
|
|
338
|
+
assert.deepEqual(
|
|
339
|
+
h.spawned[0].sends,
|
|
340
|
+
["⚠️任务异常停止:provider 5xx: upstream overloaded"],
|
|
341
|
+
"settle 后错误必须送达微信",
|
|
342
|
+
);
|
|
343
|
+
h.fire("session_shutdown");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("agent_error_recovered_then_settled_sends_nothing", async () => {
|
|
347
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
348
|
+
await settle();
|
|
349
|
+
await h.fire("message_end", { message: error_msg("transient") });
|
|
350
|
+
// 重试成功:正常发言照常出站,旧账勾销
|
|
351
|
+
await h.fire("message_end", {
|
|
352
|
+
message: { role: "assistant", content: [{ type: "text", text: "好了" }], stopReason: "stop", usage: {} },
|
|
353
|
+
});
|
|
354
|
+
await h.fire("agent_settled", { aborted: false });
|
|
355
|
+
assert.deepEqual(h.spawned[0].sends, ["好了"], "只发恢复后的发言,不报已恢复的错");
|
|
356
|
+
h.fire("session_shutdown");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("agent_settled_aborted_no_error_notice", async () => {
|
|
360
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
361
|
+
await settle();
|
|
362
|
+
await h.fire("message_end", { message: error_msg("boom") });
|
|
363
|
+
await h.fire("agent_settled", { aborted: true });
|
|
364
|
+
assert.deepEqual(h.spawned[0].sends, [], "/停止 = 用户主动,不报异常");
|
|
365
|
+
h.fire("session_shutdown");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------- 运行中心跳(2026-11-01 buglist#1 定案)
|
|
369
|
+
|
|
370
|
+
test("heartbeat_cadence_gated_by_interval_while_running", async () => {
|
|
371
|
+
let clock = 10_000;
|
|
372
|
+
const h = await start({
|
|
373
|
+
ownKey: "sA",
|
|
374
|
+
state: { session_id: "sA" },
|
|
375
|
+
ctx: { isIdle: () => false }, // agent 运行中
|
|
376
|
+
deps: { now: () => clock, heartbeatMs: 60_000 },
|
|
377
|
+
});
|
|
378
|
+
await settle();
|
|
379
|
+
assert.deepEqual(h.spawned[0].sends, [], "运行未满节拍:无心跳");
|
|
380
|
+
clock += 60_001;
|
|
381
|
+
await wait_for(() => (h.spawned[0]?.sends.length ?? 0) >= 1);
|
|
382
|
+
assert.equal(h.spawned[0].sends.length, 1, "一拍只发一张");
|
|
383
|
+
assert.ok(h.spawned[0].sends[0].includes("运行中"), "内容是运行中状态卡");
|
|
384
|
+
clock += 10_000; // 距上张卡 10s,不足节拍
|
|
385
|
+
await settle();
|
|
386
|
+
assert.equal(h.spawned[0].sends.length, 1, "节拍未到不重发");
|
|
387
|
+
clock += 60_000;
|
|
388
|
+
await wait_for(() => (h.spawned[0]?.sends.length ?? 0) >= 2, 1500);
|
|
389
|
+
h.fire("session_shutdown");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("agent_start_resets_heartbeat_clock", async () => {
|
|
393
|
+
let clock = 10_000;
|
|
394
|
+
const h = await start({
|
|
395
|
+
ownKey: "sA",
|
|
396
|
+
state: { session_id: "sA" },
|
|
397
|
+
ctx: { isIdle: () => false },
|
|
398
|
+
deps: { now: () => clock, heartbeatMs: 60_000 },
|
|
399
|
+
});
|
|
400
|
+
clock += 60_001;
|
|
401
|
+
await wait_for(() => (h.spawned[0]?.sends.length ?? 0) >= 1);
|
|
402
|
+
await h.fire("agent_start", {}); // 新一轮运行:节拍从本轮起算
|
|
403
|
+
clock += 59_000;
|
|
404
|
+
await settle();
|
|
405
|
+
assert.equal(h.spawned[0].sends.length, 1, "agent_start 后节拍重算,不足不发");
|
|
406
|
+
clock += 1_500; // 距 agent_start 共 60.5s
|
|
407
|
+
await wait_for(() => (h.spawned[0]?.sends.length ?? 0) >= 2, 1500);
|
|
408
|
+
h.fire("session_shutdown");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("idle_session_never_heartbeats", async () => {
|
|
412
|
+
let clock = 10_000;
|
|
413
|
+
const h = await start({
|
|
414
|
+
ownKey: "sA",
|
|
415
|
+
state: { session_id: "sA" },
|
|
416
|
+
ctx: { isIdle: () => true }, // 空闲
|
|
417
|
+
deps: { now: () => clock, heartbeatMs: 60_000 },
|
|
418
|
+
});
|
|
419
|
+
clock += 600_000; // 空闲再久也不发
|
|
420
|
+
await settle();
|
|
421
|
+
await settle();
|
|
422
|
+
assert.deepEqual(h.spawned[0].sends, [], "空闲不发心跳");
|
|
423
|
+
h.fire("session_shutdown");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("outbound_speech_resets_heartbeat_clock", async () => {
|
|
427
|
+
// 出站口径(用户 2026-11-01 定):发言发出即归位,不会刚回完话又紧跟状态卡
|
|
428
|
+
let clock = 10_000;
|
|
429
|
+
const h = await start({
|
|
430
|
+
ownKey: "sA",
|
|
431
|
+
state: { session_id: "sA" },
|
|
432
|
+
ctx: { isIdle: () => false },
|
|
433
|
+
deps: { now: () => clock, heartbeatMs: 30_000 },
|
|
434
|
+
});
|
|
435
|
+
await settle();
|
|
436
|
+
assert.deepEqual(h.spawned[0].sends, [], "起点 10s 不足节拍");
|
|
437
|
+
clock += 5_000; // 15s:agent 发言出站 → 归位
|
|
438
|
+
await h.fire("message_end", {
|
|
439
|
+
message: { role: "assistant", content: [{ type: "text", text: "先干着" }], stopReason: "toolUse", usage: {} },
|
|
440
|
+
});
|
|
441
|
+
assert.deepEqual(h.spawned[0].sends, ["先干着"]);
|
|
442
|
+
clock += 25_000; // 距发言 25s < 30s:不心跳
|
|
443
|
+
await settle();
|
|
444
|
+
assert.deepEqual(h.spawned[0].sends, ["先干着"], "发言后节拍重算,不紧跟状态卡");
|
|
445
|
+
clock += 5_000; // 距发言满 30s:心跳照发
|
|
446
|
+
await wait_for(() => (h.spawned[0]?.sends.length ?? 0) >= 2, 1500);
|
|
447
|
+
assert.ok(h.spawned[0].sends[1].includes("运行中"), "心跳卡内容正常");
|
|
448
|
+
h.fire("session_shutdown");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------- 换绑 / 过期
|
|
452
|
+
|
|
453
|
+
test("token_change_triggers_rebind_respawn", async () => {
|
|
454
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
455
|
+
await settle();
|
|
456
|
+
assert.equal(h.spawned.length, 1);
|
|
457
|
+
h.S.save({ ...h.S.store(), bot_token: "T2" }); // 重扫换绑
|
|
458
|
+
await settle();
|
|
459
|
+
assert.equal(h.spawned[0].stopped, true, "旧渠道停");
|
|
460
|
+
assert.equal(h.spawned.length, 2, "新 token 重拉");
|
|
461
|
+
assert.equal(h.spawned[1].cfg.token, "T2");
|
|
462
|
+
assert.equal(h.S.store().session_expired, undefined, "过期旗标清除");
|
|
463
|
+
h.fire("session_shutdown");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("session_dead_marks_expired_and_starts_qr_flow", async () => {
|
|
467
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
468
|
+
await settle();
|
|
469
|
+
h.spawned[0].dead = true; // 服务侧作废本 bot 会话
|
|
470
|
+
await settle();
|
|
471
|
+
assert.equal(h.S.store().session_expired, "1", "过期旗标落盘(壳灰 icon + 自动弹窗依据)");
|
|
472
|
+
await settle(30);
|
|
473
|
+
assert.ok(h.S.store().qrcode_grid, "出码流已起:QR 网格写镜像(弹窗的码就绪)");
|
|
474
|
+
h.fire("session_shutdown");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("token_cleared_under_running_transport_stops_zombie", async () => {
|
|
478
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
479
|
+
await settle();
|
|
480
|
+
assert.equal(h.spawned.length, 1);
|
|
481
|
+
// 壳侧把 token 清了(重扫清理/旧版壳刷新二维码):渠道必须跟着停 ——
|
|
482
|
+
// 否则僵尸轮询者拿内存旧 token 继续 ack,把刚清掉的路由字段整批复活
|
|
483
|
+
//
|
|
484
|
+
h.S.save({ ...h.S.store(), bot_token: undefined, buf: undefined, chat_id: undefined });
|
|
485
|
+
await settle();
|
|
486
|
+
assert.equal(h.spawned[0].stopped, true, "svc on 但盘上无 token → 停渠道(僵尸终结)");
|
|
487
|
+
assert.equal(h.spawned.length, 1, "无 token 不重拉(等扫码成功 on_rebind 自起)");
|
|
488
|
+
h.fire("session_shutdown");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------- 生命周期收尾
|
|
492
|
+
|
|
493
|
+
test("shell_clearing_code_cancels_stale_flow_and_regenerates", async () => {
|
|
494
|
+
// 壳【刷新二维码】只清镜像(qrcode/qrcode_grid)不通知 ext:旧协程不会再发
|
|
495
|
+
// ready,若不识别就会卡到 180s 死线
|
|
496
|
+
let begins = 0;
|
|
497
|
+
const Base = fake_wire_class();
|
|
498
|
+
class CountingWire extends Base {
|
|
499
|
+
async get_qr(tag, path) {
|
|
500
|
+
if (path.includes("get_bot_qrcode")) begins += 1;
|
|
501
|
+
return super.get_qr(tag, path);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const h = await start({
|
|
505
|
+
ownKey: "sA",
|
|
506
|
+
state: { session_id: "sA", bot_token: undefined }, // 未绑定 → 出码
|
|
507
|
+
wire: CountingWire,
|
|
508
|
+
});
|
|
509
|
+
await wait_for(() => !!h.S.store().qrcode_grid, 3000);
|
|
510
|
+
assert.ok(h.S.store().qrcode_grid, "首轮码已就绪");
|
|
511
|
+
await settle(200); // 让泵至少看到一次落地码(qr_seen 置位)再模拟壳清码
|
|
512
|
+
const b0 = begins;
|
|
513
|
+
const st = { ...h.S.store() };
|
|
514
|
+
delete st.qrcode;
|
|
515
|
+
delete st.qrcode_grid;
|
|
516
|
+
h.S.save(st); // 壳刷新:清码,指针与开关不动
|
|
517
|
+
await wait_for(() => begins > b0, 3000);
|
|
518
|
+
assert.ok(begins > b0, "清码 → 旧协程被取消,重新出码");
|
|
519
|
+
await wait_for(() => !!h.S.store().qrcode_grid, 3000);
|
|
520
|
+
assert.ok(h.S.store().qrcode_grid, "新码写回镜像(弹窗不再停在生成中)");
|
|
521
|
+
h.fire("session_shutdown");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("shutdown_stops_transport_idempotent", async () => {
|
|
525
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA" } });
|
|
526
|
+
await settle();
|
|
527
|
+
const tr = h.spawned[0];
|
|
528
|
+
await h.fire("session_shutdown");
|
|
529
|
+
await h.fire("session_shutdown"); // 幂等
|
|
530
|
+
assert.equal(tr.stopped, true, "shutdown 停渠道(不保活:进程没了服务停)");
|
|
531
|
+
});
|
|
532
|
+
test("resolve_session_file —— pi 首条消息前 getSessionFile 未就绪时回落到宿主键", () => {
|
|
533
|
+
// 真机 2026-10-28:切到新会话(还没发过消息)扫码成功,session_file 写 null
|
|
534
|
+
// → 壳按会话文件核对,icon 永远不亮(表现为“第一次切换绑不上,第二次才行”)
|
|
535
|
+
assert.equal(resolve_session_file(null, "C:/s/owner.jsonl"), "C:/s/owner.jsonl");
|
|
536
|
+
assert.equal(resolve_session_file("C:/s/own.jsonl", "C:/s/owner.jsonl"), "C:/s/own.jsonl", "ownFile 优先");
|
|
537
|
+
assert.equal(resolve_session_file(null, null), null);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("session_file_from_argv —— 恢复会话进程的第三把身份钥匙", () => {
|
|
541
|
+
// 真机 2026-11-03:停用→启用真重启 pi 后,恢复会话的进程里 getSessionFile()
|
|
542
|
+
// 首条消息前 undefined + uuid 漂移,门控两把旧钥匙同时落空 → 服务不起、
|
|
543
|
+
// 消息无人收。pi spawn 必带 --session <file>,argv 兜底解析。
|
|
544
|
+
const argv = ["node", "cli.js", "--mode", "rpc", "--session", "C:\\s\\a.jsonl", "-ne"];
|
|
545
|
+
assert.equal(session_file_from_argv(argv), "C:\\s\\a.jsonl");
|
|
546
|
+
assert.equal(session_file_from_argv(["node", "x.js"]), null, "无 --session = null");
|
|
547
|
+
assert.equal(session_file_from_argv(["node", "x.js", "--session"]), null, "缺值 = null");
|
|
548
|
+
assert.equal(session_file_from_argv(["node", "x.js", "--session", ""]), null, "空值 = null");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("switch 关再开必须能恢复通信(Stopped 终态 → 回 Pending)", async () => {
|
|
552
|
+
// 真机 2026-10-28:off 走 stop_service(true) = Stopped,wants_start 恒 false,
|
|
553
|
+
// 打开回来后渠道永远不起 → 消息不回。这里盯状态机的可达性。
|
|
554
|
+
const { Service } = await import("./state.mjs");
|
|
555
|
+
const svc = new Service();
|
|
556
|
+
svc.to_on({ send() {}, stop() {} }, "TK");
|
|
557
|
+
svc.to_stopped();
|
|
558
|
+
assert.equal(svc.wants_start(Date.now()), false, "Stopped 不自动拉起");
|
|
559
|
+
assert.equal(svc.variant, "stopped");
|
|
560
|
+
svc.to_pending(); // ext 在「enabled != 0 且 stopped」时的动作
|
|
561
|
+
assert.equal(svc.wants_start(Date.now()), true, "回 Pending 后可重启");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------- 启停通知(002·通知)
|
|
565
|
+
// 文案 = messages.mjs 文案表单源(hook.test 逐字盯);这里盯 3+2 触发点的接线。
|
|
566
|
+
// 「已连接」= 升沿:transport 出站队列入队(sends);「已失联」= 降沿:一次性直发
|
|
567
|
+
//(directs,transport 已停不依赖渠道)。路由未就绪时升沿挂起到路由就绪(hook 面)。
|
|
568
|
+
|
|
569
|
+
test("通知③ 进程重载(面板卸载→加载)+ 原有绑定 → 宣告已连接", async () => {
|
|
570
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA", chat_id: "c@im" } });
|
|
571
|
+
await wait_for(() => (h.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
572
|
+
assert.deepEqual(h.directs, [], "连接是升沿:走渠道入队,不直发");
|
|
573
|
+
h.fire("session_shutdown");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("通知① 用户 off → 直发失联;再 on → 重新宣告已连接", async () => {
|
|
577
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA", chat_id: "c@im" } });
|
|
578
|
+
await wait_for(() => (h.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
579
|
+
h.S.save({ ...h.S.store(), enabled: "0" }); // 用户 off
|
|
580
|
+
await wait_for(() => h.directs.includes(WX_LINK_OFF));
|
|
581
|
+
assert.equal(h.spawned[0].stopped, true, "服务已停");
|
|
582
|
+
h.S.save({ ...h.S.store(), enabled: undefined }); // 用户 on
|
|
583
|
+
await wait_for(() => (h.spawned[1]?.sends ?? []).includes(WX_LINK_ON), 1500);
|
|
584
|
+
assert.deepEqual(h.directs, [WX_LINK_OFF], "重连只补一条连接,失联不重发");
|
|
585
|
+
// 诊断痕迹(notice_log):挂起 → 发出全链留痕,真机 wx-state.json 可对账
|
|
586
|
+
const nlog = h.S.store().notice_log ?? [];
|
|
587
|
+
assert.ok(nlog.some((l) => l.includes("挂起:已连接")), "连接挂起有痕");
|
|
588
|
+
assert.ok(nlog.some((l) => l.includes("发出:已连接")), "连接发出有痕");
|
|
589
|
+
assert.ok(nlog.some((l) => l.includes("挂起:已失联")), "失联挂起有痕");
|
|
590
|
+
assert.ok(nlog.some((l) => l.includes("发出:已失联")), "失联发出有痕");
|
|
591
|
+
// svc_log:渠道迁移带原因标签(起/停+原因,面板互搏定位依据)
|
|
592
|
+
const slog = h.S.store().svc_log ?? [];
|
|
593
|
+
assert.ok(slog.some((l) => l.endsWith(" stop:switch-off user=true was_on=true")), "停机原因有痕");
|
|
594
|
+
assert.ok(slog.some((l) => l.endsWith(" on")), "起机有痕");
|
|
595
|
+
h.fire("session_shutdown");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("通知② 运行中卸载(session_shutdown)→ 直发失联", async () => {
|
|
599
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA", chat_id: "c@im" } });
|
|
600
|
+
await wait_for(() => (h.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
601
|
+
await h.fire("session_shutdown");
|
|
602
|
+
await settle();
|
|
603
|
+
assert.deepEqual(h.directs, [WX_LINK_OFF], "卸载即服务关停 → 直发失联");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("通知 从未连接(未绑定)时卸载 → 静默", async () => {
|
|
607
|
+
const h = await start({ ownKey: "sA", state: { enabled: "0" } });
|
|
608
|
+
await settle();
|
|
609
|
+
await h.fire("session_shutdown");
|
|
610
|
+
await settle();
|
|
611
|
+
assert.deepEqual(h.directs, [], "没宣告过连接就不宣告失联");
|
|
612
|
+
assert.equal(h.spawned.length, 0);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("通知 已知过期(session_expired=1)不宣告已连接", async () => {
|
|
616
|
+
// 002:链路通畅才宣告。过期旗标只能重扫解除,此时起渠道发必失败 —— 不宣告
|
|
617
|
+
const h = await start({
|
|
618
|
+
ownKey: "sA",
|
|
619
|
+
state: { session_id: "sA", chat_id: "c@im", session_expired: "1" },
|
|
620
|
+
});
|
|
621
|
+
await settle();
|
|
622
|
+
await settle();
|
|
623
|
+
assert.equal(h.spawned.length, 1, "渠道照常起(起停行为不变)");
|
|
624
|
+
assert.ok(!h.spawned[0].sends.includes(WX_LINK_ON), "过期不宣告");
|
|
625
|
+
h.fire("session_shutdown");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("通知① 换绑(on_rebind)→ 停机静默 + 新渠道宣告已连接", async () => {
|
|
629
|
+
// 扫码成功与 token 变更感知都走 on_rebind:落 Pending 静默(不发失联),
|
|
630
|
+
// 新 token 渠道起来后宣告连接
|
|
631
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA", chat_id: "c@im" } });
|
|
632
|
+
await wait_for(() => (h.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
633
|
+
h.S.save({ ...h.S.store(), bot_token: "T2" });
|
|
634
|
+
await wait_for(() => (h.spawned[1]?.sends ?? []).includes(WX_LINK_ON), 1500);
|
|
635
|
+
assert.deepEqual(h.directs, [], "换绑静默:不发失联");
|
|
636
|
+
h.fire("session_shutdown");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("通知② 壳卸载标记(unload_ts 晚于出生)→ 主动关停 + 失联直发 + 不再复活", async () => {
|
|
640
|
+
// PF 面板停用不回调 session_shutdown(真机 2026-11-01 铁证):wasm 壳 unload
|
|
641
|
+
// 写 unload_ts,ext 泵见晚于本进程出生时刻的标记 = 被停用 → 关停+宣告失联
|
|
642
|
+
// +释放锁,进程终态。用盘上快照比序号有竞态(快照晚于壳写 → 永不触发,
|
|
643
|
+
// tc7 实测),水位对比的是自身出生时刻,免疫此坑。
|
|
644
|
+
const h = await start({ ownKey: "sA", state: { session_id: "sA", chat_id: "c@im" } });
|
|
645
|
+
await wait_for(() => (h.spawned[0]?.sends ?? []).includes(WX_LINK_ON));
|
|
646
|
+
h.S.save({ ...h.S.store(), unload_seq: "1", unload_ts: String(Date.now()) }); // 模拟壳 unload()
|
|
647
|
+
await wait_for(() => h.directs.includes(WX_LINK_OFF));
|
|
648
|
+
assert.equal(h.spawned[0].stopped, true, "锁当场释放");
|
|
649
|
+
await settle();
|
|
650
|
+
h.S.save({ ...h.S.store(), enabled: undefined }); // 即便开关再变也不复活
|
|
651
|
+
await settle();
|
|
652
|
+
assert.equal(h.spawned.length, 1, "unloaded 终态:本进程不再起渠道");
|
|
653
|
+
const slog = h.S.store().svc_log ?? [];
|
|
654
|
+
assert.ok(slog.some((l) => l.includes("stop:shell-unload user=true was_on=true")), "停机原因有痕");
|
|
655
|
+
// unloaded 进程内粘性:runtime 替换(session_start 重入)不复活
|
|
656
|
+
await h.fire("session_start", {}, ctx_stub({ ownKey: "sA" }));
|
|
657
|
+
await settle();
|
|
658
|
+
assert.equal(h.spawned.length, 1, "session_start 重入不起渠道");
|
|
659
|
+
h.fire("session_shutdown");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("通知 出生前落盘的旧标记不误报(水位 = 出生时刻,不看快照)", async () => {
|
|
663
|
+
const h = await start({
|
|
664
|
+
ownKey: "sA",
|
|
665
|
+
// 上一轮卸载残留:unload_ts 远早于本进程出生(0 = 无标记同口径)
|
|
666
|
+
state: { session_id: "sA", chat_id: "c@im", unload_seq: "3", unload_ts: "1" },
|
|
667
|
+
});
|
|
668
|
+
await settle();
|
|
669
|
+
await settle();
|
|
670
|
+
assert.equal(h.spawned.length, 1, "照常起渠道");
|
|
671
|
+
assert.deepEqual(h.directs, [], "旧标记不是本次卸载:不报失联");
|
|
672
|
+
h.S.save({ ...h.S.store(), unload_seq: "4", unload_ts: String(Date.now()) }); // 本进程生命内的真卸载
|
|
673
|
+
await wait_for(() => h.directs.includes(WX_LINK_OFF));
|
|
674
|
+
h.fire("session_shutdown");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("通知 boot 期吃标记:session_start 前已落盘的新标记 → 自我隔离不起渠道", async () => {
|
|
678
|
+
// tc7 实测的 attach 重启竞态:壳在 ext 出生与 session_start 之间写入标记,
|
|
679
|
+
// 旧语义(快照序号当基线)会照抄新值、永不触发,服务反而在停用后起来。
|
|
680
|
+
// 新语义:出生后落盘的标记在 session_start 当场置 unloaded,渠道不起。
|
|
681
|
+
const h = await start({
|
|
682
|
+
ownKey: "sA",
|
|
683
|
+
state: {
|
|
684
|
+
session_id: "sA",
|
|
685
|
+
chat_id: "c@im",
|
|
686
|
+
unload_seq: "1",
|
|
687
|
+
unload_ts: String(Date.now() + 50), // 出生后才会出现的标记(未来 50ms)
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
await settle();
|
|
691
|
+
await settle();
|
|
692
|
+
assert.equal(h.spawned.length, 0, "boot 期已停用:渠道绝不起");
|
|
693
|
+
const slog = h.S.store().svc_log ?? [];
|
|
694
|
+
assert.ok(slog.some((l) => l.includes("boot-after-unload")), "自我隔离有痕");
|
|
695
|
+
h.fire("session_shutdown");
|
|
696
|
+
});
|