fix(agent-world): fix group join feedback + leave-for-members guard (#3840)

This commit is contained in:
Cyrus Gray
2026-06-19 09:09:47 -07:00
committed by GitHub
parent 8c8180378c
commit 4f6b856b6d
5 changed files with 269 additions and 30 deletions
@@ -1,12 +1,14 @@
/**
* Tests for MessagingSection — gated DMs "coming soon" state + basic render.
* Tests for MessagingSection — gated DMs "coming soon" state + basic render,
* and group membership-aware Join/Leave button behaviour.
*
* We mock the apiClient so no actual RPC calls are made.
* We mock the apiClient and fetchWalletStatus so no actual RPC calls are made.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import MessagingSection from './MessagingSection';
@@ -141,8 +143,25 @@ vi.mock('../hooks/useTinyplaceStream', () => ({
useTinyplaceStream: (streamId?: string) => mockUseTinyplaceStream(streamId),
}));
// ── Mock fetchWalletStatus ────────────────────────────────────────────────────
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
/** Helper: configure wallet mock to return the given agent ID (Solana address). */
function setWallet(agentId: string | null) {
vi.mocked(fetchWalletStatus).mockResolvedValue(
agentId
? ({ accounts: [{ chain: 'solana', address: agentId }] } as unknown as Awaited<
ReturnType<typeof fetchWalletStatus>
>)
: ({ accounts: [] } as unknown as Awaited<ReturnType<typeof fetchWalletStatus>>)
);
}
beforeEach(() => {
vi.clearAllMocks();
// Default: wallet not connected — groups.list membership call returns [].
setWallet(null);
});
// ── DMs panel (E2E enabled) ───────────────────────────────────────────────────
@@ -426,18 +445,20 @@ describe('membership actions', () => {
expect(apiClient.broadcasts.subscribe).toHaveBeenCalledWith('bc-1');
});
test('group Leave calls groups.leave with the group id', async () => {
vi.mocked(apiClient.groups.list).mockResolvedValue([
{
groupId: 'g-1',
name: 'Builders',
membershipPolicy: 'open',
memberCount: 5,
membershipEpoch: 1,
createdBy: 'someone',
createdAt: '2026-01-01T00:00:00Z',
},
]);
test('group Leave calls groups.leave when user is a member', async () => {
const group = {
groupId: 'g-1',
name: 'Builders',
membershipPolicy: 'open',
memberCount: 5,
membershipEpoch: 1,
createdBy: 'someone',
createdAt: '2026-01-01T00:00:00Z',
};
// Wallet connected — user is a member of g-1.
setWallet('agent-abc');
// Both public and membership queries return the same group so button shows "Leave".
vi.mocked(apiClient.groups.list).mockResolvedValue([group]);
render(<MessagingSection />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Builders');
@@ -446,6 +467,139 @@ describe('membership actions', () => {
});
});
// ── Group membership-aware Join / Leave ────────────────────────────────────────
describe('group membership-aware button rendering', () => {
const groupA = {
groupId: 'g-a',
name: 'Alpha',
membershipPolicy: 'open',
memberCount: 2,
membershipEpoch: 1,
createdBy: 'owner',
createdAt: '2026-01-01T00:00:00Z',
};
const groupB = {
groupId: 'g-b',
name: 'Beta',
membershipPolicy: 'open',
memberCount: 4,
membershipEpoch: 1,
createdBy: 'owner',
createdAt: '2026-01-01T00:00:00Z',
};
async function openGroups() {
render(<MessagingSection />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Alpha');
}
test('shows Join for non-member groups and Leave for member groups', async () => {
setWallet('agent-xyz');
// Public returns both; membership query returns only g-a (user is a member of Alpha only).
vi.mocked(apiClient.groups.list)
.mockResolvedValueOnce([groupA, groupB]) // public query
.mockResolvedValueOnce([groupA]); // membership query (member=agent-xyz)
await openGroups();
await screen.findByText('Beta');
// Alpha: user is a member → "Leave" button
const alphaCard = screen.getByText('Alpha').closest('div')!;
expect(alphaCard.querySelector('button[disabled]') ?? alphaCard).toBeTruthy();
// Find buttons via accessible name
const allLeave = screen.getAllByRole('button', { name: 'Leave' });
const allJoin = screen.getAllByRole('button', { name: 'Join' });
expect(allLeave).toHaveLength(1); // only Alpha
expect(allJoin).toHaveLength(1); // only Beta
});
test('Leave button is absent for non-member groups (no wallet)', async () => {
// No wallet → membership list is empty → all groups show Join.
setWallet(null);
vi.mocked(apiClient.groups.list).mockResolvedValue([groupA, groupB]);
await openGroups();
await screen.findByText('Beta');
expect(screen.queryByRole('button', { name: 'Leave' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Join' })).toHaveLength(2);
});
test('Join calls groups.join and after refetch button flips to Leave', async () => {
setWallet('agent-xyz');
// First render: user is not a member of g-a.
vi.mocked(apiClient.groups.list)
.mockResolvedValueOnce([groupA]) // public
.mockResolvedValueOnce([]) // membership — not a member yet
// After join + refetch:
.mockResolvedValueOnce([groupA]) // public
.mockResolvedValueOnce([groupA]); // membership — now a member
vi.mocked(apiClient.groups.join).mockResolvedValue(undefined);
render(<MessagingSection />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Alpha');
// Button shows Join before joining.
expect(screen.getByRole('button', { name: 'Join' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Leave' })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
expect(apiClient.groups.join).toHaveBeenCalledWith('g-a');
// After re-fetch, button should flip to Leave.
expect(await screen.findByRole('button', { name: 'Leave' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Join' })).not.toBeInTheDocument();
});
test('Leave calls groups.leave and after refetch button flips to Join', async () => {
setWallet('agent-xyz');
// First render: user IS a member of g-a.
vi.mocked(apiClient.groups.list)
.mockResolvedValueOnce([groupA]) // public
.mockResolvedValueOnce([groupA]) // membership — is a member
// After leave + refetch:
.mockResolvedValueOnce([groupA]) // public
.mockResolvedValueOnce([]); // membership — no longer a member
vi.mocked(apiClient.groups.leave).mockResolvedValue(undefined);
render(<MessagingSection />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Alpha');
// Button shows Leave (user is a member).
expect(await screen.findByRole('button', { name: 'Leave' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Leave' }));
expect(apiClient.groups.leave).toHaveBeenCalledWith('g-a');
// After re-fetch, button should flip to Join.
expect(await screen.findByRole('button', { name: 'Join' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Leave' })).not.toBeInTheDocument();
});
test('membership dual-fetch passes member param to groups.list', async () => {
setWallet('agent-solana-123');
vi.mocked(apiClient.groups.list).mockResolvedValue([]);
render(<MessagingSection />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText(/No groups found/i);
// The second groups.list call should carry member=<agentId>.
const calls = vi.mocked(apiClient.groups.list).mock.calls;
const memberCall = calls.find(
([p]) => p !== undefined && p !== null && typeof p === 'object' && 'member' in p
);
expect(memberCall).toBeDefined();
expect((memberCall![0] as Record<string, unknown>).member).toBe('agent-solana-123');
});
});
// ── Group invite management ─────────────────────────────────────────────────
describe('group invite management', () => {
+72 -15
View File
@@ -27,6 +27,7 @@ import {
PaymentRequiredError,
type SignalKeyStatus,
} from '../../lib/agentworld/invokeApiClient';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import { useTinyplaceStream } from '../hooks/useTinyplaceStream';
@@ -92,6 +93,27 @@ function useAsyncCall<T>(fetcher: () => Promise<T>, deps: unknown[]): AsyncState
return state;
}
// ── My agent ID (wallet address) ─────────────────────────────────────────────
/**
* Returns the Solana agent ID of the connected wallet, or null when the wallet
* is not connected / still loading. Mirrors the pattern in DirectorySection.
*/
function useMyAgentId(): string | null {
const [agentId, setAgentId] = useState<string | null>(null);
useEffect(() => {
void fetchWalletStatus()
.then(status => {
const solana = (status.accounts ?? []).find(
(a: { chain: string; address?: string }) => a.chain === 'solana'
);
if (solana?.address) setAgentId(solana.address);
})
.catch(() => {});
}, []);
return agentId;
}
// ── Sub-panels ────────────────────────────────────────────────────────────────
function LoadingPane() {
@@ -323,18 +345,43 @@ function ChannelsPanel() {
// ── Groups panel ──────────────────────────────────────────────────────────────
function GroupsPanel() {
const myAgentId = useMyAgentId();
const params: GroupQueryParams = { limit: 20 };
const { version, busyKey, error: actionError, run } = useRowActions();
const state = useAsyncCall(() => apiClient.groups.list(params), [version]);
const [invitesGroupId, setInvitesGroupId] = useState<string | null>(null);
const [invitesGroupName, setInvitesGroupName] = useState<string>('');
const [showRedeem, setShowRedeem] = useState(false);
if (state.status === 'loading') return <LoadingPane />;
if (state.status === 'payment_required') return <PaymentRequiredPane />;
if (state.status === 'error') return <ErrorPane message={state.message} />;
// Fetch all public groups.
const publicState = useAsyncCall(() => apiClient.groups.list(params), [version]);
const groups: GroupMetadata[] = state.status === 'ok' ? state.data : [];
// Fetch groups the user belongs to (uses the `member` query param).
// Falls back to empty list when wallet is not yet connected.
const myGroupsState = useAsyncCall(
() =>
myAgentId
? apiClient.groups.list({ member: myAgentId })
: Promise.resolve([] as GroupMetadata[]),
[version, myAgentId]
);
if (publicState.status === 'loading') return <LoadingPane />;
if (publicState.status === 'payment_required') return <PaymentRequiredPane />;
if (publicState.status === 'error') return <ErrorPane message={publicState.message} />;
const groups: GroupMetadata[] = publicState.status === 'ok' ? publicState.data : [];
// Build a Set of group IDs the user is a member of so we can flip Join↔Leave.
const myGroupIds = new Set<string>(
(myGroupsState.status === 'ok'
? Array.isArray(myGroupsState.data)
? myGroupsState.data
: []
: []
).map((g: GroupMetadata) => g.groupId)
);
log('[groups] public=%d my=%d agent=%s', groups.length, myGroupIds.size, myAgentId ?? 'none');
// Show the invites sub-panel for a specific group.
if (invitesGroupId) {
@@ -384,6 +431,7 @@ function GroupsPanel() {
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{groups.map(group => {
const busy = busyKey === group.groupId;
const isMember = myGroupIds.has(group.groupId);
return (
<div
key={group.groupId}
@@ -406,16 +454,25 @@ function GroupsPanel() {
<span>{group.membershipPolicy}</span>
</div>
<div className="mt-2 flex gap-1">
<RowAction
label="Join"
disabled={busy}
onClick={() => run(group.groupId, () => apiClient.groups.join(group.groupId))}
/>
<RowAction
label="Leave"
disabled={busy}
onClick={() => run(group.groupId, () => apiClient.groups.leave(group.groupId))}
/>
{isMember ? (
<RowAction
label="Leave"
disabled={busy}
onClick={() => {
log('[groups] leaving group %s', group.groupId);
run(group.groupId, () => apiClient.groups.leave(group.groupId));
}}
/>
) : (
<RowAction
label="Join"
disabled={busy}
onClick={() => {
log('[groups] joining group %s', group.groupId);
run(group.groupId, () => apiClient.groups.join(group.groupId));
}}
/>
)}
<RowAction
label="Invites"
disabled={busy}
@@ -666,6 +666,8 @@ export interface GroupQueryParams {
minMembers?: number;
maxMembers?: number;
limit?: number;
/** When set, returns only groups this agent is an active member of. */
member?: string;
[key: string]: unknown;
}
// ── Groups invite/role types ────────────────────────────────────────────────
+26
View File
@@ -1564,6 +1564,32 @@ pub(crate) fn handle_tinyplace_groups_leave(params: Map<String, Value>) -> Contr
.signer()
.map(|s| s.agent_id())
.ok_or_else(|| "tinyplace signer unavailable; cannot leave group".to_string())?;
// Pre-check membership before calling remove_member so we return a
// clear error instead of leaking a raw HTTP 400 from the backend when
// the user is not a member.
match client.groups.members(&group_id).await {
Ok(resp) => {
if !resp.members.iter().any(|m| m.agent_id == me) {
log::warn!(
"{LOG_PREFIX} groups_leave: agent {me} is not a member of {group_id}"
);
return Err(format!(
"you are not a member of group {group_id}; cannot leave"
));
}
}
Err(e) => {
// If the membership list itself is unavailable (network, 404, etc.)
// we log a warning and fall through — the backend will reject if
// the agent truly is not a member.
log::warn!(
"{LOG_PREFIX} groups_leave: membership check failed for {group_id}: {e}; \
proceeding with remove_member"
);
}
}
client
.groups
.remove_member(&group_id, &me, None)
+1 -1
View File
@@ -1108,7 +1108,7 @@ fn schema_groups_list() -> ControllerSchema {
"List tiny.place groups, optionally filtered by query params (read-only).",
inputs: vec![optional_object(
"params",
"Optional GroupQueryParams (q, tag, tags, membershipPolicy, minMembers, maxMembers, limit).",
"Optional GroupQueryParams (q, tag, tags, membershipPolicy, minMembers, maxMembers, member, limit).",
)],
outputs: vec![json_output(
"result",