@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71

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.
Files changed (170) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,229 @@
1
+ // Handler-level regression for the #212 dedup window swallowing human chat
2
+ // messages (see send_dedup.ts header, "Key selection").
3
+ //
4
+ // Before the fix, POST /api/task keyed the dedup window on
5
+ // (from, to, sha256(content)) only and ignored meta.client_request_id, so a
6
+ // user sending the same short text twice within 5 minutes got 200/429/429 and
7
+ // ONE stored row — and the app maps that 429 to "delivered". The table below
8
+ // is the repro, run against both transports:
9
+ //
10
+ // case | before (REST) | after
11
+ // ----------------------------------+---------------+-------------------------
12
+ // same text, 3 different ids | 1 row | 3 rows, 3 x ok
13
+ // same text, same id (retry) | 1 row | 1 row, 2 x replay (same id)
14
+ // different text, same id | 3 rows | 1 row, 2 x idempotency_conflict
15
+ // same text, no id (#212 agents) | 1 row | 1 row, 2 x duplicate_send
16
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
+ import { mkdtempSync, rmSync } from "fs";
18
+ import { join } from "path";
19
+ import { tmpdir } from "os";
20
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import { register } from "./auth.js";
22
+ import { db } from "./db.js";
23
+ import { registerTools } from "./tools.js";
24
+ import { sharedSendDedup } from "./send_dedup.js";
25
+
26
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-send-dedup-rid-"));
27
+ const TARGET = `dedup-rid-target-${process.pid}`;
28
+ const TARGET_NODE_ID = `node_dedup_rid_${process.pid}`;
29
+
30
+ let server: any;
31
+ let base = "";
32
+ let userToken = "";
33
+ let restNet = "";
34
+ let restUser = "";
35
+
36
+ const MCP_NET = `net_dedup_rid_${process.pid}`;
37
+ const MCP_USER = `user_dedup_rid_${process.pid}`;
38
+
39
+ function rid(n: number): string {
40
+ // App shape: dreq_ + 32 lowercase hex.
41
+ return `dreq_${n.toString(16).padStart(32, "0")}`;
42
+ }
43
+
44
+ function seedTarget(networkId: string, suffix: string) {
45
+ db.run(
46
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
47
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
48
+ [`resume_${TARGET_NODE_ID}_${suffix}`, TARGET, `${TARGET_NODE_ID}_${suffix}`, networkId],
49
+ );
50
+ db.run(
51
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
52
+ VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
53
+ [`${TARGET_NODE_ID}_${suffix}`, TARGET, networkId],
54
+ );
55
+ }
56
+
57
+ beforeAll(async () => {
58
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
59
+ restUser = `dedup_rid_${Date.now()}`;
60
+ const registered = register(restUser, "DedupRid123!", undefined, "seed");
61
+ expect(registered.ok).toBe(true);
62
+ userToken = registered.token!;
63
+ restNet = registered.network_id!;
64
+ seedTarget(restNet, "rest");
65
+
66
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [MCP_USER]);
67
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [MCP_NET, MCP_USER]);
68
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [MCP_USER, MCP_NET]);
69
+ seedTarget(MCP_NET, "mcp");
70
+
71
+ const mod: any = await import("./server.js");
72
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
73
+ base = `http://127.0.0.1:${server.port}`;
74
+ }, 30_000);
75
+
76
+ afterAll(() => {
77
+ try { server?.stop?.(true); } catch {}
78
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
79
+ });
80
+
81
+ beforeEach(() => {
82
+ for (const net of [restNet, MCP_NET]) {
83
+ if (!net) continue;
84
+ for (const table of ["tasks", "inbox", "task_events"]) {
85
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [net]); } catch {}
86
+ }
87
+ }
88
+ sharedSendDedup.clear();
89
+ });
90
+
91
+ function rowCount(networkId: string): number {
92
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1", [networkId])!.n;
93
+ }
94
+ function inboxCount(networkId: string): number {
95
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", [networkId])!.n;
96
+ }
97
+
98
+ // ── REST ────────────────────────────────────────────────────────────────
99
+
100
+ async function rest(task: string, requestId?: string): Promise<{ status: number; body: any }> {
101
+ const res = await fetch(`${base}/api/task`, {
102
+ method: "POST",
103
+ headers: { Authorization: `Bearer ${userToken}`, "Content-Type": "application/json" },
104
+ body: JSON.stringify({
105
+ alias: TARGET,
106
+ task,
107
+ priority: "normal",
108
+ network_id: restNet,
109
+ from: restUser,
110
+ meta: requestId
111
+ ? { source: "dashboard-chat", client_request_id: requestId }
112
+ : { source: "dashboard-chat" },
113
+ }),
114
+ });
115
+ return { status: res.status, body: await res.json() };
116
+ }
117
+
118
+ describe("POST /api/task dedup key selection", () => {
119
+ test("same text with 3 different request ids -> 3 rows (the reported data loss)", async () => {
120
+ const results = [await rest("好", rid(1)), await rest("好", rid(2)), await rest("好", rid(3))];
121
+ expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
122
+ const ids = results.map((r) => r.body.task_id);
123
+ expect(new Set(ids).size).toBe(3);
124
+ expect(rowCount(restNet)).toBe(3);
125
+ expect(inboxCount(restNet)).toBe(3);
126
+ });
127
+
128
+ test("same text with the same request id -> 1 row, retries replay the same task id", async () => {
129
+ const first = await rest("好", rid(7));
130
+ const second = await rest("好", rid(7));
131
+ const third = await rest("好", rid(7));
132
+ expect(first.status).toBe(200);
133
+ expect(second.status).toBe(200);
134
+ expect(third.status).toBe(200);
135
+ expect(second.body).toMatchObject({ ok: true, task_id: first.body.task_id, message_id: first.body.task_id, idempotent_replay: true });
136
+ expect(third.body.task_id).toBe(first.body.task_id);
137
+ expect(rowCount(restNet)).toBe(1);
138
+ expect(inboxCount(restNet)).toBe(1);
139
+ });
140
+
141
+ test("different text with the same request id -> 1 row, reuse fails closed (409)", async () => {
142
+ const first = await rest("第一条", rid(9));
143
+ const second = await rest("第二条", rid(9));
144
+ const third = await rest("第三条", rid(9));
145
+ expect(first.status).toBe(200);
146
+ expect(second.status).toBe(409);
147
+ expect(second.body.error).toBe("idempotency_conflict");
148
+ expect(third.status).toBe(409);
149
+ expect(rowCount(restNet)).toBe(1);
150
+ });
151
+
152
+ test("#212 guard: same text WITHOUT a request id is still deduped (429 duplicate_send)", async () => {
153
+ const results = [await rest("继续"), await rest("继续"), await rest("继续")];
154
+ expect(results.map((r) => r.status)).toEqual([200, 429, 429]);
155
+ expect(results[1].body.error).toBe("duplicate_send");
156
+ expect(rowCount(restNet)).toBe(1);
157
+ });
158
+
159
+ test("no request id, different text -> each goes through", async () => {
160
+ const results = [await rest("a"), await rest("b"), await rest("c")];
161
+ expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
162
+ expect(rowCount(restNet)).toBe(3);
163
+ });
164
+ });
165
+
166
+ // ── MCP send_task ──────────────────────────────────────────────────────
167
+
168
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
169
+
170
+ function mcpSendTask(): ToolHandler {
171
+ const mcp = new McpServer({ name: "dedup-rid-test", version: "0" }) as any;
172
+ let handler: ToolHandler | undefined;
173
+ const original = mcp.tool.bind(mcp);
174
+ mcp.tool = (name: string, description: string, schema: any, candidate: ToolHandler) => {
175
+ if (name === "send_task") handler = candidate;
176
+ return original(name, description, schema, candidate);
177
+ };
178
+ registerTools(mcp, undefined, MCP_NET, MCP_USER, MCP_USER, false, null);
179
+ if (!handler) throw new Error("send_task handler missing");
180
+ return handler;
181
+ }
182
+
183
+ async function mcp(handler: ToolHandler, task: string, requestId?: string): Promise<any> {
184
+ const result = await handler({
185
+ alias: TARGET,
186
+ task,
187
+ priority: "normal",
188
+ ...(requestId ? { meta: { source: "dashboard-chat", client_request_id: requestId } } : {}),
189
+ });
190
+ return JSON.parse(result.content[0].text);
191
+ }
192
+
193
+ describe("MCP send_task dedup key selection", () => {
194
+ test("same text with 3 different request ids -> 3 rows", async () => {
195
+ const h = mcpSendTask();
196
+ const results = [await mcp(h, "好", rid(11)), await mcp(h, "好", rid(12)), await mcp(h, "好", rid(13))];
197
+ expect(results.map((r) => r.ok)).toEqual([true, true, true]);
198
+ expect(new Set(results.map((r) => r.message_id)).size).toBe(3);
199
+ expect(rowCount(MCP_NET)).toBe(3);
200
+ });
201
+
202
+ test("same text with the same request id -> 1 row, replay returns the same task id", async () => {
203
+ const h = mcpSendTask();
204
+ const first = await mcp(h, "好", rid(17));
205
+ const second = await mcp(h, "好", rid(17));
206
+ const third = await mcp(h, "好", rid(17));
207
+ expect(first.ok).toBe(true);
208
+ expect(second).toMatchObject({ ok: true, task_id: first.message_id, idempotent_replay: true });
209
+ expect(third.task_id).toBe(first.message_id);
210
+ expect(rowCount(MCP_NET)).toBe(1);
211
+ });
212
+
213
+ test("different text with the same request id -> 1 row, idempotency_conflict", async () => {
214
+ const h = mcpSendTask();
215
+ expect((await mcp(h, "第一条", rid(19))).ok).toBe(true);
216
+ expect((await mcp(h, "第二条", rid(19))).error).toBe("idempotency_conflict");
217
+ expect((await mcp(h, "第三条", rid(19))).error).toBe("idempotency_conflict");
218
+ expect(rowCount(MCP_NET)).toBe(1);
219
+ });
220
+
221
+ test("#212 guard: same text WITHOUT a request id is still deduped", async () => {
222
+ const h = mcpSendTask();
223
+ const results = [await mcp(h, "继续"), await mcp(h, "继续"), await mcp(h, "继续")];
224
+ expect(results[0].ok).toBe(true);
225
+ expect(results[1].error).toBe("duplicate_send");
226
+ expect(results[2].error).toBe("duplicate_send");
227
+ expect(rowCount(MCP_NET)).toBe(1);
228
+ });
229
+ });
@@ -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 compatibility warning remains load-bearing during the mixed-version
7
+ // rollout. Capability-negotiated agents use send_peer_reply; old send_reply
8
+ // callers targeting an agent get explicit migration guidance while Dashboard
9
+ // targets stay quiet.
10
+
11
+ const NET_ID = "net_498_warn";
12
+ const USER_ID = "u_498_warn";
13
+ const NODE_ID = "node_498_warn";
14
+ const AGENT_ALIAS = "peer-agent-498";
15
+ const DISPATCHER_NODE_ID = "node_498_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher";
17
+ const AGENT_TOK_ID = "tok_498_warn";
18
+
19
+ interface ToolHandler {
20
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
21
+ }
22
+ interface Reply {
23
+ ok?: boolean;
24
+ message_id?: string;
25
+ session_status?: string;
26
+ warning?: string;
27
+ error?: string;
28
+ [k: string]: unknown;
29
+ }
30
+
31
+ function cleanup() {
32
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
33
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
34
+ try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
35
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
36
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
37
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
38
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
39
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
40
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
41
+ }
42
+
43
+ beforeEach(cleanup);
44
+ afterAll(cleanup);
45
+
46
+ function seed() {
47
+ db.run(
48
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
49
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
50
+ [USER_ID, USER_ID],
51
+ );
52
+ db.run(
53
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
54
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
55
+ [DISPATCHER_NODE_ID, DISPATCHER_ALIAS, DISPATCHER_ALIAS, NET_ID, `host-${DISPATCHER_ALIAS}`],
56
+ );
57
+ db.run(
58
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
59
+ VALUES (?1, ?2, ?3, datetime('now'))`,
60
+ [NET_ID, NET_ID, USER_ID],
61
+ );
62
+ db.run(
63
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
64
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
65
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
66
+ );
67
+ db.run(
68
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
69
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
70
+ [USER_ID, NET_ID],
71
+ );
72
+ db.run(
73
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
74
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
75
+ [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
76
+ );
77
+ db.run(
78
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
79
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
80
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
81
+ );
82
+ db.run(
83
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
84
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
85
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
86
+ );
87
+ }
88
+
89
+ function makeTask(from: string): string {
90
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
91
+ db.run(
92
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
93
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
94
+ [id, from, AGENT_ALIAS, NODE_ID, NET_ID],
95
+ );
96
+ return id;
97
+ }
98
+
99
+ function buildHandlers(): Record<string, ToolHandler> {
100
+ const server = new McpServer({ name: "test", version: "0" }) as any;
101
+ const tools: Record<string, ToolHandler> = {};
102
+ const origTool = server.tool.bind(server);
103
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
104
+ tools[name] = handler;
105
+ return origTool(name, _desc, _schema, handler);
106
+ };
107
+ const origRegisterTool = server.registerTool?.bind(server);
108
+ if (origRegisterTool) {
109
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
110
+ tools[name] = handler;
111
+ return origRegisterTool(name, _cfg, handler);
112
+ };
113
+ }
114
+ // Bind as a network token whose caller alias is the sender.
115
+ registerTools(
116
+ server,
117
+ undefined,
118
+ /* enforceNetworkId */ NET_ID,
119
+ /* enforceUserId */ USER_ID,
120
+ /* callerAlias */ AGENT_ALIAS,
121
+ /* callerTokenIsNetwork */ true,
122
+ /* callerTokenId */ AGENT_TOK_ID,
123
+ );
124
+ return tools;
125
+ }
126
+
127
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
128
+ const r = await handler(args);
129
+ return JSON.parse(r.content[0].text) as Reply;
130
+ }
131
+
132
+ describe("send_reply legacy/Dashboard compatibility (#698)", () => {
133
+ test("legacy call still closes the original and enqueues one no-response result", async () => {
134
+ seed();
135
+ const tools = buildHandlers();
136
+ const sendReply = tools["send_reply"];
137
+ expect(sendReply).toBeDefined();
138
+
139
+ const taskId = makeTask(DISPATCHER_ALIAS);
140
+ const reply = await call(sendReply, {
141
+ alias: DISPATCHER_ALIAS,
142
+ text: "peer reply (atomic)",
143
+ in_reply_to: taskId,
144
+ status: "replied",
145
+ from_session: AGENT_ALIAS,
146
+ });
147
+
148
+ expect(reply.ok).toBe(true);
149
+ expect(reply.warning).toContain("commhub_send_peer_reply");
150
+ expect(reply.warning).toContain("terminalized the original task");
151
+ expect(reply.warning).not.toContain("commhub_send_task");
152
+ expect(reply.warning).toContain("mixed-version");
153
+ expect(db.get<{ status: string; result: string }>(
154
+ "SELECT status, result FROM tasks WHERE task_id = ?1", taskId,
155
+ )).toEqual(expect.objectContaining({ status: "replied", result: "peer reply (atomic)" }));
156
+ expect(db.all<{ type: string; requires_response: string; session_name: string }>(
157
+ "SELECT type, requires_response, session_name FROM inbox WHERE in_reply_to = ?1", taskId,
158
+ )).toEqual([{ type: "reply", requires_response: "none", session_name: DISPATCHER_ALIAS }]);
159
+ expect(db.get<{ n: number }>(
160
+ "SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId,
161
+ )?.n).toBe(0);
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
+ });
@@ -0,0 +1,131 @@
1
+ // #1085 — send_reply must not require `alias` when `in_reply_to` is set.
2
+ //
3
+ // Symptom (deployed SDK-runtime nodes): `mcp__commhub__send_reply(task_id,
4
+ // text)` → `-32602 Invalid input: expected string, received undefined at
5
+ // alias`. That is the HUB's Zod schema rejecting a required `alias` the
6
+ // client never sent — the request dies before the handler runs, so the
7
+ // original task is never terminalized and its inbox row stays unacked
8
+ // (pending inflation).
9
+ //
10
+ // Fix (two parts, both in tools.ts):
11
+ // 1. `replyToolSchema.alias` → optional (stops the MCP-layer Zod -32602).
12
+ // 2. `handleReply` derives the target from the in_reply_to task's
13
+ // `from_name` when alias is omitted — the target was always knowable
14
+ // from the task the hub already looks up, so the param was redundant.
15
+ //
16
+ // This suite drives the registered handler directly (MCP Zod layer is
17
+ // exercised separately by the framework), so it pins part (2): omit alias
18
+ // → derive → reply lands on the original sender; omit both → clean error.
19
+ //
20
+ // 改坏报红 gate: revert the derive block (require alias again) and
21
+ // "alias omitted → derives to original sender" flips to a resolve failure.
22
+
23
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
24
+ import { db } from "./db.js";
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { registerTools } from "./tools.js";
27
+
28
+ const NET_ID = "net_1085";
29
+ const USER_ID = "u_1085";
30
+ const NODE_ID = "node_1085";
31
+ const AGENT_ALIAS = "worker-1085";
32
+ const ORIGIN_ALIAS = "boss-1085"; // the task's from_name — the derive target
33
+ const AGENT_TOK_ID = "tok_1085";
34
+
35
+ interface ToolHandler {
36
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
37
+ }
38
+ interface Reply { ok?: boolean; message_id?: string; error?: string; message?: string; [k: string]: unknown; }
39
+
40
+ function cleanup() {
41
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "nodes", "api_tokens", "network_members", "networks"]) {
42
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET_ID]); } catch {}
43
+ }
44
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
45
+ }
46
+ beforeEach(cleanup);
47
+ afterAll(cleanup);
48
+
49
+ function seed(): { taskId: string } {
50
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
51
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
52
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER_ID, NET_ID]);
53
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`, [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`]);
54
+ db.run(`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`, [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID]);
55
+ // The reply TARGET session (the task originator). Its alias is what the
56
+ // handler must derive from the task's from_name.
57
+ db.run(`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, datetime('now'), datetime('now'))`, [`res_${ORIGIN_ALIAS}`, ORIGIN_ALIAS, NET_ID]);
58
+ // A task from ORIGIN_ALIAS → the worker. Replying to it should land on
59
+ // ORIGIN_ALIAS even when the caller omits alias.
60
+ const taskId = `task_1085_${Date.now()}${Math.floor(Math.random() * 1000)}`;
61
+ db.run(`INSERT INTO tasks (task_id, from_name, to_name, status, content, requires_response, created_at, delivered_at, expires_at, network_id, priority) VALUES (?1, ?2, ?3, 'delivered', 'do the thing', 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?4, 'normal')`, [taskId, ORIGIN_ALIAS, AGENT_ALIAS, NET_ID]);
62
+ return { taskId };
63
+ }
64
+
65
+ async function getHandler(): Promise<ToolHandler> {
66
+ const server = new McpServer({ name: "test", version: "0" });
67
+ const tools: Record<string, ToolHandler> = {};
68
+ const originalTool = (server as any).tool.bind(server);
69
+ (server as any).tool = (name: string, ...rest: any[]) => {
70
+ const handler = rest[rest.length - 1];
71
+ if (typeof handler === "function") tools[name] = handler;
72
+ return originalTool(name, ...rest);
73
+ };
74
+ registerTools(server, {
75
+ enforceNetworkId: NET_ID,
76
+ enforceUserId: USER_ID,
77
+ callerAlias: AGENT_ALIAS,
78
+ callerTokenIsNetwork: true,
79
+ callerTokenId: AGENT_TOK_ID,
80
+ } as any);
81
+ const handler = tools.send_reply;
82
+ if (!handler) throw new Error("send_reply handler not registered");
83
+ return handler;
84
+ }
85
+
86
+ async function call(h: ToolHandler, args: any): Promise<Reply> {
87
+ return JSON.parse((await h(args)).content[0].text) as Reply;
88
+ }
89
+
90
+ describe("#1085 send_reply — alias optional, derived from in_reply_to", () => {
91
+ test("alias OMITTED + in_reply_to set → derives target from task from_name, reply lands on originator", async () => {
92
+ const { taskId } = seed();
93
+ const h = await getHandler();
94
+ const r = await call(h, { in_reply_to: taskId, text: "done", status: "replied", from_session: AGENT_ALIAS });
95
+ expect(r.ok).toBe(true);
96
+ // The reply inbox row must be addressed to the derived originator.
97
+ const row = db.get<{ session_name: string; in_reply_to: string }>(
98
+ "SELECT session_name, in_reply_to FROM inbox WHERE id = ?1", [r.message_id],
99
+ );
100
+ expect(row?.session_name).toBe(ORIGIN_ALIAS);
101
+ expect(row?.in_reply_to).toBe(taskId);
102
+ // And the original task is terminalized (no longer pending).
103
+ const task = db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", [taskId]);
104
+ expect(task?.status).toBe("replied");
105
+ });
106
+
107
+ test("alias EXPLICIT still works (unchanged path)", async () => {
108
+ const { taskId } = seed();
109
+ const h = await getHandler();
110
+ const r = await call(h, { alias: ORIGIN_ALIAS, in_reply_to: taskId, text: "done", from_session: AGENT_ALIAS });
111
+ expect(r.ok).toBe(true);
112
+ const row = db.get<{ session_name: string }>("SELECT session_name FROM inbox WHERE id = ?1", [r.message_id]);
113
+ expect(row?.session_name).toBe(ORIGIN_ALIAS);
114
+ });
115
+
116
+ test("alias OMITTED + no in_reply_to → clean error, not a crash", async () => {
117
+ seed();
118
+ const h = await getHandler();
119
+ const r = await call(h, { text: "orphan reply", from_session: AGENT_ALIAS });
120
+ expect(r.ok).toBe(false);
121
+ expect(r.error).toBe("reply_target_required");
122
+ });
123
+
124
+ test("alias OMITTED + in_reply_to points at nonexistent task → resolve error", async () => {
125
+ seed();
126
+ const h = await getHandler();
127
+ const r = await call(h, { in_reply_to: "task_does_not_exist_1085", text: "x", from_session: AGENT_ALIAS });
128
+ expect(r.ok).toBe(false);
129
+ expect(r.error).toBe("reply_target_unresolved");
130
+ });
131
+ });