@anteros/core 0.0.42 → 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 (55) hide show
  1. package/database/replication.ts +614 -26
  2. package/lib/agent.ts +279 -39
  3. package/lib/agentMemory.ts +103 -0
  4. package/lib/agents.ts +25 -2
  5. package/lib/define.ts +1 -1
  6. package/lib/mcpClient.ts +128 -0
  7. package/lib/providers.ts +169 -66
  8. package/lib/skills.ts +481 -0
  9. package/package.json +1 -1
  10. package/server/agents.ts +27 -6
  11. package/server/api.ts +74 -1
  12. package/tests/agent-mcp.test.ts +116 -0
  13. package/tests/agent-memory.test.ts +6 -6
  14. package/tests/agent-provider.test.ts +288 -0
  15. package/tests/agent-runtime.test.ts +369 -0
  16. package/tests/agent-skills.test.ts +177 -0
  17. package/tests/agent-summarize.test.ts +124 -0
  18. package/tests/agent-working-memory.test.ts +5 -3
  19. package/tests/agent.test.ts +18 -16
  20. package/tests/agents-api.test.ts +1 -1
  21. package/tests/api-private-fields.test.ts +196 -0
  22. package/tests/file-collection-replication.test.ts +2 -0
  23. package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
  24. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
  25. package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
  26. package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
  27. package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
  28. package/tests/fixtures/repl-only/app.ts +3 -0
  29. package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
  30. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  31. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  32. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  33. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  34. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  35. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  36. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  37. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  38. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  39. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  40. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  41. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  42. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  43. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  44. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  45. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  46. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  47. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  48. package/tests/replication-change-stream.test.ts +240 -0
  49. package/tests/replication-control.test.ts +2 -0
  50. package/tests/replication-initial-sync.test.ts +2 -0
  51. package/tests/replication-meta.test.ts +3 -0
  52. package/tests/replication.test.ts +2 -0
  53. package/tests/vars-replication.test.ts +2 -0
  54. package/types/agent.d.ts +237 -7
  55. package/types/replication.d.ts +41 -4
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The MCP **client** — an agent consuming a remote server's tools.
3
+ *
4
+ * The endpoint under test is the framework's own (`/api/:tenant/mcp`, served by
5
+ * `@hono/mcp`): the SDK client and our server are two independent implementations of
6
+ * the protocol, which is exactly what makes the round trip worth testing end to end —
7
+ * list, call, and back into the conversation as if the tool were local.
8
+ */
9
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
10
+ import { Agent, define, v } from "../index";
11
+ import { cfg, formatConfig } from "../server/config";
12
+ import { syncTenants } from "../database/tenant";
13
+ import { syncMcpTools } from "../lib/mcp";
14
+ import { closeMcpClients } from "../lib/mcpClient";
15
+ import { createApp } from "../server/hono";
16
+ import { at, fakeProvider, openaiText, openaiTool, request, type Fake } from "./fixtures/fake-provider";
17
+
18
+ const TENANT = "mcp-client";
19
+ const DIR = "packages/core/tests/fixtures/mcp-tenant";
20
+
21
+ let server: any;
22
+ let mcpUrl: string;
23
+ const servers: Fake[] = [];
24
+
25
+ const track = (fake: Fake): Fake => (servers.push(fake), fake);
26
+
27
+ beforeAll(async () => {
28
+ formatConfig({
29
+ server: { port: 4000 },
30
+ tenants: [{ id: TENANT, dir: DIR, database: { uri: "mongodb://localhost:27017/none" } }],
31
+ });
32
+ await syncTenants();
33
+ await syncMcpTools();
34
+
35
+ server = Bun.serve({ port: 0, fetch: createApp().fetch });
36
+ mcpUrl = `${server.url.href.replace(/\/$/, "")}/api/${TENANT}/mcp`;
37
+ });
38
+
39
+ afterAll(async () => {
40
+ for (const fake of servers) fake.stop();
41
+ await closeMcpClients();
42
+ try { server?.stop(true); } catch (_) {}
43
+ cfg.agents = [];
44
+ });
45
+
46
+ function makeAgent(provider: Fake, overrides: Record<string, any> = {}) {
47
+ return new Agent({
48
+ id: "test",
49
+ description: "A test agent.",
50
+ instructions: "You are a test agent.",
51
+ provider: { model: "test-model", apiKey: "test-key", baseUrl: provider.url, options: { retries: 0 } },
52
+ mcp: [mcpUrl],
53
+ ...overrides,
54
+ } as any);
55
+ }
56
+
57
+ describe("agent mcp — a remote server's tools", () => {
58
+ it("lists them as tools, calls one, and feeds the result back", async () => {
59
+ const provider = track(fakeProvider((_body, _req, hits) =>
60
+ hits === 1 ? openaiTool("echo", { msg: "hello mcp", times: 1 }) : openaiText("done")));
61
+ const agent = makeAgent(provider);
62
+
63
+ const result = await agent.generate("echo something");
64
+
65
+ // The remote tools are offered like any other
66
+ const names = request(provider, 0).body.tools.map((tool: any) => tool.function.name);
67
+ expect(names).toContain("echo");
68
+ expect(names).toContain("badge");
69
+
70
+ // …and the call really crossed the wire: the answer is the server's
71
+ const entry = at(result.toolResults, 0);
72
+ expect(entry.error).toBeUndefined();
73
+ expect(entry.result.content[0].text).toContain("hello mcp");
74
+ // What the model saw is the flattened text, not the protocol envelope
75
+ const toolMessage = result.messages.find((message) => message.role === "tool");
76
+ expect(toolMessage?.content).toContain("hello mcp");
77
+ expect(result.text).toBe("done");
78
+ });
79
+
80
+ it("lets a declared tool win over a remote one of the same name", async () => {
81
+ const provider = track(fakeProvider(() => openaiText("done")));
82
+ const local = define.Tool({
83
+ id: "echo",
84
+ description: "The tenant's own echo.",
85
+ inputSchema: v.object({}),
86
+ execute: async () => "local",
87
+ });
88
+ const agent = makeAgent(provider, { tools: { echo: local } });
89
+
90
+ await agent.generate("hi");
91
+
92
+ const offered = request(provider, 0).body.tools.filter((tool: any) => tool.function.name === "echo");
93
+ expect(offered).toHaveLength(1);
94
+ expect(offered[0].function.description).toBe("The tenant's own echo.");
95
+ });
96
+
97
+ it("contacts nothing with `tools: false`", async () => {
98
+ const provider = track(fakeProvider(() => openaiText("direct")));
99
+ const agent = makeAgent(provider);
100
+
101
+ await agent.generate("hi", { tools: false });
102
+
103
+ expect(request(provider, 0).body.tools).toBeUndefined();
104
+ });
105
+
106
+ it("runs anyway when the server is unreachable", async () => {
107
+ const provider = track(fakeProvider(() => openaiText("still here")));
108
+ const agent = makeAgent(provider, { mcp: ["http://127.0.0.1:9/mcp"] });
109
+
110
+ const result = await agent.generate("hi");
111
+
112
+ // A remote that is down costs its tools, not the run
113
+ expect(result.text).toBe("still here");
114
+ expect(request(provider, 0).body.tools).toBeUndefined();
115
+ }, 20_000);
116
+ });
@@ -336,7 +336,7 @@ describe("agent memory — end to end", () => {
336
336
  id: "memo",
337
337
  description: "A test agent.",
338
338
  instructions: "You are a test agent.",
339
- provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
339
+ provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
340
340
  memory,
341
341
  } as any);
