Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 175 additions & 2 deletions src/buzz-mcp/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,15 @@ function relay(refused: Readonly<Record<string, string>> = {}) {
}
if (kind === 9000 || kind === 9021) {
const key = kind === 9021 ? me : target;
const role = template.tags.find((t) => t[0] === "role")?.[1] ?? "member";
// Like the relay, with no role asked for a member keeps theirs and a
// new one gets the channel's join_role.
const tags = (kind: number) =>
state.find((e) => e.kind === kind && e.tags[0]?.[1] === id)?.tags ?? [];
const role =
template.tags.find((t) => t[0] === "role")?.[1] ??
tags(39002).find((t) => t[0] === "p" && t[1] === key)?.[3] ??
tags(39000).find((t) => t[0] === "join_role")?.[1] ??
"member";
put(39002, id, (tags) => [...without(tags, key), ["p", key, "", role]]);
}
if (kind === 9001 || kind === 9022)
Expand Down Expand Up @@ -1720,9 +1728,29 @@ it("refuses channel arguments that do not fit, before publishing", async () => {
await expect(
channel("channel_create", { name: "x", visibility: "open", ttl: 1.5 }),
).rejects.toThrow("ttl must be a whole number of seconds.");
await expect(
channel("channel_create", {
name: "x",
visibility: "open",
join_role: "admin",
}),
).rejects.toThrow("join_role must be member or guest.");
await expect(
channel("channel_create", {
name: "x",
visibility: "private",
join_role: "guest",
}),
).rejects.toThrow("join_role guest is for open channels.");
await expect(
channel("channel_update", { visibility: "private", join_role: "guest" }),
).rejects.toThrow("join_role guest is for open channels.");
await expect(channel("channel_update", {})).rejects.toThrow(
"Give name, about, visibility, ttl, topic or purpose.",
"Give name, about, visibility, ttl, join_role, topic or purpose.",
);
await expect(
channel("channel_update", { join_role: "owner" }),
).rejects.toThrow("join_role must be member or guest.");
await expect(
callTool(client, { channel: "chan" }, "channel_leave", {}),
).rejects.toThrow("Invalid channel ID");
Expand Down Expand Up @@ -1760,6 +1788,151 @@ it("shows a channel's details and changes them as the channel shows", async () =
);
});

it("makes a channel an announce channel and back", async () => {
const { client, published } = relay();
const context = { channel: room };
await expect(
callTool(client, context, "channel_update", { join_role: "guest" }),
).resolves.toBe(`updated ${room}`);
await expect(callTool(client, context, "channel_get", {})).resolves.toBe(
`${room} plans (stream, open, join as guest)\nabout: roadmap\nyou: owner`,
);
await expect(
callTool(client, context, "channel_update", { join_role: "member" }),
).resolves.toBe(`updated ${room}`);
expect(published.map((template) => template.tags)).toEqual([
[
["h", room],
["join_role", "guest"],
],
[
["h", room],
["join_role", "member"],
],
]);
});

it("creates an announce channel after adding its template's agents", async () => {
const { client, published, state } = relay();
client.templates = vi.fn(async () => [
{ name: "Team", canvas: "", agents: [alice] },
]);
const answer = await callTool(client, {}, "channel_create", {
name: "team",
visibility: "open",
join_role: "guest",
template: "Team",
});
const id = published[0]?.tags[0]?.[1] as string;
// Set last: an agent added with no role would otherwise join as a guest.
expect(published.map((template) => [template.kind, template.tags])).toEqual([
[
9007,
[
["h", id],
["name", "team"],
["visibility", "open"],
["channel_type", "stream"],
],
],
[
9000,
[
["h", id],
["p", alice],
],
],
[
9002,
[
["h", id],
["join_role", "guest"],
],
],
]);
expect(answer).toBe(
[
`created ${id}`,
`added ${alice} Alice Smith`,
"new people join as guests",
].join("\n"),
);
await expect(
callTool(client, { channel: id }, "channel_get", {}),
).resolves.toBe(`${id} team (stream, open, join as guest)\nyou: owner`);
const roster = () =>
state.find((e) => e.kind === 39002 && e.tags[0]?.[1] === id)?.tags;
expect(roster()).toContainEqual(["p", alice, "", "member"]);
// Now anyone added with no role is a guest, and the answer says so.
await expect(
callTool(client, { channel: id }, "members", { add: [bob] }),
).resolves.toBe(`added ${bob} as guest`);
expect(roster()).toContainEqual(["p", bob, "", "guest"]);
});

