@pi-flash-plugins/pf-wxbot 0.0.0-stage → 0.1.3

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/pi/hook.mjs ADDED
@@ -0,0 +1,231 @@
1
+ // hook —— pi 事件出站面:message_end 提取 agent 发言 → 分块 → transport 出站队列;
2
+ // 启停通知(002 §4)的置位/冲刷规则。参考:PF 仓 remote_control/mod.rs
3
+ // `on_assistant`/`flush_notice`(出站语义唯一依据)+ state.rs `stop_notice`。
4
+ //
5
+ // **与老实现的边界差异**:老模块吃流式事件(TextDelta 累积 / TextEnd 覆盖 /
6
+ // ToolCallStart 强制冲刷),因为它是进程外的采样者;pi ext 的 `message_end`
7
+ // 给的是**终态完整消息**——缓冲语义坍缩成逐消息处理,可见行为不变
8
+ // (工具调用前的发言在工具执行前发出,最终发言在消息终态发出)。
9
+ //
10
+ // 出站门:transport 句柄在(服务 On)才转发;句柄不在 = 服务关停/未起,
11
+ // 静默丢弃是产品语义(关了服务就不转发),不是事故。
12
+
13
+ import * as state from "./state.mjs";
14
+ import { Wire } from "./wire.mjs";
15
+ import { send_text } from "./poller.mjs";
16
+ import { split_long_reply_text } from "./reply.mjs";
17
+ import { WX_LINK_ON, WX_LINK_OFF } from "./messages.mjs";
18
+
19
+ // ── 启停通知文案(002·通知)───────────────────────────────────────
20
+ // ⚡ 顶替【pf icon】——sendmessage 只走文本 item,图片要先上传拿
21
+ // media id(老模块同款定夺,mod.rs:46 注释)。文案单源在 messages.mjs
22
+ // 文案表(zh 逐字 = 002 定稿),这里转出(hook.test 断言的是本文件出口)。
23
+ export { WX_LINK_ON, WX_LINK_OFF };
24
+
25
+ /** 停服务通知规则(002·通知 定案,**有意偏离** Rust stop_notice 的节制规则):
26
+ * 用户显式关停/面板卸载 + 服务当时在跑 → 宣告「已失联」。
27
+ * 不再要求「已连接已发出」——真机 2026-11-01:连接宣告可能粘在待发队列
28
+ *(渠道被夺/进程被杀抢在冲刷前),旧规则会把 002 的卸载场景吞成静默。
29
+ * was_on 本身就是「链接真的断过」的判据;没在跑(未起/已停)才静默。 */
30
+ export function stop_notice(user_stop, was_on) {
31
+ return user_stop && was_on ? false : null;
32
+ }
33
+
34
+ /** 对端投递路由就绪(`chat_id` / `user_id` 随首条入站回写,state.rs 同口径)。 */
35
+ export function is_routed(st) {
36
+ return !!(state.get_str(st, "chat_id") ?? state.get_str(st, "user_id"));
37
+ }
38
+
39
+ // ── 消息 → 发言(滤工具调用)─────────────────────────────────────
40
+
41
+ /** pi AssistantMessage → 发言文本块(保序)。只取 `type:"text"`;
42
+ * thinking / toolCall 一律滤掉(002:微信只收 agent 发言)。 */
43
+ export function assistant_speech_blocks(message) {
44
+ if (!message || message.role !== "assistant") return [];
45
+ const out = [];
46
+ for (const block of message.content ?? []) {
47
+ if (block && block.type === "text" && typeof block.text === "string") {
48
+ out.push(block.text);
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** 消息 → 待发微信的文本数组:每个发言块独立 CRLF 归一 + 3500 分块,
55
+ * 按块顺序拼接(老模块 per-content-index 缓冲 + blocks.mixed 黄金同语义)。 */
56
+ export function replies_for_message(message) {
57
+ const out = [];
58
+ for (const block of assistant_speech_blocks(message)) {
59
+ for (const chunk of split_long_reply_text(block.replace(/\r\n/g, "\n"))) {
60
+ out.push(chunk);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // ── 异常停止回执(2026-11-01 buglist#2:错误只上桌面不进微信)─────
67
+ // agent 出错的消息 stopReason="error"、content 为空——发言提取自然是空,
68
+ // 微信就一片沉默,用户只看到「没反应」+ 服务端兜底提示。错误必须发:
69
+ // message_end 记账(运行恢复即清),agent_settled(重试链彻底结束后)
70
+ // 才发出——重试中途的瞬态错不刷屏,只有真的停了才报。
71
+
72
+ /** 任务异常停止回执前缀(zh 固定,与 ⚡ 启停通知同口径)。**不带空格**:
73
+ * 超长错误走 3500 分块时断点优先空格,带空格会把「⚠️」单独切成一条。 */
74
+ export const WX_TASK_ERROR_PREFIX = "⚠️任务异常停止:";
75
+
76
+ /** 助手消息 → 异常停止通知文本;非 error 停止(正常/aborted)或缺
77
+ * errorMessage = null(用户主动 /停止 不是异常,不报)。 */
78
+ export function error_notice_text(message) {
79
+ if (!message || message.role !== "assistant") return null;
80
+ if (message.stopReason !== "error") return null;
81
+ const err = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
82
+ if (err === "") return null;
83
+ return `${WX_TASK_ERROR_PREFIX}${err}`;
84
+ }
85
+
86
+ // ── hook 主体 ────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * 出站 hook。deps 可注入(单测);生产用默认。
90
+ * - `transport()`:当前 Transport 句柄 getter(入口串接提供,服务 On 才非空)
91
+ * - `loadState()`:状态盘读取
92
+ * - `directSend(text)`:降沿一次性直发(transport 已收,走新 Wire)
93
+ */
94
+ export function create_hook(deps = {}) {
95
+ const loadState = deps.loadState ?? state.load;
96
+ const transport = deps.transport ?? (() => null);
97
+ const directSend = deps.directSend ?? default_direct_send;
98
+ const trace = deps.trace ?? (() => {}); // 诊断痕迹(真机 ext stderr 不可见,落镜像 notice_log)
99
+
100
+ // 待发通知:true = 待发「已开启」,false = 待发「已关闭」,null = 无。
101
+ // **由服务状态迁移点置位**(mod.rs:190 注释同款,不靠沿检测造假前态)。
102
+ let pending = null;
103
+ let warned_direct = false;
104
+ // 待发异常停止回执:message_end 记最新一次(重试覆盖旧的),settle 发出。
105
+ let error_text = null;
106
+
107
+ const hook = {
108
+ /** 升沿(进 On):挂起「已开启」。路由未就绪则由 flush_notice 留到下一拍。 */
109
+ on_start() {
110
+ pending = true;
111
+ trace("挂起:已连接");
112
+ },
113
+
114
+ /** 降沿(stop 迁移点):按 stop_notice 规则挂起「已失联」(或静默)。 */
115
+ on_stop(user_stop, was_on) {
116
+ pending = stop_notice(user_stop, was_on);
117
+ if (pending === false) trace("挂起:已失联"); // 静默路径不留痕
118
+ },
119
+
120
+ /** 待发通知(测试/状态盘观测用)。 */
121
+ get notice() {
122
+ return pending;
123
+ },
124
+
125
+ /** message_end 出站:滤工具调用提取发言 → 分块 → transport 出站队列。
126
+ * 异常停止(stopReason=error)只记账不即发——等 agent_settled 定论。
127
+ * 返回已入队的文本(测试断言用)。handler 不替换消息(observe-only),
128
+ * 链式语义留给后续 handler。 */
129
+ on_message_end(message) {
130
+ const notice = error_notice_text(message);
131
+ if (notice) {
132
+ error_text = notice;
133
+ return [];
134
+ }
135
+ // 正常发言落地 = 运行恢复(重试成功等):待报异常一笔勾销
136
+ // (非 error/aborted 的助手消息终态都算,缺 stopReason 的宽松视为健康)
137
+ if (message?.role === "assistant" && message?.stopReason !== "error" &&
138
+ message?.stopReason !== "aborted") {
139
+ error_text = null;
140
+ }
141
+ const tr = transport();
142
+ if (!tr) return [];
143
+ const sent = [];
144
+ for (const text of replies_for_message(message)) {
145
+ try {
146
+ tr.send(text);
147
+ sent.push(text);
148
+ } catch (e) {
149
+ // send 线程已退出(stop 竞态):丢这一条,下条消息重判
150
+ console.error(`[wxbridge] 出站入队失败:${e?.message ?? e}`);
151
+ }
152
+ }
153
+ return sent;
154
+ },
155
+
156
+ /** agent_settled(自动重试/续跑全停,本轮定论):有待报异常 → 补发微信。
157
+ * aborted = 用户主动停,不是异常;无 transport(服务关停)= 微信整体
158
+ * 不出站,账一并清( desktop 自有错误展示,不留陈账到下轮)。 */
159
+ on_agent_settled(aborted) {
160
+ const text = error_text;
161
+ error_text = null;
162
+ if (!text || aborted) return [];
163
+ const tr = transport();
164
+ if (!tr) return [];
165
+ const sent = [];
166
+ for (const chunk of split_long_reply_text(text.replace(/\r\n/g, "\n"))) {
167
+ try {
168
+ tr.send(chunk);
169
+ sent.push(chunk);
170
+ } catch (e) {
171
+ console.error(`[wxbridge] 异常回执入队失败:${e?.message ?? e}`);
172
+ break;
173
+ }
174
+ }
175
+ return sent;
176
+ },
177
+
178
+ /** 每拍调用(泵循环后/状态迁移后):冲刷待发通知。返回本次发出文本。
179
+ *
180
+ * 升沿:transport 出站队列入队即可,send 线程每次发送前重读状态。
181
+ * 降沿:transport 已 drop——一次性直发(15s 超时在 wire 层,
182
+ * 失败只 stderr,绝不阻塞调用方)。 */
183
+ flush_notice() {
184
+ if (pending === null) return [];
185
+ const st = loadState();
186
+ if (!is_routed(st)) return []; // 路由未就绪:留到下一拍
187
+ if (pending === true) {
188
+ const tr = transport();
189
+ if (!tr) return [];
190
+ try {
191
+ tr.send(WX_LINK_ON);
192
+ } catch {
193
+ return []; // send 线程已退出:留到下一拍(下一拍 transport 会被换掉)
194
+ }
195
+ pending = null;
196
+ trace("发出:已连接");
197
+ return [WX_LINK_ON];
198
+ }
199
+ // pending === false(用户关停降沿)
200
+ pending = null;
201
+ const text = WX_LINK_OFF;
202
+ trace("发出:已失联");
203
+ Promise.resolve()
204
+ .then(() => directSend(text))
205
+ .catch((e) => {
206
+ if (!warned_direct) {
207
+ warned_direct = true;
208
+ console.error(`[wxbridge] 关停通知直发失败:${e?.message ?? e}(此提示只出现一次)`);
209
+ }
210
+ });
211
+ return [text];
212
+ },
213
+ };
214
+ return hook;
215
+ }
216
+
217
+ /** 降沿直发默认实现:状态盘取路由五件套,新 Wire 一次发出(mod.rs
218
+ * wx-stop-notice 线程的 JS 对位)。不落盘任何东西——client_id 在副本上
219
+ * 临时生成(Rust `ensure_client_id(&mut st.clone())` 同款)。 */
220
+ async function default_direct_send(text) {
221
+ const st = state.load();
222
+ const token = state.get_str(st, "bot_token");
223
+ if (!token) return;
224
+ const to_user = state.get_str(st, "chat_id") ?? state.get_str(st, "user_id");
225
+ if (!to_user) return;
226
+ const from_user = state.get_str(st, "bot_user_id");
227
+ if (!from_user) return;
228
+ const client_id = state.ensure_client_id({ ...st });
229
+ const wire = new Wire(state.dump_dir());
230
+ await send_text(wire, token, from_user, to_user, text, state.get_str(st, "context_token"), client_id);
231
+ }
@@ -0,0 +1,351 @@
1
+ // hook/reply 单测 —— 三路锚点:
2
+ // ① wxprobe format/reply.rs 单测逐条镜像(同输入同断言值);
3
+ // ② parity/golden.jsonl 黄金值逐字复刻(ZCode 真实源码跑出的 71 条中
4
+ // hook 消费的 flush.*/blocks.* content 段);
5
+ // ③ remote_control/mod.rs notice 流程对拍(启停通知置位/冲刷规则)。
6
+ // 跑法:node --test packages/wechat-bridge/pi/hook.test.mjs
7
+
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+
11
+ import {
12
+ MAX_REPLY_MESSAGE_LENGTH,
13
+ split_long_reply_text,
14
+ extract_bot_assistant_response_messages,
15
+ } from "./reply.mjs";
16
+ import {
17
+ WX_LINK_ON,
18
+ WX_LINK_OFF,
19
+ WX_TASK_ERROR_PREFIX,
20
+ stop_notice,
21
+ is_routed,
22
+ assistant_speech_blocks,
23
+ replies_for_message,
24
+ error_notice_text,
25
+ create_hook,
26
+ } from "./hook.mjs";
27
+
28
+ // ---------------------------------------------------------------- ① reply.rs 单测镜像
29
+
30
+ test("split_prefers_space_break_over_earlier_newline", () => {
31
+ // 窗口内既有换行又有空格时取 max → 空格更靠后就断在空格
32
+ const text = "line1\n" + "b".repeat(40) + " " + "c".repeat(3600);
33
+ const chunks = split_long_reply_text(text);
34
+ // 首块在空格处断(index 46),剩余 3600 个 c 还要再硬切一次 → 共 3 块。
35
+ assert.equal(chunks.length, 3, JSON.stringify(chunks.map((c) => c.length)));
36
+ assert.ok(chunks[0].endsWith("b".repeat(40)), JSON.stringify(chunks[0].slice(-50)));
37
+ assert.ok(chunks[0].startsWith("line1"), JSON.stringify(chunks[0].slice(0, 10)));
38
+ assert.equal(Array.from(chunks[1]).length, 3500, "第二次无断点硬切");
39
+ });
40
+
41
+ test("split_hard_cuts_when_no_breakpoint_in_window", () => {
42
+ const chunks = split_long_reply_text("x".repeat(7000));
43
+ assert.equal(chunks.length, 2, "无断点硬切 3500");
44
+ assert.equal(Array.from(chunks[0]).length, 3500);
45
+ assert.equal(Array.from(chunks[1]).length, 3500);
46
+ });
47
+
48
+ test("split_keeps_short_text_whole_and_trims", () => {
49
+ assert.deepEqual(split_long_reply_text(" hello "), ["hello"]);
50
+ assert.deepEqual(split_long_reply_text(""), []);
51
+ assert.equal(split_long_reply_text("a".repeat(3500)).length, 1, "等于上限不切");
52
+ });
53
+
54
+ test("split_counts_by_code_points_like_rust_char", () => {
55
+ // 代理对 emoji 按码点计数:2000 个 emoji = 4000 UTF-16 单元但 2000 码点,
56
+ // 加 1600 个 ascii = 3600 码点 > 3500 → 切两块(UTF-16 切法会切出乱码块)
57
+ const text = "😀".repeat(2000) + "a".repeat(1600);
58
+ const chunks = split_long_reply_text(text);
59
+ assert.equal(chunks.length, 2);
60
+ for (const c of chunks) {
61
+ assert.ok(Array.from(c).every((ch) => !/\uD800/.test(ch)), "不产生孤立代理项");
62
+ }
63
+ });
64
+
65
+ test("flush_only_on_terminal_state", () => {
66
+ let { messages, rest } = extract_bot_assistant_response_messages(" partial ", false);
67
+ assert.deepEqual(messages, [], "非终态不 flush");
68
+ assert.equal(rest, " partial ", "原文保留在 buffer");
69
+
70
+ ({ messages, rest } = extract_bot_assistant_response_messages(" done ", true));
71
+ assert.deepEqual(messages, ["done"]);
72
+ assert.equal(rest, "", "flush 后 buffer 清空");
73
+ });
74
+
75
+ // ---------------------------------------------------------------- ② parity 黄金值(golden.jsonl 逐字)
76
+
77
+ test("parity_flush_partial", () => {
78
+ // {"id":"flush.partial","out":{"messages":[],"rest":" 正在写入 wire.rs "}}
79
+ const { messages, rest } = extract_bot_assistant_response_messages(" 正在写入 wire.rs ", false);
80
+ assert.deepEqual(messages, []);
81
+ assert.equal(rest, " 正在写入 wire.rs ");
82
+ });
83
+
84
+ test("parity_flush_terminal", () => {
85
+ // {"id":"flush.terminal","out":{"messages":["写完了\n第二行"],"rest":""}}
86
+ const { messages, rest } = extract_bot_assistant_response_messages(" 写完了\r\n第二行 ", true);
87
+ assert.deepEqual(messages, ["写完了\n第二行"]);
88
+ assert.equal(rest, "");
89
+ });
90
+
91
+ test("parity_blocks_content_segments", () => {
92
+ // blocks.* 三条的 content 段(工具调用/变更摘要块被 hook 过滤,不在移植面)
93
+ // blocks.mixed: content "我先看看测试。" → ["我先看看测试。"]
94
+ assert.deepEqual(split_long_reply_text("我先看看测试。"), ["我先看看测试。"]);
95
+ // blocks.en: "Line one.\r\nLine two." → CRLF 归一 → ["Line one.\nLine two."]
96
+ assert.deepEqual(extract_bot_assistant_response_messages("Line one.\r\nLine two.", true).messages, [
97
+ "Line one.\nLine two.",
98
+ ]);
99
+ // blocks.long_content: 尾随空格 trim,单块整发
100
+ const long =
101
+ "先来一段很长的话用来验证分块:" +
102
+ Array.from({ length: 26 }, (_, i) => `${String.fromCharCode(97 + i)}`.repeat(4)).join(" ") +
103
+ " ";
104
+ assert.deepEqual(split_long_reply_text(long.replace(/\r\n/g, "\n")), [long.trimEnd()]);
105
+ });
106
+
107
+ test("max_reply_message_length_is_3500", () => {
108
+ assert.equal(MAX_REPLY_MESSAGE_LENGTH, 3500);
109
+ });
110
+
111
+ // ---------------------------------------------------------------- 消息 → 发言(滤工具调用)
112
+
113
+ const assistant_msg = (content) => ({ role: "assistant", content });
114
+
115
+ test("speech_blocks_filter_thinking_and_tool_calls", () => {
116
+ const msg = assistant_msg([
117
+ { type: "thinking", thinking: "内部推理" },
118
+ { type: "text", text: "第一段" },
119
+ { type: "toolCall", id: "t1", name: "bash", arguments: {} },
120
+ { type: "text", text: "第二段" },
121
+ ]);
122
+ assert.deepEqual(assistant_speech_blocks(msg), ["第一段", "第二段"]);
123
+ });
124
+
125
+ test("speech_blocks_reject_non_assistant_and_malformed", () => {
126
+ assert.deepEqual(assistant_speech_blocks({ role: "user", content: [{ type: "text", text: "x" }] }), []);
127
+ assert.deepEqual(assistant_speech_blocks(null), []);
128
+ assert.deepEqual(assistant_speech_blocks(assistant_msg(undefined)), []);
129
+ // 非 text 类型/非字符串 text 一律不进发言
130
+ assert.deepEqual(
131
+ assistant_speech_blocks(assistant_msg([{ type: "text", text: 42 }, { type: "text" }, "raw"])),
132
+ [],
133
+ );
134
+ });
135
+
136
+ test("replies_chunk_per_block_in_order", () => {
137
+ // 两个发言块独立分块后按序拼接;thinking/toolCall 不产生任何块
138
+ const long = "c".repeat(7000);
139
+ const msg = assistant_msg([
140
+ { type: "text", text: "先跑个命令" },
141
+ { type: "toolCall", id: "t", name: "bash", arguments: {} },
142
+ { type: "text", text: long },
143
+ { type: "thinking", thinking: "…" },
144
+ ]);
145
+ const replies = replies_for_message(msg);
146
+ assert.equal(replies.length, 3);
147
+ assert.equal(replies[0], "先跑个命令");
148
+ assert.equal(Array.from(replies[1]).length, 3500);
149
+ assert.equal(Array.from(replies[2]).length, 3500);
150
+ });
151
+
152
+ test("replies_empty_for_tool_call_only_or_whitespace", () => {
153
+ assert.deepEqual(replies_for_message(assistant_msg([{ type: "toolCall", id: "t", name: "bash", arguments: {} }])), []);
154
+ assert.deepEqual(replies_for_message(assistant_msg([{ type: "text", text: " \r\n " }])), []);
155
+ });
156
+
157
+ // ---------------------------------------------------------------- ③ 启停通知(002·通知 定案)
158
+
159
+ test("stop_notice_rules_002", () => {
160
+ // 有意偏离 Rust 节制规则(见 hook.mjs 注释):服务在跑 + 用户关停/卸载 →
161
+ // 宣告「已失联」;没在跑或非用户关停 → 静默
162
+ assert.equal(stop_notice(true, true), false, "用户关停+在跑 → 发「已失联」");
163
+ assert.equal(stop_notice(true, false), null, "没在跑谈不上关停通知");
164
+ assert.equal(stop_notice(false, true), null, "换绑(非用户关停)→ 静默");
165
+ });
166
+
167
+ test("notice_texts_match_002_notice", () => {
168
+ // 002·通知 定稿文案(messages.mjs 文案表单源,hook.mjs 转出)
169
+ assert.equal(WX_LINK_ON, "⚡️Pi-Flash已连接");
170
+ assert.equal(WX_LINK_OFF, "⚡️Pi-Flash已失联");
171
+ });
172
+
173
+ test("is_routed_reads_chat_id_or_user_id", () => {
174
+ assert.equal(is_routed({}), false);
175
+ assert.equal(is_routed({ chat_id: " " }), false, "空串视同缺失");
176
+ assert.equal(is_routed({ chat_id: "room@chat" }), true);
177
+ assert.equal(is_routed({ user_id: "u@im.wechat" }), true, "私聊只有 user_id 也算路由就绪");
178
+ });
179
+
180
+ // ---------------------------------------------------------------- hook 流程(create_hook 注入对拍)
181
+
182
+ function mk_hook({ routed = true, transport = null, directSend } = {}) {
183
+ const sends = [];
184
+ const directs = [];
185
+ const tr = transport
186
+ ? { send: (t) => sends.push(t) }
187
+ : transport === null
188
+ ? null
189
+ : transport; // 传入 {send} 对象则用之
190
+ const hook = create_hook({
191
+ loadState: () => (routed ? { chat_id: "room@chat" } : {}),
192
+ transport: () => tr,
193
+ directSend: directSend ?? ((t) => directs.push(t)),
194
+ });
195
+ return { hook, sends, directs, tr };
196
+ }
197
+
198
+ const settle = () => new Promise((r) => setTimeout(r, 0));
199
+
200
+ test("flush_start_waits_for_route_then_sends_once", () => {
201
+ const h = mk_hook({ routed: false });
202
+ h.hook.on_start();
203
+ assert.equal(h.hook.notice, true);
204
+ assert.deepEqual(h.hook.flush_notice(), [], "路由未就绪:留到下一拍");
205
+ assert.equal(h.hook.notice, true, "挂起不丢");
206
+ // 路由就绪但 transport 未起:继续等
207
+ const h2 = mk_hook({ routed: true, transport: null });
208
+ h2.hook.on_start();
209
+ assert.deepEqual(h2.hook.flush_notice(), []);
210
+ assert.equal(h2.hook.notice, true);
211
+ // transport 就绪 → 发出并清挂起;再拍 no-op
212
+ const h3 = mk_hook({ routed: true, transport: true });
213
+ h3.hook.on_start();
214
+ assert.deepEqual(h3.hook.flush_notice(), [WX_LINK_ON]);
215
+ assert.deepEqual(h3.sends, [WX_LINK_ON]);
216
+ assert.equal(h3.hook.notice, null);
217
+ assert.deepEqual(h3.hook.flush_notice(), []);
218
+ });
219
+
220
+ test("flush_start_retries_when_send_queue_rejects", () => {
221
+ const hook = create_hook({
222
+ loadState: () => ({ chat_id: "c" }),
223
+ transport: () => ({ send: () => { throw new Error("send 线程已退出"); } }),
224
+ directSend: () => {},
225
+ });
226
+ hook.on_start();
227
+ assert.deepEqual(hook.flush_notice(), []);
228
+ assert.equal(hook.notice, true, "入队失败不丢通知,下一拍重试");
229
+ });
230
+
231
+ test("user_stop_after_announced_sends_off_via_direct", async () => {
232
+ const h = mk_hook({ routed: true, transport: true });
233
+ h.hook.on_start();
234
+ h.hook.flush_notice(); // 已连接宣告完成
235
+ h.hook.on_stop(true, true); // 用户关停
236
+ assert.equal(h.hook.notice, false);
237
+ assert.deepEqual(h.hook.flush_notice(), [WX_LINK_OFF]);
238
+ await settle();
239
+ assert.deepEqual(h.directs, [WX_LINK_OFF], "降沿走一次性直发");
240
+ assert.deepEqual(h.sends, [WX_LINK_ON], "已停的 transport 不再入队");
241
+ assert.equal(h.hook.notice, null);
242
+ assert.deepEqual(h.hook.flush_notice(), [], "冲刷完 no-op");
243
+ });
244
+
245
+ test("user_stop_while_connect_pending_still_announces", () => {
246
+ const h = mk_hook({ routed: true, transport: true });
247
+ h.hook.on_start();
248
+ // 连接宣告还没冲出去就关停(真机 2026-11-01:渠道被夺/进程被杀抢在冲刷前):
249
+ // 002·通知 = 服务在跑就报失联,不再被旧 announced 规则吞掉
250
+ h.hook.on_stop(true, true);
251
+ assert.equal(h.hook.notice, false, "was_on 即判据:挂起「已失联」");
252
+ });
253
+
254
+ test("rebind_stop_is_silent_even_after_announced", () => {
255
+ const h = mk_hook({ routed: true, transport: true });
256
+ h.hook.on_start();
257
+ h.hook.flush_notice();
258
+ h.hook.on_stop(false, true); // 换绑:stop(false) 落 Pending
259
+ assert.equal(h.hook.notice, null, "换绑静默,紧接着的 On 会发「已连接」");
260
+ });
261
+
262
+ test("message_end_queues_speech_chunks_to_transport", () => {
263
+ const h = mk_hook({ routed: true, transport: true });
264
+ const sent = h.hook.on_message_end(
265
+ assistant_msg([
266
+ { type: "text", text: "你好\r\n世界" },
267
+ { type: "toolCall", id: "t", name: "bash", arguments: {} },
268
+ ]),
269
+ );
270
+ assert.deepEqual(sent, ["你好\n世界"], "CRLF 归一后入队");
271
+ assert.deepEqual(h.sends, ["你好\n世界"]);
272
+ // 无 transport(服务关停)→ 静默丢弃(产品语义)
273
+ const h2 = mk_hook({ routed: true, transport: null });
274
+ assert.deepEqual(h2.hook.on_message_end(assistant_msg([{ type: "text", text: "x" }])), []);
275
+ assert.deepEqual(h2.sends, []);
276
+ // 非助手消息 / 工具调用消息 → 空
277
+ assert.deepEqual(h.hook.on_message_end({ role: "user", content: [{ type: "text", text: "hi" }] }), []);
278
+ assert.deepEqual(h.hook.on_message_end(assistant_msg([{ type: "thinking", thinking: "t" }])), []);
279
+ });
280
+
281
+ test("message_end_long_speech_splits_at_3500", () => {
282
+ const h = mk_hook({ routed: true, transport: true });
283
+ const sent = h.hook.on_message_end(assistant_msg([{ type: "text", text: "x".repeat(7000) }]));
284
+ assert.equal(sent.length, 2);
285
+ assert.ok(sent.every((s) => s.length === 3500));
286
+ });
287
+
288
+ // ---------------------------------------------------------------- 异常停止回执(2026-11-01 buglist#2)
289
+
290
+ /** pi 出错形态的助手消息:stopReason="error"、errorMessage 携带文案、content 空。 */
291
+ const error_msg = (errorMessage, stopReason = "error") => ({
292
+ role: "assistant",
293
+ content: [],
294
+ stopReason,
295
+ errorMessage,
296
+ usage: {},
297
+ });
298
+
299
+ test("error_notice_only_for_error_stop_with_text", () => {
300
+ assert.equal(error_notice_text(error_msg("boom")), `${WX_TASK_ERROR_PREFIX}boom`);
301
+ assert.equal(error_notice_text(error_msg(" boom ")), `${WX_TASK_ERROR_PREFIX}boom`, "首尾空白裁掉");
302
+ assert.equal(error_notice_text(error_msg(undefined)), null, "无错误文本不报");
303
+ assert.equal(error_notice_text(error_msg(" ")), null);
304
+ assert.equal(error_notice_text(error_msg("boom", "aborted")), null, "用户主动停不是异常");
305
+ assert.equal(error_notice_text(error_msg("boom", "stop")), null);
306
+ assert.equal(error_notice_text(error_msg("boom", "toolUse")), null);
307
+ assert.equal(error_notice_text({ role: "user", content: [] }), null);
308
+ assert.equal(error_notice_text(null), null);
309
+ });
310
+
311
+ test("error_message_not_sent_immediately_stashed_for_settle", () => {
312
+ const h = mk_hook({ routed: true, transport: true });
313
+ assert.deepEqual(h.hook.on_message_end(error_msg("boom")), [], "error 消息不即发");
314
+ assert.deepEqual(h.sends, [], "等 settle 定论:瞬态错不刷屏");
315
+ assert.deepEqual(h.hook.on_agent_settled(false), [`${WX_TASK_ERROR_PREFIX}boom`]);
316
+ assert.deepEqual(h.sends, [`${WX_TASK_ERROR_PREFIX}boom`]);
317
+ assert.deepEqual(h.hook.on_agent_settled(false), [], "账已清:不重发");
318
+ });
319
+
320
+ test("recovered_run_clears_stashed_error", () => {
321
+ const h = mk_hook({ routed: true, transport: true });
322
+ h.hook.on_message_end(error_msg("transient"));
323
+ // 重试成功,正常发言落地 = 运行恢复
324
+ h.hook.on_message_end(assistant_msg([{ type: "text", text: "好了" }]));
325
+ assert.deepEqual(h.hook.on_agent_settled(false), [], "恢复了的运行不报旧账");
326
+ assert.deepEqual(h.sends, ["好了"]);
327
+ });
328
+
329
+ test("aborted_settle_swallows_error_notice", () => {
330
+ const h = mk_hook({ routed: true, transport: true });
331
+ h.hook.on_message_end(error_msg("boom"));
332
+ assert.deepEqual(h.hook.on_agent_settled(true), [], "/停止语义:不报异常");
333
+ assert.deepEqual(h.hook.on_agent_settled(false), [], "账已清");
334
+ });
335
+
336
+ test("settled_without_transport_clears_silently", () => {
337
+ const h = mk_hook({ routed: true, transport: null });
338
+ h.hook.on_message_end(error_msg("boom"));
339
+ assert.deepEqual(h.hook.on_agent_settled(false), [], "服务关停 = 微信整体不出站,不留陈账");
340
+ });
341
+
342
+ test("settled_error_long_text_splits_at_3500", () => {
343
+ const h = mk_hook({ routed: true, transport: true });
344
+ h.hook.on_message_end(error_msg("x".repeat(7000)));
345
+ const sent = h.hook.on_agent_settled(false);
346
+ // 前缀 9 码点 + 7000 = 7009 → 3500/3500/9 三块;前缀内无空格,首块整带前缀
347
+ assert.equal(sent.length, 3, `应分三块:${sent.length}`);
348
+ assert.ok(sent[0].startsWith(WX_TASK_ERROR_PREFIX), "首块整带前缀");
349
+ assert.equal(Array.from(sent[0]).length, 3500);
350
+ assert.equal(Array.from(sent[2]).length, 7009 - 7000);
351
+ });