Skip to content
364 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { AbilitySection, McpServer, User } from "@g1t/contracts";
5
6import { resolveAbilities, withSetting } from "../../../packages/contracts/src/abilities.ts";
7import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts";
8import { abilitiesSection, saidText } from "./abilities-prompt.ts";
9import { Audience, type AudienceInfo, type AudiencePorts, type RepoRef } from "./audience.ts";
10import { abilityCard, connectCard, requestCard } from "./card-views.ts";
11import { DEFAULT_AUTONOMY, applyChanges } from "./definition.ts";
12import { callMcpTool, listMcpTools } from "./mcp-client.ts";
13import { type AbilityPorts, type ActionPorts, type ToolPorts, ToolBox } from "./tools.ts";
14
15// ── A small world ────────────────────────────────────────────────────────
16
17const WEB: RepoRef = { id: "rep_web", namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" };
18const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User;
19
20function world(): AudiencePorts {
21 const info: AudienceInfo = { kind: "dm", member_user_ids: ["asker"], member_count: 1 };
22 return {
23 info: async () => info,
24 users: async (ids) => [person("asker")].filter((u) => ids.includes(u.id)),
25 workspaceRepos: async () => [WEB],
26 readable: async (ids) => [WEB].filter((r) => ids.includes(r.id)),
27 };
28}
29
30const ports: ToolPorts = {
31 readFile: async () => null,
32 searchCode: async () => [],
33 listIssues: async () => [],
34 getIssue: async () => null,
35 getPull: async () => null,
36 recentPulls: async () => [],
37 searchMessages: async () => [],
38 readThread: async () => null,
39 roster: async () => "",
40 consult: async () => ({ ok: false, message: "no" }),
41};
42
43const actions: ActionPorts = {
44 remember: async () => ({ ok: true, message: "" }),
45 forget: async () => ({ ok: true, message: "" }),
46 draftIssue: async () => ({ ok: true, message: "" }),
47};
48
49const ITEMS: Record<string, { provider: string; key: string; title: string; url: string; status: string | null; body: string }> = {
50 "ENG-42": { provider: "linear", key: "ENG-42", title: "Retry flaky checks", url: "https://linear.app/acme/issue/ENG-42", status: "In Progress", body: "The queue retries." },
51 "TECH-7": { provider: "jira", key: "TECH-7", title: "Login timeout", url: "https://acme.atlassian.net/browse/TECH-7", status: "To Do", body: "Times out." },
52};
53
54type Posted = { kind: string; title: string };
55
56/** Ports that record what they were asked to do. */
57function fakeAbilityPorts(over: Partial<AbilityPorts> = {}): AbilityPorts & { posted: Posted[]; acted: string[]; refusals: string[] } {
58 const posted: Posted[] = [];
59 const acted: string[] = [];
60 const refusals: string[] = [];
61 return {
62 posted,
63 acted,
64 refusals,
65 lookup: async (_asker, reference) => (ITEMS[reference] ? { ok: true, value: ITEMS[reference]! } : { ok: false, code: "not_found", message: "None of the acme workspace's integrations knows that." }),
66 import: async (_asker, repo, reference) => ({ ok: true, value: { number: 7, item: ITEMS[reference]!, created: true } }),
67 act: async (_asker, reference, action, text) => {
68 acted.push(`${action} ${reference}: ${text}`);
69 return { ok: true, value: ITEMS[reference]! };
70 },
71 askerConnected: async () => false,
72 mcp: async (server, tool, args) => {
73 acted.push(`mcp ${server.name}.${tool.name} ${JSON.stringify(args)}`);
74 return { ok: true, value: "sunny" };
75 },
76 askFirst: async ({ summary }) => {
77 posted.push({ kind: "ability", title: summary });
78 return "abr_1";
79 },
80 connect: async (source) => {
81 posted.push({ kind: "connect", title: source.name });
82 return true;
83 },
84 request: async ({ connector, ability }) => {
85 posted.push({ kind: "request", title: connector ?? ability?.id ?? "" });
86 return true;
87 },
88 refused: ({ rule }) => {
89 refusals.push(rule);
90 },
91 ...over,
92 };
93}
94
95const SERVER: McpServer = {
96 id: "mcp_w",
97 name: "weather",
98 url: "https://mcp.example.com/",
99 tools: [
100 { name: "get_forecast", description: "Today's forecast", kind: "read", input_schema: { type: "object", properties: { city: { type: "string" } } } },
101 { name: "set_alert", description: "Set an alert", kind: "write", input_schema: { type: "object", properties: {} } },
102 ],
103 added_by: "ana",
104 added_at: "2026-10-10T00:00:00.000Z",
105 checked_at: null,
106 problem: null,
107};
108
109async function box(input: { connected?: string[]; settings?: Record<string, { level?: "alone" | "asked" | "ask" | "never"; credentials?: "workspace" | "asker" }>; servers?: McpServer[]; said?: string; session?: boolean; ports?: Partial<AbilityPorts> }) {
110 const audience = await Audience.build("acme", "asker", world());
111 const tools = new ToolBox(audience, ports, { agentId: "agt_me", notConsult: ["me"], hops: 0, maxHops: 6, session: input.session }, [], actions);
112 let abilities = { settings: {}, mcp_servers: input.servers ?? [] };
113 for (const [id, setting] of Object.entries(input.settings ?? {})) abilities = withSetting(abilities, id, setting);
114 const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities, autonomy: DEFAULT_AUTONOMY, connected: input.connected ?? [] });
115 const fake = fakeAbilityPorts(input.ports);
116 tools.useAbilities(sections, fake, input.said ?? "", input.servers ?? []);
117 return { tools, fake, sections };
118}
119
120const names = (tools: ToolBox) => tools.definitions().map((t) => t.name);
121
122// ── Offering ─────────────────────────────────────────────────────────────
123
124test("nothing connected: no outside tools but request_ability; a connected Linear offers lookup, import and act", async () => {
125 const none = await box({});
126 assert.ok(!names(none.tools).includes("lookup_outside"));
127 assert.ok(names(none.tools).includes("request_ability"), "it can still ask for what it lacks");
128 const linear = await box({ connected: ["linear"] });
129 for (const tool of ["lookup_outside", "import_outside", "act_outside"]) assert.ok(names(linear.tools).includes(tool), tool);
130});
131
132test("an ability set to Never isn't offered when it is the only one for its tool, and is refused with the rule if called anyway", async () => {
133 const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } });
134 assert.ok(!names(tools).includes("act_outside"), "Linear's only act ability is Never");
135 const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "hi" });
136 assert.equal(tried.outcome, "refused");
137 assert.match(tried.text, /no tool called act_outside/);
138 assert.deepEqual(fake.acted, []);
139});
140
141test("Never on one system, allowed on another: the tool is offered, and the rule is named per call, in the result and the audit log", async () => {
142 const { tools, fake } = await box({ connected: ["linear", "jira"], settings: { "integration:linear:comment": { level: "never" }, "integration:jira:comment": { level: "alone" } } });
143 assert.ok(names(tools).includes("act_outside"));
144 const linear = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Looks fixed." });
145 assert.equal(linear.outcome, "refused");
146 assert.match(linear.text, /Not allowed: your abilities say "Linear: Comment" is Never/);
147 assert.deepEqual(fake.refusals, ["integration:linear:comment=never"]);
148 assert.deepEqual(fake.acted, [], "nothing ran");
149 const jira = await tools.run("act_outside", { reference: "TECH-7", action: "comment", text: "On it." });
150 assert.equal(jira.outcome, "allowed");
151 assert.deepEqual(fake.acted, ["comment TECH-7: On it."]);
152 assert.equal(tools.calls.length, 2, "both calls are in the transcript");
153 assert.equal(tools.calls[0]!.outcome, "refused");
154});
155
156test("reading is alone by default; an item nobody knows is not found, and a system that isn't connected is said so", async () => {
157 const { tools } = await box({ connected: ["linear"] });
158 const read = await tools.run("lookup_outside", { reference: "ENG-42" });
159 assert.equal(read.outcome, "allowed");
160 assert.match(read.text, /<untrusted source="Linear ENG-42">/);
161 assert.match(read.text, /Retry flaky checks/);
162 const missing = await tools.run("lookup_outside", { reference: "NOPE-1" });
163 assert.equal(missing.outcome, "refused");
164 assert.match(missing.text, /No connected integration knows NOPE-1/);
165 // Jira knows TECH-7, but Jira isn't connected to this workspace as the agent sees it.
166 const jira = await tools.run("lookup_outside", { reference: "TECH-7" });
167 assert.equal(jira.outcome, "refused");
168 assert.match(jira.text, /Jira isn't connected to this workspace, so you can't read tickets there/);
169});
170
171// ── Ask first, and alone when asked for it ───────────────────────────────
172
173test("Ask first posts the card with the call, runs nothing, and tells the agent to wait; a session is told it pauses", async () => {
174 const { tools, fake } = await box({ connected: ["linear"], session: true });
175 const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Fixed in #412." });
176 assert.equal(tried.outcome, "refused");
177 assert.match(tried.text, /"Linear: Comment" is after asking first: a card was posted asking @asker \(or an owner\)/);
178 assert.match(tried.text, /Your session pauses/);
179 assert.deepEqual(fake.posted, [{ kind: "ability", title: "Comment on ENG-42 in Linear" }]);
180 assert.deepEqual(fake.acted, []);
181 assert.deepEqual(fake.refusals, ["integration:linear:comment=ask"]);
182});
183
184test("alone when asked for it: runs when the person named the item, asks first when they didn't", async () => {
185 const asked = await box({ connected: ["linear"], said: "Please import ENG-42 into web so we can track it here." });
186 const ran = await asked.tools.run("import_outside", { repo: "web", reference: "ENG-42" });
187 assert.equal(ran.outcome, "allowed");
188 assert.match(ran.text, /Opened acme\/web#7 from ENG-42/);
189 assert.deepEqual(asked.fake.posted, []);
190 const unasked = await box({ connected: ["linear"], said: "What's the state of the queue work?" });
191 const held = await unasked.tools.run("import_outside", { repo: "web", reference: "ENG-42" });
192 assert.equal(held.outcome, "refused");
193 assert.match(held.text, /^Import issues in Linear runs on its own only when asked for it, and this wasn't\. "Linear: Import issues" is only when the person asked for that: a card was posted/);
194 assert.deepEqual(unasked.fake.posted, [{ kind: "ability", title: "Import ENG-42 from Linear into acme/web" }]);
195});
196
197test("when the card can't be posted, the agent is told to ask in words", async () => {
198 const { tools } = await box({ connected: ["linear"], ports: { askFirst: async () => null } });
199 const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "x" });
200 assert.match(tried.text, /couldn't be posted just now/);
201});
202
203// ── Whose connection ─────────────────────────────────────────────────────
204
205test("the asker's own connection, missing: a Connect card, nothing runs, and the audit log says why", async () => {
206 const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } } });
207 const tried = await tools.run("lookup_outside", { reference: "ENG-42" });
208 assert.equal(tried.outcome, "refused");
209 assert.match(tried.text, /runs on @asker's own Linear connection, and they haven't connected one. A Connect card was posted/);
210 assert.deepEqual(fake.posted, [{ kind: "connect", title: "Linear" }]);
211 assert.deepEqual(fake.refusals, ["integration:linear:read=asker-not-connected"]);
212 const connected = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } }, ports: { askerConnected: async () => true } });
213 assert.equal((await connected.tools.run("lookup_outside", { reference: "ENG-42" })).outcome, "allowed");
214});
215
216// ── Requests ─────────────────────────────────────────────────────────────
217
218test("request_ability posts a Request card for an integration that isn't connected, or an ability by its id", async () => {
219 const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } });
220 const jira = await tools.run("request_ability", { needs: "jira", why: "Support files bugs there." });
221 assert.equal(jira.outcome, "allowed");
222 const comment = await tools.run("request_ability", { needs: "integration:linear:comment", why: "To tell the team when it's fixed." });
223 assert.equal(comment.outcome, "allowed");
224 assert.deepEqual(fake.posted, [
225 { kind: "request", title: "jira" },
226 { kind: "request", title: "integration:linear:comment" },
227 ]);
228 const bad = await tools.run("request_ability", { needs: "", why: "" });
229 assert.equal(bad.outcome, "refused");
230});
231
232// ── MCP ──────────────────────────────────────────────────────────────────
233
234test("an MCP server's tools are offered as <server>__<tool> with the server's schema; reads run alone, writes ask first, Never withholds", async () => {
235 const { tools, fake } = await box({ servers: [SERVER] });
236 const forecast = tools.definitions().find((t) => t.name === "weather__get_forecast");
237 assert.ok(forecast, "offered");
238 assert.deepEqual(forecast!.input_schema, SERVER.tools[0]!.input_schema);
239 assert.match(forecast!.description, /it reads/);
240 const read = await tools.run("weather__get_forecast", { city: "Lisbon" });
241 assert.equal(read.outcome, "allowed");
242 assert.match(read.text, /<untrusted source="weather get_forecast">\nsunny/);
243 const write = await tools.run("weather__set_alert", {});
244 assert.equal(write.outcome, "refused");
245 assert.match(write.text, /"weather: set_alert" is after asking first/);
246 assert.deepEqual(fake.posted, [{ kind: "ability", title: "Call set_alert on weather" }]);
247 const never = await box({ servers: [SERVER], settings: { "mcp:mcp_w:set_alert": { level: "never" } } });
248 assert.ok(!names(never.tools).includes("weather__set_alert"), "not offered at all");
249 assert.equal((await never.tools.run("weather__set_alert", {})).outcome, "refused");
250});
251
252// ── The prompt and what was said ─────────────────────────────────────────
253
254test("the prompt's abilities section names each connected system's rows and levels, and what isn't connected", async () => {
255 const { sections } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } }, servers: [SERVER] });
256 const text = abilitiesSection(sections)!;
257 assert.match(text, /- Linear: read issues on your own; import issues on your own only when they asked for it, else it asks first; comment never\./);
258 assert.match(text, /- weather: get_forecast on your own; set_alert asks first \(a card\)\./);
259 assert.match(text, /Not connected to this workspace: Jira, Sentry\./);
260 assert.equal(saidText([null, " ", "a", "b"]), "a\nb");
261});
262
263// ── The definition ───────────────────────────────────────────────────────
264
265test("a definition keeps ability settings it knows, within each kind's limit, and never above Ask for a restricted one", () => {
266 const base = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA", instructions: "Test." }, []);
267 assert.ok(base.ok);
268 const changed = applyChanges(base.value, { abilities: { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } } } }, []);
269 assert.ok(changed.ok);
270 assert.deepEqual(changed.value.abilities, { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } }, mcp_servers: [] });
271 const unknown = applyChanges(base.value, { abilities: { settings: { "integration:slack:post": { level: "alone" } } } }, []);
272 assert.ok(!unknown.ok && /no ability called integration:slack:post/.test(unknown.message));
273 const badLevel = applyChanges(base.value, { abilities: { settings: { "integration:linear:read": { level: "loud" as never } } } }, []);
274 assert.ok(!badLevel.ok);
275 const notIntegration = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, []);
276 assert.ok(!notIntegration.ok, "no such server on the agent");
277 // With the server, its tools are abilities; removing the server drops their settings.
278 const withServer = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, [], { mcp_servers: [SERVER] });
279 assert.ok(withServer.ok);
280 assert.equal(withServer.value.abilities.mcp_servers.length, 1);
281 assert.deepEqual(withServer.value.abilities.settings, { "mcp:mcp_w:get_forecast": { level: "ask" } });
282 const without = applyChanges(withServer.value, {}, [], { mcp_servers: [] });
283 assert.ok(without.ok);
284 assert.deepEqual(without.value.abilities, { settings: {}, mcp_servers: [] });
285 const g1t = applyChanges(base.value, { abilities: { settings: { "g1t:merge": { level: "alone" } } } }, []);
286 assert.ok(!g1t.ok, "g1t's own keep their choices in autonomy");
287});
288
289// ── Cards ────────────────────────────────────────────────────────────────
290
291test("the Ask-first, Connect and Request cards say what they are for and offer the right buttons", () => {
292 const waiting = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "pending", decided_by: null, result: null, ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: "Fixed in #412." });
293 assert.equal(waiting.state, "Waiting");
294 assert.deepEqual(waiting.actions?.map((a) => a.id), ["allow", "deny"]);
295 assert.equal(waiting.owner, "agents");
296 assert.equal(waiting.body, "Fixed in #412.");
297 const done = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "allowed", decided_by: "ana", result: "Commented on ENG-42.", ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: null });
298 assert.equal(done.state, "Done");
299 assert.equal(done.actions, undefined);
300 const connect = connectCard({ connector: "Linear", agent: "Margo", ability: "Read issues", asker: "ana", href: "/settings/integrations" });
301 assert.equal(connect.actions?.[0]?.href, "/settings/integrations");
302 assert.equal(connect.owner, null, "a link, no action for the service");
303 const request = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: { id: "jira", name: "Jira", available: true }, ability: null, why: "Support files bugs there.", status: "open", by: null });
304 assert.deepEqual(request.actions?.map((a) => a.id), ["ask", "open"]);
305 assert.equal(request.ref, "connector:a1:jira");
306 const asked = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: null, ability: { id: "integration:linear:comment", label: "Linear: Comment" }, why: "x", status: "asked", by: "bo" });
307 assert.equal(asked.state, "Asked");
308 assert.equal(asked.actions?.[0]?.href, "/acme/-/agents/margo/abilities");
309});
310
311// ── The MCP client ───────────────────────────────────────────────────────
312
313test("the MCP client lists tools (reads by readOnlyHint) and calls one, over JSON or an event stream", async () => {
314 const seen: { method: string; session: string | null }[] = [];
315 const fetchFn = async (_url: string, init: RequestInit) => {
316 const body = JSON.parse(String(init.body)) as { id?: number; method: string; params?: { name?: string } };
317 seen.push({ method: body.method, session: (init.headers as Record<string, string>)["mcp-session-id"] ?? null });
318 const answer = (result: unknown, sse = false) =>
319 new Response(sse ? `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: body.id, result })}\n\n` : JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), {
320 status: 200,
321 headers: { "content-type": sse ? "text/event-stream" : "application/json", "mcp-session-id": "s1" },
322 });
323 if (body.method === "initialize") return answer({ protocolVersion: "2025-06-18" });
324 if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
325 if (body.method === "tools/list") {
326 return answer(
327 {
328 tools: [
329 { name: "get_forecast", description: "Forecast", inputSchema: { type: "object", properties: { city: { type: "string" } } }, annotations: { readOnlyHint: true } },
330 { name: "set_alert", description: "Alert" },
331 { name: "bad name!" },
332 ],
333 },
334 true,
335 );
336 }
337 if (body.method === "tools/call") return answer({ content: [{ type: "text", text: `sunny in ${JSON.stringify(body.params)}` }] });
338 return new Response("{}", { status: 404 });
339 };
340 const listed = await listMcpTools("https://mcp.example.com/", fetchFn);
341 assert.ok(listed.ok);
342 assert.deepEqual(
343 listed.tools.map((t) => [t.name, t.kind]),
344 [
345 ["get_forecast", "read"],
346 ["set_alert", "write"],
347 ],
348 );
349 assert.deepEqual(listed.tools[1]!.input_schema, { type: "object", properties: {} });
350 assert.equal(seen[2]!.session, "s1", "the session id the server gave rides along");
351 const called = await callMcpTool("https://mcp.example.com/", "get_forecast", { city: "Lisbon" }, fetchFn);
352 assert.ok(called.ok);
353 assert.match(called.text, /sunny in \{"name":"get_forecast","arguments":\{"city":"Lisbon"\}\}/);
354 const down = await listMcpTools("https://mcp.example.com/", async () => {
355 throw new Error("ECONNREFUSED");
356 });
357 assert.ok(!down.ok && /couldn't be reached/.test(down.message));
358 const errored = await callMcpTool("https://mcp.example.com/", "x", {}, async (_u, init) => {
359 const body = JSON.parse(String(init.body)) as { id?: number; method: string };
360 if (body.method === "tools/call") return Response.json({ jsonrpc: "2.0", id: body.id, result: { isError: true, content: [{ type: "text", text: "no such city" }] } });
361 return Response.json({ jsonrpc: "2.0", id: body.id, result: {} });
362 });
363 assert.ok(!errored.ok && errored.message === "no such city");
364});