From 7e24ee73791e496b2b34c20b6f3c30ef8afef403 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 4 Aug 2026 18:47:57 +1000 Subject: [PATCH] fix(mesh): CTA reads resolved provider; drop the row chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call to action never disappeared after setting up a mesh agent because it asked AgentPersona.provider — the raw definition field. The mesh provider is normally set at the global layer (global-agent-config.json), so that field is null for most mesh agents and the check answered "no" for all of them. Rust already resolves the layering (definition -> global for linked instances, instance -> global for definition-less ones) in resolve_effective_config, and runtime.rs builds the ManagedAgent DTO from the resolved value. Detection now consumes that field and holds no precedence rules of its own, rather than hand-rolling a second copy of the layering that would drift from Rust's. The CTA is now a tip with a pulsing dot rather than a bare link, and it still appears when the community has capacity even if this machine is not sharing — using someone else's compute is a legitimate reason to set up an agent. Removes the ManagedAgentRow shared-compute chip. Buzz already spends per-message and per-row provenance on ownership (MessageAgentOwner: "managed by X"), which answers a governance question; a second provenance marker for a config fact competes with it for the same space. Signed-off-by: Michael Neale --- .../features/agents/ui/ManagedAgentRow.tsx | 19 +---- .../features/mesh-compute/meshAgents.test.mjs | 81 +++++++------------ .../src/features/mesh-compute/meshAgents.ts | 76 +++++++---------- .../ui/SidebarMeshComputeCard.tsx | 24 ++++-- 4 files changed, 73 insertions(+), 127 deletions(-) diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index cb0ae79ae..606d2b788 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -5,14 +5,12 @@ import { ChevronDown, ChevronRight, RefreshCw, - Share2, } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { Badge } from "@/shared/ui/badge"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; -import { usesMeshCompute } from "@/features/mesh-compute/meshAgents"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; @@ -409,23 +407,8 @@ function RuntimeBlock({

{agent.agentCommand}

- {runtimeSource || agent.model || usesMeshCompute(agent) ? ( + {runtimeSource || agent.model ? (
- {/* - Where an agent's inference runs is otherwise invisible without - opening its config, and "on shared compute" is the one provider - whose health the user can actually act on from the sidebar card. - */} - {usesMeshCompute(agent) ? ( - - - Shared compute - - ) : null} {runtimeSource ? {runtimeSource} : null} {agent.model ? {agent.model} : null}
diff --git a/desktop/src/features/mesh-compute/meshAgents.test.mjs b/desktop/src/features/mesh-compute/meshAgents.test.mjs index 245896e47..fc3fd1ce5 100644 --- a/desktop/src/features/mesh-compute/meshAgents.test.mjs +++ b/desktop/src/features/mesh-compute/meshAgents.test.mjs @@ -4,68 +4,45 @@ import test from "node:test"; /** * Contract tests for mesh agent detection and the card's call to action. * - * The CTA's value is that it stays quiet. A nudge that renders every time the - * card does becomes furniture, so most of these tests pin *silence*. + * Two things matter here. First, detection must read the **resolved** provider + * off `ManagedAgent`: the mesh provider is usually set at the global layer, so + * a persona's own `provider` field is null for most mesh agents — asking the + * persona was the original bug. Second, the CTA's value is that it stays + * quiet, so most of these tests pin *silence*. */ -import { - deriveMeshCallToAction, - summarizeMeshAgents, - usesMeshCompute, -} from "./meshAgents.ts"; +import { deriveMeshCallToAction, usesMeshCompute } from "./meshAgents.ts"; -function persona(overrides = {}) { - return { - id: "p1", - displayName: "Hop", - provider: "relay-mesh", - ...overrides, - }; +function agent(overrides = {}) { + return { pubkey: "a1", provider: "relay-mesh", ...overrides }; } -test("mesh agents are identified by provider, not by model string", () => { - const summary = summarizeMeshAgents([ - persona({ id: "a", displayName: "Zoe" }), - persona({ id: "b", displayName: "Ada" }), - persona({ id: "c", displayName: "Other", provider: "anthropic" }), - // A model that merely mentions a mesh model is not a mesh agent: intent - // lives in the persisted provider, not in a free-text model field. - persona({ id: "d", displayName: "Lookalike", provider: null }), - ]); - assert.equal(summary.count, 2); - assert.equal(summary.hasAny, true); - assert.deepEqual(summary.names, ["Ada", "Zoe"], "names sort for stable copy"); -}); - -test("provider matching tolerates whitespace and absence", () => { - assert.equal(usesMeshCompute(persona({ provider: " relay-mesh " })), true); - assert.equal(usesMeshCompute(persona({ provider: "anthropic" })), false); - assert.equal(usesMeshCompute(persona({ provider: null })), false); +test("detection reads the resolved provider, whitespace tolerant", () => { + assert.equal(usesMeshCompute(agent()), true); + assert.equal(usesMeshCompute(agent({ provider: " relay-mesh " })), true); + assert.equal(usesMeshCompute(agent({ provider: "anthropic" })), false); + // Null is the common case for an agent inheriting a NON-mesh global default, + // and for an orphaned instance. Neither is a mesh agent. + assert.equal(usesMeshCompute(agent({ provider: null })), false); assert.equal(usesMeshCompute(null), false); assert.equal(usesMeshCompute(undefined), false); }); -test("an empty persona list is not an error", () => { - const summary = summarizeMeshAgents([]); - assert.equal(summary.hasAny, false); - assert.deepEqual(summary.names, []); - assert.equal(summarizeMeshAgents(undefined).hasAny, false); -}); - -test("the CTA appears only when compute has no consumer", () => { - // Sharing, but nothing can use it — the dead end worth naming. +test("the CTA appears when there is compute and nothing using it", () => { + // Sharing from this machine. assert.deepEqual( deriveMeshCallToAction({ - personas: [persona({ provider: "anthropic" })], + agents: [agent({ provider: "anthropic" })], isSharing: true, meshHasCapacity: false, }), { kind: "createAgent", label: "Set up an agent to use it" }, ); - // Not sharing, but the community has capacity this person could use. + // Not sharing, but the community has capacity this person could use — a + // legitimate reason to set up an agent even without contributing. assert.equal( deriveMeshCallToAction({ - personas: [], + agents: [], isSharing: false, meshHasCapacity: true, }).kind, @@ -73,10 +50,10 @@ test("the CTA appears only when compute has no consumer", () => { ); }); -test("the CTA stays silent once a mesh agent exists", () => { +test("the CTA disappears the moment a mesh agent exists", () => { assert.deepEqual( deriveMeshCallToAction({ - personas: [persona()], + agents: [agent({ provider: "anthropic" }), agent({ pubkey: "a2" })], isSharing: true, meshHasCapacity: true, }), @@ -85,11 +62,9 @@ test("the CTA stays silent once a mesh agent exists", () => { }); test("no capacity anywhere means the CTA would just move the dead end", () => { - // Telling someone to build an agent for a mesh with nothing in it does not - // help them; it relocates the problem. assert.deepEqual( deriveMeshCallToAction({ - personas: [], + agents: [], isSharing: false, meshHasCapacity: false, }), @@ -97,12 +72,12 @@ test("no capacity anywhere means the CTA would just move the dead end", () => { ); }); -test("an unloaded persona list never prompts a possible duplicate", () => { - // `undefined` is "not fetched yet". Prompting someone to create an agent they - // may already have is worse than staying quiet for a moment. +test("an unloaded agent list never prompts a possible duplicate", () => { + // `undefined` is "not fetched yet". Prompting someone to create an agent + // they may already have is worse than staying quiet for a moment. assert.deepEqual( deriveMeshCallToAction({ - personas: undefined, + agents: undefined, isSharing: true, meshHasCapacity: true, }), diff --git a/desktop/src/features/mesh-compute/meshAgents.ts b/desktop/src/features/mesh-compute/meshAgents.ts index 40e133f0e..a6b9a8480 100644 --- a/desktop/src/features/mesh-compute/meshAgents.ts +++ b/desktop/src/features/mesh-compute/meshAgents.ts @@ -1,88 +1,66 @@ -import type { AgentPersona } from "@/shared/api/types"; +import type { ManagedAgent } from "@/shared/api/types"; /** * Which agents are set up to run on Buzz shared compute. * - * A persona points at the mesh by carrying `provider === "relay-mesh"` — the - * same id the backend gates mesh model discovery on - * (`managed_agents::RELAY_MESH_PROVIDER_ID`), so this asks the persistent - * record rather than inferring intent from a model string. + * ## Why this reads `ManagedAgent`, not `AgentPersona` * - * The point is a call to action: sharing compute that no agent can use is a - * dead end, and the card is where someone would notice. So the card needs to - * know not just *whether* mesh agents exist but whether the whole setup is - * inert — capacity with no consumer, or a consumer with no capacity. + * The mesh provider is usually set at the **global** layer + * (`global-agent-config.json`), not per agent — a persona's own `provider` + * field is null in that common case, so asking a persona "are you a mesh + * agent?" answers "no" for every agent on a machine whose global default is + * `relay-mesh`. That was the original bug here. + * + * Rust already resolves the layering (definition → global for linked + * instances, instance → global for definition-less ones) in + * `effective_config::resolve_effective_config`, and `runtime.rs` builds the + * `ManagedAgent` DTO from that resolved value. So this file consumes the + * resolved field and deliberately holds **no precedence rules of its own** — + * a second copy of that layering would drift from the one in Rust. */ -/** The provider id a persona carries when it runs on shared compute. */ +/** The provider id an agent carries when it runs on shared compute. */ export const RELAY_MESH_PROVIDER_ID = "relay-mesh"; -export type MeshAgentSummary = { - /** Agents configured to run on shared compute. */ - count: number; - /** Display names, for naming one specifically. Sorted for stable copy. */ - names: string[]; - /** True when at least one such agent exists. */ - hasAny: boolean; -}; - -export function summarizeMeshAgents( - personas: AgentPersona[] | undefined, -): MeshAgentSummary { - const matching = (personas ?? []).filter( - (persona) => persona.provider?.trim() === RELAY_MESH_PROVIDER_ID, - ); - const names = matching - .map((persona) => persona.displayName) - .sort((a, b) => a.localeCompare(b)); - return { count: matching.length, names, hasAny: matching.length > 0 }; -} - /** - * Whether a given persona runs on shared compute. + * Whether an agent runs on shared compute. * - * Used to decorate an agent wherever it appears, so "this one is running on the - * mesh" is legible without opening its config. + * Takes the resolved `provider` off a `ManagedAgent`; never re-derives it. */ export function usesMeshCompute( - persona: Pick | null | undefined, + agent: Pick | null | undefined, ): boolean { - return persona?.provider?.trim() === RELAY_MESH_PROVIDER_ID; + return agent?.provider?.trim() === RELAY_MESH_PROVIDER_ID; } /** - * The card's call to action, or null when none is warranted. + * The card's call to action, or `none` when none is warranted. * * Deliberately silent in the common case. A nudge that appears every time the * card renders becomes furniture, so this only speaks when the setup is - * genuinely incomplete in a way the person can act on: - * - * - sharing (or capacity exists) but no agent can use it → offer to make one - * - an agent exists but nothing is shared anywhere → offer to share - * - * Returns null while the picture is still unknown (`personas === undefined`), - * because prompting someone to create an agent they may already have is worse - * than staying quiet for a moment. + * genuinely incomplete in a way the person can act on: there is compute to use + * and nothing set up to use it. */ export type MeshCallToAction = | { kind: "createAgent"; label: string } | { kind: "none" }; export function deriveMeshCallToAction({ - personas, + agents, isSharing, meshHasCapacity, }: { - personas: AgentPersona[] | undefined; + /** Resolved agent list. `undefined` while still loading. */ + agents: ManagedAgent[] | undefined; isSharing: boolean; /** Anyone in the community is sharing, including this machine. */ meshHasCapacity: boolean; }): MeshCallToAction { // Unknown yet — say nothing rather than nudge toward a duplicate. - if (personas === undefined) { + if (agents === undefined) { return { kind: "none" }; } - if (summarizeMeshAgents(personas).hasAny) { + if (agents.some(usesMeshCompute)) { return { kind: "none" }; } // Compute with no consumer is the dead end worth naming. Only prompt when diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx index 4f19aa2c3..3e40fffe9 100644 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx @@ -8,7 +8,7 @@ import { meshStartNode, meshStopNode } from "@/shared/api/tauriMesh"; import type { MeshModelCatalog } from "@/shared/api/tauriMesh"; import { meshModelCatalog } from "@/shared/api/tauriMesh"; -import { usePersonasQuery } from "@/features/agents/hooks"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { deriveMeshCallToAction } from "../meshAgents"; import { useMeshDownloadProgress } from "../hooks/useMeshDownloadProgress"; @@ -103,11 +103,12 @@ export function SidebarMeshComputeCard({ useMeshDownloadProgress(); // Sharing compute no agent can use is a dead end, and this card is where - // someone would notice. The CTA stays silent unless the setup is actually - // incomplete — see deriveMeshCallToAction. - const { data: personas } = usePersonasQuery(); + // someone would notice. Reads the resolved `provider` off ManagedAgent, not + // a persona's raw field: the mesh provider is usually set globally, so the + // persona value is null for most mesh agents. See meshAgents.ts. + const { data: managedAgents } = useManagedAgentsQuery(); const callToAction = deriveMeshCallToAction({ - personas, + agents: managedAgents, isSharing: toggle.isSharing, meshHasCapacity: (snapshot?.sharingDeviceCount ?? 0) > 0, }); @@ -275,13 +276,22 @@ export function SidebarMeshComputeCard({ /> {callToAction.kind === "createAgent" ? ( + // A tip, not a warning: there is compute here and nothing set up to + // use it. The dot pulses to catch the eye once, then the tip simply + // disappears the moment a mesh agent exists. ) : null}