@mrrisega/dsh-remote 0.7.12-beta.5 → 0.8.0
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/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
- package/clients/dsh-remote/dingtalk-bot.mjs +283 -63
- package/clients/dsh-remote/feishu-bot.mjs +127 -1
- package/clients/dsh-remote/im-commands.mjs +302 -0
- package/clients/dsh-remote/im-format.mjs +105 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +297 -1
- package/clients/dsh-remote/test/feishu-bot.test.mjs +108 -3
- package/clients/dsh-remote/test/im-caps-fixture.mjs +8 -0
- package/clients/dsh-remote/test/im-commands.test.mjs +283 -0
- package/clients/dsh-remote/test/im-format.test.mjs +60 -0
- package/clients/dsh-remote/test/wecom-bot.test.mjs +104 -89
- package/clients/dsh-remote/wecom-bot.mjs +138 -102
- package/dsh-setup.mjs +1 -1
- package/package.json +2 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-auth.mjs +631 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +249 -32
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +35 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +101 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-commands.mjs +302 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +84 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* im-commands — 三家 IM 机器人共用命令层单测(2026-11-08)。
|
|
3
|
+
*
|
|
4
|
+
* 纯函数 + 注入式执行器,覆盖:解析(大小写/参数/未知)、/help 全命令、未知命令不转发、
|
|
5
|
+
* 免费档 /ls /use 被拦 + 升级文案、/stop 免费立即生效、/use 越界/缺参、/status 与快照同源、
|
|
6
|
+
* /unbind 免费、命令回复不含裸表格、写配置 sessionId(保留其它段)。
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
IM_COMMANDS,
|
|
16
|
+
commandAllowed,
|
|
17
|
+
executeImCommand,
|
|
18
|
+
helpText,
|
|
19
|
+
lsText,
|
|
20
|
+
parseImCommand,
|
|
21
|
+
writeImBotsSession
|
|
22
|
+
} from "../im-commands.mjs";
|
|
23
|
+
import { freeCaps, paidCaps } from "./im-caps-fixture.mjs";
|
|
24
|
+
import { fileMode } from "../wecom-bot.mjs";
|
|
25
|
+
|
|
26
|
+
function tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), "dsh-imcmd-")); }
|
|
27
|
+
|
|
28
|
+
function sessionsFixture() {
|
|
29
|
+
const rows = [
|
|
30
|
+
{ sessionId: "session-alpha", title: "重构支付回调链路(很长的标题会被截断到二十四字以内)", updatedAt: Date.UTC(2026, 10, 8, 1, 30), running: true },
|
|
31
|
+
{ sessionId: "session-beta", title: "修端口探测", updatedAt: Date.UTC(2026, 10, 7, 22, 0) },
|
|
32
|
+
{ sessionId: "session-gamma", title: "写测试", updatedAt: 0 }
|
|
33
|
+
];
|
|
34
|
+
return async () => rows;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 一个记录调用的 ctx(各用例按需覆盖)。 */
|
|
38
|
+
function makeCtx(over = {}) {
|
|
39
|
+
const calls = { switch: [], cancel: [], unbind: 0 };
|
|
40
|
+
const ctx = {
|
|
41
|
+
caps: freeCaps(),
|
|
42
|
+
tier: "free",
|
|
43
|
+
appUrl: "https://app.test/",
|
|
44
|
+
channelLabel: "钉钉",
|
|
45
|
+
sessions: sessionsFixture(),
|
|
46
|
+
currentSessionId: () => "session-alpha",
|
|
47
|
+
currentSessionTitle: "重构支付回调链路",
|
|
48
|
+
switchSession: async (arg) => { calls.switch.push(arg); return { ok: true }; },
|
|
49
|
+
cancelSession: async (arg) => { calls.cancel.push(arg); return { ok: true }; },
|
|
50
|
+
unbind: async () => { calls.unbind += 1; return { ok: true }; },
|
|
51
|
+
statusSnapshot: async () => ({
|
|
52
|
+
connected: true, authenticated: true, state: "connected", lastError: "",
|
|
53
|
+
tier: "free", usage: { month: "2026-11", messages: 3 }, messagesPerMonth: 20, remainingMessages: 17,
|
|
54
|
+
currentSessionId: "session-alpha", currentSessionTitle: "重构支付回调链路"
|
|
55
|
+
}),
|
|
56
|
+
...over
|
|
57
|
+
};
|
|
58
|
+
return { ctx, calls };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const run = (text, ctx) => executeImCommand({ text, ctx });
|
|
62
|
+
const assertNoRawTable = (text) => {
|
|
63
|
+
assert.ok(!/\|\s*-{2,}/.test(text), `不得含表格分隔行:${text}`);
|
|
64
|
+
assert.ok(!/^\s*\|.*\|\s*$/m.test(text), `不得含裸表格行:${text}`);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────── 解析 ───────────────────────────
|
|
68
|
+
|
|
69
|
+
test("parseImCommand:命令/大小写/参数/未知;普通文本返回 null", () => {
|
|
70
|
+
assert.deepEqual(parseImCommand("/help"), { name: "/help", arg: "", rest: "", unknown: false, raw: "/help" });
|
|
71
|
+
assert.equal(parseImCommand("/HELP").name, "/help", "大小写不敏感");
|
|
72
|
+
assert.equal(parseImCommand(" /Status ").name, "/status");
|
|
73
|
+
assert.equal(parseImCommand("/use 3").arg, "3");
|
|
74
|
+
assert.equal(parseImCommand("/use").arg, "");
|
|
75
|
+
assert.equal(parseImCommand("/use abc").arg, "abc");
|
|
76
|
+
assert.equal(parseImCommand("/ls 额外 参数").arg, "额外");
|
|
77
|
+
assert.equal(parseImCommand("/").unknown, true, "光一个 / 算未知命令");
|
|
78
|
+
assert.equal(parseImCommand("/wat").unknown, false);
|
|
79
|
+
assert.equal(parseImCommand("hello"), null, "普通文本不是命令");
|
|
80
|
+
assert.equal(parseImCommand("ls"), null);
|
|
81
|
+
assert.equal(parseImCommand(" /help 看下 ").arg, "看下");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─────────────────────────── /help / 未知 ───────────────────────────
|
|
85
|
+
|
|
86
|
+
test("★ /help:列出全部命令(免费/会员标注),免费档看到会员命令标注", async () => {
|
|
87
|
+
const { ctx } = makeCtx();
|
|
88
|
+
const r = await run("/help", ctx);
|
|
89
|
+
assert.equal(r.handled, true);
|
|
90
|
+
assert.equal(r.ok, true);
|
|
91
|
+
for (const c of IM_COMMANDS) assert.ok(r.text.includes(c.usage), `/help 必须包含 ${c.usage}`);
|
|
92
|
+
assert.match(r.text, /命令不会发给模型/);
|
|
93
|
+
assert.match(r.text, /\/stop/);
|
|
94
|
+
assert.match(r.text, /\/unbind/);
|
|
95
|
+
assert.match(r.text, /会员/, "免费档要标注会员命令");
|
|
96
|
+
assertNoRawTable(r.text);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("★ 未知命令:不转发给模型(handled=true),回「未知命令 + 帮助」", async () => {
|
|
100
|
+
let promptCalls = 0;
|
|
101
|
+
const { ctx } = makeCtx();
|
|
102
|
+
const r = await run("/deploy now", ctx);
|
|
103
|
+
assert.equal(r.handled, true, "★ 未知 /xxx 必须被命令层拦下");
|
|
104
|
+
assert.ok(promptCalls === 0);
|
|
105
|
+
assert.match(r.text, /未知命令:\/deploy/);
|
|
106
|
+
assert.match(r.text, /\/help/);
|
|
107
|
+
assertNoRawTable(r.text);
|
|
108
|
+
// 非命令文本仍返回 handled:false(照旧进模型)
|
|
109
|
+
const normal = await run("帮我改一下代码", ctx);
|
|
110
|
+
assert.deepEqual(normal, { handled: false });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─────────────────────────── 能力门禁 ───────────────────────────
|
|
114
|
+
|
|
115
|
+
test("★ 免费档 /ls、/use 被拦:回会员文案 + /app/promo;命令不进模型", async () => {
|
|
116
|
+
const { ctx } = makeCtx();
|
|
117
|
+
const ls = await run("/ls", ctx);
|
|
118
|
+
assert.equal(ls.handled, true);
|
|
119
|
+
assert.equal(ls.gated, true);
|
|
120
|
+
assert.match(ls.text, /会员功能/);
|
|
121
|
+
assert.match(ls.text, /https:\/\/app\.test\/promo/);
|
|
122
|
+
assert.match(ls.text, /免费可用/);
|
|
123
|
+
assertNoRawTable(ls.text);
|
|
124
|
+
const use = await run("/use 1", ctx);
|
|
125
|
+
assert.equal(use.gated, true);
|
|
126
|
+
assert.match(use.text, /\/use 是会员功能/);
|
|
127
|
+
// 免费命令不受影响
|
|
128
|
+
for (const cmd of ["/help", "/status", "/stop", "/unbind"]) {
|
|
129
|
+
const r = await run(cmd, ctx);
|
|
130
|
+
assert.equal(r.gated, undefined, `${cmd} 必须免费可用`);
|
|
131
|
+
assert.equal(r.handled, true);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ─────────────────────────── /ls ───────────────────────────
|
|
136
|
+
|
|
137
|
+
test("★ /ls(会员):≤10 条、标题截断 24、索引 + 当前标记 + 更新时间;不含裸表格", async () => {
|
|
138
|
+
const many = Array.from({ length: 15 }, (_, i) => ({ sessionId: `session-${i}`, title: `会话 ${i} 号很长的标题需要被截断处理一下`, updatedAt: Date.UTC(2026, 10, 8, 10, i % 60) }));
|
|
139
|
+
const { ctx } = makeCtx({ caps: paidCaps(), sessions: async () => many, currentSessionId: () => "session-3" });
|
|
140
|
+
const r = await run("/ls", ctx);
|
|
141
|
+
assert.equal(r.ok, true);
|
|
142
|
+
const lines = r.text.split("\n").filter((l) => /^\d+\. /.test(l));
|
|
143
|
+
assert.equal(lines.length, 10, "最多 10 条");
|
|
144
|
+
assert.match(r.text, /1\. 会话 0 号/);
|
|
145
|
+
assert.match(r.text, /4\. 会话 3 号[^\n]*(当前)/, "当前会话要有标记");
|
|
146
|
+
assert.match(r.text, /· \d{2}-\d{2} \d{2}:\d{2}/, "带更新时间");
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
const titlePart = line.replace(/^\d+\. /, "").split(" ")[0];
|
|
149
|
+
assert.ok(Array.from(titlePart.replace(/…$/, "")).length <= 24, `标题超过 24 字:${line}`);
|
|
150
|
+
}
|
|
151
|
+
assert.match(r.text, /\/use <序号>/);
|
|
152
|
+
assertNoRawTable(r.text);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("/ls:没有会话时给可操作提示", async () => {
|
|
156
|
+
const { ctx } = makeCtx({ caps: paidCaps(), sessions: async () => [] });
|
|
157
|
+
const r = await run("/ls", ctx);
|
|
158
|
+
assert.equal(r.ok, true);
|
|
159
|
+
assert.match(r.text, /最近没有可列出的会话/);
|
|
160
|
+
assert.match(r.text, /打开\/运行一个会话/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─────────────────────────── /use ───────────────────────────
|
|
164
|
+
|
|
165
|
+
test("★ /use <N>:合法序号切换(写配置由 ctx.switchSession 完成)+ 回执", async () => {
|
|
166
|
+
const { ctx, calls } = makeCtx({ caps: paidCaps() });
|
|
167
|
+
const r = await run("/use 2", ctx);
|
|
168
|
+
assert.equal(r.ok, true);
|
|
169
|
+
assert.deepEqual(calls.switch, [{ sessionId: "session-beta", title: "修端口探测" }]);
|
|
170
|
+
assert.match(r.text, /已切换到 #2 修端口探测/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("★ /use 越界 / 缺参 / 无会话:给可操作提示,不崩", async () => {
|
|
174
|
+
const { ctx, calls } = makeCtx({ caps: paidCaps() });
|
|
175
|
+
const out = await run("/use 99", ctx);
|
|
176
|
+
assert.equal(out.ok, true, "越界是提示不是崩溃");
|
|
177
|
+
assert.match(out.text, /序号超出范围:当前列表是 1-3/);
|
|
178
|
+
assert.match(out.text, /\/ls/);
|
|
179
|
+
assert.equal(calls.switch.length, 0);
|
|
180
|
+
const missing = await run("/use", ctx);
|
|
181
|
+
assert.match(missing.text, /用法:\/use <序号>/);
|
|
182
|
+
const none = await run("/use 1", makeCtx({ caps: paidCaps(), sessions: async () => [] }).ctx);
|
|
183
|
+
assert.match(none.text, /没有可切换的会话/);
|
|
184
|
+
const failing = await run("/use 1", makeCtx({ caps: paidCaps(), switchSession: async () => ({ ok: false, reason: "写配置失败" }) }).ctx);
|
|
185
|
+
assert.match(failing.text, /切换失败:写配置失败/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ─────────────────────────── /stop / /status / /unbind ───────────────────────────
|
|
189
|
+
|
|
190
|
+
test("★ /stop:免费可用、立即调 cancelSession、回执明确(没会话给说明)", async () => {
|
|
191
|
+
const { ctx, calls } = makeCtx();
|
|
192
|
+
const r = await run("/stop", ctx);
|
|
193
|
+
assert.equal(r.ok, true);
|
|
194
|
+
assert.deepEqual(calls.cancel, [{ sessionId: "session-alpha" }]);
|
|
195
|
+
assert.match(r.text, /已急停:重构支付回调链路/);
|
|
196
|
+
// 无会话
|
|
197
|
+
const none = await run("/stop", makeCtx({ currentSessionId: () => "" }).ctx);
|
|
198
|
+
assert.match(none.text, /没有正在运行的会话/);
|
|
199
|
+
// 取消失败要给"没收到确认"
|
|
200
|
+
const fail = await run("/stop", makeCtx({ cancelSession: async () => ({ ok: false, message: "通道断开" }) }).ctx);
|
|
201
|
+
assert.match(fail.text, /没收到确认:通道断开/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("★ /status:连接状态 + 当前会话 + 本月剩余(与状态快照同源;不限量显示不限)", async () => {
|
|
205
|
+
const { ctx } = makeCtx();
|
|
206
|
+
const r = await run("/status", ctx);
|
|
207
|
+
assert.equal(r.ok, true);
|
|
208
|
+
assert.match(r.text, /连接状态/);
|
|
209
|
+
assert.match(r.text, /✅ 已连接/);
|
|
210
|
+
assert.match(r.text, /当前会话:重构支付回调链路/);
|
|
211
|
+
assert.match(r.text, /本月指令:已用 3\/20/);
|
|
212
|
+
assert.match(r.text, /剩余:17 条/);
|
|
213
|
+
assertNoRawTable(r.text);
|
|
214
|
+
// 付费不限量
|
|
215
|
+
const pro = await run("/status", makeCtx({
|
|
216
|
+
caps: paidCaps(), tier: "pro",
|
|
217
|
+
statusSnapshot: async () => ({ connected: false, state: "reconnecting", tier: "pro", usage: { messages: 0 }, messagesPerMonth: 0, currentSessionId: "" })
|
|
218
|
+
}).ctx);
|
|
219
|
+
assert.match(pro.text, /未连接(reconnecting/);
|
|
220
|
+
assert.match(pro.text, /本月指令:不限(pro)/);
|
|
221
|
+
assert.match(pro.text, /当前会话:未选择/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("★ /unbind:免费可用、任何人可退订(调 unbind + 明确回执)", async () => {
|
|
225
|
+
const { ctx, calls } = makeCtx();
|
|
226
|
+
const r = await run("/unbind", ctx);
|
|
227
|
+
assert.equal(r.ok, true);
|
|
228
|
+
assert.equal(calls.unbind, 1);
|
|
229
|
+
assert.match(r.text, /已解绑/);
|
|
230
|
+
assert.match(r.text, /重新绑定/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────── 执行器健壮性 / 配置写入 ───────────────────────────
|
|
234
|
+
|
|
235
|
+
test("执行器:ctx 抛错也返回 handled:true(命令层绝不把异常漏给模型)", async () => {
|
|
236
|
+
const r = await run("/ls", makeCtx({ caps: paidCaps(), sessions: async () => { throw new Error("订阅器挂了"); } }).ctx);
|
|
237
|
+
assert.equal(r.handled, true);
|
|
238
|
+
assert.match(r.text, /命令执行失败:订阅器挂了/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("★ writeImBotsSession:写对应通道的 sessionId/sessionTitle(保留其它段,0600)", () => {
|
|
242
|
+
const dir = tmpDir();
|
|
243
|
+
try {
|
|
244
|
+
fs.writeFileSync(path.join(dir, "im-bots.json"), JSON.stringify({
|
|
245
|
+
version: 1,
|
|
246
|
+
wecom: { enabled: true, botId: "b" },
|
|
247
|
+
dingtalk: { enabled: true, clientId: "c" },
|
|
248
|
+
feishu: { enabled: false }
|
|
249
|
+
}), { mode: 0o600 });
|
|
250
|
+
const r = writeImBotsSession(dir, "dingtalk", { sessionId: "session-beta", sessionTitle: "修端口探测" });
|
|
251
|
+
assert.equal(r.ok, true);
|
|
252
|
+
const disk = JSON.parse(fs.readFileSync(path.join(dir, "im-bots.json"), "utf8"));
|
|
253
|
+
assert.equal(disk.dingtalk.sessionId, "session-beta");
|
|
254
|
+
assert.equal(disk.dingtalk.sessionTitle, "修端口探测");
|
|
255
|
+
assert.equal(disk.dingtalk.clientId, "c", "其它字段保留");
|
|
256
|
+
assert.equal(disk.wecom.botId, "b", "其它通道段保留");
|
|
257
|
+
if (process.platform !== "win32") assert.equal(fileMode(path.join(dir, "im-bots.json")) & 0o777, 0o600);
|
|
258
|
+
// 非法参数
|
|
259
|
+
assert.equal(writeImBotsSession(dir, "", {}).ok, false);
|
|
260
|
+
assert.equal(writeImBotsSession(dir, "dingtalk", { sessionId: "" }).ok, false);
|
|
261
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("helpText/commandAllowed:能力表判断与 /help 文案一致(单一真相源)", () => {
|
|
265
|
+
assert.equal(commandAllowed(IM_COMMANDS.find((c) => c.name === "/stop"), []), true, "急停不需要能力");
|
|
266
|
+
assert.equal(commandAllowed(IM_COMMANDS.find((c) => c.name === "/ls"), freeCaps()), false);
|
|
267
|
+
assert.equal(commandAllowed(IM_COMMANDS.find((c) => c.name === "/ls"), paidCaps()), true);
|
|
268
|
+
const paidHelp = helpText({ caps: paidCaps(), appUrl: "https://a/" });
|
|
269
|
+
assert.match(paidHelp, /你的档位已包含会话管理/);
|
|
270
|
+
const freeHelp = helpText({ caps: freeCaps() });
|
|
271
|
+
assert.ok(!/已包含会话管理/.test(freeHelp));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("三家接线(源级契约):wecom/dingtalk/feishu 都在进模型前调用命令层", () => {
|
|
275
|
+
for (const file of ["wecom-bot.mjs", "dingtalk-bot.mjs", "feishu-bot.mjs"]) {
|
|
276
|
+
const src = fs.readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
|
|
277
|
+
assert.ok(/from "\.\/im-commands\.mjs"/.test(src), `${file} 必须 import im-commands.mjs`);
|
|
278
|
+
assert.ok(/executeImCommand\(/.test(src), `${file} 必须调用 executeImCommand`);
|
|
279
|
+
const cmdAt = src.indexOf("executeImCommand(");
|
|
280
|
+
const promptAt = src.indexOf("promptSession(");
|
|
281
|
+
assert.ok(cmdAt > -1 && (promptAt === -1 || cmdAt < promptAt), `${file} 命令层必须出现在 promptSession 之前(cmdAt=${cmdAt}, promptAt=${promptAt})`);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
@@ -11,7 +11,10 @@ import test from "node:test";
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
CHANNEL_CAPS,
|
|
14
|
+
SUMMARY_MAX_CHARS,
|
|
14
15
|
TRUNCATED_NOTE,
|
|
16
|
+
buildTurnNotification,
|
|
17
|
+
extractTurnSummary,
|
|
15
18
|
convertMarkdownTable,
|
|
16
19
|
displayWidth,
|
|
17
20
|
formatForChannel,
|
|
@@ -227,3 +230,60 @@ test("wechat 分支:走现有特殊格式(本模块只做 plain 兜底、不
|
|
|
227
230
|
assert.ok(!unknown.includes("**"));
|
|
228
231
|
assert.equal(unknown, "标题\n\n粗");
|
|
229
232
|
});
|
|
233
|
+
|
|
234
|
+
// ─────────────────────────── 任务总结(turn_done/turn_error 通知带正文)───────────────────────────
|
|
235
|
+
|
|
236
|
+
test("★ extractTurnSummary:订阅器正文优先 → 事件字段降级 → 取不到为空(不报错)", async () => {
|
|
237
|
+
// ① 订阅器有正文
|
|
238
|
+
const sub = { lastAssistantText: async () => ({ ok: true, text: "订阅器里的总结" }) };
|
|
239
|
+
const a = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x" }, subscriber: sub, sessionId: "session-x" });
|
|
240
|
+
assert.deepEqual(a, { text: "订阅器里的总结", source: "assistant" });
|
|
241
|
+
// ② 订阅器取不到 → 事件字段(summary/message/detail/text)
|
|
242
|
+
const b = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x", message: "事件里的错误摘要" }, subscriber: { lastAssistantText: async () => ({ ok: false, code: "page-failed" }) }, sessionId: "session-x" });
|
|
243
|
+
assert.deepEqual(b, { text: "事件里的错误摘要", source: "node" });
|
|
244
|
+
// ③ 都没有 → 空(调用方降级仅标题)
|
|
245
|
+
const c = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x" }, subscriber: null });
|
|
246
|
+
assert.equal(c.text, "");
|
|
247
|
+
assert.equal(c.source, "none");
|
|
248
|
+
// ④ 订阅器抛错也不崩,继续降级
|
|
249
|
+
const d = await extractTurnSummary({ node: { kind: "turn-end", summary: "兜底" }, subscriber: { lastAssistantText: async () => { throw new Error("boom"); } }, sessionId: "s" });
|
|
250
|
+
assert.deepEqual(d, { text: "兜底", source: "node" });
|
|
251
|
+
// ⑤ 重试:ok 但空 → 第二次拿到(turn/end 瞬间正文可能还没落库)
|
|
252
|
+
let n = 0;
|
|
253
|
+
const retried = await extractTurnSummary({
|
|
254
|
+
node: { kind: "turn-end", sessionId: "s" },
|
|
255
|
+
subscriber: { lastAssistantText: async () => { n += 1; return n === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后的总结" }; } },
|
|
256
|
+
sessionId: "s",
|
|
257
|
+
retryDelays: [0, 5]
|
|
258
|
+
});
|
|
259
|
+
assert.equal(retried.text, "重试后的总结");
|
|
260
|
+
assert.equal(n, 2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("★ buildTurnNotification:标题行 + 正文(1500 字上限/通道格式化/缺正文降级);审批类返回 null", () => {
|
|
264
|
+
// completed:✅ 任务完成|标题 + 正文
|
|
265
|
+
const done = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: "改了三个文件", sessionTitle: "修端口" });
|
|
266
|
+
assert.equal(done, "✅ 任务完成|修端口\n\n改了三个文件");
|
|
267
|
+
// error / blocked / aborted
|
|
268
|
+
assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "error" }, channel: "wecom", summary: "报错摘要" }), /^❌ 出错了\n\n报错摘要$/);
|
|
269
|
+
assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "blocked" }, channel: "wecom", summary: "被拦原因" }), /^⛔ 被拦下了\n\n被拦原因$/);
|
|
270
|
+
assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "aborted" }, channel: "wecom" }), /^⏹ 已停止$/);
|
|
271
|
+
// session-error 也用 ❌ 出错了
|
|
272
|
+
assert.match(buildTurnNotification({ node: { kind: "session-error", message: "x" }, channel: "wecom" }), /^❌ 出错了/);
|
|
273
|
+
// 缺正文 → 仅标题(不留多余空段)
|
|
274
|
+
assert.equal(buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: "" }), "✅ 任务完成");
|
|
275
|
+
// 审批类不混入总结(返回 null,仍走卡片)
|
|
276
|
+
assert.equal(buildTurnNotification({ node: { kind: "approval-request", toolName: "Bash" }, channel: "wecom", summary: "不该出现" }), null);
|
|
277
|
+
// 表格按通道转换(企微不支持表格 → 可读列表)
|
|
278
|
+
const table = "| 文件 | 改动 |\n|---|---|\n| a.mjs | 修端口 |";
|
|
279
|
+
const withTable = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: table });
|
|
280
|
+
assert.ok(!/\|\s*-{2,}/.test(withTable), "★ 通知里不得有裸表格分隔行");
|
|
281
|
+
assert.match(withTable, /• 文件:a\.mjs · 改动:修端口/);
|
|
282
|
+
// 1500 字上限(长正文截断 + 注明)
|
|
283
|
+
const long = "开头" + "长".repeat(3000);
|
|
284
|
+
const longNotice = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "feishu", summary: long });
|
|
285
|
+
const body = longNotice.split("\n\n").slice(1).join("\n\n");
|
|
286
|
+
assert.ok(body.endsWith(TRUNCATED_NOTE), "超长正文要带截断提示");
|
|
287
|
+
assert.ok(Array.from(body).length <= SUMMARY_MAX_CHARS + Array.from(TRUNCATED_NOTE).length + 2, "正文不超过 1500 字(+提示)");
|
|
288
|
+
assert.ok(!body.includes("长".repeat(2000)), "明显截断");
|
|
289
|
+
});
|
|
@@ -152,6 +152,8 @@ function fakeSubscriber(over = {}) {
|
|
|
152
152
|
sub.started = false;
|
|
153
153
|
sub.closed = false;
|
|
154
154
|
sub.promptCalls = [];
|
|
155
|
+
sub.cancelCalls = [];
|
|
156
|
+
sub.cancelSession = async ({ sessionId } = {}) => { sub.cancelCalls.push({ sessionId }); return { ok: true }; };
|
|
155
157
|
sub.sessionDetails = Array.isArray(over.sessions) ? over.sessions.slice() : [];
|
|
156
158
|
sub.start = () => { sub.started = true; };
|
|
157
159
|
sub.close = () => { sub.closed = true; };
|
|
@@ -688,62 +690,56 @@ async function setupRelayBot(over = {}) {
|
|
|
688
690
|
return { server, sub, bot, lines, sends, inbound, turnEnd, close: async () => { await bot.stop(); await server.close(); } };
|
|
689
691
|
}
|
|
690
692
|
|
|
691
|
-
test("★
|
|
692
|
-
const rt = await setupRelayBot({ assistantText: { ok: true, text: "这是 agent
|
|
693
|
+
test("★ 任务通知带总结(企微):turn_done → 一条「✅ 任务完成|标题 + 正文」;同 turn 不重发;缺正文降级标题;正文不进日志", async () => {
|
|
694
|
+
const rt = await setupRelayBot({ assistantText: { ok: true, text: "这是 agent 的最终总结" } });
|
|
693
695
|
try {
|
|
694
696
|
rt.bot.start();
|
|
695
697
|
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
696
698
|
await rt.inbound("relay-1", "你好");
|
|
697
|
-
|
|
698
|
-
// ① 入站后同会话 turn/end → 只发正文,不发标题
|
|
699
699
|
rt.turnEnd({ turn: 1, seq: 11 });
|
|
700
|
-
const first = await waitFor(() => rt.sends()[0], { label: "
|
|
701
|
-
assert.equal(first.body.chatid, "user-a"
|
|
700
|
+
const first = await waitFor(() => rt.sends()[0], { label: "任务通知" });
|
|
701
|
+
assert.equal(first.body.chatid, "user-a");
|
|
702
702
|
assert.equal(first.body.msgtype, "markdown");
|
|
703
|
-
|
|
704
|
-
assert.
|
|
705
|
-
assert.
|
|
703
|
+
const content = first.body.markdown.content;
|
|
704
|
+
assert.match(content, /^✅ 任务完成/, "★ 通知必须带「任务完成」标题行");
|
|
705
|
+
assert.match(content, /\n\n这是 agent 的最终总结$/, "★ 同一条消息里带总结正文(不是另发一条)");
|
|
706
|
+
assert.deepEqual(rt.sub.lastAssistantCalls[0], { sessionId: "session-abc" }, "正文通过 lastAssistantText 取");
|
|
707
|
+
await sleep(120);
|
|
708
|
+
assert.equal(rt.sends().length, 1, "★ 同一次 turn 只发一条(不得标题一条 + 正文一条)");
|
|
709
|
+
|
|
710
|
+
// 同 turn 重复事件 → 去重,不重发
|
|
711
|
+
rt.turnEnd({ turn: 1, seq: 11 });
|
|
706
712
|
await sleep(120);
|
|
707
|
-
assert.equal(rt.sends().length, 1, "
|
|
713
|
+
assert.equal(rt.sends().length, 1, "★ 同 turn 重复事件去重");
|
|
708
714
|
|
|
709
|
-
//
|
|
715
|
+
// 其它会话的 turn/end 也带总结(订阅器能给到该会话正文)
|
|
710
716
|
rt.sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-other", reason: "completed", turn: 1, seq: 12, at: Date.now() });
|
|
711
|
-
const other = await waitFor(() => rt.sends().find((f) =>
|
|
717
|
+
const other = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("这是 agent 的最终总结") && f !== first), { label: "其它会话通知" });
|
|
712
718
|
assert.match(other.body.markdown.content, /^✅ 任务完成/);
|
|
713
719
|
|
|
714
|
-
//
|
|
715
|
-
rt.turnEnd({ turn: 2, seq: 13 });
|
|
716
|
-
const again = await waitFor(() => rt.sends().filter((f) => /任务完成/.test(f.body.markdown.content)).length >= 2, { label: "消费一次后的标题通知" });
|
|
717
|
-
assert.ok(again, "同一次入站只回传一次正文,之后回落到标题通知");
|
|
718
|
-
|
|
719
|
-
// ④ 取不到正文 → 降级标题通知(不丢消息、也不假装有正文)
|
|
720
|
+
// 取不到正文 → 降级仅标题(不丢消息、不报错)
|
|
720
721
|
await rt.inbound("relay-2", "再问一句");
|
|
721
722
|
rt.sub.assistantReply = { ok: false, code: "page-failed", text: "" };
|
|
722
723
|
rt.turnEnd({ turn: 3, seq: 14 });
|
|
723
|
-
await waitFor(() => rt.
|
|
724
|
-
const fallback = rt.sends().
|
|
725
|
-
assert.match(fallback.body.markdown.content, /^✅ 任务完成/, "
|
|
726
|
-
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
rt.turnEnd({ turn: 4, seq: 15, fromSnapshot: true });
|
|
732
|
-
await sleep(150);
|
|
733
|
-
assert.equal(rt.sends().length, beforeSnapshot + 1, "fromSnapshot 只走标题通知(一条),不触发正文回传");
|
|
734
|
-
assert.ok(!rt.sends().some((f) => f.body.markdown.content === "来自快照的旧回答"));
|
|
724
|
+
await waitFor(() => rt.sends().length >= 3, { label: "降级通知" });
|
|
725
|
+
const fallback = rt.sends()[rt.sends().length - 1];
|
|
726
|
+
assert.match(fallback.body.markdown.content, /^✅ 任务完成/, "★ 取不到正文时仅标题");
|
|
727
|
+
assert.ok(!fallback.body.markdown.content.includes("\n\n"), "无正文时不要多余空段");
|
|
728
|
+
// 正文绝不进日志(只打长度)
|
|
729
|
+
const allLogs = rt.lines.join("\n");
|
|
730
|
+
assert.ok(!allLogs.includes("这是 agent 的最终总结"), "★ 总结正文绝不能进日志");
|
|
731
|
+
assert.ok(allLogs.split("\n").some((l) => /任务通知 .*len=\d+/.test(l)), "日志只记长度");
|
|
735
732
|
} finally {
|
|
736
733
|
await rt.close();
|
|
737
734
|
}
|
|
738
735
|
});
|
|
739
736
|
|
|
740
|
-
test("★
|
|
737
|
+
test("★ 任务通知:turn/end 瞬间正文还没落库 → 短重试后仍带总结(不是直接降级标题)", async () => {
|
|
741
738
|
let call = 0;
|
|
742
739
|
const rt = await setupRelayBot({
|
|
743
740
|
assistantText: () => {
|
|
744
741
|
call += 1;
|
|
745
|
-
|
|
746
|
-
return call === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后拿到的回答" };
|
|
742
|
+
return call === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后拿到的总结" };
|
|
747
743
|
}
|
|
748
744
|
});
|
|
749
745
|
try {
|
|
@@ -751,68 +747,52 @@ test("★ 回答回传:turn/end 瞬间正文还没落库 → 短重试后仍
|
|
|
751
747
|
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
752
748
|
await rt.inbound("retry-1", "你好");
|
|
753
749
|
rt.turnEnd({ turn: 1, seq: 31 });
|
|
754
|
-
const send = await waitFor(() => rt.sends()[0], { label: "
|
|
755
|
-
|
|
750
|
+
const send = await waitFor(() => rt.sends()[0], { label: "重试后的通知" });
|
|
751
|
+
const content = send.body.markdown.content;
|
|
752
|
+
assert.match(content, /^✅ 任务完成/);
|
|
753
|
+
assert.match(content, /\n\n重试后拿到的总结$/);
|
|
756
754
|
assert.equal(rt.sub.lastAssistantCalls.length, 2, "应恰好重试一次");
|
|
757
|
-
assert.equal(rt.bot.status().answers, 1);
|
|
758
755
|
} finally {
|
|
759
756
|
await rt.close();
|
|
760
757
|
}
|
|
761
758
|
});
|
|
762
759
|
|
|
763
|
-
test("★
|
|
764
|
-
|
|
765
|
-
{
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
// 日志只打长度,不打正文
|
|
800
|
-
const allLogs = rt.lines.join("\n");
|
|
801
|
-
assert.ok(!allLogs.includes("SECRET-ANSWER-TAIL"), "★ 回答正文绝不能进日志");
|
|
802
|
-
assert.ok(!allLogs.includes("长".repeat(40)), "★ 回答正文片段也不能进日志");
|
|
803
|
-
assert.ok(allLogs.split("\n").some((l) => /回答回传 .* len=\d+/.test(l)), "日志只记长度");
|
|
804
|
-
assert.ok(allLogs.split("\n").some((l) => /已截断/.test(l)), "日志要标出截断");
|
|
805
|
-
|
|
806
|
-
// ③ 非 completed:带一句状态行 + 正文(仍是一条消息)
|
|
807
|
-
rt.sub.assistantReply = { ok: true, text: "只跑了一半的结果" };
|
|
808
|
-
await rt.inbound("err-1", "再试");
|
|
809
|
-
rt.turnEnd({ turn: 2, seq: 23, reason: "error" });
|
|
810
|
-
const errSend = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("只跑了一半的结果")), { label: "出错时回传" });
|
|
811
|
-
assert.match(errSend.body.markdown.content, /^❌ 出错了/);
|
|
812
|
-
assert.ok(errSend.body.markdown.content.includes("只跑了一半的结果"), "同一条消息里既给状态也给正文");
|
|
813
|
-
} finally {
|
|
814
|
-
await rt.close();
|
|
815
|
-
}
|
|
760
|
+
test("★ 任务通知边界:超长正文按 1500 字截断+注明;出错带错误摘要;正文不进日志", async () => {
|
|
761
|
+
const longSummary = "开头" + "长".repeat(3000) + "SECRET-SUMMARY-TAIL";
|
|
762
|
+
const rt = await setupRelayBot({ assistantText: { ok: true, text: longSummary } });
|
|
763
|
+
try {
|
|
764
|
+
rt.bot.start();
|
|
765
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
766
|
+
await rt.inbound("trunc-1", "来点长的");
|
|
767
|
+
rt.turnEnd({ turn: 1, seq: 22 });
|
|
768
|
+
const send = await waitFor(() => rt.sends()[0], { label: "截断后的通知" });
|
|
769
|
+
const content = send.body.markdown.content;
|
|
770
|
+
assert.match(content, /^✅ 任务完成/);
|
|
771
|
+
assert.ok(content.endsWith(ANSWER_TRUNCATED_NOTE), `超长正文要以「${ANSWER_TRUNCATED_NOTE}」结尾`);
|
|
772
|
+
const bodyPart = content.split("\n\n").slice(1).join("\n\n");
|
|
773
|
+
assert.ok(Array.from(bodyPart).length <= 1500 + Array.from(ANSWER_TRUNCATED_NOTE).length + 2, "正文长度不得超过 1500 字(+截断提示)");
|
|
774
|
+
assert.ok(!content.includes("SECRET-SUMMARY-TAIL"));
|
|
775
|
+
const allLogs = rt.lines.join("\n");
|
|
776
|
+
assert.ok(!allLogs.includes("SECRET-SUMMARY-TAIL"), "★ 总结正文绝不能进日志");
|
|
777
|
+
assert.ok(!allLogs.includes("长".repeat(40)), "★ 正文片段也不能进日志");
|
|
778
|
+
assert.ok(allLogs.split("\n").some((l) => /任务通知 .* len=\d+/.test(l)), "日志只记长度");
|
|
779
|
+
|
|
780
|
+
// 出错:❌ 标题 + 错误摘要(同一条消息)
|
|
781
|
+
rt.sub.assistantReply = { ok: true, text: "只跑了一半的结果" };
|
|
782
|
+
await rt.inbound("err-1", "再试");
|
|
783
|
+
rt.turnEnd({ turn: 2, seq: 23, reason: "error" });
|
|
784
|
+
const errSend = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("只跑了一半的结果")), { label: "出错通知" });
|
|
785
|
+
assert.match(errSend.body.markdown.content, /^❌ 出错了/);
|
|
786
|
+
assert.match(errSend.body.markdown.content, /\n\n只跑了一半的结果$/);
|
|
787
|
+
|
|
788
|
+
// 被拦下(blocked):状态行 + 总结
|
|
789
|
+
rt.sub.assistantReply = { ok: true, text: "被安全策略拦截的说明" };
|
|
790
|
+
await rt.inbound("block-1", "危险操作");
|
|
791
|
+
rt.turnEnd({ turn: 3, seq: 24, reason: "blocked" });
|
|
792
|
+
const blocked = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("被安全策略拦截的说明")), { label: "被拦通知" });
|
|
793
|
+
assert.match(blocked.body.markdown.content, /^⛔ 被拦下了/);
|
|
794
|
+
} finally {
|
|
795
|
+
await rt.close();
|
|
816
796
|
}
|
|
817
797
|
});
|
|
818
798
|
|
|
@@ -1716,3 +1696,38 @@ test("atomicWriteJson:覆盖写、目录自动创建、0600、失败不留临
|
|
|
1716
1696
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1717
1697
|
}
|
|
1718
1698
|
});
|
|
1699
|
+
|
|
1700
|
+
// ─────────────────────── 命令层(2026-11-08:/ls 不再转给模型)───────────────────────
|
|
1701
|
+
|
|
1702
|
+
test("★ 命令层:/help /status 直接回复、/ls 免费档被拦、/stop 免费急停;命中命令**不进 promptSession**", async () => {
|
|
1703
|
+
const rt = await setupRelayBot();
|
|
1704
|
+
try {
|
|
1705
|
+
rt.bot.start();
|
|
1706
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证" });
|
|
1707
|
+
const replies = () => rt.server.state.frames.filter((f) => f.cmd === "aibot_respond_msg");
|
|
1708
|
+
const sendCmd = async (msgid, content) => {
|
|
1709
|
+
const before = replies().length;
|
|
1710
|
+
rt.server.push({ cmd: "aibot_msg_callback", headers: { req_id: `cmd-${msgid}` }, body: { msgid: `m-${msgid}`, msgtype: "text", text: { content }, from: { userid: "user-a" } } });
|
|
1711
|
+
await waitFor(() => replies().length > before, { label: `命令 ${msgid} 回复` });
|
|
1712
|
+
return String(replies()[before].body.stream.content || "");
|
|
1713
|
+
};
|
|
1714
|
+
const help = await sendCmd("help", "/help");
|
|
1715
|
+
assert.match(help, /可用命令/);
|
|
1716
|
+
assert.match(help, /\/ls/);
|
|
1717
|
+
assert.match(help, /\/unbind/);
|
|
1718
|
+
assert.equal(rt.sub.promptCalls.length, 0, "★ 命令不得进模型");
|
|
1719
|
+
const status = await sendCmd("status", "/status");
|
|
1720
|
+
assert.match(status, /连接状态/);
|
|
1721
|
+
assert.match(status, /当前会话/);
|
|
1722
|
+
const ls = await sendCmd("ls", "/ls");
|
|
1723
|
+
assert.match(ls, /会员功能/, "免费档 /ls 必须被拦");
|
|
1724
|
+
assert.equal(rt.sub.promptCalls.length, 0);
|
|
1725
|
+
const stop = await sendCmd("stop", "/stop");
|
|
1726
|
+
assert.match(stop, /已急停|没有正在运行/);
|
|
1727
|
+
assert.deepEqual(rt.sub.cancelCalls, [{ sessionId: "session-abc" }], "★ /stop 调 cancelSession");
|
|
1728
|
+
assert.equal(rt.sub.promptCalls.length, 0, "★ 全部命令都不进模型");
|
|
1729
|
+
// 普通文本照旧进模型
|
|
1730
|
+
await rt.inbound("normal-1", "帮我改一下代码");
|
|
1731
|
+
assert.equal(rt.sub.promptCalls.length, 1);
|
|
1732
|
+
} finally { await rt.close(); }
|
|
1733
|
+
});
|