@anteros/core 0.0.41 → 0.0.44

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 (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
@@ -0,0 +1,19 @@
1
+ // Test fixture — one step, always succeeds. Shared in-process state:
2
+ // `globalThis.__wfEngine = { log: string[], attempts: Record<string, number> }`
3
+ const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
4
+
5
+ export default {
6
+ _isWorkflow_: true,
7
+ id: "simple",
8
+ name: "Simple",
9
+ version: 1,
10
+ steps: [
11
+ {
12
+ id: "one",
13
+ exec: async ({ data }: any) => {
14
+ state.log.push("simple:one");
15
+ return { echo: data?.echo ?? null };
16
+ },
17
+ },
18
+ ],
19
+ };
@@ -0,0 +1,23 @@
1
+ // Test fixture — three steps, the middle one slow: lets a test pause or cancel
2
+ // the run while it is executing (and check that the last step never runs).
3
+ const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
4
+
5
+ export default {
6
+ _isWorkflow_: true,
7
+ id: "slow",
8
+ name: "Slow",
9
+ version: 1,
10
+ steps: [
11
+ { id: "fast", exec: async () => { state.log.push("slow:fast"); return { fast: true }; } },
12
+ {
13
+ id: "slow",
14
+ exec: async ({ data }: any) => {
15
+ state.log.push("slow:slow:start");
16
+ await new Promise((resolve) => setTimeout(resolve, data?.ms ?? 300));
17
+ state.log.push("slow:slow:end");
18
+ return { slow: true };
19
+ },
20
+ },
21
+ { id: "last", exec: async () => { state.log.push("slow:last"); return { last: true }; } },
22
+ ],
23
+ };
@@ -0,0 +1,19 @@
1
+ // Test fixture — used to check the resume version guard: the test mutates
2
+ // `version` on the registered definition after a run has started.
3
+ const state = ((globalThis as any).__wfEngine ??= { log: [], attempts: {} });
4
+
5
+ export default {
6
+ _isWorkflow_: true,
7
+ id: "versioned",
8
+ name: "Versioned",
9
+ version: 1,
10
+ steps: [
11
+ {
12
+ id: "v",
13
+ exec: async () => {
14
+ state.log.push("versioned:v");
15
+ return { ok: true };
16
+ },
17
+ },
18
+ ],
19
+ };
@@ -0,0 +1,3 @@
1
+ // Test fixture — throws while being imported: the loader must isolate the
2
+ // failure and keep loading the other workflows of the tenant.
3
+ throw new Error("boom — broken workflow fixture");
@@ -0,0 +1,8 @@
1
+ // Test fixture — `enabled: false` must not be registered.
2
+ export default {
3
+ _isWorkflow_: true,
4
+ enabled: false,
5
+ id: "nightly",
6
+ name: "Nightly",
7
+ steps: [{ id: "run", exec: async () => ({ ok: true }) }],
8
+ };
@@ -0,0 +1,21 @@
1
+ // Test fixture — a second valid workflow, with a different `version` so tests
2
+ // can assert on a definition that is not the duplicated `order`.
3
+ export default {
4
+ _isWorkflow_: true,
5
+ id: "invoice",
6
+ name: "Invoices",
7
+ version: 3,
8
+ context: {
9
+ companyId: { type: "string", index: true }, // → index context.companyId_1
10
+ processedAt: { type: "date" }, // validated + coerced to a Date
11
+ },
12
+ // Beyond `context.index`: compound, unique, sparse, named
13
+ indexes: [
14
+ { key: { "context.companyId": 1, status: 1 } },
15
+ { key: { "context.batchId": 1 }, unique: true, sparse: true, name: "uniq_batch" },
16
+ ],
17
+ steps: [
18
+ { id: "generate", exec: async ({ data }: any) => ({ generated: data?.ref ?? null }) },
19
+ { id: "send", exec: async () => ({ sent: true }) },
20
+ ],
21
+ };
@@ -0,0 +1,7 @@
1
+ // Test fixture — no `id`: must be ignored (with a warning), not registered
2
+ // under a bogus `undefined` key.
3
+ export default {
4
+ _isWorkflow_: true,
5
+ name: "Anonymous",
6
+ steps: [{ id: "run", exec: async () => ({ ok: true }) }],
7
+ };
@@ -0,0 +1,13 @@
1
+ // Test fixture — same `id` as order.workflow.ts: the duplicate must be reported
2
+ // and only one definition kept (never two entries, never a crash). The context
3
+ // declaration is identical on purpose: whichever file wins, the indexes exist.
4
+ export default {
5
+ _isWorkflow_: true,
6
+ id: "order",
7
+ name: "Orders (duplicate definition)",
8
+ context: {
9
+ customerId: { type: "string", index: true },
10
+ total: { type: "number", index: -1 },
11
+ },
12
+ steps: [{ id: "charge", exec: async () => ({ charged: false }) }],
13
+ };
@@ -0,0 +1,17 @@
1
+ // Test fixture — a valid, enabled workflow with declared context fields.
2
+ export default {
3
+ _isWorkflow_: true,
4
+ enabled: true,
5
+ id: "order",
6
+ name: "Orders",
7
+ version: 2,
8
+ context: {
9
+ customerId: { type: "string", index: true }, // → index context.customerId_1
10
+ total: { type: "number", index: -1 }, // → index context.total_-1
11
+ note: { type: "string" }, // not indexed
12
+ },
13
+ steps: [
14
+ { id: "charge", exec: async ({ data }: any) => ({ charged: data?.ref ?? null }) },
15
+ { id: "notify", exec: async () => ({ notified: true }) },
16
+ ],
17
+ };
@@ -182,16 +182,19 @@ describe("rate limiting (in-memory)", () => {
182
182
  it("login limiter uses a distinct counter (not blocked by global)", async () => {
183
183
  cfg.server.rateLimit = { enabled: true, windowMs: 60_000, max: 100, login: { max: 2 } };
184
184
  const app = createApp();
185
- app.post("/api/x/login", (c) => c.json({ ok: true }));
186
185
  app.get("/echo", (c) => c.json({ ok: true }));
187
186
  const server = Bun.serve({ port: 0, fetch: app.fetch });
188
187
  const url = `${base(server)}`;
189
188
 
189
+ // The login limiter has its own bucket: the third call is refused even though
190
+ // the global limit (100) is nowhere near. What the framework answers before
191
+ // that (unknown tenant here) is not the point.
190
192
  const statuses: number[] = [];
191
193
  for (let i = 0; i < 3; i++) {
192
- statuses.push((await fetch(`${url}/api/x/login`, { method: "POST" })).status);
194
+ statuses.push((await fetch(`${url}/api/x/collections/users/login`, { method: "POST" })).status);
193
195
  }
194
- expect(statuses).toEqual([200, 200, 429]);
196
+ expect(statuses.slice(0, 2).some((status) => status === 429)).toBe(false);
197
+ expect(statuses[2]).toBe(429);
195
198
 
196
199
  // same IP on a normal route → still allowed (distinct key)
197
200
  expect((await fetch(`${url}/echo`)).status).toBe(200);
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import os from "node:os";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig } from "../server/config";
5
+ import { syncTenants } from "../database/tenant";
6
+
7
+ const TENANT = "locks-test";
8
+ const DB = "mongodb://localhost:27017/_LOCKS_TEST";
9
+
10
+ let rest: InstanceType<typeof useRest>;
11
+
12
+ /** Write a lock document as if another process had taken it */
13
+ async function seedLock(name: string, opts: { pid?: number; hostname?: string; expiresAt: number }) {
14
+ await rest.db.collection("_locks_").insertOne({
15
+ _id: `${TENANT}:${name}` as any,
16
+ tid: TENANT,
17
+ name,
18
+ acquiredAt: Date.now() - 1000,
19
+ expiresAt: opts.expiresAt,
20
+ ...(opts.pid !== undefined ? { pid: opts.pid } : {}),
21
+ ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
22
+ } as any);
23
+ }
24
+
25
+ const lockDoc = (name: string) => rest.db.collection("_locks_").findOne({ _id: `${TENANT}:${name}` as any });
26
+
27
+ beforeAll(async () => {
28
+ formatConfig({
29
+ server: { port: 4000 },
30
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
31
+ });
32
+ await syncTenants();
33
+ rest = new useRest({ tenant_id: TENANT, internal: true });
34
+ });
35
+
36
+ afterAll(async () => {
37
+ try { await rest.db.collection("_locks_").drop(); } catch (_) {}
38
+ try { await rest.db.collection("_workflows_").drop(); } catch (_) {}
39
+ try { await rest.db.collection("_replication_").drop(); } catch (_) {}
40
+ });
41
+
42
+ describe("distributed lock", () => {
43
+ it("records the owning pid and hostname", async () => {
44
+ await rest.lock("l-pid");
45
+ const doc: any = await lockDoc("l-pid");
46
+ expect(doc.pid).toBe(process.pid);
47
+ expect(doc.hostname).toBe(os.hostname());
48
+ await rest.unlock("l-pid");
49
+ });
50
+
51
+ it("refuses a second acquisition from the same process (its own pid is not stale)", async () => {
52
+ await rest.lock("l-self");
53
+ await expect(rest.lock("l-self")).rejects.toThrow(/already held/);
54
+ await rest.unlock("l-self");
55
+ });
56
+
57
+ it("takes over a lock whose local process is gone, without waiting for the TTL", async () => {
58
+ // A process that has exited: its pid no longer exists
59
+ const dead = Bun.spawn(["true"]);
60
+ const deadPid = dead.pid;
61
+ await dead.exited;
62
+
63
+ await seedLock("l-dead", { pid: deadPid, hostname: os.hostname(), expiresAt: Date.now() + 60_000 });
64
+
65
+ await rest.lock("l-dead");
66
+ const doc: any = await lockDoc("l-dead");
67
+ expect(doc.pid).toBe(process.pid);
68
+ await rest.unlock("l-dead");
69
+ });
70
+
71
+ it("waits for a lock whose local process is still alive", async () => {
72
+ const alive = Bun.spawn(["sleep", "5"]);
73
+ try {
74
+ await seedLock("l-alive", { pid: alive.pid, hostname: os.hostname(), expiresAt: Date.now() + 60_000 });
75
+
76
+ await expect(rest.lock("l-alive")).rejects.toThrow(/already held/);
77
+
78
+ // …and the lock is taken over as soon as that process is gone
79
+ alive.kill();
80
+ await alive.exited;
81
+
82
+ await rest.lock("l-alive");
83
+ const doc: any = await lockDoc("l-alive");
84
+ expect(doc.pid).toBe(process.pid);
85
+ await rest.unlock("l-alive");
86
+ } finally {
87
+ try { alive.kill(); } catch (_) {}
88
+ }
89
+ }, 20_000);
90
+
91
+ it("takes over an expired lock", async () => {
92
+ await seedLock("l-expired", { pid: 999_999, hostname: "another-host", expiresAt: Date.now() - 1000 });
93
+
94
+ await rest.lock("l-expired");
95
+ const doc: any = await lockDoc("l-expired");
96
+ expect(doc.pid).toBe(process.pid);
97
+ await rest.unlock("l-expired");
98
+ });
99
+
100
+ it("never releases a lock owned by another process", async () => {
101
+ await seedLock("l-other", { pid: 4242, hostname: "another-host", expiresAt: Date.now() + 60_000 });
102
+
103
+ await rest.unlock("l-other"); // not ours
104
+ expect(await lockDoc("l-other")).not.toBeNull();
105
+
106
+ // Ours is released
107
+ await rest.lock("l-ours");
108
+ await rest.unlock("l-ours");
109
+ expect(await lockDoc("l-ours")).toBeNull();
110
+
111
+ await rest.db.collection("_locks_").deleteOne({ name: "l-other" });
112
+ });
113
+
114
+ it("only takes over a remote lock when it expired (a foreign pid cannot be probed)", async () => {
115
+ await seedLock("l-remote", { pid: 1, hostname: "some-other-host", expiresAt: Date.now() + 60_000 });
116
+
117
+ await expect(rest.lock("l-remote")).rejects.toThrow(/already held/);
118
+ await rest.db.collection("_locks_").deleteOne({ name: "l-remote" });
119
+ });
120
+ });
package/tests/mcp.test.ts CHANGED
@@ -62,7 +62,7 @@ describe("syncMcpTools", () => {
62
62
  });
63
63
  });
64
64
 
65
- describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
65
+ describe("MCP protocol (GET/POST /api/:tenant_id/mcp)", () => {
66
66
  it("serves tools/list and tools/call over JSON-RPC", async () => {
67
67
  cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }];
68
68
  await syncMcpTools();
@@ -72,7 +72,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
72
72
  const url = server.url.href.replace(/\/$/, "");
73
73
 
74
74
  // 1. tools/list
75
- const listRes = await fetch(`${url}/mcp/${TENANT}`, {
75
+ const listRes = await fetch(`${url}/api/${TENANT}/mcp`, {
76
76
  method: "POST",
77
77
  headers: { "Content-Type": "application/json" },
78
78
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
@@ -84,7 +84,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
84
84
  expect(tools.some((t: any) => t.name === "badge")).toBe(true);
85
85
 
86
86
  // 1b. resources/list — static resources
87
- const resListRes = await fetch(`${url}/mcp/${TENANT}`, {
87
+ const resListRes = await fetch(`${url}/api/${TENANT}/mcp`, {
88
88
  method: "POST",
89
89
  headers: { "Content-Type": "application/json" },
90
90
  body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "resources/list", params: {} }),
@@ -94,7 +94,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
94
94
  expect(resources.some((r: any) => r.uri === "orders://summary")).toBe(true);
95
95
 
96
96
  // 1c. resources/templates/list
97
- const tmplRes = await fetch(`${url}/mcp/${TENANT}`, {
97
+ const tmplRes = await fetch(`${url}/api/${TENANT}/mcp`, {
98
98
  method: "POST",
99
99
  headers: { "Content-Type": "application/json" },
100
100
  body: JSON.stringify({ jsonrpc: "2.0", id: 10, method: "resources/templates/list", params: {} }),
@@ -104,7 +104,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
104
104
  expect(templates.some((t: any) => t.uriTemplate === "users://{id}")).toBe(true);
105
105
 
106
106
  // 1d. resources/read — static URI
107
- const readRes = await fetch(`${url}/mcp/${TENANT}`, {
107
+ const readRes = await fetch(`${url}/api/${TENANT}/mcp`, {
108
108
  method: "POST",
109
109
  headers: { "Content-Type": "application/json" },
110
110
  body: JSON.stringify({ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "orders://summary" } }),
@@ -114,7 +114,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
114
114
  expect(readText).toContain("\"total\":42");
115
115
 
116
116
  // 1e. resources/read — template URI with params
117
- const readTplRes = await fetch(`${url}/mcp/${TENANT}`, {
117
+ const readTplRes = await fetch(`${url}/api/${TENANT}/mcp`, {
118
118
  method: "POST",
119
119
  headers: { "Content-Type": "application/json" },
120
120
  body: JSON.stringify({ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "users://abc123" } }),
@@ -123,7 +123,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
123
123
  expect(readTpl?.result?.contents?.[0]?.text ?? "").toContain("\"id\":\"abc123\"");
124
124
 
125
125
  // 1f. resources/read — unknown URI → error
126
- const readBadRes = await fetch(`${url}/mcp/${TENANT}`, {
126
+ const readBadRes = await fetch(`${url}/api/${TENANT}/mcp`, {
127
127
  method: "POST",
128
128
  headers: { "Content-Type": "application/json" },
129
129
  body: JSON.stringify({ jsonrpc: "2.0", id: 13, method: "resources/read", params: { uri: "nope://x" } }),
@@ -132,7 +132,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
132
132
  expect(readBad?.error).toBeDefined();
133
133
 
134
134
  // 2. tools/call (valid args + Joi defaults)
135
- const callRes = await fetch(`${url}/mcp/${TENANT}`, {
135
+ const callRes = await fetch(`${url}/api/${TENANT}/mcp`, {
136
136
  method: "POST",
137
137
  headers: { "Content-Type": "application/json" },
138
138
  body: JSON.stringify({
@@ -148,7 +148,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
148
148
  expect(block.text ?? "").toContain("\"times\":1");
149
149
 
150
150
  // 2b. protocol content blocks pass through unchanged (image block)
151
- const badgeRes = await fetch(`${url}/mcp/${TENANT}`, {
151
+ const badgeRes = await fetch(`${url}/api/${TENANT}/mcp`, {
152
152
  method: "POST",
153
153
  headers: { "Content-Type": "application/json" },
154
154
  body: JSON.stringify({
@@ -161,7 +161,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
161
161
  expect(badge?.result?.content?.[0]?.mimeType).toBe("image/png");
162
162
 
163
163
  // 2b. invalid args → isError (Joi validation)
164
- const badRes = await fetch(`${url}/mcp/${TENANT}`, {
164
+ const badRes = await fetch(`${url}/api/${TENANT}/mcp`, {
165
165
  method: "POST",
166
166
  headers: { "Content-Type": "application/json" },
167
167
  body: JSON.stringify({
@@ -174,7 +174,7 @@ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
174
174
  expect(bad?.result?.content?.[0]?.text ?? "").toContain("Invalid arguments");
175
175
 
176
176
  // 3. unknown tenant → 404
177
- const notFound = await fetch(`${url}/mcp/ghost`, {
177
+ const notFound = await fetch(`${url}/api/ghost/mcp`, {
178
178
  method: "POST",
179
179
  headers: { "Content-Type": "application/json" },
180
180
  body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }),
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect, afterAll } from "bun:test";
2
+ import { normalizeMessages } from "../lib/messages";
3
+ import { MAX_FILE_BYTES } from "../lib/attachment";
4
+
5
+ /** A tiny local file server — the `file: { url }` path downloads through `fetch`. */
6
+ const servers: Array<ReturnType<typeof Bun.serve>> = [];
7
+ function serve(body: Uint8Array | string, contentType = "application/pdf") {
8
+ const server = Bun.serve({
9
+ port: 0,
10
+ hostname: "127.0.0.1",
11
+ fetch: () => new Response(body as any, { headers: { "Content-Type": contentType } }),
12
+ });
13
+ servers.push(server);
14
+ return `http://127.0.0.1:${server.port}/report.pdf`;
15
+ }
16
+
17
+ afterAll(() => {
18
+ for (const server of servers) server.stop(true);
19
+ });
20
+
21
+ const first = async (input: any) => (await normalizeMessages(input))[0] as any;
22
+
23
+ describe("normalizeMessages — the shapes it accepts", () => {
24
+ it("turns a string into one user turn", async () => {
25
+ expect(await normalizeMessages("hi")).toEqual([{ role: "user", content: "hi" }]);
26
+ });
27
+
28
+ it("accepts a single message — with or without an array", async () => {
29
+ const message = { role: "user", content: "hi" } as const;
30
+ expect(await normalizeMessages(message)).toEqual([{ role: "user", content: "hi" }]);
31
+ expect(await normalizeMessages([message])).toEqual([{ role: "user", content: "hi" }]);
32
+ });
33
+
34
+ it("keeps a whole conversation in order", async () => {
35
+ const messages = await normalizeMessages([
36
+ { role: "user", content: "hi" },
37
+ { role: "assistant", content: "hello" },
38
+ { role: "user", content: "and?" },
39
+ ]);
40
+ expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]);
41
+ expect((messages[1] as any).content).toBe("hello");
42
+ });
43
+
44
+ it("keeps only the canonical fields — a stray key never reaches the wire", async () => {
45
+ const [message] = await normalizeMessages([{ role: "user", content: "hi", threadId: "t", bogus: 1 } as any]);
46
+ expect(message).toEqual({ role: "user", content: "hi" });
47
+ });
48
+ });
49
+
50
+ describe("normalizeMessages — content parts", () => {
51
+ it("keeps text, image and file parts, url or base64", async () => {
52
+ const [message] = await normalizeMessages([{
53
+ role: "user",
54
+ content: [
55
+ { type: "text", text: "look" },
56
+ { type: "image", url: "https://example.com/a.png" },
57
+ { type: "image", data: "AAAA", mimeType: "image/png" },
58
+ { type: "file", name: "a.pdf", data: "BBBB", mimeType: "application/pdf" },
59
+ ],
60
+ }] as any);
61
+
62
+ expect((message as any).content).toEqual([
63
+ { type: "text", text: "look" },
64
+ { type: "image", url: "https://example.com/a.png" },
65
+ { type: "image", mimeType: "image/png", data: "AAAA" },
66
+ { type: "file", name: "a.pdf", mimeType: "application/pdf", data: "BBBB" },
67
+ ]);
68
+ });
69
+
70
+ it("joins a system message written as parts", async () => {
71
+ const [message] = await normalizeMessages([
72
+ { role: "system", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] },
73
+ ] as any);
74
+ expect(message).toEqual({ role: "system", content: "a\nb" });
75
+ });
76
+
77
+ it("downloads a remote file and inlines it as base64", async () => {
78
+ const url = serve("%PDF-1.7 payload");
79
+ const [message] = await normalizeMessages([
80
+ { role: "user", content: [{ type: "file", url, name: "invoice.pdf" }] },
81
+ ] as any);
82
+
83
+ const part: any = (message as any).content[0];
84
+ expect(part.type).toBe("file");
85
+ expect(part.name).toBe("invoice.pdf");
86
+ // The URL is gone — the payload travels as base64, which every provider takes
87
+ expect(part.url).toBeUndefined();
88
+ expect(Buffer.from(part.data, "base64").toString()).toBe("%PDF-1.7 payload");
89
+ });
90
+
91
+ it("leaves a remote image as a URL — the provider fetches it", async () => {
92
+ const message: any = await first([{ role: "user", content: [{ type: "image", url: "https://example.com/a.png" }] }]);
93
+ expect(message.content[0].url).toBe("https://example.com/a.png");
94
+ expect(message.content[0].data).toBeUndefined();
95
+ });
96
+
97
+ it("refuses a part without a payload, an unknown address, or an unsupported scheme", async () => {
98
+ const cases = [
99
+ [{ type: "image" }],
100
+ [{ type: "file", name: "a.pdf" }],
101
+ [{ type: "image", url: "ftp://example.com/a.png" }],
102
+ [{ type: "image", url: "./local.png" }],
103
+ ];
104
+ for (const content of cases) {
105
+ const error: any = await normalizeMessages([{ role: "user", content }] as any).catch((err) => err);
106
+ expect(error.code).toBe("INVALID_INPUT");
107
+ }
108
+ });
109
+
110
+ it("refuses a file that is too large", async () => {
111
+ const url = serve(new Uint8Array(MAX_FILE_BYTES + 1));
112
+ const error: any = await normalizeMessages([
113
+ { role: "user", content: [{ type: "file", url, name: "huge.pdf" }] },
114
+ ] as any).catch((err) => err);
115
+ expect(error.code).toBe("AGENT_FILE_TOO_LARGE");
116
+ });
117
+ });
118
+
119
+ describe("normalizeMessages — tool parts", () => {
120
+ it("hoists `tool-call` parts onto the assistant message", async () => {
121
+ const [message] = await normalizeMessages([{
122
+ role: "assistant",
123
+ content: [{ type: "tool-call", id: "call_1", name: "forecast", arguments: { city: "Paris" } }],
124
+ }] as any);
125
+
126
+ expect(message).toEqual({
127
+ role: "assistant",
128
+ content: null,
129
+ toolCalls: [{ id: "call_1", name: "forecast", args: { city: "Paris" } }],
130
+ });
131
+ });
132
+
133
+ it("merges inline calls with the ones declared as `toolCalls`", async () => {
134
+ const [message] = await normalizeMessages([{
135
+ role: "assistant",
136
+ content: [{ type: "text", text: "let me check" }, { type: "tool-call", id: "b", name: "two" }],
137
+ toolCalls: [{ id: "a", name: "one", args: {} }],
138
+ }] as any) as any[];
139
+
140
+ expect(message.content).toBe("let me check");
141
+ expect(message.toolCalls.map((call: any) => call.id)).toEqual(["a", "b"]);
142
+ });
143
+
144
+ it("keeps a raw `argsText` when the caller had one", async () => {
145
+ const [message] = await normalizeMessages([{
146
+ role: "assistant",
147
+ content: [{ type: "tool-call", id: "c", name: "f", argsText: '{"city":"Lyon"}', arguments: { city: "Lyon" } }],
148
+ }] as any) as any[];
149
+ expect(message.toolCalls[0]).toEqual({ id: "c", name: "f", args: { city: "Lyon" }, argsText: '{"city":"Lyon"}' });
150
+ });
151
+
152
+ it("turns a `tool-result` part into the `tool` message it means", async () => {
153
+ const messages = await normalizeMessages([{
154
+ role: "user",
155
+ content: [
156
+ { type: "text", text: "and?" },
157
+ { type: "tool-result", id: "call_1", name: "forecast", result: { celsius: 21 } },
158
+ { type: "tool-result", id: "call_2", name: "boom", error: "timeout" },
159
+ ],
160
+ }] as any) as any[];
161
+
162
+ expect(messages.map((message) => message.role)).toEqual(["user", "tool", "tool"]);
163
+ expect(messages[0].content).toEqual([{ type: "text", text: "and?" }]);
164
+ expect(messages[1]).toEqual({
165
+ role: "tool", toolCallId: "call_1", name: "forecast", content: '{"celsius":21}',
166
+ });
167
+ expect(messages[2]).toEqual({
168
+ role: "tool", toolCallId: "call_2", name: "boom", content: "Error: timeout", isError: true,
169
+ });
170
+ });
171
+
172
+ it("expands a `tool` message carrying several results", async () => {
173
+ const messages = await normalizeMessages([{
174
+ role: "tool",
175
+ content: [
176
+ { type: "tool-result", id: "a", result: "one" },
177
+ { type: "tool-result", id: "b", result: "two" },
178
+ ],
179
+ }] as any) as any[];
180
+
181
+ expect(messages).toEqual([
182
+ { role: "tool", toolCallId: "a", name: "tool", content: "one" },
183
+ { role: "tool", toolCallId: "b", name: "tool", content: "two" },
184
+ ]);
185
+ });
186
+
187
+ it("keeps a `tool` message written the classic way", async () => {
188
+ const [message] = await normalizeMessages([
189
+ { role: "tool", toolCallId: "call_1", name: "forecast", content: "21°C" },
190
+ ] as any);
191
+ expect(message).toEqual({ role: "tool", toolCallId: "call_1", name: "forecast", content: "21°C" });
192
+ });
193
+ });
194
+
195
+ describe("normalizeMessages — what it refuses", () => {
196
+ it("refuses an unknown role and a message without one", async () => {
197
+ for (const input of [[{ role: "robot", content: "hi" }], [{ content: "hi" }], [null]]) {
198
+ const error: any = await normalizeMessages(input as any).catch((err) => err);
199
+ expect(error.code).toBe("INVALID_INPUT");
200
+ expect(error.status).toBe(400);
201
+ }
202
+ });
203
+
204
+ it("refuses a part the role cannot carry", async () => {
205
+ const cases: any[] = [
206
+ [{ role: "user", content: [{ type: "tool-call", id: "a", name: "f" }] }],
207
+ [{ role: "assistant", content: [{ type: "image", url: "https://example.com/a.png" }] }],
208
+ [{ role: "system", content: [{ type: "image", url: "https://example.com/a.png" }] }],
209
+ [{ role: "tool", content: [{ type: "text", text: "no" }] }],
210
+ [{ role: "tool", content: "orphan" }], // no `toolCallId`
211
+ [{ role: "user", content: [{ type: "tool-result" }] }], // no id
212
+ ];
213
+ for (const input of cases) {
214
+ const error: any = await normalizeMessages(input as any).catch((err) => err);
215
+ expect(error.code).toBe("INVALID_INPUT");
216
+ }
217
+ });
218
+
219
+ it("refuses a message without content", async () => {
220
+ for (const role of ["user", "system", "tool"]) {
221
+ const error: any = await normalizeMessages([{ role }] as any).catch((err) => err);
222
+ expect(error.code).toBe("INVALID_INPUT");
223
+ }
224
+ });
225
+
226
+ it("refuses an empty content array rather than sending an empty turn", async () => {
227
+ for (const input of [
228
+ [{ role: "user", content: [] }],
229
+ [{ role: "tool", content: [] }],
230
+ [{ role: "developer", content: [] }],
231
+ ]) {
232
+ const error: any = await normalizeMessages(input as any).catch((err) => err);
233
+ expect(error.code).toBe("INVALID_INPUT");
234
+ }
235
+
236
+ // An assistant turn with no text is legitimate — a tool call carries the turn
237
+ expect(await normalizeMessages([{ role: "assistant", content: [] }] as any))
238
+ .toEqual([{ role: "assistant", content: null }]);
239
+ });
240
+ });
241
+
242
+ describe("normalizeMessages — developer role and names", () => {
243
+ it("keeps the `developer` role and a message name", async () => {
244
+ const messages = await normalizeMessages([
245
+ { role: "developer", content: "answer concisely", name: "policy" },
246
+ { role: "user", content: "hi", name: "ada" },
247
+ ] as any);
248
+
249
+ expect(messages[0]).toEqual({ role: "developer", content: "answer concisely", name: "policy" });
250
+ expect(messages[1]).toEqual({ role: "user", content: "hi", name: "ada" });
251
+ });
252
+
253
+ it("keeps the raw thinking blocks of a replayed assistant turn", async () => {
254
+ const thinking = [{ type: "thinking", thinking: "…", signature: "sig" }];
255
+ const [message] = await normalizeMessages([{ role: "assistant", content: "done", thinking }] as any) as any[];
256
+ expect(message.thinking).toEqual(thinking);
257
+ });
258
+ });