@sleep2agi/commhub-server 0.9.0-preview.21 → 0.9.0-preview.23

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.
@@ -0,0 +1,176 @@
1
+ // #461 — unit tests for the network observer SSE channel (push.ts).
2
+ //
3
+ // These tests read REAL bytes off the observer stream, so they are a
4
+ // live gate: stub pushNetworkObserverEvent out to a no-op and every
5
+ // "receives …" test below times out red. (Per 通信龙 2026-07-28 rule:
6
+ // a test that stays green when the core function is emptied is a fake
7
+ // gate.)
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ __resetSSEClientsForTest,
12
+ createNetworkObserverStream,
13
+ createSSEStream,
14
+ pushEvent,
15
+ pushNetworkObserverEvent,
16
+ } from "./push";
17
+
18
+ afterEach(() => {
19
+ __resetSSEClientsForTest();
20
+ });
21
+
22
+ type Frame = Record<string, any>;
23
+
24
+ /** Read the next SSE data frame (skipping keepalive comments), with a
25
+ * hard timeout so a silent stream fails the test instead of hanging. */
26
+ async function readFrame(
27
+ reader: ReadableStreamDefaultReader<Uint8Array>,
28
+ timeoutMs = 2_000,
29
+ ): Promise<Frame> {
30
+ const decoder = new TextDecoder();
31
+ let buf = "";
32
+ const deadline = Date.now() + timeoutMs;
33
+ while (Date.now() < deadline) {
34
+ const remaining = deadline - Date.now();
35
+ const result = await Promise.race([
36
+ reader.read(),
37
+ new Promise<never>((_, rej) =>
38
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), remaining),
39
+ ),
40
+ ]);
41
+ if (result.done) throw new Error("stream ended before a data frame arrived");
42
+ buf += decoder.decode(result.value, { stream: true });
43
+ const sep = buf.indexOf("\n\n");
44
+ if (sep === -1) continue;
45
+ const rawFrame = buf.slice(0, sep);
46
+ buf = buf.slice(sep + 2);
47
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
48
+ if (!dataLine) continue; // keepalive comment frame — keep reading
49
+ return JSON.parse(dataLine.slice(6));
50
+ }
51
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
52
+ }
53
+
54
+ describe("#461 network observer stream", () => {
55
+ test("observer receives connected frame, then new_task summary with metadata only", async () => {
56
+ const res = createNetworkObserverStream("net_obs_a");
57
+ const reader = res.body!.getReader();
58
+
59
+ const connected = await readFrame(reader);
60
+ expect(connected.type).toBe("connected");
61
+ expect(connected.observer).toBe(true);
62
+ expect(connected.network_id).toBe("net_obs_a");
63
+
64
+ pushNetworkObserverEvent("net_obs_a", {
65
+ type: "new_task",
66
+ task_id: "task-123",
67
+ from: "sender-agent",
68
+ to: "receiver-agent",
69
+ status: "delivered",
70
+ priority: "high",
71
+ });
72
+
73
+ const evt = await readFrame(reader);
74
+ expect(evt.type).toBe("new_task");
75
+ expect(evt.task_id).toBe("task-123");
76
+ expect(evt.from).toBe("sender-agent");
77
+ expect(evt.to).toBe("receiver-agent");
78
+ expect(evt.status).toBe("delivered");
79
+ expect(evt.priority).toBe("high");
80
+ expect(evt.network_id).toBe("net_obs_a");
81
+ expect(evt.scope).toBe("network");
82
+ // Summary contract: NO content field, ever.
83
+ expect("content" in evt).toBe(false);
84
+ expect("task" in evt).toBe(false);
85
+ await reader.cancel();
86
+ });
87
+
88
+ test("observer receives new_reply summary", async () => {
89
+ const res = createNetworkObserverStream("net_obs_reply");
90
+ const reader = res.body!.getReader();
91
+ await readFrame(reader); // connected
92
+
93
+ pushNetworkObserverEvent("net_obs_reply", {
94
+ type: "new_reply",
95
+ task_id: "parent-task",
96
+ message_id: "msg-9",
97
+ from: "worker",
98
+ to: "boss",
99
+ status: "completed",
100
+ });
101
+
102
+ const evt = await readFrame(reader);
103
+ expect(evt.type).toBe("new_reply");
104
+ expect(evt.task_id).toBe("parent-task");
105
+ expect(evt.message_id).toBe("msg-9");
106
+ expect(evt.status).toBe("completed");
107
+ expect("content" in evt).toBe(false);
108
+ expect("text" in evt).toBe(false);
109
+ await reader.cancel();
110
+ });
111
+
112
+ test("events are network-isolated: observer on net B never sees net A traffic", async () => {
113
+ const resB = createNetworkObserverStream("net_obs_b");
114
+ const readerB = resB.body!.getReader();
115
+ await readFrame(readerB); // connected
116
+
117
+ // Push to A first, then a marker to B. Delivery per key is
118
+ // synchronous and ordered, so if B's next frame is the marker,
119
+ // the A event provably never reached B.
120
+ pushNetworkObserverEvent("net_obs_a2", { type: "new_task", task_id: "leaked" });
121
+ pushNetworkObserverEvent("net_obs_b", { type: "new_task", task_id: "marker-b" });
122
+
123
+ const evt = await readFrame(readerB);
124
+ expect(evt.task_id).toBe("marker-b");
125
+ await readerB.cancel();
126
+ });
127
+
128
+ test("observer stream is isolated from session channels (both directions)", async () => {
129
+ const sessionRes = createSSEStream("agent-x", "net_obs_c");
130
+ const sessionReader = sessionRes.body!.getReader();
131
+ await readFrame(sessionReader); // connected
132
+
133
+ const obsRes = createNetworkObserverStream("net_obs_c");
134
+ const obsReader = obsRes.body!.getReader();
135
+ await readFrame(obsReader); // connected
136
+
137
+ // Session push must NOT hit the observer; observer push must NOT
138
+ // hit the session. Marker technique as above.
139
+ pushEvent("agent-x", { type: "new_task", inbox_count: 1, marker: "to-session" }, "net_obs_c");
140
+ pushNetworkObserverEvent("net_obs_c", { type: "new_task", task_id: "to-observer" });
141
+
142
+ const sessionEvt = await readFrame(sessionReader);
143
+ expect(sessionEvt.marker).toBe("to-session");
144
+ expect("task_id" in sessionEvt).toBe(false);
145
+
146
+ const obsEvt = await readFrame(obsReader);
147
+ expect(obsEvt.task_id).toBe("to-observer");
148
+ expect("marker" in obsEvt).toBe(false);
149
+
150
+ await sessionReader.cancel();
151
+ await obsReader.cancel();
152
+ });
153
+
154
+ test("null / undefined networkId is a silent no-op", () => {
155
+ // Must not throw — legacy null-network traffic has no observer
156
+ // stream by design (documented in #461 / the PR).
157
+ expect(() => pushNetworkObserverEvent(null, { type: "new_task" })).not.toThrow();
158
+ expect(() => pushNetworkObserverEvent(undefined, { type: "new_task" })).not.toThrow();
159
+ });
160
+
161
+ test("multiple observers on the same network all receive the event", async () => {
162
+ const r1 = createNetworkObserverStream("net_obs_multi");
163
+ const r2 = createNetworkObserverStream("net_obs_multi");
164
+ const reader1 = r1.body!.getReader();
165
+ const reader2 = r2.body!.getReader();
166
+ await readFrame(reader1);
167
+ await readFrame(reader2);
168
+
169
+ pushNetworkObserverEvent("net_obs_multi", { type: "new_task", task_id: "fanout" });
170
+
171
+ expect((await readFrame(reader1)).task_id).toBe("fanout");
172
+ expect((await readFrame(reader2)).task_id).toBe("fanout");
173
+ await reader1.cancel();
174
+ await reader2.cancel();
175
+ });
176
+ });
package/src/push.ts CHANGED
@@ -97,6 +97,31 @@ function clientKey(sessionName: string, networkId?: string | null): string {
97
97
  return `${networkId || "global"}:${sessionName}`;
98
98
  }
