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
7 changes: 7 additions & 0 deletions docs/solo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ behavior on the label, so a rename touches copy only.
agents: answer as one channel-scoped, top-level conversation. It is not access
authority. The in-app agent harness reads it from the channel list; remote
harnesses that only read `about` need to learn the label.
- The Buzz tools' `channel_create` with `solo` sends the same create for any
caller, including the standalone stdio server: signed by the caller, with the
label and any `members` it names. The caller is the creator, so an agent's
key makes a chat its human members see as shared, not their own. It sends no
message, so no agent turn starts, and it does not file the chat in a
workspace. It cannot check NIP-CL support first, so on a relay that drops the
label it reports an ordinary private channel instead.
- **Share** opens the same access dialog from Solo and Messages. This flow
preserves channel ID, history, Canvas, existing members,
Solo label and workspace filing. It neither promotes a conversation nor
Expand Down
173 changes: 171 additions & 2 deletions src/buzz-mcp/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1270,7 +1270,13 @@ function relay(refused: Readonly<Record<string, string>> = {}) {
client.publish = vi.fn(async (template: Template) => {
const id = template.tags.find((t) => t[0] === "h")?.[1] ?? "";
const target = template.tags.find((t) => t[0] === "p")?.[1] ?? "";
if (refused[target]) throw new Error(refused[target]);
// Like the relay, any refused member keeps a whole create out.
const refusal = (
template.kind === 9007
? template.tags.flatMap(([key, value]) => (key === "p" ? [value] : []))
: [target]
).find((key) => key && refused[key]);
if (refusal) throw new Error(refused[refusal]);
published.push(template);
const { kind } = template;
if (kind === 9007) {
Expand All @@ -1279,19 +1285,25 @@ function relay(refused: Readonly<Record<string, string>> = {}) {
const value = (key: string) =>
template.tags.find((t) => t[0] === key)?.[1];
const type = ["t", value("channel_type") ?? "stream"];
// Labels follow the type; `p` members join in the create.
const labels = template.tags.filter((t) => t[0] === "t");
const members = template.tags.flatMap(([key, value]) =>
key === "p" && value ? [["p", value, "", "member"]] : [],
);
state.push(
event({
kind: 39000,
tags: [
["d", id],
["name", value("name") ?? ""],
type,
...labels,
...(value("visibility") === "private" ? [["private"]] : []),
],
}),
event({
kind: 39002,
tags: [["d", id], type, ["p", me, "", "owner"]],
tags: [["d", id], type, ["p", me, "", "owner"], ...members],
}),
);
}
Expand Down Expand Up @@ -1495,6 +1507,163 @@ it("creates a system channel", async () => {
).rejects.toThrow("type must be stream, forum or system.");
});

it("creates a Solo chat with its members in the create, as Buzz does", async () => {
const { client, published } = relay();
const answer = await callTool(client, {}, "channel_create", {
name: "triage",
visibility: "private",
solo: true,
members: [alice, npubEncode(bob), alice, me],
});
const id = published[0]?.tags[0]?.[1] as string;
expect(published).toEqual([
{
kind: 9007,
content: "",
tags: [
["h", id],
["name", "triage"],
["visibility", "private"],
["channel_type", "stream"],
["t", "buzz.solo"],
["t", "buzz.session.named"],
["p", alice],
["p", bob],
],
},
]);
expect(answer).toBe(
`created ${id}\nadded ${alice} Alice Smith\nadded ${bob}`,
);
await expect(
callTool(client, {}, "channel_get", { channel: id }),
).resolves.toBe(`${id} triage (stream, solo, private)\nyou: owner`);
await expect(
callTool(client, {}, "member_list", { channel: id }),
).resolves.toBe(`${me} (owner)\n${alice} Alice Smith\n${bob} `);
// An ordinary private channel is not Solo.
await callTool(client, {}, "channel_create", {
name: "ops",
visibility: "private",
});
const plain = published[1]?.tags[0]?.[1] as string;
expect(published[1]?.tags.some(([key]) => key === "t" || key === "p")).toBe(
false,
);
await expect(
callTool(client, {}, "channel_get", { channel: plain }),
).resolves.toBe(`${plain} ops (stream, private)\nyou: owner`);
});

it("refuses Solo arguments that do not fit, before publishing", async () => {
const { client, published } = relay();
const create = (args: Record<string, unknown>) =>
callTool(client, {}, "channel_create", {
name: "x",
visibility: "private",
...args,
});
const shape = "A Solo chat is a private stream without about or template.";
await expect(create({ solo: true, visibility: "open" })).rejects.toThrow(
shape,
);
await expect(create({ solo: true, type: "forum" })).rejects.toThrow(shape);
await expect(create({ solo: true, about: "notes" })).rejects.toThrow(shape);
await expect(create({ solo: true, template: "Triage" })).rejects.toThrow(
shape,
);
for (const members of [[alice], ["alice"], alice])
await expect(create({ members })).rejects.toThrow(
"members is for Solo chats.",
);
await expect(create({ solo: true, members: ["alice"] })).rejects.toThrow(
"Not a pubkey: alice",
);
await expect(create({ solo: true, members: alice })).rejects.toThrow(
"members must be a list of pubkeys.",
);
await expect(create({ solo: "true" })).rejects.toThrow(
"solo must be true or false.",
);
const many = Array.from({ length: 33 }, (_, n) =>
Comment thread
kazemek21 marked this conversation as resolved.
Resolved
n.toString(16).padStart(64, "0"),
);
await expect(create({ solo: true, members: many })).rejects.toThrow(
"A Solo chat starts with at most 32 members.",
);
expect(published).toEqual([]);
});

it("starts a Solo chat with the relay's maximum of 32 members", async () => {
const { client, published } = relay();
const members = Array.from({ length: 32 }, (_, n) =>
(n + 1).toString(16).padStart(64, "0"),
);
await callTool(client, {}, "channel_create", {
name: "x",
visibility: "private",
solo: true,
members: [...members, me],
});
expect(
published[0]?.tags.filter(([key]) => key === "p").map(([, key]) => key),
).toEqual(members);
});

it("creates no Solo chat when a member refuses, and says what it cannot confirm", async () => {
const { client, published, state } = relay({
[alice]: "blocked: this agent only joins its owner's channels",
});
await expect(
callTool(client, {}, "channel_create", {
name: "triage",
visibility: "private",
solo: true,
members: [bob, alice],
}),
).rejects.toThrow("blocked: this agent only joins its owner's channels");
expect(published).toEqual([]);
expect(state.filter((e) => e.kind === 39000)).toHaveLength(1);

vi.useFakeTimers();
try {
// A relay without channel labels drops them and keeps the channel.
const publish = client.publish;
client.publish = vi.fn(async (template: Template) =>
publish({
...template,
tags: template.tags.filter(([key]) => key !== "t"),
}),
);
let answer = callTool(client, {}, "channel_create", {
name: "triage",
visibility: "private",
solo: true,
members: [bob],
});
await vi.runAllTimersAsync();
let id = published.at(-1)?.tags[0]?.[1] as string;
await expect(answer).resolves.toBe(
`created ${id}\nThe community did not keep the Solo label, so it is an ordinary private channel.\nadded ${bob}`,
);
// A create the community never shows leaves its members unconfirmed.
client.query = vi.fn(async () => []);
answer = callTool(client, {}, "channel_create", {
name: "triage",
visibility: "private",
solo: true,
members: [bob],
});
await vi.runAllTimersAsync();
id = published.at(-1)?.tags[0]?.[1] as string;
await expect(answer).resolves.toBe(
`The community accepted channel ${id} but does not show it yet; its members are not confirmed.`,
);
} finally {
vi.useRealTimers();
}
});

it("finds a channel past the relay's first page, by name and then by ID", async () => {
const { client, state, published } = relay();
const channel = (n: number, name: string, created_at: number) => {
Expand Down
61 changes: 58 additions & 3 deletions src/buzz-mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
import { newer } from "../features/relay/events";
import { threadReference } from "../features/relay/thread-reference";
import { soloChannelMetadata } from "../features/relay/discovery";
import { channelLabelTags } from "../features/relay/channel-labels";
import { SOLO_LABEL, SOLO_NAMED_LABEL } from "../features/solo/labels";
import type { RelayEvent } from "../features/relay/events";
import { attestedOwner } from "../features/agents/owner-attestation";
import type {
Expand Down Expand Up @@ -222,7 +224,7 @@ export const TOOLS: readonly Tool[] = [
}),
tool(
"channel_create",
"Create a channel. template: one of your owner's, by name.",
"Create a channel. template: one of your owner's, by name. solo: a private Solo chat, with members added in the create.",
{
name: str,
visibility: { type: "string", enum: ["open", "private"] },
Expand All @@ -236,6 +238,8 @@ export const TOOLS: readonly Tool[] = [
ttl,
join_role: joinRole,
template: str,
solo: { type: "boolean" },
members: { ...strs, description: "Solo only: pubkeys or npubs." },
},
["name", "visibility"],
),
Expand Down Expand Up @@ -1421,6 +1425,7 @@ class Tools {
)?.role;
const flags = [
tag(metadata, "t"),
soloChannelMetadata(metadata as RelayEvent) ? "solo" : "",
has(metadata, "private") ? "private" : "open",
has(metadata, "archived") ? "archived" : "",
tag(metadata, "ttl")
Expand Down Expand Up @@ -1450,6 +1455,27 @@ class Tools {
const role = joinRoleArg(args);
if (role === "guest" && args.visibility === "private")
throw new Error("join_role guest is for open channels.");
if (args.solo !== undefined && typeof args.solo !== "boolean")
throw new Error("solo must be true or false.");
const solo = args.solo === true;
if (!solo && args.members !== undefined)
throw new Error("members is for Solo chats.");
const members = keys(args, "members").filter(
(key) => key !== this.client.pubkey,
);
// Its members are only the ones named, so no template adds agents.
if (
solo &&
(args.visibility !== "private" ||
type !== "stream" ||
about ||
args.template !== undefined)
)
throw new Error(
"A Solo chat is a private stream without about or template.",
);
if (members.length > 32)
throw new Error("A Solo chat starts with at most 32 members.");
// Found first, so a wrong name creates nothing.
const template =
args.template === undefined
Expand All @@ -1466,12 +1492,22 @@ class Tools {
["channel_type", type],
...(about ? [["about", about]] : []),
...(ttl ? [["ttl", String(ttl)]] : []),
// As Buzz starts a Solo chat: the relay adds the `p` members in the create.
...(solo ? channelLabelTags([SOLO_LABEL, SOLO_NAMED_LABEL]) : []),
...members.map((key) => ["p", key]),
],
});
const shown = await until(() => this.metadata(id), Boolean).catch(unread);
const labeled = (now: BuzzEvent | undefined) =>
!!now && (!solo || soloChannelMetadata(now as RelayEvent));
const shown = await until(() => this.metadata(id), labeled).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" : ""}${role === "guest" ? "; join_role was not set" : ""}.`;
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" : ""}${members.length ? "; its members are not confirmed" : ""}.`;
const lines = [`created ${id}`];
if (!labeled(shown))
lines.push(
"The community did not keep the Solo label, so it is an ordinary private channel.",
);
if (members.length) lines.push(...(await this.joined(id, members)));
if (template) lines.push(...(await this.apply(id, template)));
// Last, so the template's agents join as members, not guests.
if (role === "guest")
Expand All @@ -1486,6 +1522,25 @@ class Tools {
return lines.join("\n");
}

/** Reports each of a new channel's `members` as its roster shows them. */
async joined(id: string, members: readonly string[]) {
const roster = await until(
() => this.roster(id),
(now) => members.every((key) => now.some((m) => m.pubkey === key)),
).catch(unread);
const names = await this.names(members).catch(
() => new Map<string, string>(),
);
return members.map((key) => {
const who = names.has(key) ? `${key} ${names.get(key)}` : key;
if (roster instanceof Error)
return `${who}: the community accepted it but ${roster.message}`;
return roster.some((m) => m.pubkey === key)
? `added ${who}`
: `${who}: the community accepted it but does not show it yet`;
});
}

/** Starts a new channel's Canvas and adds its agents from `template`. */
async apply(id: string, template: ChannelTemplate) {
const lines: string[] = [];
Expand Down
Loading