it("sets join_role when the template's Canvas is refused, and says when it cannot", async () => {
const { client, published } = relay();
const publish = client.publish;
client.publish = vi.fn(async (template: Template) => {
if (template.kind === 40100) throw new Error("canvas refused");
return publish(template);
});
client.templates = vi.fn(async () => [
{ name: "Team", canvas: "# Plan", agents: [alice] },
]);
const create = () =>
callTool(client, {}, "channel_create", {
name: "team",
visibility: "open",
join_role: "guest",
template: "Team",
});
let answer = await create();
let id = published[0]?.tags[0]?.[1] as string;
expect(published.map((template) => template.kind)).toEqual([9007, 9002]);
expect(answer).toBe(
`created ${id}\nCanvas not started, so no agents were added: canvas refused\nnew people join as guests`,
);
const query = client.query;
client.query = vi.fn(async (filters: readonly Filter[]) => {
if (filters.some((f) => (f.kinds as number[]).includes(39000)))
throw new Error("kind 39000 unavailable");
return query(filters);
});
answer = await create();
id = published.at(-1)?.tags[0]?.[1] as string;
expect(published.at(-1)?.kind).toBe(9007);
expect(answer).toBe(
`The community accepted channel ${id} but its state could not be read back: kind 39000 unavailable; the template was not applied; join_role was not set.`,
);
});

it("reports a new channel whose join_role was refused", async () => {
const { client, published } = relay();
const publish = client.publish;
client.publish = vi.fn(async (template: Template) => {
if (template.kind === 9002) throw new Error("restricted: admins only");
return publish(template);
});
const answer = await callTool(client, {}, "channel_create", {
name: "news",
visibility: "open",
join_role: "guest",
});
const id = published[0]?.tags[0]?.[1] as string;
expect(published.map((template) => template.kind)).toEqual([9007]);
expect(answer).toBe(
`created ${id}\njoin_role not set: restricted: admins only`,
);
// member is the default, so it needs no second event.
await callTool(client, {}, "channel_create", {
name: "chat",
visibility: "open",
join_role: "member",
});
expect(published.map((template) => template.kind)).toEqual([9007, 9007]);
});

