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
11 changes: 6 additions & 5 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,12 @@ channel-wide. No harness change, new subscription, directory or timer is added.
The development broker loads subscription filters at startup: restart the
existing dev server once to receive typing; frontend HMR alone is insufficient.

A profile **View activity** action remains available before the first frame or
after a working chip disappears. It preselects the exact identity and originating
channel, not a thread. This action is not an agent/ownership badge and may show a
waiting state for identities with no published owner-visible telemetry. Shared
agents and new activity-view permissions are out of scope.
A known agent's profile **View activity** action remains available before the
first frame or after a working chip disappears; people's profiles offer none. It
preselects the exact identity and originating channel, not a thread. The
known-agent check is display-only evidence, not an ownership badge, and the action
may show a waiting state for identities with no published owner-visible telemetry.
Shared agents and new activity-view permissions are out of scope.

The **Channel** selector filters raw entries and working-turn counts, or shows all
channels including unscoped records. For a selected channel, batches are projected
Expand Down
13 changes: 8 additions & 5 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ lines/64 KiB. Changing community, losing ownership or the local record closes th
view. Empty, loading, copy and generic error states do not expose native errors.
A packaged app cannot obtain this development-broker proof and fails closed.

When Agent Activity is enabled and the host supplies conversation context, **View
activity** opens its raw panel for this exact identity and originating channel.
When Agent Activity is enabled, the host supplies conversation context and the
profile is a known agent (a self-declared agent profile hint or a local library
record), **View activity** opens its raw panel for this exact identity and
originating channel. People's profiles show neither the card nor the action.
The Info tab's “Latest activity” card shows up to three recently updated assistant
messages or tool titles/statuses from the existing session-owned records, restricted
to this exact public key and originating channel (including threads). Retained text
Expand All @@ -80,8 +82,9 @@ no preview, never an all-channel fallback. Connecting, disconnected, unavailable
and empty states are explicit. Counts and detailed explanations stay in the
activity panel; the preview does not infer idle state or successful completion.

This action is offered for any public identity: it does not infer that the identity
is an owned/running agent. Public agent hints never grant telemetry access. The
The known-agent check is the same display-only evidence as the squircle avatar: it
does not infer that the identity is an owned/running agent. Public agent hints
never grant telemetry access. The
existing observer admission and session access-reset/generation fences remain the
authority; the child adds no library read, capture lease, socket, timer or store.
Disabling Agent Activity removes the preview/action and clears capture. Profile
Expand Down Expand Up @@ -189,7 +192,7 @@ supplies agent display metadata, not management authority.
## UI and iteration

Avatar, name, about, self-declared NIP-05 (not DNS-verified), exact copyable
npub, and an optional compact activity preview/action. **Message** is offered for a
npub, and, for known agents, an optional compact activity preview/action. **Message** is offered for a
foreign profile when the session can start direct messages; as in New message,
a known agent qualifies only while this community's ready native control manages
it, rechecked on click. It opens (or reopens) the one-to-one DM through the session's direct-message operation and
Expand Down
89 changes: 89 additions & 0 deletions src/bundled/profiles/ProfilePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import userEvent from "@testing-library/user-event";
import { createAgentActivity } from "../../features/agents/activity";
import { createAgentControl } from "../../features/agents/control";
import { controlFixture } from "../../features/agents/control-testing";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
Expand Down Expand Up @@ -305,6 +306,94 @@ it.each(["action", "initial read"])(
},
);

it("offers the activity preview to known agents only, never to people", async () => {
const person = keypair();
const agent = keypair();
const localAgent = keypair();
const activity = createAgentActivity(true, vi.fn(), () => true);
const owner = createRelaySession({
viewer: key,
relayAuthor: keypair().pubkey,
media: () => undefined,
query: async () => [
profile(person, { name: "Plain person" }),
profile(agent, { name: "Declared agent", is_agent: true }),
profile(localAgent, { name: "Local agent" }),
],
readAgentLibrary: async () => ({
definitions: [],
identities: [{ pubkey: localAgent.pubkey, name: "Local agent" }],
}),
});
const release = activity.queries.activate();
activity.state({
status: "connected",
routes: [{ id: "observer", status: "live", replay: "unknown" }],
});
const snapshot = {
status: "ready" as const,
generation: 1,
scope: `https://relay.example.test:${key}`,
viewer: key,
session: { ...owner.session, agentActivity: activity.queries },
};
const relay: RelayData = {
snapshot: () => snapshot,
subscribe: () => () => {},
retry() {},
disconnect() {},
clearCache: async () => {},
};
const context = {
channelId: "channel-a",
canOpen: () => true,
open: () => true,
};
const view = (pubkey: string) => (
<ProfilePanel
relay={relay}
target={profileTarget(pubkey) ?? ""}
context={context}
close={() => {}}
/>
);
try {
const mounted = render(view(person.pubkey));
// The settled metadata read is the barrier: no agent evidence can follow it.
await screen.findByRole("heading", { name: "Plain person" });
expect(
screen.queryByRole("region", { name: "Activity preview" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "View activity" }),
).not.toBeInTheDocument();
mounted.rerender(view(agent.pubkey));
await screen.findByRole("heading", { name: "Declared agent" });
expect(
await screen.findByRole("region", { name: "Activity preview" }),
).toHaveTextContent("No activity yet");
expect(screen.getByRole("button", { name: "View activity" })).toBeEnabled();
mounted.rerender(view(localAgent.pubkey));
await screen.findByRole("heading", { name: "Local agent" });
await act(() => owner.session.agentChoices.refresh());
expect(owner.session.agentChoices.snapshot().identities).toEqual([
expect.objectContaining({ pubkey: localAgent.pubkey }),
]);
expect(
owner.session.profiles.snapshot().get(localAgent.pubkey)?.isAgent,
).not.toBe(true);
expect(
await screen.findByRole("region", { name: "Activity preview" }),
).toHaveTextContent("No activity yet");
expect(screen.getByRole("button", { name: "View activity" })).toBeEnabled();
} finally {
cleanup();
release();
activity.dispose();
owner.dispose();
}
});