99
99
 
100
+ // ── #461 network observer channels ───────────────────────────────────
101
+ // Dashboard (or any network member) subscribes GET /events/network/:id
102
+ // and receives SUMMARY events for ALL new_task / new_reply traffic in
103
+ // that network — not just tasks it sent/received. Summary events carry
104
+ // ids + routing metadata only, never task/reply content.
105
+ //
106
+ // Key scheme: leading NUL byte makes an observer key impossible to
107
+ // forge from the session scheme above (`${networkId}:${sessionName}`)
108
+ // for any networkId the hub itself issues (net_* / "global"). Only a
109
+ // master-token caller could even inject a NUL into network_id via the
110
+ // legacy ?network_id= path, and master token is root already.
111
+ const OBSERVER_KEY_PREFIX = "\u0000netobs:";
112
+
113
+ function observerKey(networkId: string): string {
114
+ return `${OBSERVER_KEY_PREFIX}${networkId}`;
115
+ }
116
+
117
+ /** Render a client key for logs / stats. Observer keys embed a raw NUL
118
+ * byte (collision guard above); printing that verbatim makes grep treat
119
+ * hub logs as binary and breaks log shippers, and getSSEStats feeds
120
+ * /health. Display as a visible "\\0" escape instead. */
121
+ function printableKey(key: string): string {
122
+ return key.split("\u0000").join("\\0");
123
+ }
124
+
100
125
  // ─────────────────────────────────────────────────────────────────────
