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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ // Boundary validation for node DISPLAY attributes (display_name / team /
2
+ // tags). Mirrors `avatar-validate.ts` in spirit: this is the only place
3
+ // that decides what a client is allowed to store, and it trusts nothing.
4
+ //
5
+ // Two different postures on purpose:
6
+ //
7
+ // display_name / team — SCALAR fields the user typed. A wrong TYPE here
8
+ // is a client bug, so it is REJECTED (400) rather than coerced: silently
9
+ // turning `{}` into "[object Object]" would persist garbage the user
10
+ // never typed and can't explain.
11
+ //
12
+ // tags — a LIST. Same reasoning as the `channels` patch in tools.ts: the
13
+ // wire contract wants junk dropped instead of failing the whole request,
14
+ // so one fat-fingered entry from a dashboard doesn't 400 the save. Each
15
+ // element is narrowed independently (typeof → trim → length cap →
16
+ // dedup), so nothing non-string can reach the database.
17
+
18
+ /** Max stored length of display_name / team. */
19
+ export const MAX_ATTR_LEN = 120;
20
+ /** Max stored length of a single tag. */
21
+ export const MAX_TAG_LEN = 64;
22
+ /** Max number of tags kept per node. */
23
+ export const MAX_TAGS = 16;
24
+
25
+ export type ScalarAttrResult =
26
+ | { ok: true; value: string | null }
27
+ | { ok: false; reason: string };
28
+
29
+ /**
30
+ * Narrow an optional scalar display attribute.
31
+ * undefined → not part of this patch (caller leaves the column alone)
32
+ * null / "" → explicit clear
33
+ * non-string → rejected (never coerced)
34
+ * over-long → rejected (the user should see it, not silently lose text)
35
+ */
36
+ export function validateScalarAttr(raw: unknown, field: string): ScalarAttrResult {
37
+ if (raw === undefined) return { ok: true, value: null };
38
+ if (raw === null) return { ok: true, value: null };
39
+ if (typeof raw !== "string") {
40
+ return { ok: false, reason: `${field} must be a string or null` };
41
+ }
42
+ const trimmed = raw.trim();
43
+ if (trimmed.length === 0) return { ok: true, value: null };
44
+ if (trimmed.length > MAX_ATTR_LEN) {
45
+ return { ok: false, reason: `${field} exceeds ${MAX_ATTR_LEN} characters` };
46
+ }
47
+ // Control characters would corrupt any log line / table cell that renders
48
+ // the value; strip rather than reject (they are almost always a paste
49
+ // artefact, not user intent).
50
+ // eslint-disable-next-line no-control-regex
51
+ const clean = trimmed.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
52
+ return { ok: true, value: clean.length > 0 ? clean : null };
53
+ }
54
+
55
+ /**
56
+ * Narrow an untrusted tags array. NEVER throws, never rejects the request:
57
+ * unusable entries are dropped. Returns the canonical list to store.
58
+ */
59
+ export function narrowTags(raw: unknown): string[] {
60
+ if (!Array.isArray(raw)) return [];
61
+ const out: string[] = [];
62
+ const seen = new Set<string>();
63
+ for (const item of raw) {
64
+ if (typeof item !== "string") continue; // numbers/objects/arrays/null
65
+ // eslint-disable-next-line no-control-regex
66
+ const t = item.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
67
+ if (t.length === 0) continue;
68
+ if (t.length > MAX_TAG_LEN) continue; // absurd entry — drop, don't truncate
69
+ const key = t.toLowerCase(); // case-fold for dedup, keep first spelling
70
+ if (seen.has(key)) continue;
71
+ seen.add(key);
72
+ out.push(t);
73
+ if (out.length >= MAX_TAGS) break;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Parse the stored tags column back into an array. Storage is JSON text;
79
+ * anything unparseable (hand-edited DB, older format) reads as empty so a
80
+ * dashboard can always `.map()` without a guard. */
81
+ export function parseStoredTags(stored: unknown): string[] {
82
+ if (typeof stored !== "string" || stored.length === 0) return [];
83
+ try {
84
+ return narrowTags(JSON.parse(stored));
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
@@ -0,0 +1,213 @@
1
+ // Node display attributes — team / tags / display_name (Vincent 目标第 3 条).
2
+ //
3
+ // These are HUB-SIDE DISPLAY attributes. Following the `avatar_url` (#462)
4
+ // precedent they are deliberately NOT part of the RFC-024 config-apply
5
+ // pipeline: agent-node has nothing to consume, so pushing them through
6
+ // config revision/ack would ring a doorbell for a field the node does not
7
+ // understand and could wedge the config revision.
8
+ //
9
+ // They DO need concurrency protection (two dashboards editing the same
10
+ // node), which `avatar_url` lacks. Hence a dedicated `attrs_revision` CAS
11
+ // that is independent of `config_revision` — editing a tag must never bump
12
+ // the node's *config* revision.
13
+ //
14
+ // Every test drives the REAL HTTP endpoint against a booted server, not a
15
+ // helper: the contract under test is the wire shape + status codes.
16
+ //
17
+ // NOTE (test isolation): this file boots its own Bun.serve, same pattern as
18
+ // uploads-http.test.ts / api-host-supervisors-fallback.test.ts. Those files
19
+ // are per-file runs by design (single-process module-singleton conflict,
20
+ // tracked in #434) — run this file on its own.
21
+
22
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
23
+ import { mkdtempSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+
27
+ const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
28
+ process.env.COMMHUB_DB = tmpDb;
29
+ const PORT = 23000 + Math.floor(Math.random() * 1500);
30
+ process.env.PORT = String(PORT);
31
+ process.env.HOST = "127.0.0.1";
32
+
33
+ const { db } = await import("./db.js");
34
+ const { issueUserToken } = await import("./auth.js");
35
+ const { bootServer } = await import("./server.js");
36
+
37
+ const BASE = `http://127.0.0.1:${PORT}`;
38
+ const NET = "net_attrs";
39
+ const USER = "u_attrs";
40
+ /** A node row seeded the way a PRE-MIGRATION row looks: none of the new
41
+ * columns are written, so they are NULL / default at read time. */
42
+ const LEGACY_NODE = "node_attrs_legacy";
43
+ const LEGACY_ALIAS = "attrs-legacy-node";
44
+
45
+ let token = "";
46
+ let server: { stop: (force?: boolean) => void } | null = null;
47
+
48
+ beforeAll(async () => {
49
+ db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
50
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
51
+ db.run(`INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')`, [NET, USER]);
52
+ // Legacy row: only the columns that existed before this change.
53
+ db.run(
54
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id)
55
+ VALUES (?1, 'attrs-legacy', ?2, 'claude-agent-sdk', 'claude-sonnet-4-6', ?3)`,
56
+ [LEGACY_NODE, LEGACY_ALIAS, NET],
57
+ );
58
+ token = issueUserToken(USER, "attrs-test").token;
59
+ server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
60
+ await new Promise((r) => setTimeout(r, 120));
61
+ });
62
+
63
+ afterAll(() => {
64
+ try { server?.stop(true); } catch { /* already down */ }
65
+ });
66
+
67
+ async function putAttrs(ref: string, body: unknown) {
68
+ const res = await fetch(`${BASE}/api/nodes/${encodeURIComponent(ref)}/attrs?network_id=${NET}`, {
69
+ method: "PUT",
70
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
71
+ body: JSON.stringify(body),
72
+ });
73
+ return { status: res.status, json: (await res.json()) as Record<string, unknown> };
74
+ }
75
+
76
+ async function listNodes() {
77
+ const res = await fetch(`${BASE}/api/nodes?network_id=${NET}`, {
78
+ headers: { Authorization: `Bearer ${token}` },
79
+ });
80
+ const j = (await res.json()) as { nodes?: Array<Record<string, unknown>> };
81
+ return j.nodes ?? [];
82
+ }
83
+
84
+ describe("node display attrs — backward compatibility with existing rows", () => {
85
+ test("a pre-migration node row still lists fine; new fields read as empty, not missing/throwing", async () => {
86
+ const rows = await listNodes();
87
+ const row = rows.find((r) => r.node_id === LEGACY_NODE);
88
+ expect(row).toBeDefined();
89
+ // Must not blow up and must not omit the keys — the dashboard reads
90
+ // them unconditionally.
91
+ expect(row!).toHaveProperty("display_name");
92
+ expect(row!).toHaveProperty("team");
93
+ expect(row!).toHaveProperty("tags");
94
+ expect(row!.display_name ?? null).toBeNull();
95
+ expect(row!.team ?? null).toBeNull();
96
+ // tags normalises to an empty array rather than null/undefined so the
97
+ // dashboard can `.map()` without a guard.
98
+ expect(row!.tags).toEqual([]);
99
+ expect(row!.attrs_revision).toBe(0);
100
+ });
101
+ });
102
+
103
+ describe("node display attrs — write then read back", () => {
104
+ test("PUT sets display_name/team/tags, bumps attrs_revision, and GET /api/nodes reflects it", async () => {
105
+ const w = await putAttrs(LEGACY_ALIAS, {
106
+ base_attrs_revision: 0,
107
+ display_name: "研发一号机",
108
+ team: "platform",
109
+ tags: ["prod", "gpu"],
110
+ });
111
+ expect(w.status).toBe(200);
112
+ expect(w.json.ok).toBe(true);
113
+ expect(w.json.attrs_revision).toBe(1);
114
+
115
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
116
+ expect(row.display_name).toBe("研发一号机");
117
+ expect(row.team).toBe("platform");
118
+ expect(row.tags).toEqual(["prod", "gpu"]);
119
+ expect(row.attrs_revision).toBe(1);
120
+ });
121
+
122
+ test("editing attrs does NOT touch config_revision (display vs config are separate revisions)", async () => {
123
+ const before = db.get<{ config_revision: number }>(
124
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
125
+ const w = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: 1, team: "infra" });
126
+ expect(w.status).toBe(200);
127
+ const after = db.get<{ config_revision: number }>(
128
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
129
+ expect(after).toBe(before);
130
+ });
131
+
132
+ test("partial patch leaves untouched fields alone", async () => {
133
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
134
+ // team was just changed to 'infra'; display_name/tags must survive.
135
+ expect(row.team).toBe("infra");
136
+ expect(row.display_name).toBe("研发一号机");
137
+ expect(row.tags).toEqual(["prod", "gpu"]);
138
+ });
139
+ });
140
+
141
+ describe("node display attrs — CAS concurrency guard", () => {
142
+ test("stale base_attrs_revision → 409 with the current revision, and NO write happens", async () => {
143
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
144
+ const stale = await putAttrs(LEGACY_ALIAS, {
145
+ base_attrs_revision: 0, // deliberately stale
146
+ team: "should-not-land",
147
+ });
148
+ expect(stale.status).toBe(409);
149
+ expect(stale.json.ok).toBe(false);
150
+ expect(stale.json.error).toBe("attrs_revision_conflict");
151
+ expect(stale.json.current_attrs_revision).toBe(before.attrs_revision);
152
+
153
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
154
+ expect(after.team).toBe(before.team); // unchanged
155
+ expect(after.attrs_revision).toBe(before.attrs_revision);
156
+ });
157
+ });
158
+
159
+ describe("node display attrs — untrusted input is narrowed at the boundary", () => {
160
+ test("tags: non-strings dropped, blanks dropped, duplicates folded, count + length capped", async () => {
161
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
162
+ const w = await putAttrs(LEGACY_ALIAS, {
163
+ base_attrs_revision: cur,
164
+ tags: [
165
+ "keep",
166
+ "keep", // duplicate
167
+ " spaced ", // trimmed
168
+ "",
169
+ " ",
170
+ 42,
171
+ null,
172
+ { evil: true },
173
+ ["nested"],
174
+ "x".repeat(200), // over-long → dropped or truncated, never stored raw
175
+ ],
176
+ });
177
+ expect(w.status).toBe(200);
178
+ const tags = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.tags as string[];
179
+ expect(Array.isArray(tags)).toBe(true);
180
+ expect(tags).toContain("keep");
181
+ expect(tags).toContain("spaced");
182
+ // one "keep", no junk, nothing absurdly long
183
+ expect(tags.filter((t) => t === "keep")).toHaveLength(1);
184
+ expect(tags.every((t) => typeof t === "string" && t.length > 0 && t.length <= 64)).toBe(true);
185
+ expect(tags.some((t) => t.includes("evil") || t.includes("nested"))).toBe(false);
186
+ });
187
+
188
+ test("display_name/team: non-string values are rejected, not coerced", async () => {
189
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
190
+ const bad = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: cur, team: { not: "a string" } });
191
+ expect(bad.status).toBe(400);
192
+ expect(bad.json.ok).toBe(false);
193
+ });
194
+ });
195
+
196
+ describe("node display attrs — auth + scope", () => {
197
+ test("no token → 401, and the row is untouched", async () => {
198
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
199
+ const res = await fetch(`${BASE}/api/nodes/${LEGACY_ALIAS}/attrs?network_id=${NET}`, {
200
+ method: "PUT",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ base_attrs_revision: before.attrs_revision, team: "hijack" }),
203
+ });
204
+ expect(res.status).toBe(401);
205
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
206
+ expect(after.team).toBe(before.team);
207
+ });
208
+
209
+ test("unknown node ref → 404", async () => {
210
+ const r = await putAttrs("no-such-node-ref", { base_attrs_revision: 0, team: "x" });
211
+ expect(r.status).toBe(404);
212
+ });
213
+ });
@@ -0,0 +1,202 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #498 regression — the send_reply hub response gains a `warning` field
7
+ // pointing at commhub_send_task ONLY when the target alias resolves to
8
+ // a real agent node (i.e. not hub / not api / not an unbound alias).
9
+ // Silence-affirms the Dashboard path.
10
+ //
11
+ // Why this test exists (通信龙 2026-07-30 裁定): the warning is a pure
12
+ // hint — deleting it doesn't break any behavior, so it's exactly the
13
+ // kind of code a future refactor drops silently. This test is the
14
+ // tripwire: if the branch goes away, this test turns red.
15
+ //
16
+ // witnessed-red methodology (`feedback_witnessed_red_before_witnessed_green`):
17
+ // I confirmed both assertions fail cleanly when the `warning` branch is
18
+ // reverted (see docs/tests/p-498-reply-warning/witnessed-red.txt), so
19
+ // this suite genuinely catches regressions rather than green-when-empty.
20
+
21
+ const NET_ID = "net_498_warn";
22
+ const USER_ID = "u_498_warn";
23
+ const NODE_ID = "node_498_warn";
24
+ const AGENT_ALIAS = "peer-agent-498";
25
+ const AGENT_TOK_ID = "tok_498_warn";
26
+
27
+ interface ToolHandler {
28
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
29
+ }
30
+ interface Reply {
31
+ ok?: boolean;
32
+ message_id?: string;
33
+ session_status?: string;
34
+ warning?: string;
35
+ error?: string;
36
+ [k: string]: unknown;
37
+ }
38
+
39
+ function cleanup() {
40
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
41
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
42
+ try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
43
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
44
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
45
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
46
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
47
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
48
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
49
+ }
50
+
51
+ beforeEach(cleanup);
52
+ afterAll(cleanup);
53
+
54
+ function seed() {
55
+ db.run(
56
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
57
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
58
+ [USER_ID, USER_ID],
59
+ );
60
+ db.run(
61
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
62
+ VALUES (?1, ?2, ?3, datetime('now'))`,
63
+ [NET_ID, NET_ID, USER_ID],
64
+ );
65
+ db.run(
66
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
67
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
68
+ [USER_ID, NET_ID],
69
+ );
70
+ db.run(
71
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
72
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
73
+ [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
74
+ );
75
+ db.run(
76
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
77
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
78
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
79
+ );
80
+ db.run(
81
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
82
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
83
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
84
+ );
85
+ }
86
+
87
+ function makeTask(to: string): string {
88
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
89
+ db.run(
90
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
91
+ VALUES (?1, ?2, ?3, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?4)`,
92
+ [id, "dispatcher", to, NET_ID],
93
+ );
94
+ return id;
95
+ }
96
+
97
+ function buildHandlers(): Record<string, ToolHandler> {
98
+ const server = new McpServer({ name: "test", version: "0" }) as any;
99
+ const tools: Record<string, ToolHandler> = {};
100
+ const origTool = server.tool.bind(server);
101
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
102
+ tools[name] = handler;
103
+ return origTool(name, _desc, _schema, handler);
104
+ };
105
+ const origRegisterTool = server.registerTool?.bind(server);
106
+ if (origRegisterTool) {
107
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
108
+ tools[name] = handler;
109
+ return origRegisterTool(name, _cfg, handler);
110
+ };
111
+ }
112
+ // Bind as a network token whose caller alias is the sender.
113
+ registerTools(
114
+ server,
115
+ undefined,
116
+ /* enforceNetworkId */ NET_ID,
117
+ /* enforceUserId */ USER_ID,
118
+ /* callerAlias */ AGENT_ALIAS,
119
+ /* callerTokenIsNetwork */ true,
120
+ /* callerTokenId */ AGENT_TOK_ID,
121
+ );
122
+ return tools;
123
+ }
124
+
125
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
126
+ const r = await handler(args);
127
+ return JSON.parse(r.content[0].text) as Reply;
128
+ }
129
+
130
+ describe("send_reply warning field (#498)", () => {
131
+ test("target alias resolves to a real agent → response includes `warning` field", async () => {
132
+ seed();
133
+ const tools = buildHandlers();
134
+ const sendReply = tools["send_reply"];
135
+ expect(sendReply).toBeDefined();
136
+
137
+ const taskId = makeTask(AGENT_ALIAS);
138
+ const reply = await call(sendReply, {
139
+ alias: AGENT_ALIAS,
140
+ text: "peer reply (should trigger warning)",
141
+ in_reply_to: taskId,
142
+ status: "replied",
143
+ from_session: AGENT_ALIAS,
144
+ });
145
+
146
+ expect(reply.ok).toBe(true);
147
+ expect(reply.warning).toBeDefined();
148
+ expect(typeof reply.warning).toBe("string");
149
+ const w = reply.warning as string;
150
+ // Action-pointing content — witnessed-red methodology means each
151
+ // assertion here must be one a future revert would break, not a
152
+ // shape-only check that passes on empty string.
153
+ expect(w).toContain("commhub_send_task");
154
+ // Alias value is INLINED (not just "<alias>") so the LLM has a
155
+ // copy-pasteable next command. This is the specific design 通信龙
156
+ // called out on 2026-07-30.
157
+ expect(w).toContain(`alias="${AGENT_ALIAS}"`);
158
+ expect(w).toContain("agent peers");
159
+ expect(w).toContain("not see this reply in real time");
160
+ expect(w).toContain("RFC-030");
161
+ expect(w).toContain("全网规则");
162
+ });
163
+
164
+ test("target alias is `hub` (Dashboard path) → NO warning field", async () => {
165
+ seed();
166
+ const tools = buildHandlers();
167
+ const sendReply = tools["send_reply"];
168
+
169
+ const taskId = makeTask("hub");
170
+ const reply = await call(sendReply, {
171
+ alias: "hub",
172
+ text: "dashboard reply",
173
+ in_reply_to: taskId,
174
+ status: "replied",
175
+ from_session: AGENT_ALIAS,
176
+ });
177
+
178
+ expect(reply.ok).toBe(true);
179
+ // Silence-affirms the Dashboard path. Every reply carrying a
180
+ // warning would train the LLM to ignore it, so absence here is
181
+ // load-bearing.
182
+ expect(reply.warning).toBeUndefined();
183
+ });
184
+
185
+ test("target alias is `api` (Dashboard path) → NO warning field", async () => {
186
+ seed();
187
+ const tools = buildHandlers();
188
+ const sendReply = tools["send_reply"];
189
+
190
+ const taskId = makeTask("api");
191
+ const reply = await call(sendReply, {
192
+ alias: "api",
193
+ text: "dashboard reply via api",
194
+ in_reply_to: taskId,
195
+ status: "replied",
196
+ from_session: AGENT_ALIAS,
197
+ });
198
+
199
+ expect(reply.ok).toBe(true);
200
+ expect(reply.warning).toBeUndefined();
201
+ });
202
+ });