diff --git a/docs/solo/README.md b/docs/solo/README.md index d4d22f81e7..fc8f9c19a5 100644 --- a/docs/solo/README.md +++ b/docs/solo/README.md @@ -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 diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 8885b2300b..ce81dbae4f 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -1270,7 +1270,13 @@ function relay(refused: Readonly> = {}) { 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) { @@ -1279,6 +1285,11 @@ function relay(refused: Readonly> = {}) { 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, @@ -1286,12 +1297,13 @@ function relay(refused: Readonly> = {}) { ["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], }), ); } @@ -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) => + 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) => + 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) => { diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 6782d239a1..f89756429a 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -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 { @@ -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"] }, @@ -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"], ), @@ -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") @@ -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 @@ -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") @@ -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(), + ); + 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[] = [];