342
342
  agent.setRest(rest);
@@ -371,7 +371,7 @@ describe("agent memory — end to end", () => {
371
371
  id: "shorthand",
372
372
  description: "A test agent.",
373
373
  instructions: "You are a test agent.",
374
- provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
374
+ provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
375
375
  memory: new MongoAgentMemory({ collection: MEMORY }),
376
376
  } as any);
377
377
  agent.setRest(rest);
@@ -397,7 +397,7 @@ describe("agent memory — end to end", () => {
397
397
  id: "preview",
398
398
  description: "A test agent.",
399
399
  instructions: "You are a test agent.",
400
- provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
400
+ provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
401
401
  memory: new MongoAgentMemory({ collection: MEMORY }),
402
402
  } as any);
403
403
  agent.setRest(rest);
@@ -416,7 +416,7 @@ describe("agent memory — end to end", () => {
416
416
  id: "plain",
417
417
  description: "x",
418
418
  instructions: "x",
419
- provider: { model: "m", compatible: "openai", apiKey: "k" },
419
+ provider: { model: "m", protocol: "openai", apiKey: "k" },
420
420
  } as any);
421
421
  expect(await agent.listThreads()).toEqual([]);
422
422
 
@@ -424,7 +424,7 @@ describe("agent memory — end to end", () => {
424
424
  id: "minimal",
425
425
  description: "x",
426
426
  instructions: "x",
427
- provider: { model: "m", compatible: "openai", apiKey: "k" },
427
+ provider: { model: "m", protocol: "openai", apiKey: "k" },
428
428
  // A custom store only has to implement get / save / clear
429
429
  memory: { get: () => [], save: () => { }, clear: () => { } },
430
430
  } as any);