101
126
  // Close + dispatch helpers
102
127
  // ─────────────────────────────────────────────────────────────────────
@@ -114,7 +139,7 @@ function closeClient(client: SSEClient, reason: string): void {
114
139
  // Already closed by the runtime, or controller errored. Either way
115
140
  // we're done with it.
116
141
  }
117
- console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
142
+ console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
118
143
  }
119
144
 
120
145
  /** Best-effort enqueue with backpressure guard. Returns:
@@ -215,8 +240,24 @@ function ensureLivenessSweep(): void {
215
240
 
216
241
  /** 创建 SSE Response 并注册到 clients map */
217
242
  export function createSSEStream(sessionName: string, networkId?: string | null): Response {
243
+ return createStreamForKey(
244
+ clientKey(sessionName, networkId),
245
+ { type: "connected", session: sessionName, network_id: networkId ?? null },
246
+ );
247
+ }
248
+
249
+ /** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
250
+ * Shares the exact same registration / backpressure / liveness
251
+ * machinery as session streams; only the key scheme differs. */
252
+ export function createNetworkObserverStream(networkId: string): Response {
253
+ return createStreamForKey(
254
+ observerKey(networkId),
255
+ { type: "connected", observer: true, network_id: networkId },
256
+ );
257
+ }
258
+
259
+ function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
218
260
  const encoder = new TextEncoder();
219
- const key = clientKey(sessionName, networkId);
220
261
  let client: SSEClient;
221
262
 
