@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.
- package/database/audit.ts +9 -59
- package/database/collection.ts +3 -1
- package/database/deleteLog.ts +67 -3
- package/database/file.ts +4 -3
- package/database/mongodbadapter.ts +103 -42
- package/database/replication.ts +724 -58
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1567 -0
- package/lib/agentMemory.ts +689 -0
- package/lib/agents.ts +180 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/auditResult.ts +13 -0
- package/lib/define.ts +43 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +2 -1
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +7 -1
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/mcpClient.ts +128 -0
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +970 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/skills.ts +481 -0
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +472 -0
- package/server/api.ts +80 -65
- package/server/boot.ts +11 -0
- package/server/hono.ts +8 -3
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +404 -0
- package/tests/agent.test.ts +1241 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-operations.test.ts +1 -1
- package/tests/audit-redact.test.ts +29 -3
- package/tests/audit-run-action.test.ts +1 -1
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +2 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/repl-only/app.ts +3 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +4 -2
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +246 -0
- package/tests/replication.test.ts +2 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +5 -2
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +1067 -0
- package/types/config.d.ts +16 -0
- package/types/file.d.ts +7 -7
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +6 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +70 -4
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +13 -0
- package/types/vars.d.ts +2 -2
- package/types/websocket.d.ts +6 -0
- 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,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,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
|
+
};
|
package/tests/hono.test.ts
CHANGED
|
@@ -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
|
|
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 /
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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}/
|
|
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
|
+
});
|