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
21 changes: 14 additions & 7 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,20 @@ custody is app-wide: the viewer is not an owner check, so any account in this
app on the same relay sees the same local record. It
shows process status (still "relay readiness unverified", not a listening badge),
saved harness/provider/model/workspace (labelled as saved settings, since
environment overrides may change what is launched), collapsible instructions,
saved-vs-running revision drift and diagnostics. Environment keys and
environment overrides may change what is launched), saved-vs-running revision
drift and diagnostics. Instructions are no longer displayed in the read-only
summary; the verified owner opens the existing native editor in place with
**Agent instructions**, without leaving the profile. Environment keys and
arguments are not shown. Opening the Info tab requests a status read; concurrent
requests coalesce. This summary adds no polling and observes the profile actions'
existing refresh. A failed read keeps the last evidence.
The host error, runtime-unavailable reason, unconfirmed-status notice and Retry
are shown once, by the profile actions. Browser/unavailable hosts, loading/error
without evidence, unknown keys and records saved for another community render
nothing, leaving the public identity. Community switches re-filter immediately.
Start/Stop/Restart are the separate profile actions above; harness logs stay on
Agents.
Start/Stop/Restart are the separate profile actions above. The owner-only
**Agent instructions** ingress in Info requires a unique native record; harness
logs stay on Agents.

## Owner runtime tab

Expand Down Expand Up @@ -276,7 +279,10 @@ unambiguous native match; an instance view selects that record by ID.
Archive labels come from `session.archives`, keyed by identity, rather than native
process status. Archived rows use the same explicit target and remain navigable.
The existing public profile tabs and actions are reused, not replaced by an Agents
page or a second editor. Browser fixtures exercise live/archived selection,
page or a second editor. The owner-only ingress requires a verified NIP-OA
owner and a ready, unique native match in this community; the versioned
agent-identity route opens the existing editor, never a guessed sibling.
Browser fixtures exercise live/archived selection,
tabs, back, keyboard close/focus and deletion through the real plugin/Channels
host in Chromium and WebKit. React tests cover exact actions, failed-read recovery,
non-owner denial and scope changes with synthetic native data.
Expand Down Expand Up @@ -336,8 +342,9 @@ Retired relay
presentations cannot dispatch commands. The separate runtime child owns badges
and runtime detail; actions do not infer relay readiness.

Info adds no edit ingress; the owner Runtime tab reuses the Agents editor
dialog rather than a route or second editor. Mounted React regression tests exercise exact dispatch, pending/failure/
Info adds an owner-gated route to the Agents editor for a unique native match;
the owner Runtime tab also reuses the editor dialog. Mounted React regression
tests exercise exact dispatch, pending/failure/
recovery and profile/community lifecycle through the real controller projection
with a synthetic native host. Live process/credential handover and rendered native
acceptance remain attended checks, not established by these tests.
109 changes: 108 additions & 1 deletion src/app/entity-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import {
act,
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { finalizeEvent, getPublicKey } from "nostr-tools";
Expand All @@ -9,13 +16,25 @@ import { createServices, type AppServices } from "./services";
import { bindDeepLinks } from "../features/navigation/deep-links";
import { matchesEvent } from "../features/relay/projection";
import type { ReadFilter } from "../features/relay/events";
import { createAgentControl } from "../features/agents/control";
import { controlFixture } from "../features/agents/control-testing";

let agentFixture: ReturnType<typeof controlFixture> | undefined;
vi.mock("../features/agents/control-native", () => ({
createNativeAgentControl: () =>
createAgentControl(agentFixture?.host ?? null),
}));

vi.mock("../bundled", async () => ({
bundledPlugins: [
{
manifest: { id: "buzz.projects", name: "Projects", apiVersion: 1 },
module: await import("../bundled/projects"),
},
{
manifest: { id: "buzz.agents", name: "Agents", apiVersion: 1 },
module: await import("../bundled/agents"),
},
],
}));
const key = new Uint8Array(32).fill(6),
Expand All @@ -41,6 +60,7 @@ afterEach(async () => {
stop();
await services?.dispose();
services = undefined;
agentFixture = undefined;
vi.unstubAllGlobals();
vi.unstubAllEnvs();
localStorage.clear();
Expand Down Expand Up @@ -129,3 +149,90 @@ it("keeps an OS entity intent through real community selection and Retry in App"
screen.queryByRole("button", { name: "Retry navigation" }),
).not.toBeInTheDocument();
});

it("reconnects a failed routed Agents edit from the shell and opens its exact editor", async () => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
vi.stubEnv("VITE_BUZZ_LIVE", "1");
agentFixture = controlFixture();
agentFixture.agent.relayUrl = "wss://community.example";
agentFixture.data.agents.push({
...structuredClone(agentFixture.agent),
id: "different-agent",
pubkey: "cd".repeat(32),
name: "Different agent",
});
localStorage.setItem(
`buzz-client.v1:${viewer}`,
JSON.stringify({
profile: { name: "Fixture", picture: "" },
memberships: [{ id: origin, name: "Fixture community" }],
selected: origin,
}),
);
let attempts = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url.endsWith("/identity")) return Response.json({ viewer });
if (url.endsWith("/register")) return Response.json({});
if (url.endsWith("/session")) {
attempts++;
return attempts === 1
? Response.json({ error: "relay offline" }, { status: 503 })
: Response.json({
viewer,
relayAuthor: viewer,
relayUrl: "wss://community.example",
});
}
if (url.endsWith("/query")) return Response.json([]);
return Response.json({});
}),
);
services = createServices();
const current = services;
const target = {
version: 1 as const,
kind: "page" as const,
pluginId: "buzz.agents",
pageId: "agents",
scope: { viewer, communityOrigin: origin },
route: {
version: 1 as const,
params: { pubkey: agentFixture.agent.pubkey },
},
};
render(<App services={current} />);
await waitFor(() => expect(current.relay.snapshot().status).toBe("error"));
void current.navigation.open(target);
await screen.findByRole("button", { name: "Retry navigation" });
expect(current.navigation.snapshot()).toMatchObject({
status: "failed",
reason: "unavailable",
entry: { target },
});
expect(screen.queryByRole("dialog", { name: "Edit agent" })).toBeNull();
expect(attempts).toBe(1);