222
263
  const stream = new ReadableStream<Uint8Array>({
@@ -231,13 +272,13 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
231
272
 
232
273
  if (!clients.has(key)) clients.set(key, []);
233
274
  clients.get(key)!.push(client);
234
- console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
275
+ console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
235
276
 
236
277
  // Send initial connected frame through the backpressure guard so
237
278
  // even the first byte respects the contract.
238
279
  tryEnqueueBytes(
239
280
  client,
240
- encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName, network_id: networkId ?? null })}\n\n`),
281
+ encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
241
282
  );
242
283
 
243
284
  // Periodic keepalive doubles as a half-open probe — if the
@@ -280,7 +321,12 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
280
321
 
281
322
  return new Response(stream, {
282
323
  headers: {
283
- "Content-Type": "text/event-stream",
324
+ // #426: explicit charset so legacy Windows clients (PowerShell 5.1's
325
+ // Invoke-RestMethod / Invoke-WebRequest default to ISO-8859-1 when
326
+ // Content-Type omits the charset) decode UTF-8 payloads correctly.
327
+ // Hub itself was already writing clean UTF-8 bytes; the client-side
328
+ // 双重编码样式 mojibake was the header telling the client to guess.
329
+ "Content-Type": "text/event-stream; charset=utf-8",
284
330
  "Cache-Control": "no-cache, no-transform",
285
331
  "Connection": "keep-alive",
286
332
  },
@@ -294,7 +340,7 @@ function pruneClosed(key: string): void {
294
340
  const alive = arr.filter((c) => !c.closed);
295
341
  if (alive.length === 0) {
296
342
  clients.delete(key);
297
- console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
343
+ console.log(`[${ts()}] SSE ✕ ${printableKey(key)} disconnected (0 remaining)`);
298
344
  } else if (alive.length !== arr.length) {
299
345
  clients.set(key, alive);
300
346
  }
@@ -347,6 +393,40 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
347
393
  if (needPrune) pruneClosed(key);
348
394
  }
349
395
 
396
+ /** #461 — 推送网络级摘要事件给该网络的所有观察者连接。
397
+ *
398
+ * Callers pass ROUTING METADATA ONLY (task_id / from / to / status /
399
+ * priority / message_id) — never task or reply content. The event is
400
+ * stamped with `network_id` + `scope: "network"` here so observers can
401
+ * always tell which network the event belongs to and that it came from
402
+ * the observer stream rather than their personal channel.
403
+ *
404
+ * No-op when networkId is falsy: legacy null-network traffic has no
405
+ * observer stream (the route requires an explicit network id). */
406
+ export function pushNetworkObserverEvent(
407
+ networkId: string | null | undefined,
408
+ event: Record<string, unknown>,
409
+ ): void {
410
+ if (!networkId) return;
411
+ const key = observerKey(networkId);
412
+ const arr = clients.get(key);
413
+ if (!arr || arr.length === 0) return;
414
+
415
+ const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
416
+ let needPrune = false;
417
+
418
+ for (const c of arr) {
419
+ if (c.closed) {
420
+ needPrune = true;
421
+ continue;
422
+ }
423
+ const result = tryEnqueueBytes(c, c.encoder.encode(data));
424
+ if (result === "dead") needPrune = true;
425
+ }
426
+
427
+ if (needPrune) pruneClosed(key);
428
+ }
429
+
350
430
  export function __resetSSEClientsForTest(): void {
351
431
  for (const arr of clients.values()) {
352
432
  for (const c of arr) closeClient(c, "reset-for-test");
@@ -365,7 +445,7 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
365
445
  for (const [name, arr] of clients) {
366
446
  const alive = arr.filter((c) => !c.closed);
367
447
  if (alive.length === 0) continue;
368
- sessions[name] = alive.length;
448
+ sessions[printableKey(name)] = alive.length;
369
449
  total += alive.length;
370
450
  }
371
451
  return { total, sessions };
@@ -0,0 +1,130 @@
1
+ // #426 unit tests for the withUtf8CharsetContentType helper — the
2
+ // piece that rewrites text-y Content-Type headers to include
3
+ // charset=utf-8 so legacy Windows clients don't fall back to
4
+ // ISO-8859-1 decoding. The helper lives in server/src/index.ts;
5
+ // to keep the test standalone we re-implement its pure semantics here
6
+ // and assert those. Any drift between this pure copy and the
7
+ // production helper is caught by the "identical semantics" test at
8
+ // the bottom via a source grep.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ // Pure re-implementation — MUST match server/src/index.ts:withUtf8CharsetContentType.
15
+ function withUtf8CharsetContentType(res: Response): Response {
16
+ const ct = res.headers.get("content-type");
17
+ if (!ct) return res;
18
+ if (/;\s*charset=/i.test(ct)) return res;
19
+ const trimmed = ct.trim();
20
+ if (
21
+ trimmed === "application/json" ||
22
+ trimmed.startsWith("text/") ||
23
+ trimmed.startsWith("application/json;") ||
24
+ trimmed === "application/ld+json" ||
25
+ trimmed === "application/xml"
26
+ ) {
27
+ res.headers.set("content-type", `${trimmed}; charset=utf-8`);
28
+ }
29
+ return res;
30
+ }
31
+
32
+ function makeRes(contentType: string | null, body = ""): Response {
33
+ return new Response(body, { headers: contentType ? { "content-type": contentType } : {} });
34
+ }
35
+
36
+ describe("withUtf8CharsetContentType — #426", () => {
37
+ test("appends charset to text/event-stream", () => {
38
+ const res = withUtf8CharsetContentType(makeRes("text/event-stream"));
39
+ expect(res.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
40
+ });
41
+
42
+ test("appends charset to application/json (bare)", () => {
43
+ const res = withUtf8CharsetContentType(makeRes("application/json"));
44
+ expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
45
+ });
46
+
47
+ test("appends charset to text/plain", () => {
48
+ const res = withUtf8CharsetContentType(makeRes("text/plain"));
49
+ expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8");
50
+ });
51
+
52
+ test("appends charset to text/html", () => {
53
+ const res = withUtf8CharsetContentType(makeRes("text/html"));
54
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
55
+ });
56
+
57
+ test("no-op when charset already present (case-insensitive)", () => {
58
+ for (const ct of [
59
+ "application/json; charset=utf-8",
60
+ "application/json;charset=UTF-8",
61
+ "text/event-stream;Charset=us-ascii",
62
+ "text/plain; CHARSET=iso-8859-1",
63
+ ]) {
64
+ const res = withUtf8CharsetContentType(makeRes(ct));
65
+ expect(res.headers.get("content-type")).toBe(ct);
66
+ }
67
+ });
68
+
69
+ test("no-op on binary types (opaque)", () => {
70
+ for (const ct of [
71
+ "application/octet-stream",
72
+ "image/png",
73
+ "image/jpeg",
74
+ "video/mp4",
75
+ "application/pdf",
76
+ "font/woff2",
77
+ ]) {
78
+ const res = withUtf8CharsetContentType(makeRes(ct));
79
+ expect(res.headers.get("content-type")).toBe(ct);
80
+ }
81
+ });
82
+
83
+ test("no-op when Content-Type header is absent entirely (returns as-is)", () => {
84
+ const res = withUtf8CharsetContentType(makeRes(null));
85
+ expect(res.headers.get("content-type")).toBeNull();
86
+ });
87
+
88
+ test("handles application/ld+json / application/xml", () => {
89
+ expect(withUtf8CharsetContentType(makeRes("application/ld+json")).headers.get("content-type"))
90
+ .toBe("application/ld+json; charset=utf-8");
91
+ expect(withUtf8CharsetContentType(makeRes("application/xml")).headers.get("content-type"))
92
+ .toBe("application/xml; charset=utf-8");
93
+ });
94
+
95
+ test("preserves non-charset parameters and appends charset (application/json; boundary=…)", () => {
96
+ // Bun's Response.json emits `application/json;charset=utf-8`; the test
97
+ // above covers that no-op path. When a caller sends a parametrised
98
+ // header like `application/json; profile=…`, the helper still recognises
99
+ // it via the `application/json;` prefix and appends the charset.
100
+ const res = withUtf8CharsetContentType(makeRes("application/json; profile=example"));
101
+ expect(res.headers.get("content-type")).toBe("application/json; profile=example; charset=utf-8");
102
+ });
103
+
104
+ test("returns the same Response instance (mutates in place)", () => {
105
+ const input = makeRes("text/event-stream");
106
+ const output = withUtf8CharsetContentType(input);
107
+ expect(output).toBe(input);
108
+ });
109
+
110
+ // Drift guard: the pure copy in this file MUST match the production
111
+ // helper's branching set. If someone edits one without touching the
112
+ // other, this test fails visibly instead of the helper going silently
113
+ // out of sync. Kept as fragment-level greps (not whole-body match) so
114
+ // added comments don't break the assertion.
115
+ test("production helper covers the same content-type branches", () => {
116
+ const src = readFileSync(join(import.meta.dir, "server.ts"), "utf-8");
117
+ expect(src).toContain("function withUtf8CharsetContentType(res: Response): Response {");
118
+ expect(src).toContain("if (/;\\s*charset=/i.test(ct)) return res;");
119
+ for (const branch of [
120
+ 'trimmed === "application/json"',
121
+ 'trimmed.startsWith("text/")',
122
+ 'trimmed.startsWith("application/json;")',
123
+ 'trimmed === "application/ld+json"',
124
+ 'trimmed === "application/xml"',
125
+ ]) {
126
+ expect(src).toContain(branch);
127
+ }
128
+ expect(src).toContain('res.headers.set("content-type", `${trimmed}; charset=utf-8`);');
129
+ });
130
+ });
@@ -0,0 +1,202 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #498 regression — the send_reply hub response gains a `warning` field
7
+ // pointing at commhub_send_task ONLY when the target alias resolves to
8
+ // a real agent node (i.e. not hub / not api / not an unbound alias).
9
+ // Silence-affirms the Dashboard path.
10
+ //
11
+ // Why this test exists (通信龙 2026-07-30 裁定): the warning is a pure
12
+ // hint — deleting it doesn't break any behavior, so it's exactly the
13
+ // kind of code a future refactor drops silently. This test is the
14
+ // tripwire: if the branch goes away, this test turns red.
15
+ //
16
+ // witnessed-red methodology (`feedback_witnessed_red_before_witnessed_green`):
17
+ // I confirmed both assertions fail cleanly when the `warning` branch is
18
+ // reverted (see docs/tests/p-498-reply-warning/witnessed-red.txt), so
19
+ // this suite genuinely catches regressions rather than green-when-empty.
20
+
21
+ const NET_ID = "net_498_warn";
22
+ const USER_ID = "u_498_warn";
23
+ const NODE_ID = "node_498_warn";
24
+ const AGENT_ALIAS = "peer-agent-498";
25
+ const AGENT_TOK_ID = "tok_498_warn";
26
+
27
+ interface ToolHandler {
28
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
29
+ }
30
+ interface Reply {
31
+ ok?: boolean;
32
+ message_id?: string;
33
+ session_status?: string;
34
+ warning?: string;
35
+ error?: string;
36
+ [k: string]: unknown;
37
+ }
38
+
39
+ function cleanup() {
40
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
41
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
42
+ try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
43
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
44
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
45
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
46
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
47
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
48
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
49
+ }
50
+
51
+ beforeEach(cleanup);
52
+ afterAll(cleanup);
53
+
54
+ function seed() {
55
+ db.run(
56
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
57
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
58
+ [USER_ID, USER_ID],
59
+ );
60
+ db.run(
61
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
62
+ VALUES (?1, ?2, ?3, datetime('now'))`,
63
+ [NET_ID, NET_ID, USER_ID],
64
+ );
65
+ db.run(
66
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
67
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
68
+ [USER_ID, NET_ID],
69
+ );
70
+ db.run(
71
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
72
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
73
+ [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
74
+ );
75
+ db.run(
76
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
77
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
78
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
79
+ );
80
+ db.run(
81
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
82
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
83
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
84
+ );
85
+ }
86
+
87
+ function makeTask(to: string): string {
88
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
89
+ db.run(
90
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
91
+ VALUES (?1, ?2, ?3, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?4)`,
92
+ [id, "dispatcher", to, NET_ID],
93
+ );
94
+ return id;
95
+ }
96
+
97
+ function buildHandlers(): Record<string, ToolHandler> {
98
+ const server = new McpServer({ name: "test", version: "0" }) as any;
99
+ const tools: Record<string, ToolHandler> = {};
100
+ const origTool = server.tool.bind(server);
101
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
102
+ tools[name] = handler;
103
+ return origTool(name, _desc, _schema, handler);
104
+ };
105
+ const origRegisterTool = server.registerTool?.bind(server);
106
+ if (origRegisterTool) {
107
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
108
+ tools[name] = handler;
109
+ return origRegisterTool(name, _cfg, handler);
110
+ };
111
+ }
112
+ // Bind as a network token whose caller alias is the sender.
113
+ registerTools(
114
+ server,
115
+ undefined,
116
+ /* enforceNetworkId */ NET_ID,
117
+ /* enforceUserId */ USER_ID,
118
+ /* callerAlias */ AGENT_ALIAS,
119
+ /* callerTokenIsNetwork */ true,
120
+ /* callerTokenId */ AGENT_TOK_ID,
121
+ );
122
+ return tools;
123
+ }
124
+
125
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
126
+ const r = await handler(args);
127
+ return JSON.parse(r.content[0].text) as Reply;
128
+ }
129
+
130
+ describe("send_reply warning field (#498)", () => {
131
+ test("target alias resolves to a real agent → response includes `warning` field", async () => {
132
+ seed();
133
+ const tools = buildHandlers();
134
+ const sendReply = tools["send_reply"];
135
+ expect(sendReply).toBeDefined();
136
+
137
+ const taskId = makeTask(AGENT_ALIAS);
138
+ const reply = await call(sendReply, {
139
+ alias: AGENT_ALIAS,
140
+ text: "peer reply (should trigger warning)",
141
+ in_reply_to: taskId,
142
+ status: "replied",
143
+ from_session: AGENT_ALIAS,
144
+ });
145
+
146
+ expect(reply.ok).toBe(true);
147
+ expect(reply.warning).toBeDefined();
148
+ expect(typeof reply.warning).toBe("string");
149
+ const w = reply.warning as string;
150
+ // Action-pointing content — witnessed-red methodology means each
151
+ // assertion here must be one a future revert would break, not a
152
+ // shape-only check that passes on empty string.
153
+ expect(w).toContain("commhub_send_task");
154
+ // Alias value is INLINED (not just "<alias>") so the LLM has a
155
+ // copy-pasteable next command. This is the specific design 通信龙
156
+ // called out on 2026-07-30.
157
+ expect(w).toContain(`alias="${AGENT_ALIAS}"`);
158
+ expect(w).toContain("agent peers");
159
+ expect(w).toContain("not see this reply in real time");
160
+ expect(w).toContain("RFC-030");
161
+ expect(w).toContain("全网规则");
162
+ });
163
+
164
+ test("target alias is `hub` (Dashboard path) → NO warning field", async () => {
165
+ seed();
166
+ const tools = buildHandlers();
167
+ const sendReply = tools["send_reply"];
168
+
169
+ const taskId = makeTask("hub");
170
+ const reply = await call(sendReply, {
171
+ alias: "hub",
172
+ text: "dashboard reply",
173
+ in_reply_to: taskId,
174
+ status: "replied",
175
+ from_session: AGENT_ALIAS,
176
+ });
177
+
178
+ expect(reply.ok).toBe(true);
179
+ // Silence-affirms the Dashboard path. Every reply carrying a
180
+ // warning would train the LLM to ignore it, so absence here is
181
+ // load-bearing.
182
+ expect(reply.warning).toBeUndefined();
183
+ });
184
+
185
+ test("target alias is `api` (Dashboard path) → NO warning field", async () => {
186
+ seed();
187
+ const tools = buildHandlers();
188
+ const sendReply = tools["send_reply"];
189
+
190
+ const taskId = makeTask("api");
191
+ const reply = await call(sendReply, {
192
+ alias: "api",
193
+ text: "dashboard reply via api",
194
+ in_reply_to: taskId,
195
+ status: "replied",
196
+ from_session: AGENT_ALIAS,
197
+ });
198
+
199
+ expect(reply.ok).toBe(true);
200
+ expect(reply.warning).toBeUndefined();
201
+ });
202
+ });