it.each(["ambiguous", "unmatched"])(
"Info retains recovery for a known %s identity",
async (kind) => {
Expand Down
12 changes: 7 additions & 5 deletions src/bundled/profiles/ProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,11 +414,13 @@ function ProfileDetails({
onDeleted={close}
/>
)}
<ProfileActivity
session={session}
pubkey={pubkey}
context={context}
/>
{knownAgent && (
<ProfileActivity
session={session}
pubkey={pubkey}
context={context}
/>
)}
{control &&
(!knownAgent || ownership.settled) &&
!runtimePending &&
Expand Down
35 changes: 31 additions & 4 deletions tests/browser/agent-activity.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,39 @@ it("profile activity opens the exact agent and originating channel before its fi
.getByRole("button", { name: "Channel settings", exact: true })
.click();
await expect.poll(() => app.relay.hasRoute("primary", "observer")).toBe(true);
const profile = page.getByRole("complementary", {
name: "Profile",
exact: true,
});
// A person's profile carries no activity card: neither preview nor launcher.
const personKey = generateSecretKey();
const personMessage = finalizeEvent(
{
kind: 9,
tags: [["h", profileChannelId]],
content: "Contextual person entry",
created_at: Math.floor(Date.now() / 1000),
},
personKey,
);
app.relay.publish("primary", personMessage);
await page
.locator(`[data-message-id="${personMessage.id}"]`)
.getByRole("button", { name: /profile/ })
.click();
// The settled metadata read is the barrier: no agent evidence can follow it.
await expect(
profile.getByText("No profile metadata is available in this community."),
).toBeVisible();
await expect(
profile.getByRole("region", { name: "Activity preview" }),
).toHaveCount(0);
await expect(
profile.getByRole("button", { name: "View activity", exact: true }),
).toHaveCount(0);
const agentKey = generateSecretKey();
const agent = getPublicKey(agentKey);
app.serveProfile(agentKey, { name: "Fixture agent", is_agent: true });
const message = finalizeEvent(
{
kind: 9,
Expand Down Expand Up @@ -459,10 +490,6 @@ it("profile activity opens the exact agent and originating channel before its fi
)
.toBe(true);
await avatar.click();
const profile = page.getByRole("complementary", {
name: "Profile",
exact: true,
});
await expect(
profile.getByRole("region", { name: "Activity preview" }),
).toContainText("No activity yet");
Expand Down
20 changes: 20 additions & 0 deletions tests/browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ export const test = base.extend({
),
]),
);
// Kind 0 by author for keys a test creates; served on later profile reads.
const servedProfiles = new Map();
const participants = largeSidebar
? Array.from({ length: 1001 }, (_, i) =>
(i + 1).toString(16).padStart(64, "0"),
Expand Down Expand Up @@ -639,6 +641,15 @@ export const test = base.extend({
});
return [sign(13535, [["-"]])];
}
if (filter.kinds?.includes(13534)) {
// Relay-signed roster for archive consent; the viewer is a plain member.
expect(filter).toEqual({
kinds: [13534],
authors: [getPublicKey(relayKey)],
limit: 1,
});
return [sign(13534, [["member", viewer, "member"]])];
}
if (filter.kinds?.includes(30617) || filter.kinds?.includes(30621)) {
expect(filter).toEqual({ kinds: [30617, 30621], limit: 100 });
return [];
Expand Down Expand Up @@ -838,6 +849,9 @@ export const test = base.extend({
if (filter.kinds?.includes(0))
return [
profiles.get(community),
...[...servedProfiles.values()].filter((event) =>
filter.authors?.includes(event.pubkey),
),
...membershipKeys
.filter((key) => filter.authors?.includes(getPublicKey(key)))
.map((key) =>
Expand Down Expand Up @@ -1601,6 +1615,12 @@ export const test = base.extend({
relay.observer(community, event);
return { event, plaintext, agent };
},
// Answer later kind-0 reads for this key; no live delivery is modeled.
serveProfile(key, body) {
const event = sign(0, [], JSON.stringify(body), key);
servedProfiles.set(event.pubkey, event);
return event;
},
// Change only modeled relay state. The app must consume the next real
// roster response; this does not call client purge/recovery internals.
renameChannel(id, name) {
Expand Down
16 changes: 16 additions & 0 deletions tests/browser/policy-relay.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,22 @@ export function policyRelay({
filters.flatMap((filter) => answer(communityOf(url), filter)),
);
}
if (filters.length === 2 && filters[1].kinds?.includes(13534)) {
// Identity archive consent: the target's profile plus the relay roster.
expect(filters).toEqual([
{ kinds: [0], authors: [expect.any(String)], limit: 1 },
{ kinds: [13534], authors: [relayAuthor], limit: 1 },
]);
for (const filter of filters)
report.queries.push({
community: communityOf(url),
filter,
at: performance.now(),
});
return Response.json(
filters.flatMap((filter) => answer(communityOf(url), filter)),
);
}
if (filters.length === 2 && filters[0].kinds?.includes(39000)) {
// Exact channel authority lookup, distinct from sidebar preferences.
const ids = filters[0]["#d"];
Expand Down
Loading