@sleep2agi/commhub-server 0.9.0-preview.54 → 0.9.0-preview.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.54",
3
+ "version": "0.9.0-preview.55",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ // #1909 —— POST /api/messages/ack 的 `{ agent }` 形态:把调用者收到的、来自该 agent 的
9
+ // 两张表(user_inbox + inbox reply/task/message)全部标已读。角标分母 unread_by_agent
10
+ // 数的是全集,按 id 只能 ack 拉到的那一页 ⇒ 老行永远清不掉,这一形态补上。
11
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-ack-by-agent-http-"));
12
+ let server: any;
13
+ let base = "";
14
+ let tokenA = "";
15
+ let tokenB = "";
16
+ let userA = "";
17
+ let userB = "";
18
+ let userIdA = "";
19
+ let userIdB = "";
20
+ let netA = "";
21
+ let netB = "";
22
+
23
+ const AGENT = "ack-agent-x";
24
+ const OTHER = "ack-agent-y";
25
+
26
+ function seed() {
27
+ const ui = (id: string, net: string, uid: string, from: string) =>
28
+ db.run(
29
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, content) VALUES (?1, ?2, ?3, ?4, 'agent_message', 'x')`,
30
+ [id, net, uid, from],
31
+ );
32
+ const ib = (id: string, net: string, to: string, from: string, type: string) =>
33
+ db.run(
34
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id) VALUES (?1, ?2, ?3, 'x', ?4, ?5)`,
35
+ [id, to, type, from, net],
36
+ );
37
+ ui("ui_a_x_1", netA, userIdA, AGENT);
38
+ ui("ui_a_x_2", netA, userIdA, AGENT);
39
+ ui("ui_a_y_1", netA, userIdA, OTHER);
40
+ ui("ui_b_x_1", netB, userIdB, AGENT);
41
+ ib("ib_a_x_reply", netA, userA, AGENT, "reply");
42
+ ib("ib_a_x_task", netA, userA, AGENT, "task");
43
+ ib("ib_a_x_status", netA, userA, AGENT, "status"); // 不在 reply/task/message 内,不该被 ack
44
+ ib("ib_a_y_reply", netA, userA, OTHER, "reply");
45
+ ib("ib_b_x_reply", netB, userB, AGENT, "reply");
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
50
+ const stamp = `${Date.now()}_${process.pid}`;
51
+ userA = `ack_agent_a_${stamp}`;
52
+ userB = `ack_agent_b_${stamp}`;
53
+ const a = register(userA, "AckAgent123!", undefined, "seed");
54
+ const b = register(userB, "AckAgent123!", undefined, "seed");
55
+ expect(a.ok).toBe(true);
56
+ expect(b.ok).toBe(true);
57
+ tokenA = a.token!; netA = a.network_id!;
58
+ tokenB = b.token!; netB = b.network_id!;
59
+ userIdA = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userA])!.user_id;
60
+ userIdB = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userB])!.user_id;
61
+ seed();
62
+ const mod: any = await import("./server.js");
63
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
64
+ base = `http://127.0.0.1:${server.port}`;
65
+ }, 30_000);
66
+
67
+ afterAll(() => {
68
+ try { server?.stop?.(true); } catch {}
69
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
70
+ });
71
+
72
+ async function ack(token: string, body: unknown): Promise<{ status: number; body: any }> {
73
+ const response = await fetch(`${base}/api/messages/ack`, {
74
+ method: "POST",
75
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
76
+ body: JSON.stringify(body),
77
+ });
78
+ return { status: response.status, body: await response.json() };
79
+ }
80
+
81
+ const uiAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM user_inbox WHERE message_id = ?1", [id])!.acked;
82
+ const ibAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", [id])!.acked;
83
+
84
+ async function unreadByAgent(token: string): Promise<Record<string, number>> {
85
+ const response = await fetch(`${base}/api/messages?scope=user&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
86
+ expect(response.status).toBe(200);
87
+ return ((await response.json()) as any).unread_by_agent ?? {};
88
+ }
89
+
90
+ describe("POST /api/messages/ack { agent } (#1909)", () => {
91
+ test("missing everything → message_id_required; agent + ids → ambiguous_ack", async () => {
92
+ const none = await ack(tokenA, {});
93
+ expect(none.status).toBe(400);
94
+ expect(none.body.error).toBe("message_id_required");
95
+ const both = await ack(tokenA, { agent: AGENT, message_ids: ["ui_a_x_1"] });
96
+ expect(both.status).toBe(400);
97
+ expect(both.body.error).toBe("ambiguous_ack");
98
+ // 拒绝的那次不能有副作用
99
+ expect(uiAcked("ui_a_x_1")).toBe(0);
100
+ });
101
+
102
+ test("unread_by_agent counts the seeded rows before ack", async () => {
103
+ const by = await unreadByAgent(tokenA);
104
+ expect(by[AGENT]).toBe(4); // 2 user_inbox + reply + task (status 不计)
105
+ expect(by[OTHER]).toBe(2);
106
+ });
107
+
108
+ test("agent form acks both tables, only that agent, only the caller", async () => {
109
+ const r = await ack(tokenA, { agent: ` ${AGENT} ` });
110
+ expect(r.status).toBe(200);
111
+ expect(r.body).toMatchObject({ ok: true, scope: "agent", agent: AGENT, acked: 4, acked_user_inbox: 2, acked_inbox: 2 });
112
+ expect(uiAcked("ui_a_x_1")).toBe(1);
113
+ expect(uiAcked("ui_a_x_2")).toBe(1);
114
+ expect(ibAcked("ib_a_x_reply")).toBe(1);
115
+ expect(ibAcked("ib_a_x_task")).toBe(1);
116
+ // 不该动的:别的 agent、非 reply/task/message 类型、别的用户
117
+ expect(uiAcked("ui_a_y_1")).toBe(0);
118
+ expect(ibAcked("ib_a_x_status")).toBe(0);
119
+ expect(ibAcked("ib_a_y_reply")).toBe(0);
120
+ expect(uiAcked("ui_b_x_1")).toBe(0);
121
+ expect(ibAcked("ib_b_x_reply")).toBe(0);
122
+ const by = await unreadByAgent(tokenA);
123
+ expect(by[AGENT] ?? 0).toBe(0);
124
+ expect(by[OTHER]).toBe(2);
125
+ expect((await unreadByAgent(tokenB))[AGENT]).toBe(2);
126
+ });
127
+
128
+ test("agent form is idempotent: second call changes 0 rows", async () => {
129
+ const r = await ack(tokenA, { agent: AGENT });
130
+ expect(r.status).toBe(200);
131
+ expect(r.body.acked).toBe(0);
132
+ });
133
+
134
+ test("id form still works and now reports scope ids", async () => {
135
+ const r = await ack(tokenA, { message_ids: ["ui_a_y_1", "ib_a_y_reply", "ui_b_x_1"] });
136
+ expect(r.status).toBe(200);
137
+ expect(r.body).toMatchObject({ ok: true, scope: "ids", acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
138
+ expect(uiAcked("ui_a_y_1")).toBe(1);
139
+ expect(ibAcked("ib_a_y_reply")).toBe(1);
140
+ expect(uiAcked("ui_b_x_1")).toBe(0); // 别人的 id 匹配不到
141
+ });
142
+ });
package/src/server.ts CHANGED
@@ -2755,6 +2755,34 @@ return Bun.serve({
2755
2755
  const ids: string[] = Array.isArray(body?.message_ids)
2756
2756
  ? body.message_ids.filter((x: unknown) => typeof x === "string" && x.length > 0)
2757
2757
  : (typeof body?.message_id === "string" && body.message_id ? [body.message_id] : []);
2758
+ // #1909 —— 按 agent 整体 ack:`{ agent: "<alias>" }`。角标的分母是 `unread_by_agent`,
2759
+ // 它数的是**全部**未 ack 行,而客户端一页只看得到最近 200/300 条 ⇒ 老行永远 ack 不到,
2760
+ // 红点永远清不掉(生产 admin:user_inbox 522 + inbox 5660 行)。这一形态把调用者
2761
+ // 收到的、来自该 agent 的两张表全部标已读;同样只动自己的行。
2762
+ const agentRaw = typeof body?.agent === "string" ? body.agent.trim() : "";
2763
+ if (agentRaw && ids.length > 0) {
2764
+ return withCors(req, Response.json({ ok: false, error: "ambiguous_ack" }, { status: 400 }));
2765
+ }
2766
+ if (agentRaw) {
2767
+ const uiParams: any[] = [callerUserId, agentRaw];
2768
+ let uiSql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
2769
+ WHERE user_id = ?1 AND from_session = ?2 AND acked = 0`;
2770
+ uiSql = addNetworkScope(uiSql, uiParams, restScope);
2771
+ const uiAcked = db.run(uiSql, uiParams).changes ?? 0;
2772
+ let agentInboxAcked = 0;
2773
+ const agentCallerUsername = restAuth?.username ?? "";
2774
+ if (agentCallerUsername && !userInboxAliasCollides(agentCallerUsername, restScope)) {
2775
+ const ibAgentParams: any[] = [agentCallerUsername, agentRaw];
2776
+ let ibAgentSql = `UPDATE inbox SET acked = 1
2777
+ WHERE session_name = ?1 AND from_session = ?2 AND acked = 0 AND type IN ('reply', 'task', 'message')`;
2778
+ ibAgentSql = addNetworkScope(ibAgentSql, ibAgentParams, restScope);
2779
+ agentInboxAcked = db.run(ibAgentSql, ibAgentParams).changes ?? 0;
2780
+ }
2781
+ return withCors(req, Response.json({
2782
+ ok: true, scope: "agent", agent: agentRaw,
2783
+ acked: uiAcked + agentInboxAcked, acked_user_inbox: uiAcked, acked_inbox: agentInboxAcked,
2784
+ }));
2785
+ }
2758
2786
  if (ids.length === 0) {
2759
2787
  return withCors(req, Response.json({ ok: false, error: "message_id_required" }, { status: 400 }));
2760
2788
  }
@@ -2781,7 +2809,7 @@ return Bun.serve({
2781
2809
  ibSql = addNetworkScope(ibSql, ibParams, restScope);
2782
2810
  inboxAcked = db.run(ibSql, ibParams).changes ?? 0;
2783
2811
  }
2784
- return withCors(req, Response.json({ ok: true, acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
2812
+ return withCors(req, Response.json({ ok: true, scope: "ids", acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
2785
2813
  }
2786
2814
 
2787
2815
  // ── REST: stats summary ──