@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.
- package/README.md +130 -10
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -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
|
+
});
|
package/src/send_dedup.test.ts
CHANGED
|
@@ -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({
|
package/src/send_dedup.ts
CHANGED
|
@@ -33,6 +33,26 @@
|
|
|
33
33
|
// - The structured response shape is identical between MCP and REST
|
|
34
34
|
// callers so the LLM gets the same parseable hint regardless of
|
|
35
35
|
// transport.
|
|
36
|
+
//
|
|
37
|
+
// Key selection (#2043, follow-up to #212): the content-hash key above is
|
|
38
|
+
// only right for callers that have no other notion of "one message".
|
|
39
|
+
// Human chat clients (the desktop/mobile app, the web Dashboard) mint a
|
|
40
|
+
// fresh `meta.client_request_id` per message bubble and reuse it only
|
|
41
|
+
// when retrying that same bubble. For those sends the content hash is the
|
|
42
|
+
// WRONG identity: a user who types 「好」 twice within five minutes sent
|
|
43
|
+
// two messages, and dropping the second one (while the app maps the 429
|
|
44
|
+
// to "delivered") silently loses it. So:
|
|
45
|
+
// - a send WITH a valid client_request_id is keyed on
|
|
46
|
+
// (network, from, to, client_request_id) — a new id always proceeds,
|
|
47
|
+
// identical text or not; a same-id retry is answered from the durable
|
|
48
|
+
// tasks row by the handler (task-idempotency.ts) before this index is
|
|
49
|
+
// even consulted, so this key is defence in depth, not the primary
|
|
50
|
+
// replay mechanism;
|
|
51
|
+
// - a send WITHOUT one (agent / MCP / scripted traffic) keeps the #212
|
|
52
|
+
// content-hash window unchanged.
|
|
53
|
+
// One id is one message: re-using an id with different content is a
|
|
54
|
+
// client bug and the handlers fail it closed (`idempotency_conflict`)
|
|
55
|
+
// rather than storing a second row or silently replaying the first.
|
|
36
56
|
|
|
37
57
|
import { createHash } from "crypto";
|
|
38
58
|
|
|
@@ -61,9 +81,23 @@ export function readDedupConfig(env: NodeJS.ProcessEnv = process.env): DedupConf
|
|
|
61
81
|
};
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
/** Which identity a dedup key was built from. Logged on every drop. */
|
|
85
|
+
export type DedupKeyKind = "request_id" | "content";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Optional per-send scope. When `requestId` is a non-empty string the key
|
|
89
|
+
* is the request id (scoped by network/from/to) instead of the content hash.
|
|
90
|
+
* Callers must pass the already-validated id (clientRequestIdFromMeta), not
|
|
91
|
+
* raw client input.
|
|
92
|
+
*/
|
|
93
|
+
export type DedupScope = {
|
|
94
|
+
requestId?: string | null;
|
|
95
|
+
networkId?: string | null;
|
|
96
|
+
};
|
|
97
|
+
|
|
64
98
|
export type DedupCheck =
|
|
65
99
|
| { duplicate: false }
|
|
66
|
-
| { duplicate: true; lastSentMs: number; ageMs: number };
|
|
100
|
+
| { duplicate: true; lastSentMs: number; ageMs: number; kind: DedupKeyKind };
|
|
67
101
|
|
|
68
102
|
export class SendDedup {
|
|
69
103
|
private last = new Map<string, number>();
|
|
@@ -93,6 +127,18 @@ export class SendDedup {
|
|
|
93
127
|
return `${from}|${to}|${hash}`;
|
|
94
128
|
}
|
|
95
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Pick the dedup identity for one send. A validated client request id
|
|
132
|
+
* wins over the content hash; see the header comment for why.
|
|
133
|
+
*/
|
|
134
|
+
static keyFor(from: string, to: string, content: string, scope: DedupScope = {}): { key: string; kind: DedupKeyKind } {
|
|
135
|
+
const requestId = typeof scope.requestId === "string" && scope.requestId.length > 0 ? scope.requestId : null;
|
|
136
|
+
if (requestId) {
|
|
137
|
+
return { key: `rid|${scope.networkId ?? ""}|${from}|${to}|${requestId}`, kind: "request_id" };
|
|
138
|
+
}
|
|
139
|
+
return { key: SendDedup.key(from, to, content), kind: "content" };
|
|
140
|
+
}
|
|
141
|
+
|
|
96
142
|
/**
|
|
97
143
|
* Check whether (from, to, content) was sent recently. Returns
|
|
98
144
|
* `{ duplicate: false }` when the call should proceed, or
|
|
@@ -103,10 +149,10 @@ export class SendDedup {
|
|
|
103
149
|
* AFTER the underlying side effect (inbox insert + pushEvent) succeeds,
|
|
104
150
|
* so failed sends don't accidentally block legitimate retries.
|
|
105
151
|
*/
|
|
106
|
-
check(from: string, to: string, content: string, nowMs: number = Date.now()): DedupCheck {
|
|
152
|
+
check(from: string, to: string, content: string, nowMs: number = Date.now(), scope: DedupScope = {}): DedupCheck {
|
|
107
153
|
if (!this.enabled) return { duplicate: false };
|
|
108
154
|
this.evictExpired(nowMs);
|
|
109
|
-
const k = SendDedup.
|
|
155
|
+
const { key: k, kind } = SendDedup.keyFor(from, to, content, scope);
|
|
110
156
|
const lastSentMs = this.last.get(k);
|
|
111
157
|
if (lastSentMs === undefined) return { duplicate: false };
|
|
112
158
|
const ageMs = nowMs - lastSentMs;
|
|
@@ -114,13 +160,13 @@ export class SendDedup {
|
|
|
114
160
|
this.last.delete(k);
|
|
115
161
|
return { duplicate: false };
|
|
116
162
|
}
|
|
117
|
-
return { duplicate: true, lastSentMs, ageMs };
|
|
163
|
+
return { duplicate: true, lastSentMs, ageMs, kind };
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
/** Record a successful send for future dedup checks. */
|
|
121
|
-
record(from: string, to: string, content: string, nowMs: number = Date.now()): void {
|
|
167
|
+
record(from: string, to: string, content: string, nowMs: number = Date.now(), scope: DedupScope = {}): void {
|
|
122
168
|
if (!this.enabled) return;
|
|
123
|
-
const k = SendDedup.
|
|
169
|
+
const k = SendDedup.keyFor(from, to, content, scope).key;
|
|
124
170
|
this.last.set(k, nowMs);
|
|
125
171
|
if (this.last.size > this.cfg.maxKeys) this.evictOldest();
|
|
126
172
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildServeErrorResponse, serveErrorBody } from "./serve-error.js";
|
|
3
|
+
|
|
4
|
+
// 注:「没有 error 回调时 Bun 回默认非 JSON 500」这一半没法在 bun test 里当用例:
|
|
5
|
+
// 运行器会把 fetch 里抛出的异常当成未处理错误直接判这条用例失败(实测),证据在 #695 末评。
|
|
6
|
+
describe("#695 fetch exceptions become JSON with the exception's own message", () => {
|
|
7
|
+
test("body carries THE message, both JSON-RPC-shaped and REST-shaped", () => {
|
|
8
|
+
const b = serveErrorBody(new Error("boom-695: sqlite is locked"));
|
|
9
|
+
expect(b.error.code).toBe(-32603);
|
|
10
|
+
expect(b.error.message).toBe("boom-695: sqlite is locked");
|
|
11
|
+
expect(b.message).toBe("boom-695: sqlite is locked");
|
|
12
|
+
expect(b.ok).toBe(false);
|
|
13
|
+
// 不是固定 JSON:换一个异常,message 跟着变
|
|
14
|
+
expect(serveErrorBody(new Error("other")).error.message).toBe("other");
|
|
15
|
+
expect(serveErrorBody("plain string").error.message).toBe("plain string");
|
|
16
|
+
expect(serveErrorBody(new Error("")).error.message).toBe("internal error");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("real Bun.serve: a throwing fetch answers 500 + application/json + the message (was a non-JSON default page)", async () => {
|
|
20
|
+
const server = Bun.serve({
|
|
21
|
+
port: 0,
|
|
22
|
+
fetch() { throw new Error("boom-695-live"); },
|
|
23
|
+
error: (err) => buildServeErrorResponse(err),
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/mcp`, { method: "POST", body: "{}" });
|
|
27
|
+
expect(res.status).toBe(500);
|
|
28
|
+
expect(res.headers.get("content-type") ?? "").toContain("application/json");
|
|
29
|
+
const json = await res.json() as { error: { code: number; message: string }; ok: boolean };
|
|
30
|
+
expect(json.error.code).toBe(-32603);
|
|
31
|
+
expect(json.error.message).toBe("boom-695-live");
|
|
32
|
+
expect(json.ok).toBe(false);
|
|
33
|
+
} finally {
|
|
34
|
+
server.stop(true);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
});
|