Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions docs/profiles.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Profiles: viewing public identities

The bundled `buzz.profiles` plugin supplies a minimal, read-only panel for any
The bundled `buzz.profiles` plugin supplies a profile panel for any
public identity, human or agent. It uses the current session's shared profile
directory. Agents retains agent-specific configuration/operations; this slice
adds no ownership/running badge, editor, agent-library read or execution API.
Expand All @@ -9,6 +9,17 @@ activity** opens its raw panel for this exact identity and originating channel.
This action is offered for any public identity: it does not infer that the identity
is an owned/running agent. Missing telemetry is explained by the activity panel.

Guarded invitations that fail or have an unknown outcome remain saved in the
outbox. Its generic Retry action is withheld for these records. Retrying the
add from the managed-agent profile rechecks current eligibility and reuses the
exact saved event while it is still within the relay's 15-minute timestamp
window. The ordinary channel composer uses a separate invitation path; it does
not renew or reuse these guarded profile invitations. After expiry, check
membership; if absent, remove the expired “Add agent” item from Outbox and
add the agent again from the managed-agent profile. Older unguarded invitation
records can be promoted to guarded intent when reused through that profile flow.
Remove from outbox does not revoke an invitation already dispatched to the relay.

## Boundaries

- Shared message UI recognizes author-avatar targets and identity-bound mentions.
Expand Down Expand Up @@ -88,7 +99,8 @@ heads: `live-session.test.ts` forces overlapping unread reads for 1, 2 and 130
channels; `sidebar-unread.spec.mjs` holds unread evidence through real EOSE and
checks its badges without retries. Access-loss/disconnect cancellation is unchanged.
Broad scan and native build/package acceptance remain deferred to an agreed
integration batch. No sending/signing behavior changed.
integration batch. The earlier read-only profile slice changed no sending/signing
behavior; the managed-agent admission described below does.

## Info, channels and linked instances

Expand All @@ -112,5 +124,14 @@ section stays hidden for a non-agent without a match. It never derives ownership
from the old Buzz library, self-declared profile markers, or names. The Agents
page route opens management, not a per-instance page.

The Info tab keeps the public key and linked instances; the Channels tab is read-only.
Neither list is a cross-community/global directory.
The Info tab keeps the public key and linked instances. The Channels tab offers
**Add to channel** only for an exact native-managed identity in this community
that is also a managed session choice. It offers loaded, classified stream/forum
channels with a roster, excluding archived, hidden, read-only and already-member
rows. On submission it refreshes native evidence, then checks current agent,
session and channel eligibility across the fresh roster read and at publisher
entry. The relay still decides permission; local evidence does not grant it.
Before publisher entry, navigation or loss of eligibility stops the write. Once
publication begins, leaving the tab cannot undo the request; the session outbox
retains its outcome and an unconfirmed result requires checking membership
before attempting again. Neither list is a cross-community/global directory.
51 changes: 51 additions & 0 deletions src/bundled/channels/OutboxStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Outbox, OutgoingEvent } from "../../features/relay/outbox";
import { createRelayProfiler } from "../../features/relay/profiling";
import { OutboxStatus } from "./OutboxStatus";

afterEach(cleanup);
const invitation = {
event: {
id: "invite",
kind: 9000,
content: "",
tags: [["p", "a".repeat(64)]],
},
delivery: "failed",
} as OutgoingEvent;
function show(item: OutgoingEvent) {
const retry = vi.fn();
const items = [item];
const outbox = {
snapshot: () => items,
subscribe: () => () => {},
retry,
dismiss: vi.fn(),
} as unknown as Outbox;
render(<OutboxStatus outbox={outbox} profiling={createRelayProfiler()} />);
fireEvent.click(screen.getByText("Outbox · 1 items"));
return retry;
}

it("does not offer generic retry for guarded invitations from either profile or composer", () => {
const retry = show({ ...invitation, guarded: true });
expect(
screen.queryByRole("button", { name: "Retry" }),
).not.toBeInTheDocument();
expect(
screen.getByText(/Retry from the channel or agent profile/),
).toBeVisible();
expect(
screen.getByRole("button", { name: "Remove from outbox" }),
).toBeVisible();
expect(retry).not.toHaveBeenCalled();
});

