@anteros/core 0.0.40 → 0.0.42
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 +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- 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 +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -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/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -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/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- 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/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- 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 +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- 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 +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { createApp } from "../server/hono";
|
|
3
|
+
import { formatConfig, cfg } from "../server/config";
|
|
4
|
+
import { syncTenants } from "../database/tenant";
|
|
5
|
+
import { syncCollections } from "../database/collection";
|
|
6
|
+
import { syncAgents } from "../lib/agents";
|
|
7
|
+
import { createApi } from "../lib/api";
|
|
8
|
+
import { loadServices } from "../lib/services";
|
|
9
|
+
import { loadRoutes } from "../lib/routes";
|
|
10
|
+
import { useRest } from "../database/rest";
|
|
11
|
+
import { jwt } from "../utils/func";
|
|
12
|
+
import { AUDIT_COLLECTION } from "../database/audit";
|
|
13
|
+
import { fakeProvider, at, openaiStream, openaiText, openaiTool, type Fake } from "./fixtures/fake-provider";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `POST /api/:tenant_id/agents/:agent/:action` — access control, the six actions,
|
|
17
|
+
* SSE streaming and the audit trail.
|
|
18
|
+
*
|
|
19
|
+
* One fake provider answers every case, routed on what the runtime actually
|
|
20
|
+
* sent (a stream, a JSON-schema prompt, a "loop" input, a tool result) — no
|
|
21
|
+
* registry juggling, no provider swapping mid-suite.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TENANT = "agents-api";
|
|
25
|
+
const DB = "mongodb://localhost:27017/_AGENTS_API_TEST";
|
|
26
|
+
const DIR = "packages/core/tests/fixtures/agents-api";
|
|
27
|
+
|
|
28
|
+
let provider: Fake;
|
|
29
|
+
let server: any;
|
|
30
|
+
let url = "";
|
|
31
|
+
let rest: InstanceType<typeof useRest>;
|
|
32
|
+
let token = "";
|
|
33
|
+
|
|
34
|
+
const BASE = () => `${url}/api/${TENANT}/agents`;
|
|
35
|
+
|
|
36
|
+
function post(agent: string, action: string, body?: any, withToken = true) {
|
|
37
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
38
|
+
if (withToken) headers.Authorization = `Bearer ${token}`;
|
|
39
|
+
return fetch(`${BASE()}/${agent}/${action}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers,
|
|
42
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function json(agent: string, action: string, body?: any, withToken = true) {
|
|
47
|
+
const res = await post(agent, action, body, withToken);
|
|
48
|
+
return { status: res.status, body: (await res.json()) as any };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Decode an SSE body into its events. */
|
|
52
|
+
async function readSse(res: Response): Promise<any[]> {
|
|
53
|
+
const text = await res.text();
|
|
54
|
+
return text
|
|
55
|
+
.split(/\r?\n\r?\n/)
|
|
56
|
+
.map((block) => block.split(/\r?\n/).find((line) => line.startsWith("data:"))?.slice(5).trim())
|
|
57
|
+
.filter((data): data is string => !!data)
|
|
58
|
+
.map((data) => JSON.parse(data));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function auditEntries(action: string, collection: string, since: Date) {
|
|
62
|
+
return rest.db.collection(AUDIT_COLLECTION)
|
|
63
|
+
.find({ "operation.action": action, "operation.collection": collection, ts: { $gte: since } })
|
|
64
|
+
.sort({ ts: -1 })
|
|
65
|
+
.toArray();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
// The fixture reads the provider URL from the environment at import time,
|
|
70
|
+
// so the fake server must exist before `syncAgents()`.
|
|
71
|
+
provider = fakeProvider((body) => {
|
|
72
|
+
if (body?.stream) return openaiStream(["Bon", "jour"]);
|
|
73
|
+
|
|
74
|
+
const system = typeof body?.messages?.[0]?.content === "string" ? body.messages[0].content : "";
|
|
75
|
+
if (body?.messages?.[0]?.role === "system" && system.includes("JSON Schema")) {
|
|
76
|
+
return openaiText('{"city":"Paris","celsius":21}');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The whole conversation is searched: this keeps the tool loop looping
|
|
80
|
+
if (JSON.stringify(body?.messages ?? []).includes("loop")) {
|
|
81
|
+
return openaiTool("echo", { text: "loop" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const last = body?.messages?.at(-1);
|
|
85
|
+
return last?.role === "tool" ? openaiText("Bonjour") : openaiTool("echo", { text: "hello" });
|
|
86
|
+
});
|
|
87
|
+
process.env.AGENT_API_TEST_URL = provider.url;
|
|
88
|
+
|
|
89
|
+
formatConfig({
|
|
90
|
+
server: { port: 4000, jwt: { secret: "agents-api-test-secret" } },
|
|
91
|
+
tenants: [{
|
|
92
|
+
id: TENANT,
|
|
93
|
+
dir: DIR,
|
|
94
|
+
routes: { prefix: "/v1" },
|
|
95
|
+
database: { uri: DB },
|
|
96
|
+
} as any],
|
|
97
|
+
});
|
|
98
|
+
await syncTenants();
|
|
99
|
+
await syncCollections();
|
|
100
|
+
await syncAgents();
|
|
101
|
+
await loadServices();
|
|
102
|
+
await loadRoutes();
|
|
103
|
+
|
|
104
|
+
rest = new useRest({ internal: false, tenant_id: TENANT });
|
|
105
|
+
token = await jwt.sign({ sub: "tester" });
|
|
106
|
+
|
|
107
|
+
const app = createApp();
|
|
108
|
+
server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
109
|
+
url = server.url.href.replace(/\/$/, "");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterAll(async () => {
|
|
113
|
+
try { await rest.db.dropDatabase(); } catch (_) { /* already gone */ }
|
|
114
|
+
try { server.stop(true); } catch (_) { /* already stopped */ }
|
|
115
|
+
try { provider.stop(); } catch (_) { /* already stopped */ }
|
|
116
|
+
delete process.env.AGENT_API_TEST_URL;
|
|
117
|
+
cfg.agents = [];
|
|
118
|
+
cfg.services = [];
|
|
119
|
+
cfg.routes = [];
|
|
120
|
+
cfg.tenants = [];
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("agents HTTP API — access control", () => {
|
|
124
|
+
it("denies an agent with no `api` at all", async () => {
|
|
125
|
+
const res = await json("private", "generate", { input: "hi" });
|
|
126
|
+
expect(res.status).toBe(401);
|
|
127
|
+
expect(res.body.code).toBe("ACCESS_DENIED");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("denies an action with no rule, even for an authenticated caller", async () => {
|
|
131
|
+
// `plain` only declares `generate`
|
|
132
|
+
const res = await json("plain", "history", { thread: "t" });
|
|
133
|
+
expect(res.status).toBe(401);
|
|
134
|
+
expect(res.body.code).toBe("ACCESS_DENIED");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("requires a token for a function rule", async () => {
|
|
138
|
+
const res = await json("assistant", "generate", { input: "hi" }, false);
|
|
139
|
+
expect(res.status).toBe(401);
|
|
140
|
+
expect(res.body.code).toBe("AUTH_REQUIRED");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects an invalid token", async () => {
|
|
144
|
+
const res = await fetch(`${BASE()}/assistant/generate`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json", Authorization: "Bearer not-a-token" },
|
|
147
|
+
body: JSON.stringify({ input: "hi" }),
|
|
148
|
+
});
|
|
149
|
+
const body = await res.json() as any;
|
|
150
|
+
expect(res.status).toBe(401);
|
|
151
|
+
expect(body.code).toBe("AUTH_REQUIRED");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("allows a public action without a token", async () => {
|
|
155
|
+
const res = await json("plain", "generate", { input: "hi" }, false);
|
|
156
|
+
expect(res.status).toBe(200);
|
|
157
|
+
expect(res.body.text).toBe("Bonjour");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects an unknown agent", async () => {
|
|
161
|
+
const res = await json("nope", "generate", { input: "hi" });
|
|
162
|
+
expect(res.status).toBe(400);
|
|
163
|
+
expect(res.body.code).toBe("AGENT_NOT_FOUND");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects an unknown tenant", async () => {
|
|
167
|
+
const res = await fetch(`${url}/api/nope/agents/assistant/info`, { method: "POST" });
|
|
168
|
+
expect(res.status).toBe(400);
|
|
169
|
+
expect((await res.json() as any).code).toBe("TENANT_NOT_FOUND");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("agents HTTP API — actions", () => {
|
|
174
|
+
it("info needs no model call and leaks no API key", async () => {
|
|
175
|
+
const before = provider.hits();
|
|
176
|
+
const res = await json("assistant", "info");
|
|
177
|
+
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
expect(provider.hits()).toBe(before);
|
|
180
|
+
expect(res.body.agent.id).toBe("assistant");
|
|
181
|
+
expect(res.body.agent.tools).toEqual(["echo"]);
|
|
182
|
+
expect(res.body.agent.provider.model).toBe("test-model");
|
|
183
|
+
expect(res.body.agent.provider.compatible).toBe("openai");
|
|
184
|
+
expect(res.body.agent.maxSteps).toBe(3);
|
|
185
|
+
expect(res.body.agent.memory).toBe(true);
|
|
186
|
+
expect(JSON.stringify(res.body)).not.toContain("test-key");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("generate runs the tool loop and returns the envelope", async () => {
|
|
190
|
+
const res = await json("assistant", "generate", { input: "Weather?" });
|
|
191
|
+
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
expect(res.body.text).toBe("Bonjour");
|
|
194
|
+
expect(res.body.finishReason).toBe("stop");
|
|
195
|
+
expect(res.body.toolCalls.map((call: any) => call.name)).toEqual(["echo"]);
|
|
196
|
+
expect(res.body.toolResults[0].result).toEqual({ echoed: "hello" });
|
|
197
|
+
expect(res.body.usage.requests).toBe(2);
|
|
198
|
+
// The conversation is never inlined — `history` serves it
|
|
199
|
+
expect(res.body.messages).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("accepts the run options of the in-process API — `messages` and `lastMessages`", async () => {
|
|
203
|
+
const before = provider.requests.length;
|
|
204
|
+
|
|
205
|
+
const res = await json("assistant", "generate", {
|
|
206
|
+
input: "and in Lyon?",
|
|
207
|
+
thread: "opts-1",
|
|
208
|
+
messages: [
|
|
209
|
+
{ role: "user", content: "weather in Paris?" },
|
|
210
|
+
{ role: "assistant", content: "18°C and sunny." },
|
|
211
|
+
],
|
|
212
|
+
maxSteps: 2,
|
|
213
|
+
lastMessages: 10,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(res.status).toBe(200);
|
|
217
|
+
// The run's FIRST model call (the fixture loops on a tool afterwards)
|
|
218
|
+
const sent = at<any>(provider.requests, before).body.messages;
|
|
219
|
+
const contents = sent.map((message: any) => message.content);
|
|
220
|
+
expect(contents.join("\n")).toContain("18°C and sunny.");
|
|
221
|
+
expect(contents.at(-1)).toBe("and in Lyon?");
|
|
222
|
+
expect(provider.requests.length).toBeGreaterThan(before);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("refuses injected turns the model could not have seen", async () => {
|
|
226
|
+
const res = await json("assistant", "generate", {
|
|
227
|
+
input: "hi",
|
|
228
|
+
messages: [{ role: "assistant", content: [{ type: "image", url: "https://example.com/a.png" }] }],
|
|
229
|
+
});
|
|
230
|
+
expect(res.status).toBe(400);
|
|
231
|
+
expect(res.body.code).toBe("INVALID_INPUT");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("lets a client lower the replayed window but never raise it", async () => {
|
|
235
|
+
// The fixture declares no `lastMessages`: the request is honoured as asked
|
|
236
|
+
const res = await json("assistant", "generate", { input: "hi", lastMessages: 1 });
|
|
237
|
+
expect(res.status).toBe(200);
|
|
238
|
+
expect(res.body.code).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("validates the body", async () => {
|
|
242
|
+
expect((await json("assistant", "generate", {})).status).toBe(400);
|
|
243
|
+
expect((await json("assistant", "generate", {})).body.code).toBe("INPUT_REQUIRED");
|
|
244
|
+
expect((await json("assistant", "generate", { input: [] })).body.code).toBe("INPUT_REQUIRED");
|
|
245
|
+
expect((await json("assistant", "generate", { input: [{ content: "x" }] })).body.code).toBe("INVALID_INPUT");
|
|
246
|
+
|
|
247
|
+
const malformed = await fetch(`${BASE()}/assistant/generate`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
250
|
+
body: "{oops",
|
|
251
|
+
});
|
|
252
|
+
expect(malformed.status).toBe(400);
|
|
253
|
+
expect((await malformed.json() as any).code).toBe("INVALID_JSON_BODY");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("accepts a full message array", async () => {
|
|
257
|
+
const res = await json("assistant", "generate", {
|
|
258
|
+
input: [
|
|
259
|
+
{ role: "user", content: "First" },
|
|
260
|
+
{ role: "assistant", content: "Noted" },
|
|
261
|
+
{ role: "user", content: "Second" },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
expect(res.status).toBe(200);
|
|
265
|
+
expect(res.body.text).toBe("Bonjour");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("accepts a single message, and the unified content parts", async () => {
|
|
269
|
+
// One message, not an array — the same shape the in-process runtime takes
|
|
270
|
+
const single = await json("assistant", "generate", { input: { role: "user", content: "hi" } });
|
|
271
|
+
expect(single.status).toBe(200);
|
|
272
|
+
expect(single.body.text).toBe("Bonjour");
|
|
273
|
+
|
|
274
|
+
// …and a conversation written with `tool-call` / `tool-result` parts
|
|
275
|
+
const unified = await json("assistant", "generate", {
|
|
276
|
+
input: [
|
|
277
|
+
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
|
|
278
|
+
{ role: "assistant", content: [{ type: "tool-call", id: "call_9", name: "echo", arguments: { text: "hi" } }] },
|
|
279
|
+
{ role: "user", content: [{ type: "tool-result", id: "call_9", name: "echo", result: "why not" }] },
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
expect(unified.status).toBe(200);
|
|
283
|
+
|
|
284
|
+
// The runtime turned the parts into the canonical turns before calling the model
|
|
285
|
+
const sent = at<any>(provider.requests, provider.requests.length - 1).body.messages;
|
|
286
|
+
expect(sent.map((message: any) => message.role)).toEqual(["system", "user", "assistant", "tool"]);
|
|
287
|
+
expect(sent[2].tool_calls[0].function.name).toBe("echo");
|
|
288
|
+
expect(sent[3].tool_call_id).toBe("call_9");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("refuses a message role or a content part it cannot carry", async () => {
|
|
292
|
+
expect((await json("assistant", "generate", { input: { role: "robot", content: "hi" } })).body.code)
|
|
293
|
+
.toBe("INVALID_INPUT");
|
|
294
|
+
expect((await json("assistant", "generate", {
|
|
295
|
+
input: [{ role: "assistant", content: [{ type: "image", url: "https://example.com/a.png" }] }],
|
|
296
|
+
})).body.code).toBe("INVALID_INPUT");
|
|
297
|
+
expect((await json("assistant", "generate", { input: "" })).body.code).toBe("INPUT_REQUIRED");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("lets a client lower maxSteps but never raise it", async () => {
|
|
301
|
+
// Declared ceiling is 3, and the input keeps the tool loop looping:
|
|
302
|
+
// a client asking for 1 gets exactly 1 model call…
|
|
303
|
+
const one = await json("assistant", "generate", { input: "loop", maxSteps: 1 });
|
|
304
|
+
expect(one.body.finishReason).toBe("max_steps");
|
|
305
|
+
|
|
306
|
+
// …and one asking for 99 is still capped at the declared 3
|
|
307
|
+
const before = provider.hits();
|
|
308
|
+
const many = await json("assistant", "generate", { input: "loop", maxSteps: 99 });
|
|
309
|
+
expect(provider.hits() - before).toBe(3);
|
|
310
|
+
expect(many.body.finishReason).toBe("max_steps");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("object returns a validated object", async () => {
|
|
314
|
+
const res = await json("assistant", "object", { input: "Weather in Paris?" });
|
|
315
|
+
|
|
316
|
+
expect(res.status).toBe(200);
|
|
317
|
+
expect(res.body.object).toEqual({ city: "Paris", celsius: 21 });
|
|
318
|
+
expect(res.body.text).toContain("Paris");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("object is refused without a declared schema", async () => {
|
|
322
|
+
const res = await json("plain", "object", { input: "hi" });
|
|
323
|
+
expect(res.status).toBe(400);
|
|
324
|
+
expect(res.body.code).toBe("AGENT_NO_OBJECT_SCHEMA");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("history and clear work through the memory", async () => {
|
|
328
|
+
await json("assistant", "generate", { input: "remember me", thread: "thread-1" });
|
|
329
|
+
|
|
330
|
+
const history = await json("assistant", "history", { thread: "thread-1" });
|
|
331
|
+
expect(history.status).toBe(200);
|
|
332
|
+
expect(history.body.messages.map((m: any) => m.role)).toEqual(["user", "assistant", "tool", "assistant"]);
|
|
333
|
+
expect(history.body.messages[0].content).toBe("remember me");
|
|
334
|
+
expect(history.body.messages[3].content).toBe("Bonjour");
|
|
335
|
+
|
|
336
|
+
expect((await json("assistant", "history", {})).body.code).toBe("THREAD_ID_REQUIRED");
|
|
337
|
+
expect((await json("assistant", "clear", { thread: "thread-1" })).body.ok).toBe(true);
|
|
338
|
+
expect((await json("assistant", "history", { thread: "thread-1" })).body.messages).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("still accepts the deprecated `threadId` / `resourceId` body fields", async () => {
|
|
342
|
+
await json("assistant", "generate", { input: "legacy", threadId: "old-1", resourceId: "u-old" });
|
|
343
|
+
expect((await json("assistant", "history", { threadId: "old-1", resourceId: "u-old" })).body.messages[0].content)
|
|
344
|
+
.toBe("legacy");
|
|
345
|
+
// Same key as the canonical spelling — the two name one thread
|
|
346
|
+
expect((await json("assistant", "history", { thread: "old-1", resource: "u-old" })).body.messages[0].content)
|
|
347
|
+
.toBe("legacy");
|
|
348
|
+
expect((await json("assistant", "threads", { resourceId: "u-old" })).body.threads).toHaveLength(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("accepts `memory: { thread, resource }` on every action", async () => {
|
|
352
|
+
const memory = { resource: "u-mem", thread: "c-mem" };
|
|
353
|
+
|
|
354
|
+
const generated = await json("assistant", "generate", {
|
|
355
|
+
input: "Remember my favorite color is blue.",
|
|
356
|
+
memory,
|
|
357
|
+
});
|
|
358
|
+
expect(generated.status).toBe(200);
|
|
359
|
+
|
|
360
|
+
// Readable — and listed — with the same shorthand
|
|
361
|
+
const history = await json("assistant", "history", { memory });
|
|
362
|
+
expect(history.body.messages.length).toBeGreaterThan(1);
|
|
363
|
+
expect(history.body.messages[0].content).toBe("Remember my favorite color is blue.");
|
|
364
|
+
|
|
365
|
+
const threads = await json("assistant", "threads", { memory: { resource: "u-mem" } });
|
|
366
|
+
expect(threads.body.threads.map((thread: any) => thread.threadId)).toEqual(["c-mem"]);
|
|
367
|
+
|
|
368
|
+
// `THREAD_ID_REQUIRED` names the option and its shorthand
|
|
369
|
+
const missing = await json("assistant", "history", { memory: { resource: "u-mem" } });
|
|
370
|
+
expect(missing.body.code).toBe("THREAD_ID_REQUIRED");
|
|
371
|
+
expect(missing.body.message).toContain("memory.thread");
|
|
372
|
+
|
|
373
|
+
// `readOnly` runs a preview and stores nothing
|
|
374
|
+
await json("assistant", "generate", {
|
|
375
|
+
input: "just looking",
|
|
376
|
+
memory: { ...memory, thread: "c-readonly", readOnly: true },
|
|
377
|
+
});
|
|
378
|
+
const preview = await json("assistant", "history", { memory: { ...memory, thread: "c-readonly" } });
|
|
379
|
+
expect(preview.body.messages).toEqual([]);
|
|
380
|
+
|
|
381
|
+
// `clear` too
|
|
382
|
+
expect((await json("assistant", "clear", { memory })).body.ok).toBe(true);
|
|
383
|
+
expect((await json("assistant", "history", { memory })).body.messages).toEqual([]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("accepts files as base64, and sends them to the model", async () => {
|
|
387
|
+
const before = provider.hits();
|
|
388
|
+
|
|
389
|
+
const res = await json("assistant", "generate", {
|
|
390
|
+
input: "What are the totals?",
|
|
391
|
+
files: [{ name: "data.csv", data: Buffer.from("ref,total\nA-1,99").toString("base64") }],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(res.status).toBe(200);
|
|
395
|
+
const request = provider.requests[provider.hits() - 1]!;
|
|
396
|
+
expect(request.body.messages[1].content[0].text).toBe("What are the totals?");
|
|
397
|
+
expect(request.body.messages[1].content[1].text).toContain("--- data.csv ---");
|
|
398
|
+
expect(before).toBeLessThan(provider.hits());
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("refuses a file a model cannot read", async () => {
|
|
402
|
+
const res = await json("assistant", "generate", {
|
|
403
|
+
input: "hi",
|
|
404
|
+
files: [{ name: "legacy.doc", data: "AAAA" }],
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(res.status).toBe(400);
|
|
408
|
+
expect(res.body.code).toBe("AGENT_FILE_UNSUPPORTED");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("keeps threads isolated", async () => {
|
|
412
|
+
await json("assistant", "generate", { input: "thread A", thread: "iso-a" });
|
|
413
|
+
expect((await json("assistant", "history", { thread: "iso-b" })).body.messages).toEqual([]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("lists the caller's threads", async () => {
|
|
417
|
+
await json("assistant", "generate", { input: "What is the weather?", thread: "listed", resource: "u-list" });
|
|
418
|
+
await json("assistant", "generate", { input: "someone else", thread: "other", resource: "u-other" });
|
|
419
|
+
|
|
420
|
+
const res = await json("assistant", "threads", { resource: "u-list" });
|
|
421
|
+
expect(res.status).toBe(200);
|
|
422
|
+
expect(res.body.threads.map((thread: any) => thread.threadId)).toEqual(["listed"]);
|
|
423
|
+
expect(res.body.threads[0].title).toBe("What is the weather?");
|
|
424
|
+
expect(res.body.threads[0].messages).toBeGreaterThan(0);
|
|
425
|
+
|
|
426
|
+
// The `resource` namespaces the thread: without it, the key is the bare id
|
|
427
|
+
const unscoped = await json("assistant", "history", { thread: "listed" });
|
|
428
|
+
expect(unscoped.body.messages).toEqual([]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("refuses `threads` without a memory", async () => {
|
|
432
|
+
const res = await json("plain", "threads", {});
|
|
433
|
+
expect(res.status).toBe(401); // no rule for `threads` on `plain`
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("agents HTTP API — streaming", () => {
|
|
438
|
+
it("streams the runtime events then a done payload", async () => {
|
|
439
|
+
const res = await post("assistant", "stream", { input: "hi" });
|
|
440
|
+
|
|
441
|
+
expect(res.status).toBe(200);
|
|
442
|
+
expect(res.headers.get("Content-Type")).toContain("text/event-stream");
|
|
443
|
+
expect(res.headers.get("X-Accel-Buffering")).toBe("no");
|
|
444
|
+
|
|
445
|
+
const events = await readSse(res);
|
|
446
|
+
expect(events.filter((e) => e.type === "text").map((e) => e.text).join("")).toBe("Bonjour");
|
|
447
|
+
expect(events.find((e) => e.type === "finish").finishReason).toBe("stop");
|
|
448
|
+
|
|
449
|
+
const done = events.find((e) => e.type === "done");
|
|
450
|
+
expect(done.result.text).toBe("Bonjour");
|
|
451
|
+
expect(done.result.usage.requests).toBe(1);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("requires a token like every other action", async () => {
|
|
455
|
+
const res = await post("assistant", "stream", { input: "hi" }, false);
|
|
456
|
+
expect(res.status).toBe(401);
|
|
457
|
+
expect((await res.json() as any).code).toBe("AUTH_REQUIRED");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("takes the same run options as `generate` — `messages` and `lastMessages` included", async () => {
|
|
461
|
+
const before = provider.requests.length;
|
|
462
|
+
|
|
463
|
+
// Injected turns, exactly like a `generate` body
|
|
464
|
+
const res = await post("assistant", "stream", {
|
|
465
|
+
input: "and in Lyon?",
|
|
466
|
+
thread: "stream-1",
|
|
467
|
+
messages: [
|
|
468
|
+
{ role: "user", content: "weather in Paris?" },
|
|
469
|
+
{ role: "assistant", content: "18°C and sunny." },
|
|
470
|
+
],
|
|
471
|
+
lastMessages: 4,
|
|
472
|
+
});
|
|
473
|
+
expect(res.status).toBe(200);
|
|
474
|
+
await readSse(res);
|
|
475
|
+
|
|
476
|
+
// The model saw the injected turns after the thread and before the question
|
|
477
|
+
const sent = at<any>(provider.requests, before).body.messages;
|
|
478
|
+
const contents = sent.map((message: any) => message.content);
|
|
479
|
+
expect(contents.at(-1)).toBe("and in Lyon?");
|
|
480
|
+
expect(contents.join("\n")).toContain("18°C and sunny.");
|
|
481
|
+
expect(provider.requests.length).toBeGreaterThan(before);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe("agents in every context", () => {
|
|
486
|
+
it("is injected into a collection hook, bound to the hook's rest", async () => {
|
|
487
|
+
(globalThis as any).__agentsHookProbe = undefined;
|
|
488
|
+
await rest.insertOne("notes", { title: "probe" });
|
|
489
|
+
|
|
490
|
+
const probe = (globalThis as any).__agentsHookProbe;
|
|
491
|
+
expect(probe.ids).toEqual(["assistant", "plain", "private"]);
|
|
492
|
+
expect(probe.bound).toBe(true);
|
|
493
|
+
// …with the in-process `api` facade next to it, bound to the same client
|
|
494
|
+
expect(probe.slug).toBe("notes");
|
|
495
|
+
expect(probe.notes).toBeGreaterThanOrEqual(0); // a real count, not a stub
|
|
496
|
+
delete (globalThis as any).__agentsHookProbe;
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("is injected into a service action, with its `api` facade", async () => {
|
|
500
|
+
const result: any = await rest.runService("probe", "check");
|
|
501
|
+
|
|
502
|
+
expect(result.agents).toEqual(["assistant", "plain", "private"]);
|
|
503
|
+
expect(result.hasAssistant).toBe(true);
|
|
504
|
+
expect(result.bound).toBe(true);
|
|
505
|
+
// `api.service(name).run(...)` is the bound `rest.runService` — and it recurses fine
|
|
506
|
+
expect(result.nested).toEqual({ from: "api" });
|
|
507
|
+
expect(result.varType).toBe("function");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("routes a write through the same path as `rest` — hooks included", async () => {
|
|
511
|
+
(globalThis as any).__agentsHookProbe = undefined;
|
|
512
|
+
|
|
513
|
+
const api = createApi(rest);
|
|
514
|
+
await api.collection("notes").insertOne({ title: "via api" });
|
|
515
|
+
|
|
516
|
+
// The collection hook fired, exactly as it does for `rest.insertOne`
|
|
517
|
+
expect((globalThis as any).__agentsHookProbe?.slug).toBe("notes");
|
|
518
|
+
expect(await rest.countDocuments("notes", { title: "via api" })).toBe(1);
|
|
519
|
+
|
|
520
|
+
// The bound client reads, updates and deletes through the same client
|
|
521
|
+
const notes = await api.collection("notes").find({ $match: { title: "via api" } });
|
|
522
|
+
const note = notes[0]!;
|
|
523
|
+
expect(note.title).toBe("via api");
|
|
524
|
+
await api.collection("notes").updateOne(String(note._id), { $set: { title: "renamed" } });
|
|
525
|
+
expect((await api.collection("notes").findOne(String(note._id))).title).toBe("renamed");
|
|
526
|
+
await api.collection("notes").deleteOne(String(note._id));
|
|
527
|
+
expect(await api.collection("notes").countDocuments({ title: "renamed" })).toBe(0);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("is injected into a route handler, with its `api` facade", async () => {
|
|
531
|
+
const res = await fetch(`${url}/v1/probe`);
|
|
532
|
+
expect(res.status).toBe(200);
|
|
533
|
+
|
|
534
|
+
const body = await res.json() as any;
|
|
535
|
+
expect(body.agents).toEqual(["assistant", "plain", "private"]);
|
|
536
|
+
expect(body.hasAssistant).toBe(true);
|
|
537
|
+
expect(body.slug).toBe("notes");
|
|
538
|
+
expect(body.sameAsRest).toBe(true);
|
|
539
|
+
expect(body.agentBound).toBe(true);
|
|
540
|
+
expect(body.unknownAgent).toBeUndefined();
|
|
541
|
+
expect(body.vars).toBe("function");
|
|
542
|
+
expect(body.serviceRun).toBe("function");
|
|
543
|
+
// …and the bound service client really runs the action, from a route
|
|
544
|
+
expect(body.serviceCall).toEqual({ from: "route" });
|
|
545
|
+
expect(body.fileUrl).toBe("/api/agents-api/files/invoices/f1.pdf?w=800");
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe("agents HTTP API — audit", () => {
|
|
550
|
+
it("records the call, the usage and the tool names — never the answer", async () => {
|
|
551
|
+
const since = new Date();
|
|
552
|
+
await json("assistant", "generate", { input: "Weather?", thread: "audit-thread" });
|
|
553
|
+
|
|
554
|
+
const entries = await auditEntries("agent.generate", "_agents_:assistant", since);
|
|
555
|
+
expect(entries.length).toBe(1);
|
|
556
|
+
|
|
557
|
+
const entry: any = entries[0];
|
|
558
|
+
expect(entry.operation.status).toBe("success");
|
|
559
|
+
expect(entry.operation.input.agent).toBe("assistant");
|
|
560
|
+
expect(entry.operation.input.thread).toBe("audit-thread");
|
|
561
|
+
expect(entry.operation.input.text).toBe("Weather?");
|
|
562
|
+
expect(entry.operation.result.tools).toEqual(["echo"]);
|
|
563
|
+
expect(entry.operation.result.finishReason).toBe("stop");
|
|
564
|
+
expect(entry.operation.result.usage.requests).toBe(2);
|
|
565
|
+
expect(entry.operation.result.steps).toBe(2);
|
|
566
|
+
|
|
567
|
+
// No model answer, no tool arguments anywhere in the entry
|
|
568
|
+
const serialized = JSON.stringify(entry);
|
|
569
|
+
expect(serialized).not.toContain("Bonjour");
|
|
570
|
+
expect(serialized).not.toContain("hello");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("records a denied call as an error", async () => {
|
|
574
|
+
const since = new Date();
|
|
575
|
+
await json("private", "generate", { input: "nope" });
|
|
576
|
+
|
|
577
|
+
const entries = await auditEntries("agent.generate", "_agents_:private", since);
|
|
578
|
+
expect(entries.length).toBe(1);
|
|
579
|
+
expect((entries[0] as any).operation.status).toBe("error");
|
|
580
|
+
expect((entries[0] as any).operation.error.code).toBe("ACCESS_DENIED");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("audits the streamed run once, at its end", async () => {
|
|
584
|
+
const since = new Date();
|
|
585
|
+
const res = await post("assistant", "stream", { input: "hi" });
|
|
586
|
+
await res.text();
|
|
587
|
+
|
|
588
|
+
const entries = await auditEntries("agent.stream", "_agents_:assistant", since);
|
|
589
|
+
expect(entries.length).toBe(1);
|
|
590
|
+
expect((entries[0] as any).operation.result.finishReason).toBe("stop");
|
|
591
|
+
expect(JSON.stringify(entries[0])).not.toContain("Bonjour");
|
|
592
|
+
});
|
|
593
|
+
});
|
package/tests/api.test.ts
CHANGED
|
@@ -537,3 +537,54 @@ describe("watch", () => {
|
|
|
537
537
|
await stream.close();
|
|
538
538
|
});
|
|
539
539
|
});
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* `rest` is the **original, fully supported surface** — `api` (the facade) and the
|
|
543
|
+
* SDK namespaces only *group* it. Nothing is removed when something is added, so a
|
|
544
|
+
* tenant that wrote `rest.find('orders', …)` never has to migrate: this contract
|
|
545
|
+
* fails loudly if a method ever disappears.
|
|
546
|
+
*/
|
|
547
|
+
describe("rest — the original surface (contract)", () => {
|
|
548
|
+
const COLLECTION_METHODS = [
|
|
549
|
+
"find", "findOne", "insertOne", "insertMany",
|
|
550
|
+
"updateOne", "findOneAndUpdate", "updateMany",
|
|
551
|
+
"deleteOne", "deleteMany", "bulkWrite", "bulkUpdate",
|
|
552
|
+
"aggregate", "countDocuments", "watch",
|
|
553
|
+
"dropCollection", "dropIndex", "dropIndexes",
|
|
554
|
+
"runAction",
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
const FAMILY_METHODS = ["runService", "stats", "workflows", "lock", "unlock"];
|
|
558
|
+
const SESSION_METHODS = ["startSession", "endSession", "startTransaction", "commitTransaction", "abortTransaction"];
|
|
559
|
+
const GETTERS = ["db", "audit", "cache", "vars", "workflow"];
|
|
560
|
+
|
|
561
|
+
it("keeps every method and every member a tenant may already be using", () => {
|
|
562
|
+
for (const name of [...COLLECTION_METHODS, ...FAMILY_METHODS, ...SESSION_METHODS]) {
|
|
563
|
+
expect(typeof (rest as any)[name]).toBe("function");
|
|
564
|
+
}
|
|
565
|
+
for (const name of GETTERS) {
|
|
566
|
+
expect((rest as any)[name]).toBeDefined();
|
|
567
|
+
}
|
|
568
|
+
expect(rest.tenant_id).toBe(TEST_TENANT);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("keeps the collection methods working exactly as before — `api` is additive", async () => {
|
|
572
|
+
const created = await rest.insertOne("items", { title: "legacy-surface", count: 1 });
|
|
573
|
+
const [found] = await rest.find("items", { $match: { title: "legacy-surface" } });
|
|
574
|
+
expect(found!.title).toBe("legacy-surface");
|
|
575
|
+
|
|
576
|
+
await rest.updateOne("items", created._id, { $set: { count: 2 } });
|
|
577
|
+
expect((await rest.findOne("items", created._id)).count).toBe(2);
|
|
578
|
+
expect(await rest.countDocuments("items", { title: "legacy-surface" })).toBe(1);
|
|
579
|
+
expect(await rest.aggregate("items", [{ $match: { title: "legacy-surface" } }, { $count: "n" }]))
|
|
580
|
+
.toEqual([{ n: 1 }]);
|
|
581
|
+
|
|
582
|
+
await rest.deleteOne("items", created._id);
|
|
583
|
+
expect(await rest.findOne("items", created._id)).toBeNull();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("keeps `rest.vars` working, as the facade re-exposes the same object", async () => {
|
|
587
|
+
await rest.vars.set("legacy", "key", "value");
|
|
588
|
+
expect(await rest.vars.get<string>("legacy", "key")).toBe("value");
|
|
589
|
+
});
|
|
590
|
+
});
|