it("leaves, joins, archives, unarchives and deletes a channel", async () => {
const { client, published } = relay();
const context = { channel: room };
Expand Down
67 changes: 61 additions & 6 deletions src/buzz-mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { attestedOwner } from "../features/agents/owner-attestation";
import type {
BuzzClient,
BuzzEvent,
ChannelTemplate,
Context,
Filter,
Memory,
Expand All @@ -51,6 +52,12 @@ const ttl = {
type: "integer",
description: "Seconds without a message before it archives; 0: never.",
} as const;
const joinRole = {
type: "string",
enum: ["member", "guest"],
description:
"Role for people who join or are added without one. guest on an open channel: anyone reads, only members post.",
} as const;
const tool = (
name: string,
description: string,
Expand Down Expand Up @@ -185,6 +192,7 @@ export const TOOLS: readonly Tool[] = [
"system: left out of ordinary channel lists; anyone can still list an open one.",
},
ttl,
join_role: joinRole,
template: str,
},
["name", "visibility"],
Expand All @@ -195,6 +203,7 @@ export const TOOLS: readonly Tool[] = [
about: str,
visibility: { type: "string", enum: ["open", "private"] },
ttl,
join_role: joinRole,
topic: str,
purpose: str,
}),
Expand Down Expand Up @@ -1356,6 +1365,7 @@ class Tools {
tag(metadata, "ttl")
? `archives after ${tag(metadata, "ttl")}s idle`
: "",
tag(metadata, "join_role") === "guest" ? "join as guest" : "",
].filter(Boolean);
return [
`${channel} ${tag(metadata, "name") ?? ""} (${flags.join(", ")})`,
Expand All @@ -1376,6 +1386,9 @@ class Tools {
const type = args.type ?? "stream";
if (type !== "stream" && type !== "forum" && type !== "system")
throw new Error("type must be stream, forum or system.");
const role = joinRoleArg(args);
if (role === "guest" && args.visibility === "private")
throw new Error("join_role guest is for open channels.");
// Found first, so a wrong name creates nothing.
const template =
args.template === undefined
Expand All @@ -1396,9 +1409,25 @@ class Tools {
});
const shown = await until(() => this.metadata(id), Boolean).catch(unread);
if (!shown || shown instanceof Error)
return `The community accepted channel ${id} but ${shown ? shown.message : "does not show it yet"}${template ? "; the template was not applied" : ""}.`;
return `The community accepted channel ${id} but ${shown ? shown.message : "does not show it yet"}${template ? "; the template was not applied" : ""}${role === "guest" ? "; join_role was not set" : ""}.`;
const lines = [`created ${id}`];
if (!template) return lines.join("\n");
if (template) lines.push(...(await this.apply(id, template)));
// Last, so the template's agents join as members, not guests.
if (role === "guest")
try {
const answer = await this.update(id, { join_role: role });
lines.push(
answer === `updated ${id}` ? "new people join as guests" : answer,
);
} catch (error) {
lines.push(`join_role not set: ${reason(error)}`);
}
return lines.join("\n");
}

/** Starts a new channel's Canvas and adds its agents from `template`. */
async apply(id: string, template: ChannelTemplate) {
const lines: string[] = [];
// The template's own agents, as Buzz adds them; each is tried.
const agents = template.agents.filter((key) => key !== this.client.pubkey);
if (template.canvas.trim())
Expand All @@ -1417,10 +1446,10 @@ class Tools {
lines.push(
`Canvas not started${agents.length ? ", so no agents were added" : ""}: ${reason(error)}`,
);
return lines.join("\n");
return lines;
}
if (agents.length) lines.push(...(await this.change(id, agents, [])).lines);
return lines.join("\n");
return lines;
}

/** The owner's template named `name`, as one, without guessing. */
Expand Down Expand Up @@ -1461,8 +1490,18 @@ class Tools {
}
if (args.ttl !== undefined)
wanted.push(["ttl", String(seconds(args.ttl) ?? "")]);
const role = joinRoleArg(args);
if (
role === "guest" &&
(args.visibility ?? (has(metadata, "private") ? "private" : "open")) ===
"private"
)
throw new Error("join_role guest is for open channels.");
if (role) wanted.push(["join_role", role]);
if (!wanted.length)
throw new Error("Give name, about, visibility, ttl, topic or purpose.");
throw new Error(
"Give name, about, visibility, ttl, join_role, topic or purpose.",
);
const value = (key: string) => wanted.find((t) => t[0] === key)?.[1];
checkDetails(
value("name"),
Expand Down Expand Up @@ -1531,13 +1570,20 @@ class Tools {
);
const who = (key: string) =>
names.has(key) ? `${key} ${names.get(key)}` : key;
// With no role asked for, the relay gives a new member the channel's
// join_role, which is guest on an announce channel.
const shown = (key: string) =>
role ??
(roster instanceof Error
? undefined
: roster.find((m) => m.pubkey === key)?.role);
const report = (key: string, verb: string, done?: typeof added) =>
refused.has(key)
? `not ${verb} ${who(key)}: ${refused.get(key)}`
: roster instanceof Error
? `${who(key)}: the community accepted it but ${roster.message}`
: done?.(roster, key)
? `${verb} ${who(key)}${verb === "added" && role ? ` as ${role}` : ""}`
? `${verb} ${who(key)}${verb === "added" && (role || shown(key) !== "member") ? ` as ${shown(key)}` : ""}`
: `${who(key)}: the community accepted it but does not show it yet`;
return {
changed: refused.size < add.length + remove.length,
Expand Down Expand Up @@ -2118,6 +2164,15 @@ function seconds(value: unknown) {
return value;
}

/** A `join_role` argument: undefined when absent. */
function joinRoleArg(args: Args) {
const value = args.join_role;
if (value === undefined) return undefined;
if (value !== "member" && value !== "guest")
throw new Error("join_role must be member or guest.");
return value;
}

/** A `since` or `before` argument: a Unix time in seconds, inclusive. */
function unixTime(args: Args, key: string) {
const value = args[key];
Expand Down
Loading