it("preserves generic retry for legacy unguarded invitations", () => {
const retry = show(invitation);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(retry).toHaveBeenCalledWith("invite");
});
8 changes: 8 additions & 0 deletions src/bundled/channels/OutboxStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function OutboxStatus({
}
</span>{" "}
{!isWorkflowOperation(item.event) &&
!item.guarded &&
(item.delivery === "failed" || item.delivery === "unknown") && (
<Button
type="button"
Expand All @@ -57,6 +58,13 @@ export function OutboxStatus({
Retry
</Button>
)}{" "}
{item.guarded &&
(item.delivery === "failed" || item.delivery === "unknown") && (
<span>
Retry from the channel or agent profile after checking
membership.
</span>
)}{" "}
{item.delivery !== "sending" && (
<Button
type="button"
Expand Down
261 changes: 261 additions & 0 deletions src/bundled/profiles/ProfileAddChannel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { AgentControl } from "../../features/agents/control";
import type { ChannelList } from "../../features/relay/contracts";
import type { RelaySession } from "../../features/relay/session";
import { ProfileAddChannel } from "./ProfileAddChannel";

const pubkey = "a".repeat(64);
const viewer = "b".repeat(64);
const scope = `https://relay.example.test:${viewer}`;
const rows: ChannelList = {
status: "ready",
channels: [
{ id: "one", name: "One", channelType: "stream", members: [viewer] },
{
id: "joined",
name: "Joined",
channelType: "forum",
members: [pubkey, viewer],
},
{ id: "dm", name: "DM", channelType: "dm", members: [viewer] },
{ id: "unknown", name: "Unknown", members: [viewer] },
],
};
function fixture(managed = true) {
let finishRefresh: (() => void) | undefined;
let holdRefresh = false;
let currentRows = rows;
let currentNative = true;
const unavailable = { status: "error" };
let resolve!: () => void;
let reject!: (error: Error) => void;
const addAgents = vi.fn(
(_id: string, _keys: readonly string[], _active?: () => boolean) =>
new Promise<void>((yes, no) => {
resolve = yes;
reject = no;
}),
);
const identities = { identities: [{ pubkey, managed }] };
let native = {
status: "ready",
data: { agents: [{ pubkey, relayUrl: "https://relay.example.test" }] },
};
const nativeListeners = new Set<() => void>();
const updateNative = (status: "ready" | "error") => {
native = { ...native, status };
for (const listener of nativeListeners) listener();
};
const session = {
scope,
channels: { list: () => currentRows },
workSessions: { available: true, addAgents },
agentChoices: {
snapshot: () => identities,
subscribe: () => () => {},
},
} as unknown as RelaySession;
const control = {
snapshot: () => (currentNative ? native : unavailable),
subscribe: (listener: () => void) => {
nativeListeners.add(listener);
return () => nativeListeners.delete(listener);
},
refresh: vi.fn(() =>
holdRefresh
? new Promise<void>((resolve) => {
finishRefresh = resolve;
})
: Promise.resolve(),
),
} as unknown as AgentControl;
return {
session,
control,
addAgents,
holdRefresh: () => {
holdRefresh = true;
},
releaseRefresh: () => {
holdRefresh = false;
finishRefresh?.();
},
loseNative: () => {
currentNative = false;
},
updateNative,
switchScope: () => {
(session as { scope: string }).scope =
`https://other.example.test:${viewer}`;
},
hideChannel: () => {
currentRows = {
...rows,
channels: rows.channels.map((row) =>
row.id === "one" ? { ...row, hidden: true } : row,
),
};
},
addMembership: () => {
currentRows = {
...rows,
channels: rows.channels.map((row) =>
row.id === "one" ? { ...row, members: [viewer, pubkey] } : row,
),
};
},
resolve: () => resolve(),
reject: (message: string) => reject(new Error(message)),
};
}
function show(f = fixture()) {
render(
<ProfileAddChannel
session={f.session}
control={f.control}
pubkey={pubkey}
scope={scope}
list={rows}
/>,
);
return f;
}
afterEach(cleanup);
it("offers only classified nonmember channels and waits for confirmed admission", async () => {
const f = show();
fireEvent.click(screen.getByRole("button", { name: /Add to channel/ }));
const dialog = screen.getByRole("dialog");
const select = screen.getByRole("combobox", { name: "Channel" });
expect(select.textContent).toContain("#One");
expect(select.textContent).not.toMatch(/Joined|DM|Unknown/);
fireEvent.change(select, { target: { value: "one" } });
fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
expect(f.addAgents.mock.calls[0]?.slice(0, 2)).toEqual(["one", [pubkey]]);
expect(
screen.getByRole("button", { name: "Adding…" }).hasAttribute("disabled"),
).toBe(true);
expect(dialog).toBeTruthy();
await act(async () => f.resolve());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("keeps the dialog open with a relay error and permits retry", async () => {
const f = show();
fireEvent.click(screen.getByRole("button", { name: /Add to channel/ }));
fireEvent.change(screen.getByRole("combobox"), { target: { value: "one" } });
fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
await act(async () => f.reject("Membership denied"));
expect(screen.getByRole("alert").textContent).toBe("Membership denied");
fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
await waitFor(() => expect(f.addAgents).toHaveBeenCalledTimes(2));
await act(async () => f.resolve());
});
it("does not offer a write action without native and session agent evidence", () => {
show(fixture(false));
expect(screen.queryByRole("button", { name: /Add to channel/ })).toBeNull();
});

function submit() {
fireEvent.click(screen.getByRole("button", { name: /Add to channel/ }));
fireEvent.change(screen.getByRole("combobox"), { target: { value: "one" } });
fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
}
it.each(["native", "channel", "unmount"])(
"does not start admission when %s changes during native refresh",
async (change) => {
const f = fixture();
f.holdRefresh();
const view = render(
<ProfileAddChannel
session={f.session}
control={f.control}
pubkey={pubkey}
scope={scope}
list={rows}
/>,
);
submit();
await waitFor(() => expect(f.control.refresh).toHaveBeenCalledOnce());
if (change === "native") f.loseNative();
else if (change === "channel") f.hideChannel();
else view.unmount();
await act(async () => f.releaseRefresh());
expect(f.addAgents).not.toHaveBeenCalled();
},
);
it("passes current native, session, and channel eligibility into admission", async () => {
const f = show();
submit();
await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
const active = f.addAgents.mock.calls[0]?.[2] as unknown as () => boolean;
expect(active()).toBe(true);
f.hideChannel();
expect(active()).toBe(false);
await act(async () => f.resolve());
});

it("keeps admission active when its own roster update adds the agent", async () => {
const f = show();
submit();
await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
const active = f.addAgents.mock.calls[0]?.[2] as unknown as () => boolean;
f.addMembership();
expect(active()).toBe(true);
await act(async () => f.resolve());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});

it("invalidates an in-flight gate after the session scope changes", async () => {
const f = show();
submit();
await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
const active = f.addAgents.mock.calls[0]?.[2] as unknown as () => boolean;
f.switchScope();
expect(active()).toBe(false);
await act(async () => f.resolve());
});

it("keeps the operation visible after a notifying native refresh failure and retries safely", async () => {
const f = fixture();
f.holdRefresh();
show(f);
submit();
await waitFor(() => expect(f.control.refresh).toHaveBeenCalledOnce());
await act(async () => f.updateNative("error"));
const dialog = screen.getByRole("dialog", { name: "Add agent to channel" });
expect(dialog).toBeTruthy();
expect(
screen.getByRole("button", { name: "Adding…" }).hasAttribute("disabled"),
).toBe(true);
await act(async () => f.releaseRefresh());
expect(f.addAgents).not.toHaveBeenCalled();
expect(screen.getByText(/Channel addition cancelled/)).toBeTruthy();
expect(
screen
.getByRole("button", { name: "Add to channel" })
.hasAttribute("disabled"),
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Retry agents" }));
expect(f.control.refresh).toHaveBeenCalledTimes(2);
await act(async () => f.updateNative("ready"));
expect(dialog).toBeTruthy();
expect(
screen
.getByRole("button", { name: "Add to channel" })
.hasAttribute("disabled"),
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
await act(async () => f.resolve());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
Loading
Loading