diff --git a/packages/simplex-chat-mcp/src/control.ts b/packages/simplex-chat-mcp/src/control.ts index c3ddd4a9eb..3bd6597d73 100644 --- a/packages/simplex-chat-mcp/src/control.ts +++ b/packages/simplex-chat-mcp/src/control.ts @@ -1,18 +1,10 @@ import {ChatEvent, T} from "@simplex-chat/types" import {api} from "simplex-chat" import {Action, ActionHooks, Actions, ConnectAction, Subject, ToolError, leaveVerbs, subjectText, summary} from "./actions" -import {ExitError, chatErrorOf, log, logError, oneLine, shellWord} from "./log" +import {chatErrorOf, log, logError, oneLine} from "./log" import {chatNumber, chatRef, isChatRef, utcMinutes, validName, viewName} from "./messages" -import {qrCodeText} from "./qr" import {AgentTools, chatSubject, isActive, isConnected, memberCurrent} from "./tools" -export type QrCodeSize = "small" | "large" - -export interface OwnerEntry { - memberId: number - name: string -} - const controlCommands: T.ChatBotCommand[] = [ {type: "command", keyword: "pending", label: "Pending actions"}, {type: "command", keyword: "list", label: "Chats and approvals"}, @@ -25,19 +17,28 @@ const listedChats = 100 const maxLineLength = 100 const truncationMark = "…" const longestError = "€".repeat(600) -const greeting = "I'm your agent. I ask here before I connect or send." -const noOwners = "No owners in the agent control chat." +const greeting = "I'm your agents' Main group. Your Claude Code sessions ask here before they connect or send." +const noOwners = "No owners in the Main group." export interface ControlData { control: true nextAction: number } +export interface SessionView { + readonly number: number + readonly displayName: string + chat: api.ChatApi + tools: Pick +} + +export interface Sessions { + get(number: number): SessionView | undefined + open(): SessionView[] +} + export interface ControlOptions { displayName: string - owners?: OwnerEntry[] - registerHint: string - qrCode: QrCodeSize } export interface Command { @@ -51,38 +52,40 @@ interface ChatApproval { approvedAt: string } -type OwnerRequest = - | {type: "approve" | "reject"; action: number; chat?: string} - | {type: "approveChat" | "revoke"; chat: string} - | {type: "pending" | "list"} - -interface Shown { - name: (name: string) => string - command: (command: string) => string +interface ChatTarget { + session: number + chat: string } -const inLog: Shown = {name: oneLine, command: command => command} -const inChat: Shown = {name: safeName, command: codeSpan} +type OwnerRequest = + | {type: "approve" | "reject"; action: number; target?: ChatTarget} + | {type: "approveChat" | "revoke"; target: ChatTarget} + | {type: "pending" | "list"} export class Control implements Pick { private members: T.GroupMember[] = [] private writes: Promise = Promise.resolve() private ownerCommands: Promise = Promise.resolve() + private linkText?: string constructor( private readonly chat: api.ChatApi, private group: T.GroupInfo, private readonly options: ControlOptions, private readonly actions: Actions, - private readonly tools: Pick, + private readonly sessions: Sessions, ) {} get groupId(): number { return this.group.groupId } - get chatRef(): string { - return `#${this.group.groupId}` + get link(): string | undefined { + return this.linkText + } + + owners(): string[] { + return this.approvers().map(({memberProfile}) => oneLine(memberProfile.displayName)) } async load(): Promise { @@ -95,22 +98,19 @@ export class Control implements Pick { - if (this.actions.lost) return - const {owners} = this.options - if (owners === undefined) return this.startOwners() - if (owners.length === 0) return this.startObserver() - return this.startListed(owners) + if (this.actions.lost || this.approvers().length > 0) return + if (this.humans().length > 0) log(noOwners) + await this.openLink() } refusal(action: Action): string | undefined { const {mode} = this.actions const approver = this.longestApproverName() - if (action.tool === "connect") action.linkTextBytes = maxCardLength - largestCard({...action, linkTextBytes: 0}, mode, approver) - if (action.tool !== "leave" && largestCard(action, mode, approver) > maxCardLength) return cardTooLong[action.tool] + const header = this.header(action) + if (action.tool === "connect") action.linkTextBytes = maxCardLength - largestCard({...action, linkTextBytes: 0}, mode, approver, header) + if (action.tool !== "leave" && largestCard(action, mode, approver, header) > maxCardLength) return cardTooLong[action.tool] if (this.approvers().length > 0) return undefined - return this.options.owners === undefined - ? "The agent has no owner. The person who runs it has to join its control chat with the link the server printed." - : "The agent has no approving owner. The person who runs it has to restart it with the --owners options it printed." + return this.linkText === undefined ? "The agent has no owner. Run /simplex:link in Claude Code." : `The agent has no owner. Open this link in the SimpleX app and join: ${this.linkText}` } standingApprover(chatInfo: T.ChatInfo): string | undefined { @@ -125,11 +125,11 @@ export class Control implements Pick { - return this.post(cardText(action, this.actions.mode)) + return this.post(this.card(action)) } async outcome(action: Action): Promise { - const text = cardText(action, this.actions.mode) + const text = this.card(action) if (action.card === undefined) await this.post(text) else await this.chat.apiUpdateChatItem(T.ChatType.Group, this.groupId, action.card, {type: "text", text}, false) } @@ -143,31 +143,26 @@ export class Control implements Pick this.ownerCommand(sender, command)) .then(reply => (reply === undefined ? undefined : this.chat.apiSendTextReply(ci, reply))) - .catch(error => logError("control chat command", error)) + .catch(error => logError("Main group command", error)) } async onEvent(event: ChatEvent): Promise { if (this.actions.lost) return if (losesControl(event)) return this.lose() - const before = this.humanOwners() + const before = this.approvers() await this.refreshMembers() - const {owners} = this.options - if (event.type === "joinedGroupMember" && this.isHuman(event.member)) { - if (owners === undefined) return this.ownerJoined(event.member) - if (owners.length === 0) return this.observerJoined(event.member) - } - if (owners === undefined && before.length > 0 && this.approvers().length === 0) { + if (event.type === "joinedGroupMember" && this.isHuman(event.member)) return this.ownerJoined(event.member) + if (before.length > 0 && this.approvers().length === 0) { log(noOwners) - return this.openLink(T.GroupMemberRole.Member) + await this.openLink() } - if (owners !== undefined && owners.length > 0) await this.reportOwnerChanges(owners, before) } async ownerCommand(sender: T.GroupMember, command: Command): Promise { this.actions.expireDue() const request = ownerRequest(command) if (request === undefined) return commandsReply - if (!this.isApprover(sender)) return this.options.owners === undefined ? "Only owners approve actions." : "Only the owners listed in --owners approve actions." + if (!this.isApprover(sender)) return "Only owners approve actions." switch (request.type) { case "pending": { const lines = this.actions.pending().filter(({card}) => card !== undefined).map(pendingLine) @@ -177,14 +172,16 @@ export class Control implements Pick { - const chatInfo = await this.agentChat(chat) - if (chatInfo === undefined) return `No chat ${chat}. /list shows the agent's chats.` - const approvedBy = sender.memberProfile.displayName - const approval: ChatApproval = {approvedBy, approvedById: sender.groupMemberId, approvedAt: new Date().toISOString()} - await this.setCustomData(chat, {...customDataOf(chatInfo), simplexMcp: approval}) - const approver = safeName(approvedBy) - for (const action of this.actions.pending()) { - if (action.tool === "send" && action.chat === chat && action.card !== undefined) this.actions.approve(action, approver) - } - return `Approved for ${chatName(chatSubject(chatInfo))} (${chat}) by ${approver}. /'revoke ${chat}'` + async rename(displayName: string): Promise { + this.options.displayName = displayName + await this.load() } - private async revoke(chat: string): Promise { - const chatInfo = await this.agentChat(chat) - if (chatInfo === undefined || approvalOf(customDataOf(chatInfo)) === undefined) return `Messages to ${chat} have no standing approval.` + async reset(userId: number): Promise { + if (!this.actions.lost) this.actions.loseControl() + this.group = await resetControlChat(this.chat, userId, this.group, this.options.displayName, this.actions.next) + this.actions.regainControl() + await this.load() + await this.openLink() + return this.linkText! + } + + private card(action: Action): string { + return `${this.header(action)}\n${cardText(action, this.actions.mode)}` + } + + private header({session}: Action): string { + const view = this.sessions.get(session) + return view === undefined ? `Session ${session}` : `Session ${session} · ${safeName(view.displayName)}` + } + + private async approveChat(sender: T.GroupMember, target: ChatTarget): Promise { + const view = this.sessions.get(target.session) + if (view === undefined) return noSession(target.session) + const chatInfo = await agentChat(view, target.chat) + if (chatInfo === undefined) return `No chat ${targetText(target)}. /list shows the agents' chats.` + const approvedBy = sender.memberProfile.displayName + const approval: ChatApproval = {approvedBy, approvedById: sender.groupMemberId, approvedAt: new Date().toISOString()} + await setCustomData(view.chat, target.chat, {...customDataOf(chatInfo), simplexMcp: approval}) + const approver = safeName(approvedBy) + for (const action of this.actions.pending()) { + if (action.tool === "send" && action.session === target.session && action.chat === target.chat && action.card !== undefined) this.actions.approve(action, approver) + } + return `Approved for ${chatName(chatSubject(chatInfo))} (${targetText(target)}) by ${approver}. /'revoke ${targetText(target)}'` + } + + private async revoke(target: ChatTarget): Promise { + const view = this.sessions.get(target.session) + const chatInfo = view === undefined ? undefined : await agentChat(view, target.chat) + if (view === undefined || chatInfo === undefined || approvalOf(customDataOf(chatInfo)) === undefined) return `Messages to ${targetText(target)} have no standing approval.` const {simplexMcp, ...customData} = customDataOf(chatInfo) as {simplexMcp: unknown} - await this.setCustomData(chat, customData) - return `Messages to ${chatName(chatSubject(chatInfo))} (${chat}) need approval again.` + await setCustomData(view.chat, target.chat, customData) + return `Messages to ${chatName(chatSubject(chatInfo))} (${targetText(target)}) need approval again.` } private async chatsText(): Promise { - const chats = await this.tools.agentChats() - const lines = chats.slice(0, listedChats).map(({chatInfo}) => { - const approvedBy = this.standingApprover(chatInfo) - const approval = approvedBy === undefined ? "" : `, approved for this chat by ${approvedBy}` - const connection = isConnected(chatInfo) ? "" : ", not connected" - return truncatedLine(`${chatRef(chatInfo)} ${chatName(chatSubject(chatInfo))}${approval}${connection}`, maxLineLength) - }) - const rest = chats.length - lines.length - if (rest > 0) lines.push(`${rest} older chat${rest === 1 ? " is" : "s are"} not shown.`) - return lines.length > 0 ? lines.join("\n") : "No chats." - } - - private async agentChat(chat: string): Promise { - try { - return (await this.tools.knownChat(chat)).chatInfo - } catch (error) { - if (error instanceof ToolError) return undefined - throw error + const views = this.sessions.open() + if (views.length === 0) return "No open sessions." + const lines: string[] = [] + for (const view of views) { + lines.push(`Session ${view.number} · ${safeName(view.displayName)}`) + const chats = await view.tools.agentChats() + for (const {chatInfo} of chats.slice(0, listedChats)) { + const approvedBy = this.standingApprover(chatInfo) + const approval = approvedBy === undefined ? "" : `, approved for this chat by ${approvedBy}` + const connection = isConnected(chatInfo) ? "" : ", not connected" + lines.push(truncatedLine(`${view.number}:${chatRef(chatInfo)} ${chatName(chatSubject(chatInfo))}${approval}${connection}`, maxLineLength)) + } + const rest = chats.length - Math.min(chats.length, listedChats) + if (rest > 0) lines.push(`${rest} older chat${rest === 1 ? " is" : "s are"} not shown.`) } - } - - private setCustomData(chat: string, customData: object): Promise { - return chat.startsWith("@") ? this.chat.apiSetContactCustomData(chatNumber(chat), customData) : this.chat.apiSetGroupCustomData(chatNumber(chat), customData) - } - - private async startOwners(): Promise { - if (this.humanOwners().length > 0) return log(this.options.registerHint) - if (this.humans().length > 0) log(noOwners) - await this.openLink(T.GroupMemberRole.Member) - } - - private async startObserver(): Promise { - if (this.humans().length === 0) return this.openLink(T.GroupMemberRole.Observer) - await this.announce(shown => `${this.humans().map(member => joinedText(member, shown.name)).join(". ")}. ${restartText(this.humans(), shown)}`) - } - - private async startListed(owners: OwnerEntry[]): Promise { - const humans = this.humans() - const listed = owners.map(owner => { - const member = humans.find(human => listsMember(owner, human)) - if (!member) throw new ExitError(`--owners: member ${entryText(owner)} is not in the agent control chat. Start with --reset-owners --owners, or use the IDs of current members.`, 2) - return member - }) - for (const member of listed) { - if (member.memberRole !== T.GroupMemberRole.Owner) await this.chat.apiSetMembersRole(this.groupId, [member.groupMemberId], T.GroupMemberRole.Owner) - } - for (const member of humans) { - if (!listed.includes(member)) await this.chat.apiRemoveMembers(this.groupId, [member.groupMemberId]) - } - await this.deleteLink() - await this.refreshMembers() - log(`Owners: ${owners.map(entryText).join(",")}.`) - log(this.options.registerHint) + return lines.join("\n") } private async ownerJoined(member: T.GroupMember): Promise { @@ -276,47 +262,22 @@ export class Control implements Pick { - await this.deleteLink() - await this.announce(shown => `${joinedText(member, shown.name)}. ${restartText(this.humans(), shown)}`) - } - - private async reportOwnerChanges(owners: OwnerEntry[], before: T.GroupMember[]): Promise { - const after = this.humanOwners() - const isListed = (member: T.GroupMember): boolean => owners.some(owner => listsMember(owner, member)) - for (const member of after) { - if (!isListed(member) && !before.some(sameMember(member))) { - await this.announce(shown => `${memberText(member, shown.name)} is now an owner and can't approve actions yet. ${restartText(after, shown)}`) - } - } - for (const member of before) { - if (isListed(member) && !after.some(sameMember(member))) { - const remaining = (shown: Shown): string => (after.length > 0 ? restartText(after, shown) : "No owners remain: restart the server with --reset-owners --owners") - await this.announce(shown => `${memberText(member, shown.name)} is no longer an owner. ${remaining(shown)}`) - } - } } private lose(): void { this.actions.loseControl() - log(`The agent lost its control chat. Restart the server with --reset-owners.`) + log("The agent lost its Main group. Run /simplex:reset-owners in Claude Code.") } - private async openLink(role: T.GroupMemberRole): Promise { + private async openLink(): Promise { await this.deleteLink() - const link = await this.chat.apiCreateGroupLink(this.groupId, role) - log("Join the agent control chat in the SimpleX app. The link works once:") - log(qrCodeText(link, "ansi")) - log(link) - log(role === T.GroupMemberRole.Observer - ? "The link adds an observer. Its ID and name are printed when it joins." - : `Whoever joins by this link becomes an owner of "${this.options.displayName}" and approves its actions. To approve owners yourself, start with --owners.`) + this.linkText = await this.chat.apiCreateGroupLink(this.groupId, T.GroupMemberRole.Member) + log(`Main group link (works once): ${this.linkText}`) + log(`Whoever joins by this link becomes an owner of "${this.options.displayName}" and approves the agents' actions.`) } private async deleteLink(): Promise { + this.linkText = undefined try { await this.chat.apiDeleteGroupLink(this.groupId) } catch (error) { @@ -325,11 +286,6 @@ export class Control implements Pick string): Promise { - log(text(inLog)) - await this.post(text(inChat)) - } - private async post(text: string): Promise { const [item] = await this.chat.apiSendMessages([T.ChatType.Group, this.groupId], [{msgContent: {type: "text", text}, mentions: {}}]) return item.chatItem.meta.itemId @@ -343,10 +299,6 @@ export class Control implements Pick this.isHuman(member)) } - private humanOwners(): T.GroupMember[] { - return this.humans().filter(({memberRole}) => memberRole === T.GroupMemberRole.Owner) - } - private approvers(): T.GroupMember[] { return this.humans().filter(member => this.isApprover(member)) } @@ -361,9 +313,7 @@ export class Control implements Pick listsMember(entry, member)) + return member.memberRole === T.GroupMemberRole.Owner } } @@ -381,12 +331,12 @@ export async function createControlChat(chat: api.ChatApi, userId: number, displ const group = await chat.apiNewGroup(userId, { displayName, fullName: "", - description: "Supervise your AI agent here: approve or reject its connections and messages with one tap.", + description: "Supervise your AI agents here: approve or reject their connections and messages with one tap.", groupPreferences: {history: {enable: T.GroupFeatureEnabled.On}, commands: controlCommands}, }) const customData = controlData(nextAction) await chat.apiSetGroupCustomData(group.groupId, customData) - log(`Agent control chat "${displayName}" created.`) + log(`Main group "${displayName}" created.`) return {...group, customData} } @@ -431,7 +381,7 @@ export function cardText(action: Action, mode: Actions["mode"]): string { case "performing": return [...request, `${approval}; interrupted: the server stopped.`].join("\n") case "done": - return [...request, action.standing ? `${approval}. /'revoke ${action.chat}'` : `${approval}.`].join("\n") + return [...request, action.standing ? `${approval}. /'revoke ${action.session}:${action.chat}'` : `${approval}.`].join("\n") case "failed": return [...request, `${approval}; failed: ${codeSpan(oneLine(action.error ?? ""))}.`].join("\n") case "rejected": @@ -465,24 +415,25 @@ function requestLines(action: Action): string[] { } function commandsLine(action: Action): string { + const chat = action.tool === "connect" ? "" : `${action.session}:${action.chat}` switch (action.tool) { case "connect": return `/'approve ${action.n}' or /'reject ${action.n}'` case "send": - return `/'approve ${action.chat} ${action.n}', /'approve ${action.chat} all' or /'reject ${action.chat} ${action.n}'` + return `/'approve ${chat} ${action.n}', /'approve ${chat} all' or /'reject ${chat} ${action.n}'` case "leave": - return `/'approve ${action.chat} ${action.n}' or /'reject ${action.chat} ${action.n}'` + return `/'approve ${chat} ${action.n}' or /'reject ${chat} ${action.n}'` } } function pendingLine(action: Action): string { - return `${action.n} ${summary(action, safeName).what} expires ${utcMinutes(action.expires)}` + return `${action.n} session ${action.session}: ${summary(action, safeName).what} expires ${utcMinutes(action.expires)}` } -function largestCard(action: Action, mode: Actions["mode"], approver: string): number { +function largestCard(action: Action, mode: Actions["mode"], approver: string, header: string): number { const decidedBy = action.decidedBy ?? approver const cards: Action[] = [action, {...action, state: "done", decidedBy}, {...action, state: "failed", decidedBy, error: longestError}] - return Math.max(...cards.map(card => encodedLength(cardText(card, mode)))) + return Math.max(...cards.map(card => encodedLength(`${header}\n${cardText(card, mode)}`))) } function encodedLength(text: string): number { @@ -552,13 +503,40 @@ function approvalOf(customData: object | undefined): {approvedBy: string; approv return typeof data?.approvedBy === "string" && typeof data.approvedById === "number" ? {approvedBy: data.approvedBy, approvedById: data.approvedById} : undefined } +async function agentChat(view: SessionView, chat: string): Promise { + try { + return (await view.tools.knownChat(chat)).chatInfo + } catch (error) { + if (error instanceof ToolError) return undefined + throw error + } +} + +function setCustomData(chat: api.ChatApi, ref: string, customData: object): Promise { + return ref.startsWith("@") ? chat.apiSetContactCustomData(chatNumber(ref), customData) : chat.apiSetGroupCustomData(chatNumber(ref), customData) +} + +function chatTarget(word: string): ChatTarget | undefined { + const match = /^([1-9]\d*):(.+)$/.exec(word) + return match !== null && isChatRef(match[2]) ? {session: Number(match[1]), chat: match[2]} : undefined +} + +function targetText({session, chat}: ChatTarget): string { + return `${session}:${chat}` +} + +function noSession(session: number): string { + return `No open session ${session}. /list shows the open sessions.` +} + function ownerRequest({keyword, params}: Command): OwnerRequest | undefined { const words = params === "" ? [] : params.split(/\s+/) - const chat = words.length === 2 && isChatRef(words[0]) ? words[0] : undefined - const value = words.length === 1 || chat !== undefined ? words.at(-1) : undefined - if ((keyword === "approve" || keyword === "reject") && value !== undefined && /^\d+$/.test(value)) return {type: keyword, action: Number(value), chat} - if (keyword === "approve" && chat !== undefined && value === "all") return {type: "approveChat", chat} - if (keyword === "revoke" && chat === undefined && value !== undefined && isChatRef(value)) return {type: "revoke", chat: value} + const target = words.length === 2 ? chatTarget(words[0]) : undefined + const value = words.length === 1 || target !== undefined ? words.at(-1) : undefined + if ((keyword === "approve" || keyword === "reject") && value !== undefined && /^\d+$/.test(value)) return {type: keyword, action: Number(value), target} + if (keyword === "approve" && target !== undefined && value === "all") return {type: "approveChat", target} + const revoked = keyword === "revoke" && words.length === 1 ? chatTarget(words[0]) : undefined + if (revoked !== undefined) return {type: "revoke", target: revoked} if ((keyword === "pending" || keyword === "list") && words.length === 0) return {type: keyword} return undefined } @@ -580,30 +558,6 @@ function isLost({membership}: T.GroupInfo): boolean { return membership.memberRole !== T.GroupMemberRole.Owner || !statuses.includes(membership.memberStatus) } -function listsMember({memberId, name}: OwnerEntry, member: T.GroupMember): boolean { - return member.groupMemberId === memberId && oneLine(member.memberProfile.displayName) === name -} - -function sameMember(member: T.GroupMember): (other: T.GroupMember) => boolean { - return other => other.groupMemberId === member.groupMemberId -} - -function memberText({groupMemberId, memberProfile}: T.GroupMember, showName: (name: string) => string): string { - return `${showName(memberProfile.displayName)} (member ${groupMemberId})` -} - function joinedText(member: T.GroupMember, showName: (name: string) => string): string { return `${showName(member.memberProfile.displayName)} joined (member ${member.groupMemberId})` } - -export function entryText({memberId, name}: OwnerEntry): string { - return shellWord(`${memberId}:${name}`, /^\d+:[\p{L}\p{N}_-]+$/u) -} - -function ownersText(members: T.GroupMember[]): string { - return members.map(({groupMemberId, memberProfile}) => entryText({memberId: groupMemberId, name: oneLine(memberProfile.displayName)})).join(",") -} - -function restartText(members: T.GroupMember[], shown: Shown): string { - return `Restart the server with ${shown.command(`--owners ${ownersText(members)}`)}` -} diff --git a/packages/simplex-chat-mcp/test/unit/cards.test.ts b/packages/simplex-chat-mcp/test/unit/cards.test.ts index e47025b544..482c3b2697 100644 --- a/packages/simplex-chat-mcp/test/unit/cards.test.ts +++ b/packages/simplex-chat-mcp/test/unit/cards.test.ts @@ -97,7 +97,7 @@ describe("connect cards", () => { describe("send cards", () => { it("asks with the text as code", () => { expect(cardText(action(send({text: "use `rm -rf` and /approve 7"}), "pending"), mode)).toBe( - ["I want to send a message to bot SimpleX Directory (@directory.simplex):", "`use 'rm -rf' and /approve 7`", "/'approve @4 1', /'approve @4 all' or /'reject @4 1'"].join("\n"), + ["I want to send a message to bot SimpleX Directory (@directory.simplex):", "`use 'rm -rf' and /approve 7`", "/'approve 1:@4 1', /'approve 1:@4 all' or /'reject 1:@4 1'"].join("\n"), ) }) @@ -108,7 +108,7 @@ describe("send cards", () => { "`Is it waterproof?`", "replying to Switchback: `Ridgecrest, $142`", "mentioning @Switchback, @'Ana Lu'", - "/'approve @4 1', /'approve @4 all' or /'reject @4 1'", + "/'approve 1:@4 1', /'approve 1:@4 all' or /'reject 1:@4 1'", ]) expect(cardText(action(fields, "done", {decidedBy: "Alice"}), mode).split("\n")).toEqual([ "I sent a message to business Ridgeline, where I am incognito:", @@ -139,7 +139,7 @@ describe("send cards", () => { "`trail running shoes`", "replying to `Switch /'approve @3 all'`: `hi`", "mentioning `@Ana /'approve 8'`", - "/'approve @4 1', /'approve @4 all' or /'reject @4 1'", + "/'approve 1:@4 1', /'approve 1:@4 all' or /'reject 1:@4 1'", ]) Object.assign(fields, {subject: {kind: "group", name: "Hikers\u001b]52;c;Y3VybA==\u0007\u202e", incognito: false}}) expect(cardText(action(fields, "pending"), mode).split("\n")[0]).toBe("I want to send a message to group `Hikers ]52;c;Y3VybA==\u00a0`:") @@ -167,21 +167,33 @@ describe("send cards", () => { it("shows a send under the standing approval of its chat", () => { const fields: NewAction = {tool: "send", input: {chat: "#2", text: "hello"}, subject: {kind: "business", name: "ep", incognito: false}, chat: "#2", mentions: [], responses: new Collected()} const standing = {standing: true, decidedBy: "Alice"} - expect(cardText(action(fields, "done", standing), mode).split("\n")).toEqual(["I sent a message to business ep:", "`hello`", "Approved for this chat by Alice. /'revoke #2'"]) + expect(cardText(action(fields, "done", standing), mode).split("\n")).toEqual(["I sent a message to business ep:", "`hello`", "Approved for this chat by Alice. /'revoke 1:#2'"]) expect(cardText(action(fields, "failed", {...standing, error: "boom"}), mode).split("\n")).toEqual(["I want to send a message to business ep:", "`hello`", "Approved for this chat by Alice; failed: `boom`."]) expect(cardText(action(fields, "performing", {...standing, interrupted: true}), mode).split("\n").at(-1)).toBe("Approved for this chat by Alice; interrupted: the server stopped.") }) }) +describe("session in card commands", () => { + it("qualifies the chat with the session number and leaves connect commands unqualified", () => { + const of = {n: 7, session: 2} + const leave: NewAction = {tool: "leave", input: {chat: "#5"}, subject: {kind: "group", name: "Hikers", incognito: false}, chat: "#5"} + const lastLine = (fields: NewAction, state: State, extra: Partial = {}): string | undefined => cardText(action(fields, state, {...of, ...extra}), mode).split("\n").at(-1) + expect(lastLine(connect(), "pending")).toBe("/'approve 7' or /'reject 7'") + expect(lastLine(send(), "pending")).toBe("/'approve 2:@4 7', /'approve 2:@4 all' or /'reject 2:@4 7'") + expect(lastLine(leave, "pending")).toBe("/'approve 2:#5 7' or /'reject 2:#5 7'") + expect(lastLine(send(), "done", {standing: true, decidedBy: "Alice"})).toBe("Approved for this chat by Alice. /'revoke 2:@4'") + }) +}) + describe("leave cards", () => { function leave(chat: string, subject: {kind: string; name?: string; simplexName?: string}): NewAction { return {tool: "leave", input: {chat}, subject: {...subject, incognito: false}, chat} } it("asks to delete a contact with its kind and to leave a group", () => { - expect(cardText(action(leave("@3", directory), "pending"), mode).split("\n")).toEqual(["I want to delete bot SimpleX Directory (@3).", "/'approve @3 1' or /'reject @3 1'"]) + expect(cardText(action(leave("@3", directory), "pending"), mode).split("\n")).toEqual(["I want to delete bot SimpleX Directory (@3).", "/'approve 1:@3 1' or /'reject 1:@3 1'"]) expect(cardText(action(leave("@5", {kind: "contact", name: "Ana Lu"}), "pending"), mode).split("\n")[0]).toBe("I want to delete contact Ana Lu (@5).") - expect(cardText(action(leave("#2", {kind: "business", name: "ep"}), "pending"), mode).split("\n")).toEqual(["I want to leave business ep (#2).", "/'approve #2 1' or /'reject #2 1'"]) + expect(cardText(action(leave("#2", {kind: "business", name: "ep"}), "pending"), mode).split("\n")).toEqual(["I want to leave business ep (#2).", "/'approve 1:#2 1' or /'reject 1:#2 1'"]) expect(cardText(action(leave("#7", {kind: "group", name: "/approve 9"}), "pending"), mode).split("\n")[0]).toBe("I want to leave group `/approve 9` (#7).") }) diff --git a/packages/simplex-chat-mcp/test/unit/commands.test.ts b/packages/simplex-chat-mcp/test/unit/commands.test.ts index d356165b16..d5a4252124 100644 --- a/packages/simplex-chat-mcp/test/unit/commands.test.ts +++ b/packages/simplex-chat-mcp/test/unit/commands.test.ts @@ -1,8 +1,8 @@ import {CallToolResult} from "@modelcontextprotocol/server" import {ChatEvent, T} from "@simplex-chat/types" -import {Actions, Mode, NewAction, ToolError} from "../../src/actions" -import {Control, OwnerEntry, cardText, parseCommand} from "../../src/control" -import {ChatItemsWatch, Collected, chatRef, utcMinutes} from "../../src/messages" +import {Action, Actions, Mode, NewAction, ToolError} from "../../src/actions" +import {Control, Sessions, SessionView, cardText, parseCommand} from "../../src/control" +import {ChatItemsWatch, Collected, utcMinutes} from "../../src/messages" import {AgentTools} from "../../src/tools" import {FakeChat, actionsWith, contact, direct, event, fakeHooks, group, groupChat, item, member, settled, signal, user} from "./fixtures" @@ -36,25 +36,24 @@ function agentChats(): FakeChat { return chat } -async function controlWith(chat: FakeChat, actions: Actions, owners?: OwnerEntry[]): Promise<{control: Control; tools: AgentTools}> { - const tools = new AgentTools({chat: chat.api(), session: 1, user, actions, watch: new ChatItemsWatch(), standingApprover: chatInfo => control.standingApprover(chatInfo)}) - const withoutControlChat: Pick = { - agentChats: async () => (await tools.agentChats()).filter(({chatInfo}) => chatRef(chatInfo) !== "#10"), - knownChat: async ref => { - if (ref === "#10") throw new ToolError(`No chat ${ref}. list shows the agent's chats.`) - return tools.knownChat(ref) - }, - } - const control: Control = new Control(chat.api(), group(10, "agent"), {displayName: "agent", owners, registerHint: "hint", qrCode: "small"}, actions, withoutControlChat) +const sessionLine = "Session 1 · SimpleX MCP - simplex-chat-6" + +function sessionsOf(...views: SessionView[]): Sessions { + return {get: n => views.find(({number}) => number === n), open: () => views} +} + +async function controlWith(chat: FakeChat, actions: Actions, session = 1): Promise<{control: Control; tools: AgentTools}> { + const tools = new AgentTools({chat: chat.api(), user, actions, watch: new ChatItemsWatch(), session, standingApprover: chatInfo => control.standingApprover(chatInfo)}) + const control: Control = new Control(chat.api(), group(10, "SimpleX MCP"), {displayName: "SimpleX MCP"}, actions, sessionsOf({number: session, displayName: "SimpleX MCP - simplex-chat-6", chat: chat.api(), tools})) await control.load() return {control, tools} } -async function setup(owners?: OwnerEntry[]) { +async function setup() { const fake = fakeHooks() const actions = actionsWith("requests", fake, 5) const chat = agentChats() - const {control} = await controlWith(chat, actions, owners) + const {control} = await controlWith(chat, actions) const action = actions.create(1, "k", fields()) await settled() return {fake, actions, chat, control, action} @@ -100,10 +99,12 @@ describe("owner commands", () => { const {control, actions, action} = await setup() const connect = actions.create(1, "c", {tool: "connect", input: {target: "@directory.simplex"}, subject: {kind: "bot", name: "SimpleX Directory", incognito: false}, plan: {cardKind: "bot", type: "contact", takesMessage: true}, messages: new Collected()}) await settled() - expect(await control.ownerCommand(alice, {keyword: "reject", params: "#2 5"})).toBe("Action 5 is not for #2.") - expect(await control.ownerCommand(alice, {keyword: "approve", params: "@4 6"})).toBe("Action 6 is not for @4.") + expect(await control.ownerCommand(alice, {keyword: "reject", params: "1:#2 5"})).toBe("Action 5 is not for 1:#2.") + expect(await control.ownerCommand(alice, parseCommand("/approve 2:@4 5")!)).toBe("Action 5 is not for 2:@4.") + expect(await control.ownerCommand(alice, parseCommand("/approve 2:@4 6")!)).toBe("Action 6 is not for 2:@4.") + expect(await control.ownerCommand(alice, parseCommand("/approve 1:@4 6")!)).toBe("Action 6 is not for 1:@4.") expect(action.state).toBe("pending") - expect(await control.ownerCommand(alice, {keyword: "approve", params: "@4 5"})).toBeUndefined() + expect(await control.ownerCommand(alice, parseCommand("/approve 1:@4 5")!)).toBeUndefined() expect(action).toMatchObject({state: "performing", decidedBy: "Alice"}) expect(await control.ownerCommand(alice, {keyword: "approve", params: "6"})).toBeUndefined() expect(connect.state).toBe("performing") @@ -119,7 +120,7 @@ describe("owner commands", () => { it("refuses unknown numbers and numbers from an earlier run", async () => { const {control} = await setup() expect(await control.ownerCommand(alice, {keyword: "approve", params: "4"})).toBe("Action 4 is not pending.") - expect(await control.ownerCommand(alice, {keyword: "reject", params: "@4 99"})).toBe("Action 99 is not pending.") + expect(await control.ownerCommand(alice, {keyword: "reject", params: "1:@4 99"})).toBe("Action 99 is not pending.") }) it("lists pending actions", async () => { @@ -129,10 +130,10 @@ describe("owner commands", () => { const remove = actions.create(1, "d", {tool: "leave", input: {chat: "@4"}, subject: {kind: "bot", name: "SimpleX Directory", incognito: false}, chat: "@4"}) await settled() expect((await control.ownerCommand(alice, {keyword: "pending", params: ""}))!.split("\n")).toEqual([ - `5 send to bot SimpleX Directory expires ${utcMinutes(action.expires)}`, - `6 connect to bot SimpleX Directory expires ${utcMinutes(connect.expires)}`, - `7 leave business ep expires ${utcMinutes(leave.expires)}`, - `8 delete bot SimpleX Directory expires ${utcMinutes(remove.expires)}`, + `5 session 1: send to bot SimpleX Directory expires ${utcMinutes(action.expires)}`, + `6 session 1: connect to bot SimpleX Directory expires ${utcMinutes(connect.expires)}`, + `7 session 1: leave business ep expires ${utcMinutes(leave.expires)}`, + `8 session 1: delete bot SimpleX Directory expires ${utcMinutes(remove.expires)}`, ]) expect(utcMinutes(action.expires)).toMatch(/^\d{4}-\d\d-\d\d \d\d:\d\d UTC$/) for (const pending of [action, connect, leave, remove]) actions.reject(pending, "Alice") @@ -145,7 +146,7 @@ describe("owner commands", () => { const name = "Hikers\n/'approve 7' or /'reject 7'" const pending = actions.create(1, "n", {...fields("#2"), subject: {kind: "group", name, incognito: false}} as NewAction) await settled() - expect(await control.ownerCommand(alice, {keyword: "pending", params: ""})).toBe(`6 send to group \`Hikers /'approve 7' or /'reject 7'\` expires ${utcMinutes(pending.expires)}`) + expect(await control.ownerCommand(alice, {keyword: "pending", params: ""})).toBe(`6 session 1: send to group \`Hikers /'approve 7' or /'reject 7'\` expires ${utcMinutes(pending.expires)}`) }) it("quotes approver names on cards and replies", async () => { @@ -153,67 +154,57 @@ describe("owner commands", () => { const mallory = {...alice, memberProfile: {...alice.memberProfile, displayName: "Mallory\n/'approve 9'"}} chat.members.set(10, [self, mallory]) await control.load() - expect(await control.ownerCommand(mallory, {keyword: "reject", params: "@4 5"})).toBeUndefined() + expect(await control.ownerCommand(mallory, {keyword: "reject", params: "1:@4 5"})).toBeUndefined() expect(action.decidedBy).toBe("`Mallory /'approve 9'`") expect(cardText(action, "requests").split("\n").at(-1)).toBe("Rejected by `Mallory /'approve 9'`.") - expect(await control.ownerCommand(mallory, {keyword: "approve", params: "@4 all"})).toBe("Approved for bot SimpleX Directory (@4) by `Mallory /'approve 9'`. /'revoke @4'") - expect((await control.ownerCommand(mallory, {keyword: "list", params: ""}))!.split("\n")[0]).toBe("@4 bot SimpleX Directory, approved for this chat by `Mallory /'approve 9'`") + expect(await control.ownerCommand(mallory, {keyword: "approve", params: "1:@4 all"})).toBe("Approved for bot SimpleX Directory (1:@4) by `Mallory /'approve 9'`. /'revoke 1:@4'") + expect((await control.ownerCommand(mallory, {keyword: "list", params: ""}))!.split("\n")[1]).toBe("1:@4 bot SimpleX Directory, approved for this chat by `Mallory /'approve 9'`") expect(customDataOf(chat, "@4")).toMatchObject({simplexMcp: {approvedBy: "Mallory\n/'approve 9'"}}) expect(actions.pending()).toEqual([]) }) - it("refuses chat references longer than 15 digits, such as an alias of the agent control chat", async () => { + it("refuses chat references longer than 15 digits, which wrap around to the numbers of other chats", async () => { const {control, chat} = await setup() const alias = `#${(1n << 64n) + 10n}` - for (const params of [`${alias} all`, "@1234567890123456 all"]) { + for (const params of [`1:${alias} all`, "1:@1234567890123456 all"]) { expect(await control.ownerCommand(alice, {keyword: "approve", params})).toBe("Commands: /approve, /reject, /revoke, /pending, /list. Each card shows its commands.") } - expect(await control.ownerCommand(alice, {keyword: "revoke", params: alias})).toBe("Commands: /approve, /reject, /revoke, /pending, /list. Each card shows its commands.") - expect(await control.ownerCommand(alice, {keyword: "approve", params: "@123456789012345 all"})).toBe("No chat @123456789012345. /list shows the agent's chats.") + expect(await control.ownerCommand(alice, {keyword: "revoke", params: `1:${alias}`})).toBe("Commands: /approve, /reject, /revoke, /pending, /list. Each card shows its commands.") + expect(await control.ownerCommand(alice, {keyword: "approve", params: "1:@123456789012345 all"})).toBe("No chat 1:@123456789012345. /list shows the agents' chats.") expect(chat.customData).toEqual([]) }) it("answers other commands with the list", async () => { const {control} = await setup() - const others = ["help", "approve five", "approve", "approve all", "approve #2", "approve 5 6", "approve #2 5 6", "reject #2 all", "revoke", "revoke 2", "revoke #2 all", "pending 5", "list all"] + const others = [ + "help", "approve five", "approve", "approve all", "approve 1:#2", "approve 5 6", "approve 1:#2 5 6", "reject 1:#2 all", "revoke", "revoke 2", "revoke 1:#2 all", "pending 5", "list all", + "approve @4 5", "approve @4 all", "revoke @4", "approve 0:@4 5", "approve 01:@4 5", "approve 1:4 5", "approve 1: 5", "revoke 1:#2:@4", + ] for (const text of others) { expect(await control.ownerCommand(bob, parseCommand(`/${text}`)!)).toBe("Commands: /approve, /reject, /revoke, /pending, /list. Each card shows its commands.") } }) - it("accepts only approvers", async () => { - const unlisted = await setup() - for (const params of ["5", "@4 all"]) expect(await unlisted.control.ownerCommand(bob, {keyword: "approve", params})).toBe("Only owners approve actions.") - expect(await unlisted.control.ownerCommand(bob, {keyword: "revoke", params: "@4"})).toBe("Only owners approve actions.") - expect(await unlisted.control.ownerCommand(bob, {keyword: "list", params: ""})).toBe("Only owners approve actions.") - expect(unlisted.chat.customData).toEqual([]) - const listed = await setup([{memberId: 3, name: "Alice"}]) - expect(await listed.control.ownerCommand(member(8, "Carol", T.GroupMemberRole.Owner), {keyword: "approve", params: "5"})).toBe("Only the owners listed in --owners approve actions.") - expect(await listed.control.ownerCommand(member(8, "Carol", T.GroupMemberRole.Owner), {keyword: "approve", params: "@4 all"})).toBe("Only the owners listed in --owners approve actions.") - expect(await listed.control.ownerCommand(alice, {keyword: "pending", params: ""})).toMatch(/^5 {2}send/) - const none = await setup([]) - expect(await none.control.ownerCommand(alice, {keyword: "approve", params: "5"})).toBe("Only the owners listed in --owners approve actions.") + it("accepts only owners", async () => { + const {control, chat, action} = await setup() + for (const params of ["5", "1:@4 all"]) expect(await control.ownerCommand(bob, {keyword: "approve", params})).toBe("Only owners approve actions.") + expect(await control.ownerCommand(bob, {keyword: "revoke", params: "1:@4"})).toBe("Only owners approve actions.") + expect(await control.ownerCommand(bob, {keyword: "list", params: ""})).toBe("Only owners approve actions.") + expect(chat.customData).toEqual([]) + expect(action.state).toBe("pending") + expect(await control.ownerCommand(member(8, "Carol", T.GroupMemberRole.Owner), {keyword: "pending", params: ""})).toMatch(/^5 {2}session 1: send/) }) - it("accepts a listed owner by the name on one line, as the restart line prints it", async () => { - const chat = agentChats() - const ana = member(3, "Ana\nLu, Jr", T.GroupMemberRole.Owner) - chat.members.set(10, [self, ana]) - const {control} = await controlWith(chat, actionsWith("requests", fakeHooks()), [{memberId: 3, name: "Ana Lu, Jr"}]) - await control.start() - expect(await control.ownerCommand(ana, {keyword: "pending", params: ""})).toBe("No pending actions.") - }) - - it("replies in the agent control chat to commands only", async () => { + it("replies in the Main group to commands only", async () => { const {control, chat} = await setup() const chatInfo = groupChat(group(10, "agent")) control.onMessage(event(chatInfo, item(1, "/approve 4", {type: "groupRcv", groupMember: alice}))) control.onMessage(event(chatInfo, item(2, "thanks", {type: "groupRcv", groupMember: alice}))) control.onMessage(event(chatInfo, item(3, "/approve 5\nplease", {type: "groupRcv", groupMember: alice}))) - control.onMessage(event(chatInfo, item(4, "/approve @4 all", {type: "groupRcv", groupMember: alice}))) - control.onMessage(event(chatInfo, item(5, "/revoke @4", {type: "groupRcv", groupMember: alice}))) + control.onMessage(event(chatInfo, item(4, "/approve 1:@4 all", {type: "groupRcv", groupMember: alice}))) + control.onMessage(event(chatInfo, item(5, "/revoke 1:@4", {type: "groupRcv", groupMember: alice}))) await new Promise(resolve => setImmediate(resolve)) - expect(chat.replies).toEqual(["Action 4 is not pending.", "Approved for bot SimpleX Directory (@4) by Alice. /'revoke @4'", "Messages to bot SimpleX Directory (@4) need approval again."]) + expect(chat.replies).toEqual(["Action 4 is not pending.", "Approved for bot SimpleX Directory (1:@4) by Alice. /'revoke 1:@4'", "Messages to bot SimpleX Directory (1:@4) need approval again."]) }) }) @@ -226,19 +217,18 @@ describe("approval of all messages to a chat", () => { const other = actions.create(1, "k3", fields("#2")) const leave = actions.create(1, "k4", {tool: "leave", input: {chat: "@4"}, subject: {kind: "bot", name: "SimpleX Directory", incognito: false}, chat: "@4"}) await settled() - expect(await control.ownerCommand(alice, {keyword: "approve", params: "@4 all"})).toBe("Approved for bot SimpleX Directory (@4) by Alice. /'revoke @4'") + expect(await control.ownerCommand(alice, {keyword: "approve", params: "1:@4 all"})).toBe("Approved for bot SimpleX Directory (1:@4) by Alice. /'revoke 1:@4'") expect(customDataOf(chat, "@4")).toEqual({other: 1, simplexMcp: {approvedBy: "Alice", approvedById: 3, approvedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/)}}) expect([action, second, other, leave].map(({state, decidedBy}) => [state, decidedBy])).toEqual([["performing", "Alice"], ["performing", "Alice"], ["pending", undefined], ["pending", undefined]]) expect(control.standingApprover(chatInfoOf(chat, "@4"))).toBe("Alice") expect(control.standingApprover(chatInfoOf(chat, "#2"))).toBeUndefined() }) - it("approves a group chat and refuses the agent control chat and unknown chats", async () => { + it("approves a group chat and refuses unknown chats", async () => { const {control, chat} = await setup() - expect(await control.ownerCommand(alice, {keyword: "approve", params: "#2 all"})).toBe("Approved for business ep (#2) by Alice. /'revoke #2'") + expect(await control.ownerCommand(alice, {keyword: "approve", params: "1:#2 all"})).toBe("Approved for business ep (1:#2) by Alice. /'revoke 1:#2'") expect(customDataOf(chat, "#2")).toMatchObject({simplexMcp: {approvedBy: "Alice", approvedById: 3}}) - expect(await control.ownerCommand(alice, {keyword: "approve", params: "#10 all"})).toBe("No chat #10. /list shows the agent's chats.") - expect(await control.ownerCommand(alice, {keyword: "approve", params: "@99 all"})).toBe("No chat @99. /list shows the agent's chats.") + expect(await control.ownerCommand(alice, {keyword: "approve", params: "1:@99 all"})).toBe("No chat 1:@99. /list shows the agents' chats.") expect(chat.customData.map(({chat: ref}) => ref)).toEqual(["#2"]) }) @@ -246,13 +236,13 @@ describe("approval of all messages to a chat", () => { const {control, chat} = await setup() const {contact: directory} = chatInfoOf(chat, "@4") as T.ChatInfo.Direct directory.customData = {other: 1} - await control.ownerCommand(alice, {keyword: "approve", params: "@4 all"}) - expect(await control.ownerCommand(alice, {keyword: "revoke", params: "@4"})).toBe("Messages to bot SimpleX Directory (@4) need approval again.") + await control.ownerCommand(alice, {keyword: "approve", params: "1:@4 all"}) + expect(await control.ownerCommand(alice, {keyword: "revoke", params: "2:@4"})).toBe("Messages to 2:@4 have no standing approval.") + expect(await control.ownerCommand(alice, {keyword: "revoke", params: "1:@4"})).toBe("Messages to bot SimpleX Directory (1:@4) need approval again.") expect(customDataOf(chat, "@4")).toEqual({other: 1}) expect(control.standingApprover(chatInfoOf(chat, "@4"))).toBeUndefined() - expect(await control.ownerCommand(alice, {keyword: "revoke", params: "@4"})).toBe("Messages to @4 have no standing approval.") - expect(await control.ownerCommand(alice, {keyword: "revoke", params: "#10"})).toBe("Messages to #10 have no standing approval.") - expect(await control.ownerCommand(alice, {keyword: "revoke", params: "#99"})).toBe("Messages to #99 have no standing approval.") + expect(await control.ownerCommand(alice, {keyword: "revoke", params: "1:@4"})).toBe("Messages to 1:@4 have no standing approval.") + expect(await control.ownerCommand(alice, {keyword: "revoke", params: "1:#99"})).toBe("Messages to 1:#99 have no standing approval.") expect(chat.customData.map(({chat: ref}) => ref)).toEqual(["@4", "@4"]) }) @@ -270,30 +260,30 @@ describe("approval of all messages to a chat", () => { await control.load() expect(control.standingApprover(chatInfoOf(chat, "@4"))).toBeUndefined() chat.members.set(10, [self, alice, {...bob, memberRole: T.GroupMemberRole.Owner}]) - expect((await controlWith(chat, actions, [{memberId: 3, name: "Alice"}])).control.standingApprover(chatInfoOf(chat, "@4"))).toBe("Alice") - expect((await controlWith(chat, actions, [{memberId: 7, name: "Bob"}])).control.standingApprover(chatInfoOf(chat, "@4"))).toBeUndefined() + await control.load() + expect(control.standingApprover(chatInfoOf(chat, "@4"))).toBe("Alice") }) }) -describe("chat list in the agent control chat", () => { +describe("chat list in the Main group", () => { it("lists the agent's chats with their approvals and connection state", async () => { const {control, chat} = await setup() chat.chats.set("@6", {chatInfo: direct({...contact(6, "Ask SimpleX Team"), activeConn: undefined}), items: []}) chat.chats.set("#7", {chatInfo: groupChat(group(7, "/approve 9", member(1, "agent"))), items: []}) - await control.ownerCommand(alice, {keyword: "approve", params: "@4 all"}) + await control.ownerCommand(alice, {keyword: "approve", params: "1:@4 all"}) expect((await control.ownerCommand(alice, {keyword: "list", params: ""}))!.split("\n")).toEqual([ - "@4 bot SimpleX Directory, approved for this chat by Alice", - "#2 business ep", - "@6 contact Ask SimpleX Team, not connected", - "#7 group `/approve 9`", + sessionLine, + "1:@4 bot SimpleX Directory, approved for this chat by Alice", + "1:#2 business ep", + "1:#10 group agent, not connected", + "1:@6 contact Ask SimpleX Team, not connected", + "1:#7 group `/approve 9`", ]) }) - it("says when the agent has no chats", async () => { - const chat = new FakeChat() - chat.chats.set("#10", {chatInfo: groupChat(group(10, "agent")), items: []}) - const {control} = await controlWith(chat, actionsWith("requests", fakeHooks())) - expect(await control.ownerCommand(alice, {keyword: "list", params: ""})).toBe("No chats.") + it("shows only the session line of a session without chats", async () => { + const {control} = await controlWith(new FakeChat(), actionsWith("requests", fakeHooks())) + expect(await control.ownerCommand(alice, {keyword: "list", params: ""})).toBe(sessionLine) }) it("lists the 100 most recent chats, each line at most 100 bytes, and counts the older ones", async () => { @@ -301,31 +291,15 @@ describe("chat list in the agent control chat", () => { chat.chats.set("#7", {chatInfo: groupChat(group(7, "😀".repeat(30), member(1, "agent"))), items: []}) for (let i = 100; i < 200; i++) chat.chats.set(`@${i}`, {chatInfo: direct(contact(i, `contact ${i}`)), items: []}) const lines = (await control.ownerCommand(alice, {keyword: "list", params: ""}))!.split("\n") - expect(lines).toHaveLength(101) - expect(lines.slice(0, 4)).toEqual(["@4 bot SimpleX Directory", "#2 business ep", `#7 group ${"😀".repeat(21)}…`, "@100 contact contact 100"]) - expect(lines.at(-1)).toBe("3 older chats are not shown.") - chat.chats.delete("@198") - chat.chats.delete("@199") + expect(lines).toHaveLength(102) + expect(lines.slice(0, 6)).toEqual([sessionLine, "1:@4 bot SimpleX Directory", "1:#2 business ep", "1:#10 group agent, not connected", `1:#7 group ${"😀".repeat(21)}…`, "1:@100 contact contact 100"]) + expect(lines.every(line => Buffer.byteLength(line) <= 100)).toBe(true) + expect(lines.at(-1)).toBe("4 older chats are not shown.") + for (const ref of ["@197", "@198", "@199"]) chat.chats.delete(ref) expect((await control.ownerCommand(alice, {keyword: "list", params: ""}))!.split("\n").at(-1)).toBe("1 older chat is not shown.") }) }) -describe("member announcements", () => { - it("quote a member's name and the restart command in the chat, and keep them on one line in the log", async () => { - const lines: string[] = [] - jest.spyOn(process.stdout, "write").mockImplementation(text => (lines.push(String(text).trimEnd()), true)) - const chat = agentChats() - const {control} = await controlWith(chat, actionsWith("requests", fakeHooks()), [{memberId: 3, name: "Alice"}]) - const mallory = member(7, "Bob\u001b]52;c;Y3VybA==\u0007\n/'approve 9'", T.GroupMemberRole.Owner) - chat.members.set(10, [self, alice, mallory]) - await control.onEvent({type: "memberRole", user, groupInfo: group(10, "agent"), byMember: alice, member: mallory, fromRole: T.GroupMemberRole.Member, toRole: T.GroupMemberRole.Owner} as unknown as ChatEvent) - const restart = "is now an owner and can't approve actions yet. Restart the server with" - const owners = "--owners 3:Alice,'7:Bob ]52;c;Y3VybA== /'\\''approve 9'\\'''" - expect(lines).toEqual([`Bob ]52;c;Y3VybA== /'approve 9' (member 7) ${restart} ${owners}`]) - expect(chat.sent.map(({messages: [{msgContent}]}) => msgContent.text)).toEqual([`\`Bob ]52;c;Y3VybA== /'approve 9'\` (member 7) ${restart} \`${owners}\``]) - }) -}) - describe("command menu", () => { const menu = [ {type: "command", keyword: "pending", label: "Pending actions"}, @@ -337,10 +311,11 @@ describe("command menu", () => { const actions = actionsWith("requests", fakeHooks()) const old = group(10, "agent", self, {groupProfile: {displayName: "agent", fullName: "", groupPreferences: {history: {enable: "on"}, commands: [{type: "command", keyword: "approve", label: "Approve", params: "N"}]}}}) const tools = new AgentTools({chat: chat.api(), session: 1, user, actions, watch: new ChatItemsWatch(), standingApprover: () => undefined}) - await new Control(chat.api(), old, {displayName: "agent", registerHint: "hint", qrCode: "small"}, actions, tools).load() + const sessions = sessionsOf({number: 1, displayName: "agent", chat: chat.api(), tools}) + await new Control(chat.api(), old, {displayName: "agent"}, actions, sessions).load() expect(chat.profiles).toEqual([{groupId: 10, groupProfile: {displayName: "agent", fullName: "", groupPreferences: {history: {enable: "on"}, commands: menu}}}]) const current = group(10, "agent", self, {groupProfile: {displayName: "agent", fullName: "", groupPreferences: {history: {enable: "on"}, commands: menu}}}) - await new Control(chat.api(), current, {displayName: "agent", registerHint: "hint", qrCode: "small"}, actions, tools).load() + await new Control(chat.api(), current, {displayName: "agent"}, actions, sessions).load() expect(chat.profiles).toHaveLength(1) }) }) @@ -370,7 +345,7 @@ describe("sends to a chat approved for all messages", () => { expect(result!.structuredContent).toMatchObject({action: 1, status: "done", chat: "@4", responses: [], more: 0}) expect(chat.sent.filter(({chat: [, id]}) => id === 4).map(({messages: [{msgContent}]}) => msgContent.text)).toEqual(["trail shoes"]) await settled() - expect(cards(chat)).toEqual(["I sent a message to bot SimpleX Directory:\n`trail shoes`\nApproved for this chat by Alice. /'revoke @4'"]) + expect(cards(chat)).toEqual([`${sessionLine}\nI sent a message to bot SimpleX Directory:\n\`trail shoes\`\nApproved for this chat by Alice. /'revoke 1:@4'`]) }) it("posts a card when the approving member is no longer an approver", async () => { @@ -379,7 +354,7 @@ describe("sends to a chat approved for all messages", () => { const sending = tools.call("send", {chat: "@4", text: "trail shoes", wait: 0}, call) await settled() expect(actions.get(1)).toMatchObject({state: "pending", standing: false}) - expect(cards(chat)).toEqual(["I want to send a message to bot SimpleX Directory:\n`trail shoes`\n/'approve @4 1', /'approve @4 all' or /'reject @4 1'"]) + expect(cards(chat)).toEqual([`${sessionLine}\nI want to send a message to bot SimpleX Directory:\n\`trail shoes\`\n/'approve 1:@4 1', /'approve 1:@4 all' or /'reject 1:@4 1'`]) expect(chat.sent.filter(({chat: [, id]}) => id === 4)).toEqual([]) controller.abort() expect(await sending).toBeUndefined() @@ -391,7 +366,7 @@ describe("sends to a chat approved for all messages", () => { const leaving = tools.call("leave", {chat: "@4"}, call) await settled() expect(actions.get(1)).toMatchObject({tool: "leave", state: "pending", standing: false}) - expect(cards(chat)).toEqual(["I want to delete bot SimpleX Directory (@4).\n/'approve @4 1' or /'reject @4 1'"]) + expect(cards(chat)).toEqual([`${sessionLine}\nI want to delete bot SimpleX Directory (@4).\n/'approve 1:@4 1' or /'reject 1:@4 1'`]) controller.abort() expect(await leaving).toBeUndefined() }) @@ -401,12 +376,12 @@ describe("sends to a chat approved for all messages", () => { expect(control.standingApprover(chatInfoOf(chat, "@4"))).toBeUndefined() const {chats} = (await tools.call("list", {}, signal().call))!.structuredContent as {chats: {chat: string}[]} expect(chats.find(({chat: ref}) => ref === "@4")).not.toHaveProperty("approved") - expect((await control.ownerCommand(alice, {keyword: "list", params: ""}))!.split("\n")[0]).toBe("@4 bot SimpleX Directory") - expect(await control.ownerCommand(alice, {keyword: "approve", params: "#2 all"})).toBe("Approvals apply in mode requests; this server runs in mode host.") + expect((await control.ownerCommand(alice, {keyword: "list", params: ""}))!.split("\n")[1]).toBe("1:@4 bot SimpleX Directory") + expect(await control.ownerCommand(alice, {keyword: "approve", params: "1:#2 all"})).toBe("Approvals apply in mode requests; this server runs in mode host.") expect(chat.customData).toEqual([]) expect((await tools.call("send", {chat: "@4", text: "trail shoes", wait: 0}, signal().call))!.structuredContent).toMatchObject({action: 1, status: "done"}) await settled() - expect(cards(chat)).toEqual(["I sent a message to bot SimpleX Directory:\n`trail shoes`\nPerformed without approval (mode host)."]) + expect(cards(chat)).toEqual([`${sessionLine}\nI sent a message to bot SimpleX Directory:\n\`trail shoes\`\nPerformed without approval (mode host).`]) }) }) @@ -446,7 +421,7 @@ describe("approval card size", () => { it("accepts a text whose largest card has 15000 bytes and refuses one byte more, counting multi-byte characters", async () => { const {chat, actions, tools, textOf} = await sendTools() - const atLimit = "€".repeat(4374) + "aa" + const atLimit = "€".repeat(4360) + "a" expect(textOf(await tools.call("send", {chat: "#2", text: `${atLimit}a`}, signal().call))).toBe(tooLong) expect(actions.next).toBe(1) const {controller, call} = signal() @@ -460,7 +435,7 @@ describe("approval card size", () => { it("counts escaped characters and the standing approval line of a chat approved for all messages", async () => { const {chat, actions, tools, textOf} = await sendTools() - const atLimit = "\b".repeat(2183) + "aa" + const atLimit = "\b".repeat(2176) + "a" expect(textOf(await tools.call("send", {chat: "@4", text: `${atLimit}a`, wait: 0}, signal().call))).toBe(tooLong) expect(actions.next).toBe(1) expect((await tools.call("send", {chat: "@4", text: atLimit, wait: 0}, signal().call))!.structuredContent).toMatchObject({action: 1, status: "done"}) @@ -470,9 +445,9 @@ describe("approval card size", () => { it("measures the card with the longest name of the current approvers", async () => { const {actions, tools, textOf} = await sendTools([alice, member(8, "😀".repeat(50), T.GroupMemberRole.Owner)]) - expect(textOf(await tools.call("send", {chat: "#2", text: "€".repeat(4374) + "aa"}, signal().call))).toBe(tooLong) + expect(textOf(await tools.call("send", {chat: "#2", text: "€".repeat(4360) + "a"}, signal().call))).toBe(tooLong) const {controller, call} = signal() - const sending = tools.call("send", {chat: "#2", text: "€".repeat(4309) + "aa"}, call) + const sending = tools.call("send", {chat: "#2", text: "€".repeat(4295) + "a"}, call) await flushed() expect(actions.get(1)).toMatchObject({state: "pending"}) controller.abort() @@ -506,11 +481,179 @@ describe("approval card size", () => { const action = actions.create(1, "c", connectFields("Ridgeline", {description, welcome: "Welcome!"})) await flushed() const [card] = chat.sent.filter(({chat: [, id]}) => id === 10).map(({messages: [{msgContent}]}) => msgContent.text.split("\n")) - expect(card).toHaveLength(3) - expect(card[1]).toMatch(/^`Trail shoes\. .*…`$/) - const failed = encodedLength(cardText({...action, state: "failed", decidedBy: "Alice", error: "€".repeat(600)}, "requests")) + expect(card).toHaveLength(4) + expect(card[0]).toBe(sessionLine) + expect(card[2]).toMatch(/^`Trail shoes\. .*…`$/) + const failed = encodedLength(`${sessionLine}\n${cardText({...action, state: "failed", decidedBy: "Alice", error: "€".repeat(600)}, "requests")}`) expect(failed).toBeLessThanOrEqual(15000) expect(failed).toBeGreaterThan(15000 - 8) expect(action.tool === "connect" && action.plan.description).toBe(description) }) }) + +describe("sessions in the Main group", () => { + it("starts cards with the session line and names the session in commands", async () => { + const {chat, control, action} = await setup() + await control.postCard(action) + const card = chat.sent.at(-1)!.messages[0].msgContent.text.split("\n") + expect(card[0]).toBe("Session 1 · SimpleX MCP - simplex-chat-6") + expect(card.at(-1)).toBe("/'approve 1:@4 5', /'approve 1:@4 all' or /'reject 1:@4 5'") + }) + + it("starts edited cards with the session line, and names a closed session by its number", async () => { + const fake = fakeHooks() + const actions = actionsWith("requests", fake, 5) + const chat = agentChats() + const {control} = await controlWith(chat, actions, 2) + const open = actions.create(2, "k2", fields()) + const closed = actions.create(3, "k3", fields()) + await settled() + actions.reject(open, "Alice") + await control.outcome(open) + await control.outcome({...closed, card: 900}) + expect(chat.updates.map(({text}) => text.split("\n")[0])).toEqual(["Session 2 · SimpleX MCP - simplex-chat-6", "Session 3"]) + expect(chat.updates[0].text.split("\n").at(-1)).toBe("Rejected by Alice.") + }) + + it("quotes a session name that is not a valid display name", async () => { + const chat = agentChats() + const actions = actionsWith("requests", fakeHooks(), 5) + const tools = new AgentTools({chat: chat.api(), user, actions, watch: new ChatItemsWatch(), session: 1, standingApprover: () => undefined}) + const control = new Control(chat.api(), group(10, "SimpleX MCP"), {displayName: "SimpleX MCP"}, actions, sessionsOf({number: 1, displayName: "SimpleX MCP - /approve 9\nnow", chat: chat.api(), tools})) + await control.load() + await control.postCard(actions.create(1, "k", fields())) + expect(chat.sent.at(-1)!.messages[0].msgContent.text.split("\n")[0]).toBe("Session 1 · `SimpleX MCP - /approve 9 now`") + expect((await control.ownerCommand(alice, parseCommand("/list")!))!.split("\n")[0]).toBe("Session 1 · `SimpleX MCP - /approve 9 now`") + }) + + it("approves all messages to a chat of one session through that session's database", async () => { + const fake = fakeHooks() + const actions = actionsWith("requests", fake, 5) + const hubChat = agentChats() + const one = agentChats() + const two = agentChats() + const tool = (chat: FakeChat, session: number) => new AgentTools({chat: chat.api(), user, actions, watch: new ChatItemsWatch(), session, standingApprover: () => undefined}) + const control = new Control(hubChat.api(), group(10, "SimpleX MCP"), {displayName: "SimpleX MCP"}, actions, sessionsOf( + {number: 1, displayName: "SimpleX MCP - one", chat: one.api(), tools: tool(one, 1)}, + {number: 2, displayName: "SimpleX MCP - two", chat: two.api(), tools: tool(two, 2)}, + )) + await control.load() + const first = actions.create(1, "k1", fields()) + const second = actions.create(2, "k2", fields()) + await settled() + expect(await control.ownerCommand(alice, parseCommand("/approve 2:@4 all")!)).toBe("Approved for bot SimpleX Directory (2:@4) by Alice. /'revoke 2:@4'") + expect(two.customData.at(-1)).toMatchObject({chat: "@4", customData: {simplexMcp: {approvedBy: "Alice", approvedById: 3}}}) + expect(one.customData).toEqual([]) + expect(hubChat.customData.filter(({chat}) => chat === "@4")).toEqual([]) + expect(second.state).toBe("performing") + expect(first.state).toBe("pending") + expect(await control.ownerCommand(alice, parseCommand("/approve 3:@4 all")!)).toBe("No open session 3. /list shows the open sessions.") + expect(await control.ownerCommand(alice, parseCommand("/approve 2:@9 all")!)).toBe("No chat 2:@9. /list shows the agents' chats.") + expect(await control.ownerCommand(alice, parseCommand("/revoke 2:@4")!)).toBe("Messages to bot SimpleX Directory (2:@4) need approval again.") + expect(two.customData.at(-1)).toEqual({chat: "@4", customData: {}}) + expect(one.customData).toEqual([]) + }) + + it("lists chats and pending actions grouped by session", async () => { + const {control} = await setup() + expect(await control.ownerCommand(alice, parseCommand("/list")!)).toBe( + ["Session 1 · SimpleX MCP - simplex-chat-6", "1:@4 bot SimpleX Directory", "1:#2 business ep", "1:#10 group agent, not connected"].join("\n"), + ) + expect(await control.ownerCommand(alice, parseCommand("/pending")!)).toMatch(/^5 session 1: send to bot SimpleX Directory expires \d{4}-\d\d-\d\d \d\d:\d\d UTC$/) + }) + + it("lists the chats of every open session under its session line", async () => { + const actions = actionsWith("requests", fakeHooks()) + const one = agentChats() + const two = new FakeChat() + two.chats.set("@8", {chatInfo: direct(contact(8, "Ana Lu")), items: []}) + const tool = (chat: FakeChat, session: number) => new AgentTools({chat: chat.api(), user, actions, watch: new ChatItemsWatch(), session, standingApprover: () => undefined}) + const control = new Control(one.api(), group(10, "SimpleX MCP"), {displayName: "SimpleX MCP"}, actions, sessionsOf( + {number: 1, displayName: "SimpleX MCP - one", chat: one.api(), tools: tool(one, 1)}, + {number: 4, displayName: "SimpleX MCP - two", chat: two.api(), tools: tool(two, 4)}, + )) + await control.load() + expect((await control.ownerCommand(alice, parseCommand("/list")!))!.split("\n")).toEqual([ + "Session 1 · SimpleX MCP - one", + "1:@4 bot SimpleX Directory", + "1:#2 business ep", + "1:#10 group agent, not connected", + "Session 4 · SimpleX MCP - two", + "4:@8 contact Ana Lu", + ]) + }) + + it("says no session is open", async () => { + const actions = actionsWith("requests", fakeHooks()) + const control = new Control(agentChats().api(), group(10, "SimpleX MCP"), {displayName: "SimpleX MCP"}, actions, sessionsOf()) + await control.load() + expect(await control.ownerCommand(alice, parseCommand("/list")!)).toBe("No open sessions.") + }) + + it("puts the link into the refusal while the Main group has no owner", async () => { + const chat = agentChats() + chat.members.set(10, [self]) + const {control} = await controlWith(chat, actionsWith("requests", fakeHooks())) + const action = {...fields(), n: 1, session: 1, state: "pending", standing: false} as unknown as Action + expect(control.link).toBeUndefined() + expect(control.refusal(action)).toBe("The agent has no owner. Run /simplex:link in Claude Code.") + await control.start() + expect(control.link).toBe("https://simplex.chat/g#link") + expect(control.refusal(action)).toBe("The agent has no owner. Open this link in the SimpleX app and join: https://simplex.chat/g#link") + }) + + it("opens no link at start while the Main group has an owner", async () => { + const {control, chat} = await setup() + await control.start() + expect(control.link).toBeUndefined() + expect(chat.links).toEqual([]) + }) + + it("makes the first person who joins the owner and deletes the link", async () => { + const chat = agentChats() + chat.members.set(10, [self]) + const {control} = await controlWith(chat, actionsWith("requests", fakeHooks())) + await control.start() + const carol = member(9, "Carol", T.GroupMemberRole.Member) + chat.members.set(10, [self, carol]) + chat.roles = [] + await control.onEvent({type: "joinedGroupMember", user, groupInfo: group(10, "SimpleX MCP"), member: carol} as unknown as ChatEvent) + expect(chat.roles).toEqual([{groupId: 10, members: [9], role: T.GroupMemberRole.Owner}]) + expect(control.link).toBeUndefined() + }) + + it("opens a new link when the last owner leaves", async () => { + const {control, chat} = await setup() + chat.members.set(10, [self, bob]) + await control.onEvent({type: "leftMember", user, groupInfo: group(10, "SimpleX MCP"), member: alice} as unknown as ChatEvent) + expect(control.owners()).toEqual([]) + expect(control.link).toBe("https://simplex.chat/g#link") + expect(chat.links).toEqual([{groupId: 10, role: T.GroupMemberRole.Member}]) + }) + + it("names the owners on one line", async () => { + const {control, chat} = await setup() + chat.members.set(10, [self, member(3, "Ana\nLu", T.GroupMemberRole.Owner), bob, member(8, "Carol", T.GroupMemberRole.Owner, T.GroupMemberStatus.Left)]) + await control.load() + expect(control.owners()).toEqual(["Ana Lu"]) + }) + + it("renames the Main group", async () => { + const {control, chat} = await setup() + chat.profiles = [] + await control.rename("Agents") + expect(chat.profiles.map(({groupProfile}) => groupProfile.displayName)).toEqual(["Agents"]) + }) + + it("resets the Main group: fails pending actions and opens a new link", async () => { + const {actions, chat, control, action} = await setup() + const link = await control.reset(user.userId) + expect(action.state).toBe("failed") + expect(actions.lost).toBe(false) + expect(link).toBe("https://simplex.chat/g#link") + expect(chat.newGroups).toHaveLength(1) + expect(chat.deleted).toEqual([{chatType: T.ChatType.Group, chatId: 10, mode: {type: "full", notify: true}}]) + expect(control.groupId).toBe(20) + expect(chat.links).toEqual([{groupId: 20, role: T.GroupMemberRole.Member}]) + }) +}) diff --git a/packages/simplex-chat-mcp/test/unit/fixtures.ts b/packages/simplex-chat-mcp/test/unit/fixtures.ts index 4e75c6ab1d..41227b0da2 100644 --- a/packages/simplex-chat-mcp/test/unit/fixtures.ts +++ b/packages/simplex-chat-mcp/test/unit/fixtures.ts @@ -118,6 +118,9 @@ export class FakeChat { left: number[] = [] customData: {chat: string; customData?: object}[] = [] profiles: {groupId: number; groupProfile: T.GroupProfile}[] = [] + roles: {groupId: number; members: number[]; role: T.GroupMemberRole}[] = [] + links: {groupId: number; role: T.GroupMemberRole}[] = [] + newGroups: {userId: number; groupProfile: T.GroupProfile}[] = [] members = new Map() chats = new Map() responses = new Map() @@ -192,6 +195,22 @@ export class FakeChat { async apiDeleteGroupLink(): Promise {} + async apiCreateGroupLink(groupId: number, role: T.GroupMemberRole): Promise { + this.links.push({groupId, role}) + return "https://simplex.chat/g#link" + } + + async apiSetMembersRole(groupId: number, members: number[], role: T.GroupMemberRole): Promise { + this.roles.push({groupId, members, role}) + } + + async apiNewGroup(userId: number, groupProfile: T.GroupProfile): Promise { + this.newGroups.push({userId, groupProfile}) + const created = group(20, groupProfile.displayName) + this.chats.set("#20", {chatInfo: groupChat(created), items: []}) + return created + } + async apiUpdateGroupProfile(groupId: number, groupProfile: T.GroupProfile): Promise { this.profiles.push({groupId, groupProfile}) return {...group(groupId, groupProfile.displayName), groupProfile} diff --git a/packages/simplex-chat-mcp/test/unit/messages.test.ts b/packages/simplex-chat-mcp/test/unit/messages.test.ts index 85e93d8c5f..ae7eb76e44 100644 --- a/packages/simplex-chat-mcp/test/unit/messages.test.ts +++ b/packages/simplex-chat-mcp/test/unit/messages.test.ts @@ -235,7 +235,7 @@ describe("receive", () => { expect((await tools.call("receive", {chat: "4"}, signal().call))!.isError).toBe(true) }) - it("refuses chat references longer than 15 digits, such as an alias of the agent control chat", async () => { + it("refuses chat references longer than 15 digits, which wrap around to the numbers of other chats", async () => { const chat = new FakeChat() chat.chats.set("#10", {chatInfo: groupChat(group(10, "agent")), items: [item(1, "/approve 5", {type: "groupRcv", groupMember: member(3, "Alice")})]}) const {tools} = agentTools(chat)