@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131

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 (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -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
+ });
@@ -0,0 +1,458 @@
1
+ // #507 — send_reply attachments regression + parity with send_task.
2
+ //
3
+ // Before this PR: send_reply had no `attachments` field in its Zod schema.
4
+ // MCP's default schema-validation strips unknown fields, so a caller
5
+ // passing `attachments` would see `ok:true` and never learn the field
6
+ // was dropped. Nothing landed in `tasks.meta_json`. Silent failure.
7
+ //
8
+ // After this PR: schema declares `attachments` (top-level, parity with
9
+ // REST /api/task L506) and `meta` (parity with send_task L837). Handler
10
+ // validates via `validateAttachments` (uploads.ts:359 — the same helper
11
+ // the REST path uses), persists into inbox.meta_json + tasks.meta_json
12
+ // (parity with send_task L952/957), and **echoes attachments READ BACK
13
+ // FROM DB** in the response (lead 2b5f6634 catch: echoing the in-memory
14
+ // variable would still succeed if the UPDATE actually landed 0 rows —
15
+ // the echo needs to prove the DB write, not the caller's intent).
16
+ //
17
+ // Coverage:
18
+ // Positive P1-P5 — attachments actually persist + echoed correctly
19
+ // Reverse (e) — no-attachments call is byte-identical to today
20
+ // Negative N1-N6 — malformed attachments explicitly rejected
21
+ // No-in_reply_to — attachments still persisted onto inbox.meta_json
22
+ // Cross-network — same permission rules as bare send_reply
23
+
24
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
25
+ import { db } from "./db.js";
26
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
+ import { registerTools } from "./tools.js";
28
+
29
+ const NET_ID = "net_507_att";
30
+ const USER_ID = "u_507_att";
31
+ const NODE_ID = "node_507_att";
32
+ const AGENT_ALIAS = "peer-507";
33
+ const AGENT_TOK_ID = "tok_507_att";
34
+
35
+ interface ToolHandler {
36
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
37
+ }
38
+
39
+ interface Reply {
40
+ ok?: boolean;
41
+ message_id?: string;
42
+ session_status?: string;
43
+ warning?: string;
44
+ error?: string;
45
+ message?: string;
46
+ attachments_saved?: Array<{ type: string; file_id: string; name?: string; mime?: string; size?: number }>;
47
+ [k: string]: unknown;
48
+ }
49
+
50
+ function cleanup() {
51
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
52
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
53
+ try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
54
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
55
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
56
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
57
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
58
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
59
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
60
+ }
61
+
62
+ beforeEach(cleanup);
63
+ afterAll(cleanup);
64
+
65
+ function seed(): { taskId: string } {
66
+ db.run(
67
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
68
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
69
+ [USER_ID, USER_ID],
70
+ );
71
+ db.run(
72
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
73
+ VALUES (?1, ?2, ?3, datetime('now'))`,
74
+ [NET_ID, NET_ID, USER_ID],
75
+ );
76
+ db.run(
77
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
78
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
79
+ [USER_ID, NET_ID],
80
+ );
81
+ db.run(
82
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
83
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
84
+ [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
85
+ );
86
+ db.run(
87
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
88
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
89
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
90
+ );
91
+ // Also seed hub session so alias="hub" is a valid dashboard-style target.
92
+ db.run(
93
+ `INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at)
94
+ VALUES ('res_hub_507', 'hub', 'idle', ?1, datetime('now'), datetime('now'))
95
+ ON CONFLICT DO NOTHING`,
96
+ [NET_ID],
97
+ );
98
+ // Seed a task the reply will target. Dashboard-style: from_name='hub',
99
+ // to_name=agent, status='delivered'.
100
+ const taskId = `task_507_${Date.now()}${Math.floor(Math.random() * 1000)}`;
101
+ db.run(
102
+ `INSERT INTO tasks (task_id, from_name, to_name, status, content, requires_response, created_at, delivered_at, expires_at, network_id, priority)
103
+ VALUES (?1, 'hub', ?2, 'delivered', 'test task from dashboard', 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?3, 'normal')`,
104
+ [taskId, AGENT_ALIAS, NET_ID],
105
+ );
106
+ return { taskId };
107
+ }
108
+
109
+ async function getSendReplyHandler(): Promise<ToolHandler> {
110
+ const server = new McpServer({ name: "test", version: "0" });
111
+ const tools: Record<string, ToolHandler> = {};
112
+ const originalTool = (server as any).tool.bind(server);
113
+ (server as any).tool = (name: string, ...rest: any[]) => {
114
+ // send_reply is registered with (name, desc, schema, handler)
115
+ const handler = rest[rest.length - 1];
116
+ if (typeof handler === "function") tools[name] = handler;
117
+ return originalTool(name, ...rest);
118
+ };
119
+ registerTools(server, {
120
+ enforceNetworkId: NET_ID,
121
+ enforceUserId: USER_ID,
122
+ callerAlias: AGENT_ALIAS,
123
+ callerTokenIsNetwork: true,
124
+ callerTokenId: AGENT_TOK_ID,
125
+ } as any);
126
+ const handler = tools.send_reply;
127
+ if (!handler) throw new Error("send_reply handler not registered");
128
+ return handler;
129
+ }
130
+
131
+ async function callReply(handler: ToolHandler, args: any): Promise<Reply> {
132
+ const result = await handler(args);
133
+ return JSON.parse(result.content[0].text) as Reply;
134
+ }
135
+
136
+ // A valid file_id shape (matches FILE_ID_REGEX /^[A-Za-z0-9_-]{8,64}$/).
137
+ // Note: these attachments do NOT need to point at real uploaded files
138
+ // for the persistence/echo tests — validateAttachments only checks shape.
139
+ // The dashboard proxy resolves file_id to disk at download time separately.
140
+ function att(file_id: string, extra: Record<string, unknown> = {}) {
141
+ return { type: "file", file_id, name: "test.bin", size: 1024, mime: "application/octet-stream", ...extra };
142
+ }
143
+
144
+ function readTaskMeta(taskId: string): any {
145
+ const row = db.get<{ meta_json: string | null }>(
146
+ "SELECT meta_json FROM tasks WHERE task_id = ?1", [taskId],
147
+ );
148
+ return row?.meta_json ? JSON.parse(row.meta_json) : null;
149
+ }
150
+
151
+ function readInboxMeta(msgId: string): any {
152
+ const row = db.get<{ meta_json: string | null }>(
153
+ "SELECT meta_json FROM inbox WHERE id = ?1", [msgId],
154
+ );
155
+ return row?.meta_json ? JSON.parse(row.meta_json) : null;
156
+ }
157
+
158
+ describe("#507 — send_reply attachments (positive)", () => {
159
+ test("P1: two attachments persist to tasks.meta_json AND inbox.meta_json AND echo READ BACK FROM DB", async () => {
160
+ const { taskId } = seed();
161
+ const handler = await getSendReplyHandler();
162
+ const a1 = att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "one.pdf" });
163
+ const a2 = att("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", { name: "two.png" });
164
+ const reply = await callReply(handler, {
165
+ alias: AGENT_ALIAS,
166
+ text: "here you go",
167
+ in_reply_to: taskId,
168
+ status: "replied" as const,
169
+ attachments: [a1, a2],
170
+ });
171
+ expect(reply.ok).toBe(true);
172
+ expect(reply.attachments_saved).toBeDefined();
173
+ expect(reply.attachments_saved!.length).toBe(2);
174
+ expect(reply.attachments_saved!.map((a) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort());
175
+
176
+ // Verify persistence — the echo must come from what actually landed on disk.
177
+ const taskMeta = readTaskMeta(taskId);
178
+ // #1823 —— 任务行上回复附件在 reply_attachments;attachments 键留给提问者自己带的附件
179
+ expect(taskMeta?.attachments).toBeUndefined();
180
+ expect(taskMeta?.reply_attachments?.length).toBe(2);
181
+ expect(taskMeta.reply_attachments.map((a: any) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort());
182
+
183
+ const inboxMeta = readInboxMeta(reply.message_id!);
184
+ expect(inboxMeta?.attachments?.length).toBe(2);
185
+ });
186
+
187
+ test("P2: meta.attachments (nested form) also accepted", async () => {
188
+ const { taskId } = seed();
189
+ const handler = await getSendReplyHandler();
190
+ const a1 = att("cccccccccccccccccccccccccccccccc");
191
+ const reply = await callReply(handler, {
192
+ alias: AGENT_ALIAS,
193
+ text: "nested-form",
194
+ in_reply_to: taskId,
195
+ status: "replied" as const,
196
+ meta: { attachments: [a1], other_field: "preserved" },
197
+ });
198
+ expect(reply.ok).toBe(true);
199
+ expect(reply.attachments_saved!.length).toBe(1);
200
+ const persisted = readTaskMeta(taskId);
201
+ expect(persisted?.reply_attachments?.[0]?.file_id).toBe(a1.file_id);
202
+ expect(persisted?.attachments).toBeUndefined();
203
+ // #1823 —— 回复 meta 的兄弟字段属于回复(inbox 行),不写进提问者的任务行
204
+ expect(readInboxMeta(reply.message_id)?.other_field).toBe("preserved");
205
+ });
206
+
207
+ test("P3: top-level attachments WIN over meta.attachments when both supplied (parity with REST /api/task L2101)", async () => {
208
+ const { taskId } = seed();
209
+ const handler = await getSendReplyHandler();
210
+ const topLevel = att("dddddddddddddddddddddddddddddddd", { name: "wins.pdf" });
211
+ const nested = att("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", { name: "loses.pdf" });
212
+ const reply = await callReply(handler, {
213
+ alias: AGENT_ALIAS,
214
+ text: "conflict resolution",
215
+ in_reply_to: taskId,
216
+ status: "replied" as const,
217
+ attachments: [topLevel],
218
+ meta: { attachments: [nested], other: "kept" },
219
+ });
220
+ expect(reply.ok).toBe(true);
221
+ expect(reply.attachments_saved!.length).toBe(1);
222
+ expect(reply.attachments_saved![0].file_id).toBe(topLevel.file_id);
223
+ const persisted = readTaskMeta(taskId);
224
+ expect(persisted?.reply_attachments?.length).toBe(1);
225
+ expect(persisted?.reply_attachments?.[0]?.file_id).toBe(topLevel.file_id);
226
+ expect(readInboxMeta(reply.message_id)?.other).toBe("kept");
227
+ });
228
+
229
+ // #1823 —— 提问者随任务带的附件必须留在 attachments;回复附件另放 reply_attachments。
230
+ // (Vincent 2026-09-06:回复的图被画进提问者气泡;以前整体 COALESCE 替换还会把提问者的附件覆盖掉)
231
+ test("P6 (#1823): asker's attachments survive a reply with attachments; reply goes to reply_attachments", async () => {
232
+ const { taskId } = seed();
233
+ const asked = att("11111111111111111111111111111111", { name: "asked.png" });
234
+ db.run("UPDATE tasks SET meta_json = ?1 WHERE task_id = ?2", [JSON.stringify({ attachments: [asked], origin: "dashboard" }), taskId]);
235
+ const handler = await getSendReplyHandler();
236
+ const answer = att("22222222222222222222222222222222", { name: "answer.md" });
237
+ const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "here", in_reply_to: taskId, status: "replied" as const, attachments: [answer] });
238
+ expect(reply.ok).toBe(true);
239
+ const persisted = readTaskMeta(taskId);
240
+ expect(persisted?.attachments?.map((a: any) => a.file_id)).toEqual([asked.file_id]);
241
+ expect(persisted?.origin).toBe("dashboard");
242
+ expect(persisted?.reply_attachments?.map((a: any) => a.file_id)).toEqual([answer.file_id]);
243
+ expect(readInboxMeta(reply.message_id)?.attachments?.[0]?.file_id).toBe(answer.file_id);
244
+ });
245
+ });
246
+
247
+ describe("#507 — reverse (e): no-attachments call is byte-identical to pre-#507", () => {
248
+ test("R-e1: reply with no attachments field returns response without attachments_saved key at all", async () => {
249
+ const { taskId } = seed();
250
+ const handler = await getSendReplyHandler();
251
+ const reply = await callReply(handler, {
252
+ alias: AGENT_ALIAS,
253
+ text: "no attachments here",
254
+ in_reply_to: taskId,
255
+ status: "replied" as const,
256
+ });
257
+ expect(reply.ok).toBe(true);
258
+ expect(reply.message_id).toBeDefined();
259
+ // Reverse-(e) core: key must not appear at all (undefined, not empty).
260
+ // If a caller had a strict equality assertion on the response shape,
261
+ // adding an empty attachments_saved field would break it.
262
+ expect("attachments_saved" in reply).toBe(false);
263
+ });
264
+
265
+ test("R-e2: reply with attachments=undefined returns response without attachments_saved key", async () => {
266
+ const { taskId } = seed();
267
+ const handler = await getSendReplyHandler();
268
+ const reply = await callReply(handler, {
269
+ alias: AGENT_ALIAS,
270
+ text: "explicit undefined",
271
+ in_reply_to: taskId,
272
+ status: "replied" as const,
273
+ attachments: undefined,
274
+ });
275
+ expect(reply.ok).toBe(true);
276
+ expect("attachments_saved" in reply).toBe(false);
277
+ });
278
+
279
+ test("R-e3: reply with attachments=[] (empty array) returns response without attachments_saved key", async () => {
280
+ const { taskId } = seed();
281
+ const handler = await getSendReplyHandler();
282
+ const reply = await callReply(handler, {
283
+ alias: AGENT_ALIAS,
284
+ text: "explicit empty array",
285
+ in_reply_to: taskId,
286
+ status: "replied" as const,
287
+ attachments: [],
288
+ });
289
+ expect(reply.ok).toBe(true);
290
+ // Empty array is a valid input — validator returns ok+empty.
291
+ // Response shape stays clean (no attachments_saved field).
292
+ expect("attachments_saved" in reply).toBe(false);
293
+ });
294
+
295
+ test("R-e4: reply with no attachments does not touch pre-existing tasks.meta_json (COALESCE preserves)", async () => {
296
+ const { taskId } = seed();
297
+ const handler = await getSendReplyHandler();
298
+ // Pre-set a meta_json on the task row so we can prove COALESCE preserves it.
299
+ db.run("UPDATE tasks SET meta_json = ?1 WHERE task_id = ?2", [
300
+ JSON.stringify({ pre_existing: true, some_field: "must survive" }),
301
+ taskId,
302
+ ]);
303
+ const reply = await callReply(handler, {
304
+ alias: AGENT_ALIAS,
305
+ text: "no attachments — must not clobber existing meta",
306
+ in_reply_to: taskId,
307
+ status: "replied" as const,
308
+ });
309
+ expect(reply.ok).toBe(true);
310
+ const meta = readTaskMeta(taskId);
311
+ expect(meta?.pre_existing).toBe(true);
312
+ expect(meta?.some_field).toBe("must survive");
313
+ });
314
+ });
315
+
316
+ describe("#507 — negative: malformed attachments explicitly rejected (bad_attachments)", () => {
317
+ test("N1: attachments not an array → 400 bad_attachments", async () => {
318
+ const { taskId } = seed();
319
+ const handler = await getSendReplyHandler();
320
+ const reply = await callReply(handler, {
321
+ alias: AGENT_ALIAS,
322
+ text: "malformed",
323
+ in_reply_to: taskId,
324
+ status: "replied" as const,
325
+ attachments: "not an array" as any,
326
+ });
327
+ expect(reply.ok).toBe(false);
328
+ expect(reply.error).toBe("bad_attachments");
329
+ expect(typeof reply.message).toBe("string");
330
+ // Reply never landed on disk (validation happened before DB write).
331
+ const meta = readTaskMeta(taskId);
332
+ expect(meta).toBeNull();
333
+ });
334
+
335
+ test("N2: too many attachments (>20) → 400 bad_attachments", async () => {
336
+ const { taskId } = seed();
337
+ const handler = await getSendReplyHandler();
338
+ // 21 valid-shape attachments
339
+ const many = Array.from({ length: 21 }, (_, i) => att("a".repeat(32).slice(0, 30) + (i < 10 ? "0" + i : "" + i)));
340
+ const reply = await callReply(handler, {
341
+ alias: AGENT_ALIAS,
342
+ text: "too many",
343
+ in_reply_to: taskId,
344
+ status: "replied" as const,
345
+ attachments: many,
346
+ });
347
+ expect(reply.ok).toBe(false);
348
+ expect(reply.error).toBe("bad_attachments");
349
+ expect(reply.message).toMatch(/too many/i);
350
+ });
351
+
352
+ test("N3: bad file_id (contains path separator) → 400 bad_attachments", async () => {
353
+ const { taskId } = seed();
354
+ const handler = await getSendReplyHandler();
355
+ const reply = await callReply(handler, {
356
+ alias: AGENT_ALIAS,
357
+ text: "bad file_id",
358
+ in_reply_to: taskId,
359
+ status: "replied" as const,
360
+ attachments: [{ type: "file", file_id: "../etc/passwd", name: "x", size: 1 }],
361
+ });
362
+ expect(reply.ok).toBe(false);
363
+ expect(reply.error).toBe("bad_attachments");
364
+ expect(reply.message).toMatch(/file_id/i);
365
+ });
366
+
367
+ test("N4: wrong type field (must be 'file') → 400 bad_attachments", async () => {
368
+ const { taskId } = seed();
369
+ const handler = await getSendReplyHandler();
370
+ const reply = await callReply(handler, {
371
+ alias: AGENT_ALIAS,
372
+ text: "bad type",
373
+ in_reply_to: taskId,
374
+ status: "replied" as const,
375
+ attachments: [{ type: "url", file_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }],
376
+ });
377
+ expect(reply.ok).toBe(false);
378
+ expect(reply.error).toBe("bad_attachments");
379
+ expect(reply.message).toMatch(/type/i);
380
+ });
381
+
382
+ test("N5: attachment size > MAX_UPLOAD_BYTES → 400 bad_attachments", async () => {
383
+ const { taskId } = seed();
384
+ const handler = await getSendReplyHandler();
385
+ const reply = await callReply(handler, {
386
+ alias: AGENT_ALIAS,
387
+ text: "too big",
388
+ in_reply_to: taskId,
389
+ status: "replied" as const,
390
+ attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { size: 999_999_999_999 })],
391
+ });
392
+ expect(reply.ok).toBe(false);
393
+ expect(reply.error).toBe("bad_attachments");
394
+ expect(reply.message).toMatch(/size/i);
395
+ });
396
+
397
+ test("N6: attachment name too long (>255) → 400 bad_attachments", async () => {
398
+ const { taskId } = seed();
399
+ const handler = await getSendReplyHandler();
400
+ const reply = await callReply(handler, {
401
+ alias: AGENT_ALIAS,
402
+ text: "long name",
403
+ in_reply_to: taskId,
404
+ status: "replied" as const,
405
+ attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "x".repeat(256) })],
406
+ });
407
+ expect(reply.ok).toBe(false);
408
+ expect(reply.error).toBe("bad_attachments");
409
+ expect(reply.message).toMatch(/name/i);
410
+ });
411
+
412
+ test("N7: non-object attachment item → 400 bad_attachments", async () => {
413
+ const { taskId } = seed();
414
+ const handler = await getSendReplyHandler();
415
+ const reply = await callReply(handler, {
416
+ alias: AGENT_ALIAS,
417
+ text: "junk item",
418
+ in_reply_to: taskId,
419
+ status: "replied" as const,
420
+ attachments: ["string-not-object"] as any,
421
+ });
422
+ expect(reply.ok).toBe(false);
423
+ expect(reply.error).toBe("bad_attachments");
424
+ });
425
+ });
426
+
427
+ describe("#507 — echo integrity (read-back-from-DB, not in-memory)", () => {
428
+ test("echo values are exactly what the DB round-trip produced (proves read-back not passthrough)", async () => {
429
+ const { taskId } = seed();
430
+ const handler = await getSendReplyHandler();
431
+ const a = att("ffffffffffffffffffffffffffffffff", {
432
+ name: "roundtrip.pdf",
433
+ mime: "application/pdf",
434
+ size: 4096,
435
+ });
436
+ const reply = await callReply(handler, {
437
+ alias: AGENT_ALIAS,
438
+ text: "roundtrip check",
439
+ in_reply_to: taskId,
440
+ status: "replied" as const,
441
+ attachments: [a],
442
+ });
443
+ expect(reply.ok).toBe(true);
444
+ // The echoed attachment must equal what DB round-trip gives back —
445
+ // fetch independently and compare.
446
+ const inboxMeta = readInboxMeta(reply.message_id!);
447
+ const dbAttachment = inboxMeta?.attachments?.[0];
448
+ expect(reply.attachments_saved![0]).toEqual(dbAttachment);
449
+ // Also the echo must contain the full shape as re-hydrated from JSON
450
+ // (proves it went through normalize/serialize/read/parse — not just
451
+ // an in-memory pass-through).
452
+ expect(reply.attachments_saved![0].file_id).toBe(a.file_id);
453
+ expect(reply.attachments_saved![0].name).toBe(a.name);
454
+ expect(reply.attachments_saved![0].mime).toBe(a.mime);
455
+ expect(reply.attachments_saved![0].size).toBe(a.size);
456
+ expect(reply.attachments_saved![0].type).toBe("file");
457
+ });
458
+ });
@@ -146,6 +146,60 @@ describe("SendDedup", () => {
146
146
  });
147
147
  });
148
148
 
149
+ describe("SendDedup key selection (client_request_id vs content)", () => {
150
+ const t0 = 1_700_000_000_000;
151
+ const A = "dreq_00000000000000000000000000000001";
152
+ const B = "dreq_00000000000000000000000000000002";
153
+
154
+ it("keyFor picks the request id when present, content hash otherwise", () => {
155
+ const withId = SendDedup.keyFor("alice", "bob", "好", { requestId: A, networkId: "net1" });
156
+ expect(withId.kind).toBe("request_id");
157
+ expect(withId.key).toBe(`rid|net1|alice|bob|${A}`);
158
+ const noId = SendDedup.keyFor("alice", "bob", "好", {});
159
+ expect(noId.kind).toBe("content");
160
+ expect(noId.key).toBe(SendDedup.key("alice", "bob", "好"));
161
+ // Empty / null ids fall back to the content key.
162
+ expect(SendDedup.keyFor("alice", "bob", "好", { requestId: "" }).kind).toBe("content");
163
+ expect(SendDedup.keyFor("alice", "bob", "好", { requestId: null }).kind).toBe("content");
164
+ });
165
+
166
+ it("same text with different request ids is never a duplicate", () => {
167
+ const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
168
+ d.record("alice", "bob", "好", t0, { requestId: A });
169
+ expect(d.check("alice", "bob", "好", t0 + 1000, { requestId: B })).toEqual({ duplicate: false });
170
+ });
171
+
172
+ it("same request id is a duplicate even with different content (one id = one message)", () => {
173
+ const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
174
+ d.record("alice", "bob", "first", t0, { requestId: A });
175
+ const again = d.check("alice", "bob", "second", t0 + 1000, { requestId: A });
176
+ expect(again.duplicate).toBe(true);
177
+ if (again.duplicate) expect(again.kind).toBe("request_id");
178
+ });
179
+
180
+ it("request-id keys are scoped by network, sender and target", () => {
181
+ const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
182
+ d.record("alice", "bob", "x", t0, { requestId: A, networkId: "net1" });
183
+ expect(d.check("alice", "bob", "x", t0 + 1, { requestId: A, networkId: "net2" })).toEqual({ duplicate: false });
184
+ expect(d.check("carol", "bob", "x", t0 + 1, { requestId: A, networkId: "net1" })).toEqual({ duplicate: false });
185
+ expect(d.check("alice", "dave", "x", t0 + 1, { requestId: A, networkId: "net1" })).toEqual({ duplicate: false });
186
+ });
187
+
188
+ it("#212 regression guard: without an id, same content is still a duplicate and reports kind=content", () => {
189
+ const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
190
+ d.record("agent", "peer", "same task", t0);
191
+ const again = d.check("agent", "peer", "same task", t0 + 1000);
192
+ expect(again.duplicate).toBe(true);
193
+ if (again.duplicate) expect(again.kind).toBe("content");
194
+ });
195
+
196
+ it("an id-bearing send does not shadow a later id-less send of the same text", () => {
197
+ const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
198
+ d.record("alice", "bob", "好", t0, { requestId: A });
199
+ expect(d.check("alice", "bob", "好", t0 + 1000)).toEqual({ duplicate: false });
200
+ });
201
+ });
202
+
149
203
  describe("buildDuplicateSendPayload", () => {
150
204
  it("contains the Chinese LLM-facing hint with target alias and window minutes", () => {
151
205
  const payload = buildDuplicateSendPayload({