From e71c96c5f3781f6961deca38844361513de97ef5 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 10 Sep 2026 15:07:16 -1000 Subject: [PATCH 1/4] feat(agent-channels): add agent channel relationship page Signed-off-by: Taylor Ho Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- crates/plugin-manager/src/lib.rs | 4 + crates/plugin-manager/tests/management.rs | 1 + docs/agent-channels.md | 59 ++ docs/plugin-architecture.md | 3 +- src/app/pages.integration.test.mjs | 29 +- .../agent-channels/AgentChannelsPage.tsx | 580 ++++++++++++++++++ src/bundled/agent-channels/index.tsx | 13 + src/bundled/agent-channels/manifest.json | 1 + .../agent-channels/relationships.test.ts | 305 +++++++++ src/bundled/agent-channels/relationships.ts | 294 +++++++++ src/bundled/index.ts | 6 + tests/browser/layout.spec.mjs | 5 +- tests/fixtures/agent-channels.html | 13 + tests/fixtures/agent-channels.tsx | 103 ++++ 14 files changed, 1412 insertions(+), 4 deletions(-) create mode 100644 docs/agent-channels.md create mode 100644 src/bundled/agent-channels/AgentChannelsPage.tsx create mode 100644 src/bundled/agent-channels/index.tsx create mode 100644 src/bundled/agent-channels/manifest.json create mode 100644 src/bundled/agent-channels/relationships.test.ts create mode 100644 src/bundled/agent-channels/relationships.ts create mode 100644 tests/fixtures/agent-channels.html create mode 100644 tests/fixtures/agent-channels.tsx diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 63886dd3..b860f1f2 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -69,6 +69,10 @@ pub fn bundled_manifests() -> Vec { .expect("projects manifest"), serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json")) .expect("agents manifest"), + serde_json::from_str(include_str!( + "../../../src/bundled/agent-channels/manifest.json" + )) + .expect("agent channels manifest"), ] } fn is_bundled(id: &str) -> bool { diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 678b7f06..f357f675 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -276,6 +276,7 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { "buzz.bestie", "buzz.projects", "buzz.agents", + "buzz.agent-channels", "buzz.emoji", "buzz.mentions", ] { diff --git a/docs/agent-channels.md b/docs/agent-channels.md new file mode 100644 index 00000000..63e7dcd6 --- /dev/null +++ b/docs/agent-channels.md @@ -0,0 +1,59 @@ +# Agent channels: selected-community relationship view + +## First slice + +Agent channels is a bundled page plugin that lists authorized channels in the +currently selected community where an identity from the current Buzz agent +library is a member now or has authored activity returned by a finite verified +relay read. It is a relationship view, not a directory, ownership proof, +runtime monitor, or cross-community index. + +The plugin injects only `pages` and `relay`. It reuses the session-owned agent +library, identity archive evidence, channel roster, verified finite reader and +media helper. It does not create a relay connection, cache, outbox or host route. +Switching community or reconnecting remounts its session-owned subtree using +scope plus generation. + +## Relationship model + +`bundled/agent-channels/relationships.ts` produces three display-independent +records: + +- agent nodes group exact non-archived library identity pubkeys by saved agent + definition; definitions without an active linked identity are omitted; +- channel nodes retain channel ID, name, relay-authored type and archive state; +- edges retain exact agent/channel IDs, current/past/unknown membership, first + and last observed activity, and observed message count. + +Current membership comes only from the channel summary's relay-signed exact +member list. Activity comes only from verified or relay-accepted kind-9 events +authored by exact library identity pubkeys and carrying an authorized channel's +`h` tag; pending, unknown-delivery and failed local outbox events are excluded. +Display names never establish a relationship. + +The list is one projection of this model. A later node-and-edge visualization can +consume the same nodes and edges without changing relay ownership or scraping UI. + +## Bounds and truthful states + +Activity uses background-priority finite reads in bounded author and authorized- +channel batches. A shared 2,000-event request budget is allocated across all +batches, then the eligible results are merged by recency. The relationship +projection is separately capped at 10,000 edges and reports when that display +bound is reached. The read contract +does not return a historical completeness bound, so the page says **observed** +activity and never claims complete lifetime history. A channel omitted from the +current authorized roster is never displayed from activity alone. + +The page exposes disconnected, connecting, unavailable library, loading, empty, +partial roster, failed update and retry states. Existing edges remain visible +when a later update fails. Archive evidence affects which saved identities are +shown; it is not channel access evidence. + +## Validation + +The pure projection tests cover exact identity grouping, current/past/unknown +edges, authorized-channel filtering and shared-reader inputs. App composition +covers registration, disable/re-enable and relay-session lifetime. The native +plugin manager catalogs the manifest independently so Settings and CLI reserve +and enable the same bundled ID as the browser runtime. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 70c2b641..921d412d 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -48,6 +48,7 @@ features/messages/ reusable timeline, message, thread and composer UI bundled/channels/ Channels navigation, sidebar, page layout and panel placement bundled/projects/ title-only Projects page scaffold bundled/agents/ read-only current-Buzz agent library page +bundled/agent-channels/ relationship-first current-community channel list features/agents/ shared session-owned local library view bundled/github/ builtin GitHub panel plugin bundled/bestie/ builtin companion panel and its snake launcher @@ -92,7 +93,7 @@ render failures and remounts on target or revision changes. Unloading a plugin removes its contributions and closes its panel. Other pages can use these same contracts with their own layout and local navigation. -The initial distribution contains Channels, Projects, Agents, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects +The initial distribution contains Channels, Projects, Agents, Agent channels, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects is an enabled-by-default scaffold with only a centered title and no relay dependency. GitHub recognizes repository, pull request, issue, and commit URLs and loads public object details on demand. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index f9b0f0f5..e272e3c7 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -29,7 +29,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", services = createServices(); assert.deepEqual(services.pages.snapshot(), []); await settle(); - assert.equal(services.pages.snapshot().length, 3); + assert.equal(services.pages.snapshot().length, 4); await vi.waitFor(() => assert.equal(services.conversation.tools.snapshot().length, 2), ); @@ -110,7 +110,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .some((panel) => panel.pluginId === "buzz.bestie"), false, ); - assert.equal(services.pages.snapshot().length, 3); + assert.equal(services.pages.snapshot().length, 4); await services.plugins.change("enable", "buzz.bestie"); // Management completion is not activation completion; Cordis still owns import/disposal barriers. await vi.waitFor(() => @@ -139,6 +139,31 @@ test("the app runtime exposes ready bundled pages and removes them on disable", /Connect to a community/, ); const session = services.relay.snapshot().session; + const agentChannels = services.pages + .snapshot() + .find((page) => page.pluginId === "buzz.agent-channels"); + assert.equal(agentChannels.title, "Agent channels"); + assert.match( + renderToStaticMarkup(createElement(agentChannels.component)), + /Connect to a community to see where your agents work/, + ); + await services.plugins.change("disable", "buzz.agent-channels"); + assert.equal( + services.pages + .snapshot() + .some((page) => page.pluginId === "buzz.agent-channels"), + false, + ); + assert.equal(services.relay.snapshot().session, session); + await services.plugins.change("enable", "buzz.agent-channels"); + await vi.waitFor(() => + assert.ok( + services.pages + .snapshot() + .some((page) => page.pluginId === "buzz.agent-channels"), + ), + ); + await services.plugins.change("disable", "buzz.agent-channels"); await services.plugins.change("disable", "buzz.agents"); assert.equal( services.pages.snapshot().some((page) => page.pluginId === "buzz.agents"), diff --git a/src/bundled/agent-channels/AgentChannelsPage.tsx b/src/bundled/agent-channels/AgentChannelsPage.tsx new file mode 100644 index 00000000..26ca352a --- /dev/null +++ b/src/bundled/agent-channels/AgentChannelsPage.tsx @@ -0,0 +1,580 @@ +import { Clock3, History, RefreshCw, Search, UsersRound } from "lucide-react"; +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { AgentLibrary } from "../../features/agents/library"; +import { useChannelList, useRelayConnection } from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { Avatar } from "../../shared/Avatar"; +import { avatarSource } from "../../shared/avatar-source"; +import { + agentNodes, + buildAgentChannelGraph, + GRAPH_EDGE_LIMIT, + HISTORY_EVENT_LIMIT, + readAgentActivity, + type AgentNode, +} from "./relationships"; + +export function AgentChannelsPage({ relay }: { relay: RelayData }) { + const connection = useRelayConnection(relay); + return ( +
+ {connection.status === "ready" ? ( + + ) : ( + + )} +
+ ); +} + +function ConnectionState({ + relay, + status, +}: { + relay: RelayData; + status: "disconnected" | "connecting" | "error"; +}) { + return ( +
+
+ ); +} + +type ActivityState = Readonly<{ + status: "idle" | "loading" | "ready" | "error"; + events: Awaited>; + error?: string; +}>; +const noActivity: ActivityState["events"] = Object.freeze([]); +const noLibrary: AgentLibrary = Object.freeze({ + definitions: Object.freeze([]), + identities: Object.freeze([]), +}); + +function AgentChannels({ session }: { session: RelaySession }) { + const library = useSyncExternalStore( + session.agentLibrary.subscribe, + session.agentLibrary.snapshot, + session.agentLibrary.snapshot, + ); + const archives = useSyncExternalStore( + session.archives.subscribe, + session.archives.snapshot, + session.archives.snapshot, + ); + const channels = useChannelList(session.channels); + const retainedLibrary = useRef(noLibrary); + if (library.status === "ready") retainedLibrary.current = library; + const displayedLibrary = + library.status === "ready" ? library : retainedLibrary.current; + const [activity, setActivity] = useState({ + status: "idle", + events: noActivity, + }); + const activityRef = useRef(activity); + activityRef.current = activity; + const [activityAttempt, setActivityAttempt] = useState(0); + const [query, setQuery] = useState(""); + + useEffect(() => { + void session.agentLibrary.refresh(); + void session.archives.refresh(); + }, [session]); + + const archivedPubkeys = useMemo(() => new Set(archives.archived), [archives]); + const agents = useMemo( + () => agentNodes(displayedLibrary, (pubkey) => archivedPubkeys.has(pubkey)), + [displayedLibrary, archivedPubkeys], + ); + const pubkeys = useMemo( + () => agents.flatMap((agent) => agent.identityPubkeys), + [agents], + ); + const channelIds = useMemo( + () => channels.channels.map((channel) => channel.id), + [channels.channels], + ); + + useEffect(() => { + // An explicit attempt token restarts this finite read even when the library + // and roster snapshots are referentially unchanged. + void activityAttempt; + if (library.status !== "ready" || channels.status !== "ready") return; + if (!pubkeys.length || !channelIds.length) { + setActivity({ status: "idle", events: noActivity }); + return; + } + const controller = new AbortController(); + setActivity({ + status: "loading", + events: activityRef.current.events, + }); + void readAgentActivity( + session, + pubkeys, + channelIds, + controller.signal, + ).then( + (events) => { + if (!controller.signal.aborted) + setActivity({ status: "ready", events }); + }, + (error) => { + if (!controller.signal.aborted) + setActivity({ + status: "error", + events: activityRef.current.events, + error: error instanceof Error ? error.message : String(error), + }); + }, + ); + return () => controller.abort(); + }, [ + session, + library.status, + channels.status, + pubkeys, + channelIds, + activityAttempt, + ]); + + const graph = useMemo( + () => + buildAgentChannelGraph( + channels.channels, + agents, + activity.events, + channels.coverage !== "partial" && channels.status === "ready", + ), + [ + channels.channels, + channels.coverage, + channels.status, + agents, + activity.events, + ], + ); + const agentsById = useMemo( + () => new Map(graph.agents.map((agent) => [agent.id, agent])), + [graph.agents], + ); + const edgesByChannel = useMemo(() => { + const grouped = new Map>(); + for (const edge of graph.edges) { + const edges = grouped.get(edge.channelId); + if (edges) edges.push(edge); + else grouped.set(edge.channelId, [edge]); + } + return grouped; + }, [graph.edges]); + const rows = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + return graph.channels + .flatMap((channel) => { + const edges = edgesByChannel.get(channel.id) ?? []; + if (!edges.length) return []; + const relatedAgents = edges.flatMap((edge) => { + const agent = agentsById.get(edge.agentId); + return agent ? [agent] : []; + }); + if ( + needle && + !channel.name.toLocaleLowerCase().includes(needle) && + !relatedAgents.some((agent) => + agent.name.toLocaleLowerCase().includes(needle), + ) + ) + return []; + return [ + { + channel, + edges, + agents: relatedAgents, + current: edges.some((edge) => edge.membership === "current"), + membershipUnknown: edges.some( + (edge) => edge.membership === "unknown", + ), + lastObservedAt: Math.max( + 0, + ...edges.map((edge) => edge.lastObservedAt ?? 0), + ), + }, + ]; + }) + .sort( + (a, b) => + Number(b.current) - Number(a.current) || + b.lastObservedAt - a.lastObservedAt || + a.channel.name.localeCompare(b.channel.name, "en"), + ); + }, [graph, edgesByChannel, agentsById, query]); + + const relationshipChannelCount = useMemo( + () => new Set(graph.edges.map((edge) => edge.channelId)).size, + [graph.edges], + ); + const updateError = library.error ?? channels.error ?? activity.error; + const loading = + library.status === "loading" || + channels.status === "loading" || + activity.status === "loading"; + const refresh = () => { + setActivityAttempt((attempt) => attempt + 1); + void session.agentLibrary.refresh(); + void session.archives.refresh(); + session.channels.refreshList?.(); + }; + + return ( +
+
+
+

CURRENT COMMUNITY

+

+ Agent channels +

+

+ Where your Buzz agents are members now, plus recent agent activity + we could verify. +

+
+ +
+ +
+ + +
+ +
+ + {loading && ( +

+ Updating relationships… +

+ )} +
+ + {updateError && !!rows.length && ( +
+

+ Showing the relationships already found, but the latest update did + not finish. {updateError} +

+ +
+ )} + + 0} + hasRows={rows.length > 0} + query={query} + retry={refresh} + /> + + {!!rows.length && ( +
    + {rows.map((row) => ( + + ))} +
+ )} + + {!!rows.length && channels.coverage === "partial" && ( +

+ The community roster is partial, so membership is shown as unknown and + additional channel relationships may be missing from this view. +

+ )} + {!!rows.length && graph.edgesLimited && ( +

+ This view is limited to the {GRAPH_EDGE_LIMIT.toLocaleString()} most + relevant agent-channel relationships. +

+ )} + {!!rows.length && activity.events.length >= HISTORY_EVENT_LIMIT && ( +

+ Activity is limited to the {HISTORY_EVENT_LIMIT.toLocaleString()} most + recent eligible messages returned for these agents. +

+ )} + {!!rows.length && archives.status !== "ready" && ( +

+ Archive visibility is unavailable. Saved library identities remain + visible; this does not grant channel access. +

+ )} +
+ ); +} + +function Stat({ value, label }: { value: number; label: string }) { + return ( +
+ + {value} + + {label} +
+ ); +} + +function PageState({ + library, + channels, + activity, + hasAgents, + hasRows, + query, + retry, +}: { + library: AgentLibrary & { status: string; error?: string }; + channels: ReturnType; + activity: ActivityState; + hasAgents: boolean; + hasRows: boolean; + query: string; + retry: () => void; +}) { + if (library.status === "unavailable") + return ( + + ); + const error = library.error ?? channels.error ?? activity.error; + if (error && !hasRows) + return ( +
+

Couldn’t update agent channels. {error}

+ +
+ ); + if (library.status === "ready" && !hasAgents) + return ( + + ); + if (!hasRows && library.status === "ready" && channels.status === "ready") + return ( + + ); + return null; +} + +function EmptyState({ text }: { text: string }) { + return ( +
+

+ {text} +

+
+ ); +} + +const CHANNEL_DETAILS_LIMIT = 200; + +function ChannelRow({ + channel, + edges, + agents, + current, + membershipUnknown, + lastObservedAt, + session, +}: { + channel: ReturnType["channels"][number]; + edges: ReturnType["edges"]; + agents: readonly AgentNode[]; + current: boolean; + membershipUnknown: boolean; + lastObservedAt: number; + session: RelaySession; +}) { + const [expanded, setExpanded] = useState(false); + const details = useMemo(() => { + const agentsById = new Map(agents.map((agent) => [agent.id, agent])); + return edges.slice(0, CHANNEL_DETAILS_LIMIT).flatMap((edge) => { + const agent = agentsById.get(edge.agentId); + return agent ? [{ edge, agent }] : []; + }); + }, [agents, edges]); + return ( +
  • +
    setExpanded(event.currentTarget.open)} + > + +
    +
    +

    + {channel.name} +

    + + {current + ? "Current" + : membershipUnknown + ? "Membership unknown" + : "Past activity"} + +
    +

    + {channel.kind ?? "channel"} + + {agents.length} {agents.length === 1 ? "agent" : "agents"} + + {lastObservedAt > 0 && ( + + + )} +

    +
    + +
    + {expanded && ( +
      + {details.map(({ edge, agent }) => ( +
    • + {agent.name} + + {edge.membership === "current" && "Member now"} + {edge.membership === "past" && "No longer a member"} + {edge.membership === "unknown" && "Membership unknown"} + {edge.observedMessageCount > 0 && ( + + + )} + +
    • + ))} + {edges.length > CHANNEL_DETAILS_LIMIT && ( +
    • + Showing {CHANNEL_DETAILS_LIMIT.toLocaleString()} of{" "} + {edges.length.toLocaleString()} relationships in this channel. +
    • + )} +
    + )} +
    +
  • + ); +} + +function AvatarStack({ + agents, + session, +}: { + agents: readonly AgentNode[]; + session: RelaySession; +}) { + return ( +
    agent.name).join(", ")} + > + {agents.slice(0, 4).map((agent, index) => { + const source = avatarSource(agent.avatar); + const picture = source?.startsWith("data:") + ? source + : source + ? session.media(source) + : undefined; + return ( + + ); + })} + {agents.length > 4 && ( + + +{agents.length - 4} + + )} +
    + ); +} + +function formatTime(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(new Date(timestamp * 1000)); +} diff --git a/src/bundled/agent-channels/index.tsx b/src/bundled/agent-channels/index.tsx new file mode 100644 index 00000000..e63f0045 --- /dev/null +++ b/src/bundled/agent-channels/index.tsx @@ -0,0 +1,13 @@ +import type { PluginModule } from "../../plugins/api"; +import { AgentChannelsPage } from "./AgentChannelsPage"; + +export const inject = ["pages", "relay"]; +export const apply: PluginModule["apply"] = (ctx) => { + const relay = ctx.relay; + ctx.pages.register({ + id: "agent-channels", + title: "Agent channels", + layout: "workspace", + component: () => , + }); +}; diff --git a/src/bundled/agent-channels/manifest.json b/src/bundled/agent-channels/manifest.json new file mode 100644 index 00000000..e758a74f --- /dev/null +++ b/src/bundled/agent-channels/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.agent-channels", "name": "Agent channels", "apiVersion": 1 } diff --git a/src/bundled/agent-channels/relationships.test.ts b/src/bundled/agent-channels/relationships.test.ts new file mode 100644 index 00000000..c04c9fd0 --- /dev/null +++ b/src/bundled/agent-channels/relationships.test.ts @@ -0,0 +1,305 @@ +import { expect, it, vi } from "vitest"; +import type { AgentLibrary } from "../../features/agents/library"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { keypair, message } from "../../features/relay/testing"; +import { + agentNodes, + buildAgentChannelGraph, + GRAPH_EDGE_LIMIT, + readAgentActivity, + type AgentNode, +} from "./relationships"; + +const rizz = keypair(); +const fizz = keypair(); +const library: AgentLibrary = { + definitions: [ + { id: "rizz", name: "Rizz" }, + { id: "fizz", name: "Fizz" }, + { id: "empty", name: "No identity" }, + ], + identities: [ + { pubkey: rizz.pubkey, name: "Rizz", definitionId: "rizz" }, + { pubkey: fizz.pubkey, name: "Fizz", definitionId: "fizz" }, + ], +}; +const channels: ChannelSummary[] = [ + { id: "current", name: "Current", members: [rizz.pubkey] }, + { id: "past", name: "Past", members: [] }, + { id: "unknown", name: "Unknown" }, +]; + +it("keeps exact active identities and omits definitions without a usable identity", () => { + expect(agentNodes(library, (key) => key === fizz.pubkey)).toEqual([ + { + id: "definition:rizz", + name: "Rizz", + identityPubkeys: [rizz.pubkey], + }, + ]); +}); + +it("projects current membership and verified activity into graph-ready edges", () => { + const agents = agentNodes(library, () => false); + const graph = buildAgentChannelGraph(channels, agents, [ + message(rizz, "past", "older", 10), + message(rizz, "past", "newer", 20), + { ...message(rizz, "past", "pending", 30), delivery: "sending" }, + message(fizz, "unknown", "hello", 15), + message(fizz, "not-authorized", "hidden", 30), + ]); + expect(graph.edgesLimited).toBe(false); + expect(graph.edges).toEqual([ + { + agentId: "definition:rizz", + channelId: "current", + membership: "current", + observedMessageCount: 0, + }, + { + agentId: "definition:rizz", + channelId: "past", + membership: "past", + firstObservedAt: 10, + lastObservedAt: 20, + observedMessageCount: 2, + }, + { + agentId: "definition:fizz", + channelId: "unknown", + membership: "unknown", + firstObservedAt: 15, + lastObservedAt: 15, + observedMessageCount: 1, + }, + ]); +}); + +it("downgrades roster membership when coverage is partial", () => { + const [agent] = agentNodes(library, () => false); + expect( + buildAgentChannelGraph(channels, agent ? [agent] : [], [], false).edges, + ).toEqual([ + { + agentId: "definition:rizz", + channelId: "current", + membership: "unknown", + observedMessageCount: 0, + }, + ]); +}); + +it("caps the relationship projection while preserving current edges first", () => { + const crowdedAgent: AgentNode = { + id: "crowded", + name: "Crowded", + identityPubkeys: [rizz.pubkey], + }; + const crowdedChannels = Array.from( + { length: GRAPH_EDGE_LIMIT + 1 }, + (_, index): ChannelSummary => ({ + id: `channel-${index}`, + name: `Channel ${index}`, + members: index === GRAPH_EDGE_LIMIT ? [rizz.pubkey] : [], + }), + ); + const activity = crowdedChannels + .slice(0, GRAPH_EDGE_LIMIT) + .map((channel, index) => ({ + id: String(index).padStart(64, "0"), + pubkey: rizz.pubkey, + created_at: index, + kind: 9, + content: "observed", + tags: [["h", channel.id]], + })); + const graph = buildAgentChannelGraph( + crowdedChannels, + [crowdedAgent], + activity, + ); + expect(graph.edgesLimited).toBe(true); + expect(graph.edges).toHaveLength(GRAPH_EDGE_LIMIT); + expect(graph.edges[0]).toMatchObject({ + channelId: `channel-${GRAPH_EDGE_LIMIT}`, + membership: "current", + }); +}); + +it("reads activity through the shared session with exact authors and cancellation", async () => { + const events = [message(rizz, "current", "hello", 20)]; + const read = vi.fn().mockResolvedValue(events); + const controller = new AbortController(); + const result = await readAgentActivity( + { read } as never, + [rizz.pubkey, rizz.pubkey], + ["current", "current"], + controller.signal, + ); + expect(result).toEqual(events); + expect(read).toHaveBeenCalledWith( + [ + { + kinds: [9], + authors: [rizz.pubkey], + "#h": ["current"], + limit: 500, + }, + ], + { signal: controller.signal, priority: "background" }, + ); +}); + +it("uses eligible relay observations rather than pending outbox rows for pagination", async () => { + const remote = Array.from({ length: 500 }, (_, index) => ({ + ...message(rizz, "current", `remote-${index}`, 1_000 - index), + id: String(index).padStart(64, "0"), + ...(index === 0 + ? { delivery: "accepted" as const } + : index === 1 + ? { delivery: "seen" as const } + : {}), + })); + const pending = { + ...message(rizz, "current", "pending", 750), + id: String(250).padStart(64, "0"), + delivery: "sending" as const, + }; + const read = vi + .fn() + .mockResolvedValueOnce([ + pending, + ...remote.filter((event) => event.id !== pending.id), + ]) + .mockResolvedValue([]); + await readAgentActivity( + { read } as never, + [rizz.pubkey], + ["current"], + new AbortController().signal, + ); + expect(read).toHaveBeenCalledTimes(2); + expect(read.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ + until: 501, + before_id: String(499).padStart(64, "0"), + }), + ]); +}); + +it("keeps accepted local observations out of the relay pagination cursor", async () => { + const remote = Array.from({ length: 500 }, (_, index) => ({ + ...message(rizz, "current", `remote-${index}`, 1_000 - index), + id: String(index).padStart(64, "0"), + })); + const acceptedLocal = { + ...message(rizz, "current", "accepted-local", 1), + id: "f".repeat(64), + delivery: "accepted" as const, + }; + const read = vi + .fn() + .mockResolvedValueOnce([acceptedLocal, ...remote]) + .mockResolvedValue([]); + + const result = await readAgentActivity( + { read } as never, + [rizz.pubkey], + ["current"], + new AbortController().signal, + ); + + expect(result).toContainEqual(acceptedLocal); + expect(read).toHaveBeenCalledTimes(2); + expect(read.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ + until: 501, + before_id: String(499).padStart(64, "0"), + }), + ]); +}); + +it("shares the aggregate ceiling fairly across author batches", async () => { + const authors = Array.from({ length: 301 }, (_, index) => `author-${index}`); + const calls = new Map(); + const read = vi.fn( + async ([filter]: [{ authors: string[]; limit: number }]) => { + const batch = filter.authors[0] ?? ""; + const page = calls.get(batch) ?? 0; + calls.set(batch, page + 1); + return Array.from({ length: filter.limit }, (_, index) => { + const sequence = page * filter.limit + index; + return { + id: `${batch === authors[300] ? "b" : "a"}${String(sequence).padStart(63, "0")}`, + pubkey: batch, + created_at: (batch === authors[300] ? 3_000 : 2_000) - sequence, + kind: 9, + content: "", + tags: [["h", "current"]], + }; + }); + }, + ); + const result = await readAgentActivity( + { read } as never, + authors, + ["current"], + new AbortController().signal, + ); + expect(result).toHaveLength(2_000); + expect(new Set(read.mock.calls.map((call) => call[0][0].limit))).toEqual( + new Set([500]), + ); + expect(result[0]?.pubkey).toBe(authors[300]); + expect(read).toHaveBeenCalledTimes(4); +}); + +it("removes ineligible outbox rows before applying the aggregate ceiling", async () => { + const authors = Array.from({ length: 301 }, (_, index) => `author-${index}`); + const read = vi.fn(async ([filter]: [{ authors: string[] }]) => { + const pubkey = filter.authors[0] ?? ""; + if (pubkey === authors[300]) + return [ + { + id: "newest-eligible", + pubkey, + created_at: 4_000, + kind: 9, + content: "", + tags: [["h", "current"]], + delivery: "seen" as const, + }, + ]; + return [ + ...Array.from({ length: 2_000 }, (_, index) => ({ + id: `pending-${index}`, + pubkey, + created_at: 10_000 - index, + kind: 9, + content: "", + tags: [["h", "current"]], + delivery: "sending" as const, + })), + { + id: "older-eligible", + pubkey, + created_at: 1_000, + kind: 9, + content: "", + tags: [["h", "current"]], + }, + ]; + }); + + const result = await readAgentActivity( + { read } as never, + authors, + ["current"], + new AbortController().signal, + ); + + expect(result.map((event) => event.id)).toEqual([ + "newest-eligible", + "older-eligible", + ]); +}); diff --git a/src/bundled/agent-channels/relationships.ts b/src/bundled/agent-channels/relationships.ts new file mode 100644 index 00000000..1dd579c8 --- /dev/null +++ b/src/bundled/agent-channels/relationships.ts @@ -0,0 +1,294 @@ +import type { AgentLibrary } from "../../features/agents/library"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { VisibleEvent } from "../../features/relay/projection"; +import type { RelaySession } from "../../features/relay/session"; + +export type AgentNode = Readonly<{ + id: string; + name: string; + avatar?: string; + identityPubkeys: readonly string[]; +}>; +export type ChannelNode = Readonly<{ + id: string; + name: string; + kind?: ChannelSummary["channelType"]; + archived: boolean; +}>; +export type AgentChannelEdge = Readonly<{ + agentId: string; + channelId: string; + membership: "current" | "past" | "unknown"; + firstObservedAt?: number; + lastObservedAt?: number; + observedMessageCount: number; +}>; +export type AgentChannelGraph = Readonly<{ + agents: readonly AgentNode[]; + channels: readonly ChannelNode[]; + edges: readonly AgentChannelEdge[]; + edgesLimited: boolean; +}>; + +const HISTORY_PAGE_SIZE = 500; +export const HISTORY_EVENT_LIMIT = 2_000; +export const GRAPH_EDGE_LIMIT = 10_000; +const AUTHOR_BATCH_SIZE = 300; +const CHANNEL_BATCH_SIZE = 300; + +export function agentNodes( + library: AgentLibrary, + archived: (pubkey: string) => boolean, +): readonly AgentNode[] { + const visible = library.identities.filter( + (identity) => !archived(identity.pubkey), + ); + const definitions = new Map( + library.definitions.map((definition) => [definition.id, definition]), + ); + const grouped = library.definitions.map((definition) => ({ + id: `definition:${definition.id}`, + name: definition.name, + ...(definition.avatar ? { avatar: definition.avatar } : {}), + identityPubkeys: visible + .filter((identity) => identity.definitionId === definition.id) + .map((identity) => identity.pubkey), + })); + const loose = visible + .filter( + (identity) => + !identity.definitionId || !definitions.has(identity.definitionId), + ) + .map((identity) => ({ + id: `identity:${identity.pubkey}`, + name: identity.name, + ...(identity.avatar ? { avatar: identity.avatar } : {}), + identityPubkeys: [identity.pubkey], + })); + return Object.freeze( + [...grouped, ...loose] + .filter((agent) => agent.identityPubkeys.length > 0) + .map((agent) => Object.freeze(agent)), + ); +} + +export function isObservedActivityEvent(event: VisibleEvent): boolean { + return ( + event.delivery === undefined || + event.delivery === "accepted" || + event.delivery === "seen" + ); +} + +export function buildAgentChannelGraph( + channels: readonly ChannelSummary[], + agents: readonly AgentNode[], + activity: readonly VisibleEvent[], + membershipComplete = true, +): AgentChannelGraph { + const channelNodes = channels.map((channel) => + Object.freeze({ + id: channel.id, + name: channel.name, + kind: channel.channelType, + archived: !!channel.archived, + }), + ); + const channelsById = new Map( + channels.map((channel) => [channel.id, channel]), + ); + const agentsById = new Map(agents.map((agent) => [agent.id, agent])); + const agentByIdentity = new Map( + agents.flatMap((agent) => + agent.identityPubkeys.map((pubkey) => [pubkey, agent] as const), + ), + ); + const observed = new Map< + string, + { + agentId: string; + channelId: string; + first: number; + last: number; + count: number; + } + >(); + for (const event of activity) { + if (event.kind !== 9 || !isObservedActivityEvent(event)) continue; + const agent = agentByIdentity.get(event.pubkey); + const channelId = event.tags.find(([name]) => name === "h")?.[1]; + if (!agent || !channelId || !channelsById.has(channelId)) continue; + const key = `${agent.id}\u0000${channelId}`; + const edge = observed.get(key); + if (edge) { + edge.first = Math.min(edge.first, event.created_at); + edge.last = Math.max(edge.last, event.created_at); + edge.count++; + } else { + observed.set(key, { + agentId: agent.id, + channelId, + first: event.created_at, + last: event.created_at, + count: 1, + }); + } + } + const memberChannelsByIdentity = new Map(); + for (const channel of channels) + for (const pubkey of channel.members ?? []) { + const memberChannels = memberChannelsByIdentity.get(pubkey); + if (memberChannels) memberChannels.push(channel.id); + else memberChannelsByIdentity.set(pubkey, [channel.id]); + } + const edgeKeys = new Set(observed.keys()); + const currentKeys = new Set(); + for (const agent of agents) + for (const pubkey of agent.identityPubkeys) + for (const channelId of memberChannelsByIdentity.get(pubkey) ?? []) { + const key = `${agent.id}\u0000${channelId}`; + edgeKeys.add(key); + currentKeys.add(key); + } + const sortedEdgeKeys = [...edgeKeys].sort((a, b) => { + const aObserved = observed.get(a); + const bObserved = observed.get(b); + return ( + Number(currentKeys.has(b)) - Number(currentKeys.has(a)) || + (bObserved?.last ?? 0) - (aObserved?.last ?? 0) || + a.localeCompare(b) + ); + }); + const edges: AgentChannelEdge[] = []; + for (const key of sortedEdgeKeys) { + if (edges.length >= GRAPH_EDGE_LIMIT) break; + const separator = key.indexOf("\u0000"); + const agentId = key.slice(0, separator); + const channelId = key.slice(separator + 1); + const agent = agentsById.get(agentId); + const channel = channelsById.get(channelId); + if (!agent || !channel) continue; + const activityEdge = observed.get(key); + const membership = currentKeys.has(key) + ? membershipComplete + ? "current" + : "unknown" + : channel.members && membershipComplete + ? "past" + : "unknown"; + edges.push( + Object.freeze({ + agentId, + channelId, + membership, + ...(activityEdge + ? { + firstObservedAt: activityEdge.first, + lastObservedAt: activityEdge.last, + } + : {}), + observedMessageCount: activityEdge?.count ?? 0, + }), + ); + } + return Object.freeze({ + agents, + channels: Object.freeze(channelNodes), + edges: Object.freeze(edges), + edgesLimited: sortedEdgeKeys.length > edges.length, + }); +} + +/** Reads a bounded recent activity sample through the shared verified session. + * The response has no completeness bound, so callers must describe it as observed activity. */ +export async function readAgentActivity( + session: RelaySession, + pubkeys: readonly string[], + channelIds: readonly string[], + signal: AbortSignal, +): Promise { + const authorBatches = batches([...new Set(pubkeys)], AUTHOR_BATCH_SIZE); + const channelBatches = batches([...new Set(channelIds)], CHANNEL_BATCH_SIZE); + const queries = authorBatches.flatMap((authors) => + channelBatches.map((channels) => ({ authors, channels })), + ); + const events = new Map(); + let remainingBudget = HISTORY_EVENT_LIMIT; + for (const [index, query] of queries.entries()) { + const batchBudget = Math.floor(remainingBudget / (queries.length - index)); + for (const event of await readActivityBatch( + session, + query.authors, + query.channels, + signal, + batchBudget, + )) + events.set(event.id, event); + remainingBudget -= batchBudget; + } + return Object.freeze( + [...events.values()] + .filter(isObservedActivityEvent) + .sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id)) + .slice(0, HISTORY_EVENT_LIMIT), + ); +} + +async function readActivityBatch( + session: RelaySession, + authors: readonly string[], + channelIds: readonly string[], + signal: AbortSignal, + eventBudget: number, +): Promise { + const events = new Map(); + const relayEvents = new Map(); + let cursor: { createdAt: number; eventId: string } | undefined; + while (relayEvents.size < eventBudget) { + signal.throwIfAborted(); + const requestLimit = Math.min( + HISTORY_PAGE_SIZE, + eventBudget - relayEvents.size, + ); + const page = await session.read( + [ + { + kinds: [9], + authors, + "#h": channelIds, + limit: requestLimit, + ...(cursor + ? { until: cursor.createdAt, before_id: cursor.eventId } + : {}), + }, + ], + { signal, priority: "background" }, + ); + for (const event of page) events.set(event.id, event); + const relayPage = page.filter( + (event) => + isObservedActivityEvent(event) && event.delivery !== "accepted", + ); + for (const event of relayPage) relayEvents.set(event.id, event); + if (page.length < requestLimit || relayEvents.size >= eventBudget) break; + const oldest = [...relayPage].sort( + (a, b) => a.created_at - b.created_at || b.id.localeCompare(a.id), + )[0]; + if (!oldest) break; + const next = { createdAt: oldest.created_at, eventId: oldest.id }; + if ( + cursor && + (next.createdAt > cursor.createdAt || + (next.createdAt === cursor.createdAt && next.eventId <= cursor.eventId)) + ) + break; + cursor = next; + } + return Object.freeze([...events.values()].filter(isObservedActivityEvent)); +} + +function batches(values: readonly T[], size: number): readonly T[][] { + return Array.from({ length: Math.ceil(values.length / size) }, (_, index) => + values.slice(index * size, (index + 1) * size), + ); +} diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 42a731cd..91f5f701 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -6,6 +6,8 @@ import profilesManifest from "./profiles/manifest.json"; import * as profiles from "./profiles"; import mentionsManifest from "./mentions/manifest.json"; import * as mentions from "./mentions"; +import agentChannelsManifest from "./agent-channels/manifest.json"; +import * as agentChannels from "./agent-channels"; import emojiManifest from "./emoji/manifest.json"; import * as emoji from "./emoji"; import agentsManifest from "./agents/manifest.json"; @@ -31,4 +33,8 @@ export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...bestieManifest, apiVersion: 1 }, module: bestie }, { manifest: { ...projectsManifest, apiVersion: 1 }, module: projects }, { manifest: { ...agentsManifest, apiVersion: 1 }, module: agents }, + { + manifest: { ...agentChannelsManifest, apiVersion: 1 }, + module: agentChannels, + }, ]; diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index 9ddd1ecb..af014278 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -550,7 +550,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await page.setViewportSize({ width: 1280, height: 832 }); await page.goto(app.origin); const nav = page.getByRole("navigation", { name: "Pages", exact: true }); - const titles = ["Home", "Messages", "Projects", "Agents"]; + const titles = ["Home", "Messages", "Projects", "Agent channels", "Agents"]; await expect(nav.getByRole("button")).toHaveText(titles); await nav.getByRole("button", { name: "Projects", exact: true }).click(); const surface = page.getByRole("region", { name: "Projects", exact: true }); @@ -592,6 +592,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await expect(nav.getByRole("button")).toHaveText([ "Home", "Messages", + "Agent channels", "Agents", ]); await projects.click(); @@ -605,6 +606,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await expect(nav.getByRole("button")).toHaveText([ "Home", "Projects", + "Agent channels", "Agents", ]); await channels.click(); @@ -613,6 +615,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await expect(page.getByRole("main").getByRole("button")).toHaveText([ "Messages", "Projects", + "Agent channels", "Agents", "Make it yoursSettings", ]); diff --git a/tests/fixtures/agent-channels.html b/tests/fixtures/agent-channels.html new file mode 100644 index 00000000..fed3d94c --- /dev/null +++ b/tests/fixtures/agent-channels.html @@ -0,0 +1,13 @@ + + + + + + + Agent channels + + +
    + + + diff --git a/tests/fixtures/agent-channels.tsx b/tests/fixtures/agent-channels.tsx new file mode 100644 index 00000000..737883c9 --- /dev/null +++ b/tests/fixtures/agent-channels.tsx @@ -0,0 +1,103 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { AgentChannelsPage } from "../../src/bundled/agent-channels/AgentChannelsPage"; +import type { RelayData } from "../../src/features/relay/service"; +import type { RelaySession } from "../../src/features/relay/session"; +import { keypair, message } from "../../src/features/relay/testing"; +import "../../src/shared/styles/globals.css"; + +document.documentElement.dataset.colorMode = "dark"; + +const rizz = keypair(); +const fizz = keypair(); +const honey = keypair(); +const carl = keypair(); +const library = Object.freeze({ + status: "ready" as const, + definitions: Object.freeze([ + { id: "rizz", name: "Rizz" }, + { id: "fizz", name: "Fizz" }, + { id: "honey", name: "Honey" }, + { id: "carl", name: "Carl" }, + ]), + identities: Object.freeze([ + { pubkey: rizz.pubkey, name: "Rizz", definitionId: "rizz" }, + { pubkey: fizz.pubkey, name: "Fizz", definitionId: "fizz" }, + { pubkey: honey.pubkey, name: "Honey", definitionId: "honey" }, + { pubkey: carl.pubkey, name: "Carl", definitionId: "carl" }, + ]), +}); +const archives = Object.freeze({ status: "ready" as const, archived: [] }); +const channels = Object.freeze({ + status: "ready" as const, + channels: Object.freeze([ + { + id: "map", + name: "buzz-agent-channel-map", + channelType: "stream" as const, + members: [rizz.pubkey, carl.pubkey], + }, + { + id: "design", + name: "buzz-design", + channelType: "forum" as const, + members: [rizz.pubkey, fizz.pubkey, honey.pubkey], + }, + { + id: "history", + name: "buzz-plugin-architecture", + channelType: "stream" as const, + members: [], + }, + ]), +}); +const activity = [ + message(rizz, "map", "Working", 1_789_080_500), + message(carl, "map", "Handoff", 1_789_080_100), + message(fizz, "design", "Review", 1_789_000_000), + message(rizz, "history", "Earlier work", 1_788_000_000), +]; +const session = { + agentLibrary: { + snapshot: () => library, + subscribe: () => () => {}, + refresh: async () => {}, + }, + archives: { + snapshot: () => archives, + subscribe: () => () => {}, + refresh: async () => {}, + state: () => "not-archived", + }, + channels: { + list: () => channels, + subscribeList: () => () => {}, + ensureList() {}, + window: () => ({ status: "idle", rows: [] }), + subscribeWindow: () => () => {}, + ensure() {}, + loadOlder() {}, + refreshList() {}, + }, + read: async () => activity, + media: () => undefined, +} as unknown as RelaySession; +const relaySnapshot = Object.freeze({ + status: "ready" as const, + scope: "fixture", + generation: 1, + viewer: keypair().pubkey, + session, +}); +const relay = { + snapshot: () => relaySnapshot, + subscribe: () => () => {}, +} as unknown as RelayData; + +createRoot(document.getElementById("root") as HTMLElement).render( + +
    + +
    +
    , +); From 10564d09ec0a95c252efc64771c729de449dffea Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 10 Sep 2026 15:32:57 -1000 Subject: [PATCH 2/4] fix(agent-channels): respect relay channel filter limit Signed-off-by: Taylor Ho Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- .../agent-channels/relationships.test.ts | 24 +++++++++++++++++++ src/bundled/agent-channels/relationships.ts | 4 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/bundled/agent-channels/relationships.test.ts b/src/bundled/agent-channels/relationships.test.ts index c04c9fd0..b1e0bd9e 100644 --- a/src/bundled/agent-channels/relationships.test.ts +++ b/src/bundled/agent-channels/relationships.test.ts @@ -150,6 +150,30 @@ it("reads activity through the shared session with exact authors and cancellatio ); }); +it("keeps explicit channel filters within the relay's 128-channel limit", async () => { + const channelIds = Array.from( + { length: 129 }, + (_, index) => `channel-${index}`, + ); + const read = vi.fn().mockResolvedValue([]); + + await readAgentActivity( + { read } as never, + [rizz.pubkey], + channelIds, + new AbortController().signal, + ); + + expect(read).toHaveBeenCalledTimes(2); + expect(read.mock.calls.map((call) => call[0][0]["#h"])).toEqual([ + channelIds.slice(0, 128), + channelIds.slice(128), + ]); + expect( + Math.max(...read.mock.calls.map((call) => call[0][0]["#h"].length)), + ).toBe(128); +}); + it("uses eligible relay observations rather than pending outbox rows for pagination", async () => { const remote = Array.from({ length: 500 }, (_, index) => ({ ...message(rizz, "current", `remote-${index}`, 1_000 - index), diff --git a/src/bundled/agent-channels/relationships.ts b/src/bundled/agent-channels/relationships.ts index 1dd579c8..4d2a0f92 100644 --- a/src/bundled/agent-channels/relationships.ts +++ b/src/bundled/agent-channels/relationships.ts @@ -34,7 +34,9 @@ const HISTORY_PAGE_SIZE = 500; export const HISTORY_EVENT_LIMIT = 2_000; export const GRAPH_EDGE_LIMIT = 10_000; const AUTHOR_BATCH_SIZE = 300; -const CHANNEL_BATCH_SIZE = 300; +// Buzz rejects filters with more than 128 explicit channels. Keep this bound +// local to the activity projection rather than widening the shared reader API. +const CHANNEL_BATCH_SIZE = 128; export function agentNodes( library: AgentLibrary, From ed817691e127117b147d6d8a07ab12fcdb976ad8 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 13 Sep 2026 05:37:52 -1000 Subject: [PATCH 3/4] feat(agent-channels): map observable agent outcomes Signed-off-by: Taylor Ho Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- docs/agent-channels.md | 48 +- docs/plugin-architecture.md | 15 +- src/app/pages.integration.test.mjs | 4 +- src/app/services.ts | 3 + .../agent-channels/AgentChannelsPage.tsx | 1588 ++++++++++++++--- src/bundled/agent-channels/dashboard.test.ts | 95 + src/bundled/agent-channels/dashboard.ts | 148 ++ src/bundled/agent-channels/index.tsx | 7 +- src/bundled/agent-channels/manifest.json | 2 +- src/bundled/agent-channels/outcomes.test.ts | 103 ++ src/bundled/agent-channels/outcomes.ts | 95 + src/bundled/github/index.tsx | 36 +- src/features/objects/service.test.ts | 69 + src/features/objects/service.ts | 91 + src/shared/styles/globals.css | 2 + tests/browser/agent-dashboard.spec.mjs | 91 + tests/browser/layout.spec.mjs | 8 +- tests/fixtures/agent-channels.html | 2 +- tests/fixtures/agent-channels.tsx | 69 +- 19 files changed, 2185 insertions(+), 291 deletions(-) create mode 100644 src/bundled/agent-channels/dashboard.test.ts create mode 100644 src/bundled/agent-channels/dashboard.ts create mode 100644 src/bundled/agent-channels/outcomes.test.ts create mode 100644 src/bundled/agent-channels/outcomes.ts create mode 100644 src/features/objects/service.test.ts create mode 100644 src/features/objects/service.ts create mode 100644 tests/browser/agent-dashboard.spec.mjs diff --git a/docs/agent-channels.md b/docs/agent-channels.md index 63e7dcd6..63b61941 100644 --- a/docs/agent-channels.md +++ b/docs/agent-channels.md @@ -1,18 +1,18 @@ -# Agent channels: selected-community relationship view +# Agent dashboard: selected-community relationship view ## First slice -Agent channels is a bundled page plugin that lists authorized channels in the +Agent dashboard is a bundled page plugin that gives an agent-first view of currently selected community where an identity from the current Buzz agent library is a member now or has authored activity returned by a finite verified relay read. It is a relationship view, not a directory, ownership proof, runtime monitor, or cross-community index. -The plugin injects only `pages` and `relay`. It reuses the session-owned agent -library, identity archive evidence, channel roster, verified finite reader and -media helper. It does not create a relay connection, cache, outbox or host route. -Switching community or reconnecting remounts its session-owned subtree using -scope plus generation. +The plugin injects `pages`, `relay`, and the shared external-object provider +registry. It reuses the session-owned agent library, identity archive evidence, +channel roster, verified finite reader and media helper. It does not create a +relay connection, cache, outbox or host route. Switching community or +reconnecting remounts its session-owned subtree using scope plus generation. ## Relationship model @@ -31,8 +31,38 @@ authored by exact library identity pubkeys and carrying an authorized channel's `h` tag; pending, unknown-delivery and failed local outbox events are excluded. Display names never establish a relationship. -The list is one projection of this model. A later node-and-edge visualization can -consume the same nodes and edges without changing relay ownership or scraping UI. +## Dashboard projection + +The page projects the relationship records into one row per agent, ordered by +current channel membership and recent observed activity. Selecting an agent +centers its avatar in a bounded relationship map: channel nodes form the first +ring, and other agent avatars connect through channels they share with the +selected agent. The map deliberately limits visible nodes for legibility; the +selected agent's full channel list remains available below it and supplies the +non-visual equivalent for keyboard, touch and narrow layouts. + +The dashboard summary retains current/past/unknown membership, sampled message +count and last observed activity. It does not infer presence, running state, +task status or complete lifetime work from messages. + +## Pull request outcomes + +The optional **Outcomes** view extracts canonical pull request links directly +from eligible agent-authored activity and retains each signed message ID, +channel, agent and share timestamp as association evidence. Repeated shares of +the same pull request are grouped rather than double-counted. + +External details come through `features/objects`, a typed contribution registry +that is separate from panel presentation. The GitHub plugin registers both its +existing panel and an object provider, so Agent dashboard never imports GitHub +implementation code. The provider supplies current public state, title, author, +branch and change facts with bounded four-at-a-time loading; disabling GitHub +removes both capabilities. Private or unavailable GitHub objects remain visible +from their signed Buzz reference with a truthful enrichment error. + +A shared link establishes observable association, not pull request authorship or +causation. The UI therefore says **shared by** and **associated work**, never +claims that an agent authored a pull request or caused its merge. ## Bounds and truthful states diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 921d412d..59962173 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -42,13 +42,14 @@ app/ host, startup, navigation, Settings plugins/ installation, lifecycle, contribution ownership features/pages/ page contract and host rendering features/panels/ target resolution, launcher contract and reusable card/frame +features/objects/ typed external-object identification and data loading features/shortcuts/ in-app binding dispatch, focus rules and plugin ownership features/relay/ shared channel data, queries, profiles and durable delivery features/messages/ reusable timeline, message, thread and composer UI bundled/channels/ Channels navigation, sidebar, page layout and panel placement bundled/projects/ title-only Projects page scaffold bundled/agents/ read-only current-Buzz agent library page -bundled/agent-channels/ relationship-first current-community channel list +bundled/agent-channels/ agent-first dashboard and relationship map features/agents/ shared session-owned local library view bundled/github/ builtin GitHub panel plugin bundled/bestie/ builtin companion panel and its snake launcher @@ -84,16 +85,20 @@ and returns false after the originating opening, channel, session or host presentation retires. This is not a global navigation API or an access grant. Channel-header launchers use the separate public `channelContext` metadata contract described below; launcher/fallback panels need not have either context. -A plugin that needs shared data declares `relay` in its -injection list and passes those capabilities to its components using a closure, -just as the bundled Channels page does. The conversation preview adds only the two demonstrated component surfaces. +A page that needs integration facts without panel UI uses +`objects.resolve(target)` / `objects.load(target, signal)`; providers own +recognition and bounded external loading, and disappear when their plugin is +disabled. A plugin that needs shared data declares `relay` in its injection list +and passes those capabilities to its components using a closure, just as the +bundled Channels page does. The conversation preview adds only the two +demonstrated component surfaces. Channels owns its selected channel and docked target. The panel view isolates render failures and remounts on target or revision changes. Unloading a plugin removes its contributions and closes its panel. Other pages can use these same contracts with their own layout and local navigation. -The initial distribution contains Channels, Projects, Agents, Agent channels, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects +The initial distribution contains Channels, Projects, Agents, Agent dashboard, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects is an enabled-by-default scaffold with only a centered title and no relay dependency. GitHub recognizes repository, pull request, issue, and commit URLs and loads public object details on demand. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index e272e3c7..89c3f5e9 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -142,10 +142,10 @@ test("the app runtime exposes ready bundled pages and removes them on disable", const agentChannels = services.pages .snapshot() .find((page) => page.pluginId === "buzz.agent-channels"); - assert.equal(agentChannels.title, "Agent channels"); + assert.equal(agentChannels.title, "Agent dashboard"); assert.match( renderToStaticMarkup(createElement(agentChannels.component)), - /Connect to a community to see where your agents work/, + /Connect to a community to see where your agents are working/, ); await services.plugins.change("disable", "buzz.agent-channels"); assert.equal( diff --git a/src/app/services.ts b/src/app/services.ts index 8b762229..1bf4c686 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -5,6 +5,7 @@ import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; import { createCommunities } from "../features/communities/service"; import { PanelsService } from "../features/panels/service"; +import { ObjectsService } from "../features/objects/service"; import { Context } from "@deepseek-ai/cordis"; import { PagesService } from "../features/pages/service"; import { bundledPlugins } from "../bundled"; @@ -22,6 +23,7 @@ export function createServices() { const shortcuts = new ShortcutsService(ctx); const pages = new PagesService(ctx); const panels = new PanelsService(ctx); + const objects = new ObjectsService(ctx); const conversation = new ConversationService(ctx); const communities = createCommunities( ctx, @@ -36,6 +38,7 @@ export function createServices() { conversation, pages, panels, + objects, plugins, relay, communities, diff --git a/src/bundled/agent-channels/AgentChannelsPage.tsx b/src/bundled/agent-channels/AgentChannelsPage.tsx index 26ca352a..7116387a 100644 --- a/src/bundled/agent-channels/AgentChannelsPage.tsx +++ b/src/bundled/agent-channels/AgentChannelsPage.tsx @@ -1,37 +1,69 @@ -import { Clock3, History, RefreshCw, Search, UsersRound } from "lucide-react"; import { + Check, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + History, + Network, + RefreshCw, + Rocket, + Search, + Sparkles, + UsersRound, +} from "lucide-react"; +import { + type CSSProperties, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; +import type { + ExternalObjectDetails, + Objects, +} from "../../features/objects/service"; import type { AgentLibrary } from "../../features/agents/library"; import { useChannelList, useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; import { Avatar } from "../../shared/Avatar"; import { avatarSource } from "../../shared/avatar-source"; +import { + buildAgentDashboard, + buildAgentFocusNetwork, + type AgentDashboardRow, + type AgentFocusNetwork, +} from "./dashboard"; +import { extractAgentOutcomes, type AgentOutcomeReference } from "./outcomes"; import { agentNodes, buildAgentChannelGraph, GRAPH_EDGE_LIMIT, HISTORY_EVENT_LIMIT, readAgentActivity, - type AgentNode, + type ChannelNode, } from "./relationships"; -export function AgentChannelsPage({ relay }: { relay: RelayData }) { +export function AgentChannelsPage({ + relay, + objects, +}: { + relay: RelayData; + objects: Objects; +}) { const connection = useRelayConnection(relay); return (
    {connection.status === "ready" ? ( - ) : ( @@ -49,14 +81,14 @@ function ConnectionState({ }) { return (
    -
    + ); +} + +type LoadedOutcome = Readonly<{ + outcome: AgentOutcomeReference; + details?: ExternalObjectDetails; + error?: string; +}>; + +function ViewTab({ + active, + icon, + label, + count, + onSelect, +}: { + active: boolean; + icon: React.ReactNode; + label: string; + count?: number | undefined; + onSelect: () => void; +}) { + return ( + + ); +} + +function OutcomesDashboard({ + references, + objects, + dashboard, + channels, + session, + query, + onSelectAgent, +}: { + references: readonly AgentOutcomeReference[]; + objects: Objects; + dashboard: readonly AgentDashboardRow[]; + channels: readonly ChannelNode[]; + session: RelaySession; + query: string; + onSelectAgent: (agentId: string) => void; +}) { + const [loaded, setLoaded] = useState([]); + const [status, setStatus] = useState<"idle" | "loading" | "ready">("idle"); + const [selectedKey, setSelectedKey] = useState(); + useEffect(() => { + if (!references.length) { + setLoaded([]); + setStatus("idle"); + return; + } + const controller = new AbortController(); + setStatus("loading"); + void loadOutcomeDetails(references, objects, controller.signal).then( + (next) => { + if (!controller.signal.aborted) { + setLoaded(next); + setStatus("ready"); + } + }, + () => { + if (!controller.signal.aborted) setStatus("ready"); + }, + ); + return () => controller.abort(); + }, [objects, references]); + + const agents = useMemo( + () => new Map(dashboard.map((row) => [row.agent.id, row])), + [dashboard], + ); + const channelNames = useMemo( + () => new Map(channels.map((channel) => [channel.id, channel.name])), + [channels], + ); + const needle = query.trim().toLocaleLowerCase(); + const visible = loaded.filter(({ outcome, details }) => { + if (!needle) return true; + return ( + details?.title.toLocaleLowerCase().includes(needle) || + outcome.reference.group?.toLocaleLowerCase().includes(needle) || + outcome.evidence.some((evidence) => + agents + .get(evidence.agentId) + ?.agent.name.toLocaleLowerCase() + .includes(needle), + ) + ); + }); + const merged = loaded.filter( + (item) => item.details?.state === "Merged", + ).length; + const open = loaded.filter((item) => item.details?.state === "open").length; + const draft = loaded.filter((item) => item.details?.state === "Draft").length; + const repositoryCount = new Set( + loaded.flatMap(({ outcome }) => outcome.reference.group ?? []), + ).size; + const selected = + visible.find((item) => item.outcome.key === selectedKey) ?? visible[0]; + + if (!references.length) + return ( +
    +
    + ); + + return ( +
    +
    +
    +
    +
    +

    +

    +

    + Outcomes +

    +

    + Pull requests your agents shared, enriched with their latest + public GitHub state. Association is backed by the signed Buzz + message. +

    +
    + {status === "loading" && ( +

    + Following branches to GitHub… +

    + )} +
    +
    + } + value={merged} + label="merged" + tone="success" + /> + } + value={open} + label="open" + /> + } + value={draft} + label="draft" + /> + } + value={repositoryCount} + label="repositories" + /> +
    +
    + + {status === "ready" && visible.length === 0 ? ( + + ) : ( +
    +
    + setSelectedKey(key)} + /> +
    +
    +

    All pull requests

    + + {visible.length} outcomes + +
    +
    + {visible.map((item) => ( + setSelectedKey(item.outcome.key)} + /> + ))} +
    +
    +
    + {selected && ( + + )} +
    )} +

    + Public GitHub data only. “Shared by” proves observable association—not + PR authorship or that an agent caused the merge. +

    +
    + ); +} + +function OutcomeFlow({ + items, + selected, + agents, + channelNames, + session, + onSelect, +}: { + items: readonly LoadedOutcome[]; + selected: LoadedOutcome | undefined; + agents: Map; + channelNames: Map; + session: RelaySession; + onSelect: (key: string) => void; +}) { + return ( +
    +
    +
    +

    Shipping paths

    +

    + Follow each signed share from agent to GitHub outcome. +

    +
    +
    + + Landed + + + + In flight + +
    +
    +
    +
    +
    + Agent signal + Channel + Branch + GitHub outcome +
    +
    + {items.length ? ( + items.map((item) => ( + onSelect(item.outcome.key)} + /> + )) + ) : ( +
    + Tracing shared pull requests… +
    + )} +
    +
    +
    ); } -function Stat({ value, label }: { value: number; label: string }) { +function OutcomePath({ + item, + selected, + agents, + channelNames, + session, + onSelect, +}: { + item: LoadedOutcome; + selected: boolean; + agents: Map; + channelNames: Map; + session: RelaySession; + onSelect: () => void; +}) { + const evidence = item.outcome.evidence[0]; + const uniqueAgentIds = [ + ...new Set(item.outcome.evidence.map((item) => item.agentId)), + ]; + const uniqueChannelIds = [ + ...new Set(item.outcome.evidence.map((item) => item.channelId)), + ]; + const state = item.details?.state; + const merged = state === "Merged"; + const facts = new Map(item.details?.facts ?? []); + const title = + item.details?.title ?? + `${item.outcome.reference.group} ${item.outcome.reference.label}`; + return ( + + ); +} + +async function loadOutcomeDetails( + references: readonly AgentOutcomeReference[], + objects: Objects, + signal: AbortSignal, +): Promise { + const results: LoadedOutcome[] = []; + const queue = [...references]; + await Promise.all( + Array.from({ length: Math.min(4, queue.length) }, async () => { + while (queue.length) { + signal.throwIfAborted(); + const outcome = queue.shift(); + if (!outcome) return; + try { + const details = await objects.load(outcome.reference.url, signal); + results.push( + Object.freeze({ outcome, ...(details ? { details } : {}) }), + ); + } catch (error) { + if (signal.aborted) throw error; + results.push( + Object.freeze({ + outcome, + error: error instanceof Error ? error.message : String(error), + }), + ); + } + } + }), + ); + const order = new Map( + references.map((outcome, index) => [outcome.key, index]), + ); + return Object.freeze( + results.sort( + (a, b) => + (order.get(a.outcome.key) ?? 0) - (order.get(b.outcome.key) ?? 0), + ), + ); +} + +function OutcomeMetric({ + icon, + value, + label, + tone, +}: { + icon: React.ReactNode; + value: number; + label: string; + tone?: "success"; +}) { return ( -
    - +
    +
    + {icon} {label} +
    +
    {value} - - {label} +
    ); } +function OutcomeRow({ + item, + selected, + agents, + channelNames, + onSelect, +}: { + item: LoadedOutcome; + selected: boolean; + agents: Map; + channelNames: Map; + onSelect: () => void; +}) { + const evidence = item.outcome.evidence[0]; + const agent = evidence ? agents.get(evidence.agentId) : undefined; + const state = item.details?.state; + return ( + + ); +} + +function OutcomeState({ + state, + loading, +}: { + state: string | undefined; + loading?: boolean; +}) { + const merged = state === "Merged"; + return ( + + {loading ? "checking" : state || "unavailable"} + + ); +} + +function OutcomeInspector({ + item, + agents, + channelNames, + onSelectAgent, +}: { + item: LoadedOutcome; + agents: Map; + channelNames: Map; + onSelectAgent: (agentId: string) => void; +}) { + const facts = new Map(item.details?.facts ?? []); + const uniqueAgents = [ + ...new Set(item.outcome.evidence.map((evidence) => evidence.agentId)), + ]; + return ( + + ); +} + +function InspectorFact({ + label, + value, +}: { + label: string; + value: string | number; +}) { + return ( +
    +
    {label}
    +
    + {value} +
    +
    + ); +} + +function AgentCard({ + row, + selected, + session, + onSelect, +}: { + row: AgentDashboardRow; + selected: boolean; + session: RelaySession; + onSelect: () => void; +}) { + return ( + + ); +} + +const VIEW_WIDTH = 800; +const VIEW_HEIGHT = 500; + +function AgentNetwork({ + focus, + session, + onSelectAgent, +}: { + focus: AgentFocusNetwork; + session: RelaySession; + onSelectAgent: (agentId: string) => void; +}) { + const channelPositions = radialPositions( + focus.channels.length, + 152, + VIEW_WIDTH / 2, + VIEW_HEIGHT / 2, + -Math.PI / 2, + ); + const collaboratorPositions = radialPositions( + focus.collaborators.length, + 229, + VIEW_WIDTH / 2, + VIEW_HEIGHT / 2, + -Math.PI / 2 + Math.PI / Math.max(focus.collaborators.length, 1), + ); + const channelPosition = new Map( + focus.channels.map(({ channel }, index) => [ + channel.id, + channelPositions[index], + ]), + ); + + return ( +
    +
    + + {focus.selected.agent.name} is connected to {focus.channels.length}{" "} + displayed channels and {focus.collaborators.length} displayed + collaborators. + +
    + + +
    +
    + +
    + + {focus.selected.agent.name} + +
    + + {focus.channels.map((relationship, index) => { + const position = channelPositions[index]; + if (!position) return null; + return ( +
    + + #{relationship.channel.name} + + + {relationship.edge.membership === "current" + ? "current" + : relationship.edge.membership === "past" + ? "past" + : "membership unknown"} + {relationship.edge.observedMessageCount > 0 + ? ` · ${relationship.edge.observedMessageCount}` + : ""} + +
    + ); + })} + + {focus.collaborators.map((collaborator, index) => { + const position = collaboratorPositions[index]; + if (!position) return null; + return ( + + ); + })} +
    + {(focus.channelsLimited || focus.collaboratorsLimited) && ( +

    + Showing the strongest relationships for a readable map. Full channel + detail is below. +

    + )} +
    + ); +} + +function AgentDetails({ focus }: { focus: AgentFocusNetwork }) { + const row = focus.selected; + return ( +
    + +
    +
    +

    Channel activity

    + + {row.relationships.length} total + +
    +
      + {row.relationships.map(({ channel, edge }) => ( +
    • + + + #{channel.name} + + + {channel.kind ?? "channel"} ·{" "} + {membershipLabel(edge.membership)} + + + + {edge.observedMessageCount > 0 ? ( + <> + + + + {edge.lastObservedAt + ? formatRelativeTime(edge.lastObservedAt) + : ""} + + + ) : ( + "No sampled messages" + )} + +
    • + ))} +
    +
    +
    + ); +} + +function Metric({ value, label }: { value: string | number; label: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} + +function AgentAvatar({ + row, + session, + className, +}: { + row: AgentDashboardRow; + session: RelaySession; + className: string; +}) { + const source = avatarSource(row.agent.avatar); + const picture = source?.startsWith("data:") + ? source + : source + ? session.media(source) + : undefined; + return ; +} + function PageState({ library, channels, activity, hasAgents, - hasRows, - query, + hasRelationships, retry, }: { library: AgentLibrary & { status: string; error?: string }; channels: ReturnType; activity: ActivityState; hasAgents: boolean; - hasRows: boolean; - query: string; + hasRelationships: boolean; retry: () => void; }) { if (library.status === "unavailable") @@ -397,10 +1548,10 @@ function PageState({ ); const error = library.error ?? channels.error ?? activity.error; - if (error && !hasRows) + if (error && !hasRelationships) return (
    -

    Couldn’t update agent channels. {error}

    +

    Couldn’t update the agent dashboard. {error}

    @@ -410,15 +1561,17 @@ function PageState({ return ( ); - if (!hasRows && library.status === "ready" && channels.status === "ready") + if ( + !hasRelationships && + library.status === "ready" && + channels.status === "ready" + ) return ( ); @@ -428,6 +1581,11 @@ function PageState({ function EmptyState({ text }: { text: string }) { return (
    +