await userEvent.click(
screen.getByRole("button", { name: "Retry navigation" }),
);
await waitFor(() => expect(current.relay.snapshot().status).toBe("ready"));
const dialog = await screen.findByRole("dialog", { name: "Edit agent" });
expect(within(dialog).getByLabelText("Name")).toHaveValue("Fixture agent");
expect(within(dialog).getByLabelText("Agent instructions")).toHaveValue(
"Help with the project.",
);
expect(current.navigation.snapshot()).toMatchObject({
status: "opened",
entry: { target },
});
expect(attempts).toBe(2);
});
4 changes: 3 additions & 1 deletion src/app/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ export function useAppNavigation(services: AppServices) {
// Retrying presentation must also repair its failed dependency. Only touch the
// selected, authorized destination; never reconnect an unrelated community.
if (
(pageKey === channelsKey || pageKey === "buzz.projects/projects") &&
(pageKey === channelsKey ||
pageKey === "buzz.projects/projects" ||
pageKey === "buzz.agents/agents") &&
!state.ingress &&
!failure &&
!waiting
Expand Down
49 changes: 45 additions & 4 deletions src/bundled/agents/AgentControlPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { useIdentityNames } from "../../features/identity-names/react";
import { useAgentControl } from "../../features/agents/control-react";
import { sameCommunityAgents } from "../../features/agents/choices";
import type { PageNavigation } from "../../features/navigation/service";
import { useEffect, useState, type ReactNode } from "react";
import type {
AgentControl,
Expand All @@ -23,11 +25,17 @@ export function AgentControlPanel({
createOwner,
resolveName,
children,
editTarget,
editRequest,
onCloseTarget,
}: {
resolveName?: ReturnType<typeof useIdentityNames>;
control: AgentControl;
importDestination?: string;
createOwner?: string | undefined;
editTarget?: string | null;
editRequest?: PageNavigation;
onCloseTarget?: () => void;
children?: (
state: AgentControlState,
edit: (agent: AgentView, avatar?: string) => void,
Expand All @@ -49,8 +57,10 @@ export function AgentControlPanel({
avatar?: string;
} | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const edit = (agent: AgentView, avatar?: string) =>
const edit = (agent: AgentView, avatar?: string) => {
setSelected({ id: agent.id, ...(avatar ? { avatar } : {}) });
if (editTarget) onCloseTarget?.();
};
const duplicate = (agent: AgentView) =>
setAdding({
destination: agent.relayUrl,
Expand Down Expand Up @@ -81,7 +91,36 @@ export function AgentControlPanel({
...facts,
{ pubkey: agent.pubkey, name: agent.name, isAgent: true },
]) ?? agent.name;
const editing = state.data?.agents.find((agent) => agent.id === selected?.id);
// Route selection takes precedence over card-local editing. Never guess among
// multiple native records for the same public identity in this community.
const routed =
editTarget && importDestination && createOwner
? sameCommunityAgents(
state.data?.agents ?? [],
`${importDestination}:${createOwner}`,
).filter((agent) => agent.pubkey === editTarget)
: [];
const editing = editTarget
? routed.length === 1
? routed[0]
: undefined
: state.data?.agents.find((agent) => agent.id === selected?.id);
useEffect(() => {
if (
!editRequest ||
editRequest.signal.aborted ||
state.status === "loading" ||
state.status === "idle"
)
return;
if (state.status !== "ready") {
editRequest.complete({ status: "failed", reason: "unavailable" });
} else if (editing) {
editRequest.complete({ status: "opened" });
} else {
editRequest.complete({ status: "failed", reason: "not-found" });
}
}, [editRequest, editing, state.status]);
const deletion = state.data?.agents.find((agent) => agent.id === deleting);
return (
<section
Expand Down Expand Up @@ -194,8 +233,10 @@ export function AgentControlPanel({
displayName={label(editing)}
control={control}
state={state}
avatar={selected?.avatar}
onClose={() => setSelected(null)}
avatar={editTarget ? undefined : selected?.avatar}
onClose={
editTarget ? (onCloseTarget ?? (() => {})) : () => setSelected(null)
}
/>
)}
{deletion && control.delete && (
Expand Down
Loading
Loading