mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 06:32:59 +00:00
mcp: share the Main group across sessions
This commit is contained in:
1 parent
87f280c3a3
commit
65d206c992
5 files changed
+452
-324
No files matched your search
@@ -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<AgentTools, "knownChat" | "agentChats">
|
||||
}
|
||||
|
||||
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<ActionHooks, "refusal" | "saveCounter" | "postCard" | "outcome"> {
|
||||
private members: T.GroupMember[] = []
|
||||
private writes: Promise<unknown> = Promise.resolve()
|
||||
private ownerCommands: Promise<unknown> = 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<AgentTools, "knownChat" | "agentChats">,
|
||||
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<void> {
|
||||
@@ -95,22 +98,19 @@ export class Control implements Pick<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
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<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
}
|
||||
|
||||
async postCard(action: Action): Promise<number> {
|
||||
return this.post(cardText(action, this.actions.mode))
|
||||
return this.post(this.card(action))
|
||||
}
|
||||
|
||||
async outcome(action: Action): Promise<void> {
|
||||
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<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
this.ownerCommands = this.ownerCommands
|
||||
.then(() => 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<void> {
|
||||
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<string | undefined> {
|
||||
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<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
return this.chatsText()
|
||||
case "approveChat":
|
||||
if (this.actions.mode === "host") return "Approvals apply in mode requests; this server runs in mode host."
|
||||
return this.approveChat(sender, request.chat)
|
||||
return this.approveChat(sender, request.target)
|
||||
case "revoke":
|
||||
return this.revoke(request.chat)
|
||||
return this.revoke(request.target)
|
||||
case "approve":
|
||||
case "reject": {
|
||||
const action = this.actions.get(request.action)
|
||||
if (action === undefined || action.state !== "pending" || action.card === undefined) return `Action ${request.action} is not pending.`
|
||||
if (request.chat !== undefined && (action.tool === "connect" || action.chat !== request.chat)) return `Action ${request.action} is not for ${request.chat}.`
|
||||
if (request.target !== undefined && (action.tool === "connect" || action.session !== request.target.session || action.chat !== request.target.chat)) {
|
||||
return `Action ${request.action} is not for ${targetText(request.target)}.`
|
||||
}
|
||||
const approver = safeName(sender.memberProfile.displayName)
|
||||
if (request.type === "approve") this.actions.approve(action, approver)
|
||||
else this.actions.reject(action, approver)
|
||||
@@ -193,81 +190,70 @@ export class Control implements Pick<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
}
|
||||
}
|
||||
|
||||
private async approveChat(sender: T.GroupMember, chat: string): Promise<string> {
|
||||
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<void> {
|
||||
this.options.displayName = displayName
|
||||
await this.load()
|
||||
}
|
||||
|
||||
private async revoke(chat: string): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<T.ChatInfo | undefined> {
|
||||
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<void> {
|
||||
return chat.startsWith("@") ? this.chat.apiSetContactCustomData(chatNumber(chat), customData) : this.chat.apiSetGroupCustomData(chatNumber(chat), customData)
|
||||
}
|
||||
|
||||
private async startOwners(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -276,47 +262,22 @@ export class Control implements Pick<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
await this.refreshMembers()
|
||||
await this.post(greeting)
|
||||
log(`${joinedText(member, oneLine)} and is now an owner. The link is deleted.`)
|
||||
log(this.options.registerHint)
|
||||
}
|
||||
|
||||
private async observerJoined(member: T.GroupMember): Promise<void> {
|
||||
await this.deleteLink()
|
||||
await this.announce(shown => `${joinedText(member, shown.name)}. ${restartText(this.humans(), shown)}`)
|
||||
}
|
||||
|
||||
private async reportOwnerChanges(owners: OwnerEntry[], before: T.GroupMember[]): Promise<void> {
|
||||
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<void> {
|
||||
private async openLink(): Promise<void> {
|
||||
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<void> {
|
||||
this.linkText = undefined
|
||||
try {
|
||||
await this.chat.apiDeleteGroupLink(this.groupId)
|
||||
} catch (error) {
|
||||
@@ -325,11 +286,6 @@ export class Control implements Pick<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
}
|
||||
}
|
||||
|
||||
private async announce(text: (shown: Shown) => string): Promise<void> {
|
||||
log(text(inLog))
|
||||
await this.post(text(inChat))
|
||||
}
|
||||
|
||||
private async post(text: string): Promise<number> {
|
||||
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<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
return this.members.filter(member => 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<ActionHooks, "refusal" | "saveCounter" | "p
|
||||
}
|
||||
|
||||
private isApprover(member: T.GroupMember): boolean {
|
||||
const {owners} = this.options
|
||||
const owner = member.memberRole === T.GroupMemberRole.Owner
|
||||
return owners === undefined ? owner : owner && owners.some(entry => 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<T.ChatInfo | undefined> {
|
||||
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<void> {
|
||||
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)}`)}`
|
||||
}
|
||||
@@ -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<Action> = {}): 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).")
|
||||
})
|
||||
|
||||
|
||||
@@ -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<AgentTools, "knownChat" | "agentChats"> = {
|
||||
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}])
|
||||
})
|
||||
})
|
||||
@@ -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<number, T.GroupMember[]>()
|
||||
chats = new Map<string, FakeChatData>()
|
||||
responses = new Map<string, object>()
|
||||
@@ -192,6 +195,22 @@ export class FakeChat {
|
||||
|
||||
async apiDeleteGroupLink(): Promise<void> {}
|
||||
|
||||
async apiCreateGroupLink(groupId: number, role: T.GroupMemberRole): Promise<string> {
|
||||
this.links.push({groupId, role})
|
||||
return "https://simplex.chat/g#link"
|
||||
}
|
||||
|
||||
async apiSetMembersRole(groupId: number, members: number[], role: T.GroupMemberRole): Promise<void> {
|
||||
this.roles.push({groupId, members, role})
|
||||
}
|
||||
|
||||
async apiNewGroup(userId: number, groupProfile: T.GroupProfile): Promise<T.GroupInfo> {
|
||||
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<T.GroupInfo> {
|
||||
this.profiles.push({groupId, groupProfile})
|
||||
return {...group(groupId, groupProfile.displayName), groupProfile}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user