@@ -599,7 +599,7 @@ describe("agents.memory.Redis", () => {
599
599
  id: "redis-agent",
600
600
  description: "A test agent.",
601
601
  instructions: "You are a test agent.",
602
- provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
602
+ provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
603
603
  memory: new RedisAgentMemory({ client }),
604
604
  } as any);
605
605
  agent._tenant_ = "v1";
@@ -0,0 +1,288 @@
1
+ /**
2
+ * What an agent puts **on the wire** for the two things the provider can enforce
3
+ * better than a prompt: a JSON **schema**, and the prompt **cache** of the stable
4
+ * prefix.
5
+ *
6
+ * Anthropic has no `response_format` — the schema travels as a tool the model is
7
+ * forced to call, and the adapter turns that call back into text, so the runtime
8
+ * never sees a tool call it would try to execute. A streaming run gets the same
9
+ * treatment, delta by delta.
10
+ */
11
+ import { describe, it, expect, afterAll } from "bun:test";
12
+ import { define, v, Agent } from "../index";
13
+ import {
14
+ anthropicText,
15
+ anthropicTool,
16
+ at,
17
+ fakeProvider,
18
+ openaiText,
19
+ request,
20
+ sse,
21
+ type Fake,
22
+ } from "./fixtures/fake-provider";
23
+
24
+ const servers: Fake[] = [];
25
+ function track(fake: Fake): Fake {
26
+ servers.push(fake);
27
+ return fake;
28
+ }
29
+
30
+ afterAll(() => {
31
+ for (const server of servers) server.stop();
32
+ });
33
+
34
+ const priority = v.object({ priority: v.string().required() });
35
+
36
+ function makeAgent(
37
+ overrides: Record<string, any> = {},
38
+ provider: Record<string, any> = {},
39
+ options: Record<string, any> = {},
40
+ ) {
41
+ return new Agent({
42
+ id: "test",
43
+ description: "A test agent.",
44
+ instructions: "You are a test agent.",
45
+ provider: {
46
+ model: "test-model",
47
+ apiKey: "test-key",
48
+ protocol: "openai",
49
+ options: { retries: 0, ...options },
50
+ ...provider,
51
+ },
52
+ ...overrides,
53
+ } as any);
54
+ }
55
+
56
+ const tool = () => define.Tool({
57
+ id: "forecast",
58
+ description: "Current weather for a city",
59
+ inputSchema: v.object({ city: v.string().required() }),
60
+ execute: async ({ city }: any) => ({ city, celsius: 21 }),
61
+ });
62
+
63
+ describe("structured output on an OpenAI-compatible gateway", () => {
64
+ it("sends the real `json_schema`, not just `json_object`", async () => {
65
+ const provider = track(fakeProvider(() => openaiText('{"priority":"high"}')));
66
+ const agent = makeAgent({}, { baseUrl: provider.url });
67
+
68
+ const result = await agent.generate("Triage this", { schema: priority });
69
+
70
+ expect(result.object).toEqual({ priority: "high" });
71
+
72
+ const format = request(provider, 0).body.response_format;
73
+ expect(format.type).toBe("json_schema");
74
+ // The agent id names the schema when nothing else does
75
+ expect(format.json_schema.name).toBe("test");
76
+ expect(format.json_schema.schema.type).toBe("object");
77
+ expect(format.json_schema.schema.properties.priority.type).toBe("string");
78
+ });
79
+
80
+ it("names it from `api.object`, and describes it", async () => {
81
+ const provider = track(fakeProvider(() => openaiText('{"priority":"low"}')));
82
+ // What the HTTP `object` action runs: a structured call carrying `api.object`
83
+ const api = { object: { schema: priority, name: "triage", description: "The triage verdict." } };
84
+ const agent = makeAgent({ api }, { baseUrl: provider.url });
85
+
86
+ await agent.generate("Triage this", { structuredOutput: api.object });
87
+
88
+ const format = request(provider, 0).body.response_format.json_schema;
89
+ expect(format.name).toBe("triage");
90
+ expect(format.description).toBe("The triage verdict.");
91
+ });
92
+
93
+ it("sanitizes a name a provider would refuse", async () => {
94
+ const provider = track(fakeProvider(() => openaiText('{"priority":"low"}')));
95
+ const agent = makeAgent({
96
+ api: { object: { schema: priority, name: "support.billing" } },
97
+ }, { baseUrl: provider.url });
98
+
99
+ await agent.generate("Triage this", { structuredOutput: { schema: priority, name: "support.billing" } });
100
+
101
+ expect(request(provider, 0).body.response_format.json_schema.name).toBe("support_billing");
102
+ });
103
+
104
+ it("falls back to `json_object` once when the gateway refuses the schema", async () => {
105
+ const provider = track(fakeProvider((body) => {
106
+ if (body.response_format?.type === "json_schema") {
107
+ return new Response(JSON.stringify({ error: { message: "response_format json_schema is not supported" } }), {
108
+ status: 400,
109
+ headers: { "Content-Type": "application/json" },
110
+ });
111
+ }
112
+ return openaiText('{"priority":"high"}');
113
+ }));
114
+ const agent = makeAgent({}, { baseUrl: provider.url });
115
+
116
+ const result = await agent.generate("Triage this", { schema: priority });
117
+
118
+ expect(result.object).toEqual({ priority: "high" });
119
+ expect(request(provider, 0).body.response_format.type).toBe("json_schema");
120
+ expect(request(provider, 1).body.response_format).toEqual({ type: "json_object" });
121
+ });
122
+
123
+ it("does not mask an unrelated 400", async () => {
124
+ const provider = track(fakeProvider(() => new Response(
125
+ JSON.stringify({ error: { message: "I do not like your temperature" } }),
126
+ { status: 400, headers: { "Content-Type": "application/json" } },
127
+ )));
128
+ const agent = makeAgent({}, { baseUrl: provider.url });
129
+
130
+ await expect(agent.generate("Triage this", { schema: priority }))
131
+ .rejects.toMatchObject({ code: "AGENT_PROVIDER_ERROR" });
132
+ expect(provider.hits()).toBe(1); // no retry: the schema was not the problem
133
+ });
134
+ });
135
+
136
+ describe("structured output on Anthropic", () => {
137
+ it("forces a schema tool, and turns its call back into the answer", async () => {
138
+ const provider = track(fakeProvider(() => anthropicTool("structured_output", { priority: "high" })));
139
+ const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
140
+
141
+ const result = await agent.generate("Triage this", { schema: priority });
142
+
143
+ const body = request(provider, 0).body;
144
+ expect(body.tool_choice).toEqual({ type: "tool", name: "structured_output" });
145
+ expect(body.tools).toHaveLength(1);
146
+ expect(body.tools[0].name).toBe("structured_output");
147
+ expect(body.tools[0].input_schema.type).toBe("object");
148
+
149
+ // The forced call **is** the answer: parsed and validated, never a tool call
150
+ expect(result.object).toEqual({ priority: "high" });
151
+ expect(result.toolCalls).toEqual([]);
152
+ });
153
+
154
+ it("keeps the agent's own tools, and the schema tool beside them", async () => {
155
+ const provider = track(fakeProvider(() => anthropicTool("structured_output", { priority: "low" })));
156
+ const agent = makeAgent({ tools: { forecast: tool() } }, { protocol: "anthropic", baseUrl: provider.url });
157
+
158
+ await agent.generate("Triage this", { schema: priority });
159
+
160
+ expect(request(provider, 0).body.tools.map((tool: any) => tool.name))
161
+ .toEqual(["forecast", "structured_output"]);
162
+ });
163
+
164
+ it("does not force a tool when extended thinking is on, and still parses", async () => {
165
+ const provider = track(fakeProvider(() => anthropicText('{"priority":"high"}')));
166
+ const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
167
+
168
+ const result = await agent.generate("Triage this", { schema: priority, thinking: true });
169
+
170
+ const body = request(provider, 0).body;
171
+ expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 4096 });
172
+ // `tool_choice` is refused alongside thinking — the prompt carries the schema
173
+ expect(body.tool_choice).toBeUndefined();
174
+ expect(body.tools).toBeUndefined();
175
+ expect(result.object).toEqual({ priority: "high" });
176
+ });
177
+
178
+ it("streams the forced tool's JSON as text", async () => {
179
+ const provider = track(fakeProvider(() => sse([
180
+ JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } }),
181
+ JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_1", name: "structured_output" } }),
182
+ JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"priority"' } }),
183
+ JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"high"}' } }),
184
+ JSON.stringify({ type: "content_block_stop", index: 0 }),
185
+ JSON.stringify({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 7 } }),
186
+ JSON.stringify({ type: "message_stop" }),
187
+ ])));
188
+ const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
189
+
190
+ const stream = agent.stream("Triage this", { schema: priority });
191
+ const chunks: any[] = [];
192
+ for await (const chunk of stream.fullStream) chunks.push(chunk);
193
+
194
+ expect(await stream.text).toBe('{"priority":"high"}');
195
+ expect(await stream.object).toEqual({ priority: "high" });
196
+ expect(await stream.toolCalls).toEqual([]);
197
+ // It arrived as text deltas — never as a tool call the run would execute
198
+ expect(chunks.filter((chunk) => chunk.type === "tool_call")).toHaveLength(0);
199
+ expect(at<any>(chunks, 0).type).toBe("text");
200
+ });
201
+ });
202
+
203
+ describe("prompt cache", () => {
204
+ it("marks the stable prefix on Anthropic — system and last tool", async () => {
205
+ const provider = track(fakeProvider(() => anthropicText("ok")));
206
+ const agent = makeAgent({ tools: { forecast: tool() } }, { protocol: "anthropic", baseUrl: provider.url }, { cache: true });
207
+
208
+ await agent.generate("hi");
209
+
210
+ const body = request(provider, 0).body;
211
+ expect(body.system).toEqual([{ type: "text", text: "You are a test agent.", cache_control: { type: "ephemeral" } }]);
212
+ expect(body.tools[body.tools.length - 1].cache_control).toEqual({ type: "ephemeral" });
213
+ });
214
+
215
+ it("leaves the payload alone when it is not asked for", async () => {
216
+ const provider = track(fakeProvider(() => anthropicText("ok")));
217
+ const agent = makeAgent({ tools: { forecast: tool() } }, { protocol: "anthropic", baseUrl: provider.url });
218
+
219
+ await agent.generate("hi");
220
+
221
+ const body = request(provider, 0).body;
222
+ expect(body.system).toBe("You are a test agent.");
223
+ expect(body.tools[0].cache_control).toBeUndefined();
224
+ });
225
+
226
+ it("is a no-op on an OpenAI-compatible gateway, which caches by itself", async () => {
227
+ const provider = track(fakeProvider(() => openaiText("ok")));
228
+ const agent = makeAgent({}, { baseUrl: provider.url }, { cache: true });
229
+
230
+ await agent.generate("hi");
231
+
232
+ expect(request(provider, 0).body.messages[0]).toEqual({ role: "system", content: "You are a test agent." });
233
+ });
234
+ });
235
+
236
+ describe("provider protocol", () => {
237
+ /** An agent whose provider is exactly what is passed — no injected default. */
238
+ function providerAgent(provider: Record<string, any>) {
239
+ return new Agent({
240
+ id: "test",
241
+ description: "A test agent.",
242
+ instructions: "You are a test agent.",
243
+ provider: { model: "test-model", apiKey: "test-key", options: { retries: 0 }, ...provider },
244
+ } as any);
245
+ }
246
+
247
+ it("calls the Messages API when `protocol: 'anthropic'`", async () => {
248
+ const provider = track(fakeProvider(() => anthropicText("ok")));
249
+ const agent = providerAgent({ protocol: "anthropic", baseUrl: provider.url });
250
+
251
+ await agent.generate("hi");
252
+
253
+ expect(request(provider, 0).path).toBe("/v1/messages");
254
+ });
255
+
256
+ it("still accepts the deprecated `compatible` alias", async () => {
257
+ const provider = track(fakeProvider(() => anthropicText("ok")));
258
+ const agent = providerAgent({ compatible: "anthropic", baseUrl: provider.url });
259
+
260
+ await agent.generate("hi");
261
+
262
+ expect(request(provider, 0).path).toBe("/v1/messages");
263
+ });
264
+
265
+ it("lets `protocol` win when the deprecated alias is set too", async () => {
266
+ const provider = track(fakeProvider(() => openaiText("ok")));
267
+ const agent = providerAgent({ compatible: "anthropic", protocol: "openai", baseUrl: provider.url });
268
+
269
+ await agent.generate("hi");
270
+
271
+ // A bare host gets `/v1` appended by the OpenAI adapter
272
+ expect(request(provider, 0).path).toBe("/v1/chat/completions");
273
+ });
274
+
275
+ it("normalizes to `protocol` in the serializable view, never leaking the alias", () => {
276
+ const agent = providerAgent({ compatible: "anthropic" });
277
+
278
+ const info = agent.toJSON();
279
+ expect(info.provider.protocol).toBe("anthropic");
280
+ expect(info.provider.compatible).toBeUndefined();
281
+ });
282
+
283
+ it("refuses an unknown protocol with `AGENT_PROVIDER_INVALID`", async () => {
284
+ const agent = providerAgent({ protocol: "gemini" });
285
+
286
+ await expect(agent.generate("hi")).rejects.toThrow(/Unknown provider 'protocol'/);
287
+ });
288
+ });