From fdb0ece546e63ccbf5957b81d98f74d2dc6be7d9 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Mon, 9 Nov 2026 13:41:50 -0800
Subject: [PATCH 1/8] fix(agents): remove BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES
Delete the build flag that kept workflow messages from authorizing local
agent requests in listed communities. Delegation is again decided only by
the relay-signed buzz:workflow-mention check, as before #2518. The relay's
secure workflows mode now withholds that tag for runs the owner or a timer
did not start.
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
docs/buzz-help.md | 5 --
docs/configuration.md | 13 ----
docs/unread.md | 3 +-
docs/workflows.md | 14 +---
src/bundled/claude-code/runtime.test.ts | 43 -----------
src/bundled/goose/runtime.ts | 1 -
src/features/agent-harness/kit.ts | 13 +---
src/features/agents/owner-attestation.ts | 36 +--------
src/features/agents/reads.ts | 3 -
.../agents/workflow-authorization.test.ts | 77 +------------------
vite.config.ts | 9 ---
11 files changed, 12 insertions(+), 205 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index d78a0806a4..fa81d516d3 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -957,11 +957,6 @@ available rather than silently discarding it.
- On desktop, Buzz starts every agent with `BUZZ_VERSION` set to the app's
version, and agents usually pass it on to the shell commands they run. A
script or shell config can check for it to tell it is running under Buzz.
-- In communities where your build restricts workflows, workflow messages do
- not automatically start or steer local Claude, Codex, Goose, or Pi agents. To
- have one act on a workflow result, send it a direct request explaining what
- to do with that result. Other communities still let a workflow you or your
- agents created mention your agents.
- **Agents** is where people create and manage agents. For supported types,
open an agent there and use **Identity → Instructions**. Opening its avatar or
name in a conversation shows its profile, where **Settings → Instructions**
diff --git a/docs/configuration.md b/docs/configuration.md
index 325f05d4cb..944f65e721 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -9,7 +9,6 @@ Build defaults are readable binary data, **never secret storage**.
| Input | Consumer and boundary |
| --- | --- |
| `BUZZ_EXTERNAL_MCP_COMMUNITIES` | Frontend build: space-separated secure community origins allowed to authorize external MCP access. Empty disables access. See below. |
-| `BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` | Frontend build: space-separated secure community origins where workflow messages cannot start or steer local agents. Empty keeps checked workflow delegation everywhere. See below. |
| `BUZZ_BUILDERLAB_URL` | Shared frontend/native build: the live development broker's Builderlab, and whether a build without an added sign-in server keeps the built-in one; no runtime override. See below. |
| `BUZZ_BUILDERLAB_APPS_DOMAIN` | Shared frontend/native build: exact Apps cookie domain paired with the login origin for native viewer SSO; no runtime override. |
| `BUZZ_BUILDERLAB_APPS_CONTROL_PLANE` | Shared frontend/native build: HTTPS origin of the Apps control plane for the configured login origin, for deployments the built-in public table does not list; no runtime override. |
@@ -48,18 +47,6 @@ repository's workflow sets none. Community names do not grant access. The Agents
opening a connection and on each tool call. Consent disables other communities,
and Settings hides External apps there. Normal agent MCP servers are unchanged.
-## Workflow restriction community flag
-
-`BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` is a space-separated list of `https://`
-or `wss://` community origins, parsed as the external flag is. The default is
-empty: in every community, a workflow mention can start or steer a local Claude,
-Codex, Goose or Pi agent when its creator is the agent's owner or a sibling the
-owner attested. Each build lists its own restricted community origins. In a listed community no
-workflow-marked message authorizes a local agent request, in bundled or plugin
-harnesses; the message stays visible. While any community is listed, an agent
-whose community cannot be read is restricted too. See
-[workflows](workflows.md).
-
## Packaged community default
Internal packagers can set a public community origin for fresh installs:
diff --git a/docs/unread.md b/docs/unread.md
index 2ee79d07a8..df1a67646e 100644
--- a/docs/unread.md
+++ b/docs/unread.md
@@ -379,7 +379,8 @@ channel-mute mention exception. Ordinary unread, DM and participating-thread
rules still apply. No message or read marker is rewritten.
The relay currently emits `buzz:workflow-mention` only for recipients named in
-the stored template as well as the rendered output. If substituted input alone
+the stored template as well as the rendered output, and a relay running
+[secure workflows mode](https://github.com/block/buzz/blob/main/docs/operator-secure-workflows.md) may leave it off, so no agent acts on the message. If substituted input alone
names the owner, its single `p` tag cannot distinguish that mention from owner
attribution and does not create mention attention. Put the owner's explicit
`@Name` in the template when they should be alerted. Distinguishing substituted
diff --git a/docs/workflows.md b/docs/workflows.md
index e6eee5ea2b..4bed1837d2 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -256,21 +256,13 @@ The event must contain a canonical `buzz:workflow-mention` for the receiving
agent; all such tags
must have exactly two fields, a lowercase-hex pubkey, and no duplicate targets.
The relay emits these tags only for targets present in the owner-authored template
-and the rendered output. Legacy `p` tags route mentions but do not authorize them.
+and the rendered output. A relay running
+[secure workflows mode](https://github.com/block/buzz/blob/main/docs/operator-secure-workflows.md) may leave the tag off; the message still posts, but
+no agent acts on it. Legacy `p` tags route mentions but do not authorize them.
Workflows created and maintained under an owned agent's own key remain owned by
that agent and can wake agents with the same proven human owner. This does not
grant an agent permission to edit a workflow owned by a different signing identity.
-Builds can withhold this delegation per community. In communities listed by
-`BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES`, no message carrying a `buzz:workflow` tag
-authorizes a local agent request, whatever its signer, owner or mention tags, in
-bundled or plugin harnesses. Workflow messages there stay visible but do not start
-or steer local Claude, Codex, Goose or Pi agents, including for owner-created
-static templates and scheduled workflows. Each build lists its own restricted
-communities; communities it does not list keep the delegation above. The restriction
-does not prevent workflow output from influencing a later authorized turn. See
-[configuration](configuration.md#workflow-restriction-community-flag).
-
Cached-only startup intentionally shows the actual signer until a live connection
establishes explicit relay authority. Saved operator-contact `relayAuthor` is not
promoted to `self`, and no new trusted storage is introduced. Signer-based search
diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts
index 3937b13aec..f1decf1b42 100644
--- a/src/bundled/claude-code/runtime.test.ts
+++ b/src/bundled/claude-code/runtime.test.ts
@@ -215,7 +215,6 @@ function setup(
status: "ready",
session,
viewer: alice,
- origin: "https://agent.example",
scope: `https://agent.example:${alice}`,
}),
} as unknown as RelayData;
@@ -2247,48 +2246,6 @@ it.each([true, false])(
},
);
-it.each([
- ["https://agent.example", false],
- ["https://other.example", true],
-])(
- "refuses an owner's workflow mention where the build restricts %s",
- async (restricted, runs) => {
- vi.stubEnv("VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES", restricted);
- const relay = keypair();
- const event = eventDto(
- signed(relay, {
- kind: 9,
- content: "@Claude workflow",
- tags: [
- ["h", channel],
- ["p", self],
- ["buzz:workflow", "true"],
- ["buzz:workflow-owner", alice],
- ["buzz:workflow-mention", self],
- ],
- }),
- );
- const f = setup([event], { workflowAuthority: relay.pubkey });
- try {
- await f.deliver({ type: "mention", event });
- if (runs) {
- await vi.waitFor(() =>
- expect(f.claudes().some((process) => process.prompts.length)).toBe(
- true,
- ),
- );
- } else {
- await flush(10);
- expect(f.claudes()).toHaveLength(0);
- expect(f.workingUntil).not.toHaveBeenCalled();
- }
- } finally {
- f.runtime.dispose();
- vi.unstubAllEnvs();
- }
- },
-);
-
it.each([false, true])(
"handles sibling workflow authorization with cancellation=%s",
async (cancelled) => {
diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts
index 9dec34fae8..55f6b3323a 100644
--- a/src/bundled/goose/runtime.ts
+++ b/src/bundled/goose/runtime.ts
@@ -405,7 +405,6 @@ export class GooseRuntime {
if (
!(await fromOwner(trigger.event, agent.owner, {
agent: agent.pubkey,
- community: snapshot?.origin,
authority:
snapshot?.status === "ready"
? snapshot.session.workflowAuthority
diff --git a/src/features/agent-harness/kit.ts b/src/features/agent-harness/kit.ts
index 25f017c416..3bc4f2a9e5 100644
--- a/src/features/agent-harness/kit.ts
+++ b/src/features/agent-harness/kit.ts
@@ -223,17 +223,8 @@ export function harnessKit(sources: Sources): HarnessKit {
const wake = trigger.type !== "mention" ? trigger : undefined;
if (trigger.type === "mention") {
// Only the owner or an attested sibling. Unlike the bundled harnesses,
- // which kept relay workflow mentions for compatibility, no workflow
- // authority is given, so no workflow mention is delegated; the
- // community still applies the build's workflow restriction.
- if (
- !(await fromOwner(trigger.event, agent.owner, {
- agent: agent.pubkey,
- authority: undefined,
- community: sources.relay(agent.key)?.snapshot().origin,
- }))
- )
- return undefined;
+ // which kept relay workflow mentions for compatibility, no workflow check.
+ if (!(await fromOwner(trigger.event, agent.owner))) return undefined;
signal.throwIfAborted();
}
const event =
diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts
index ca346c5fe0..4cf2208bb1 100644
--- a/src/features/agents/owner-attestation.ts
+++ b/src/features/agents/owner-attestation.ts
@@ -1,6 +1,5 @@
import { schnorr } from "@noble/curves/secp256k1.js";
import { hexToBytes } from "nostr-tools/utils";
-import { communityDestination } from "../communities/destination";
import type { ProfileQueries } from "../relay/profile-directory";
import { hasEventProof, type EventData } from "../relay/events";
import { workflowMentionOwner } from "../relay/workflow-attribution";
@@ -90,28 +89,9 @@ function satisfied(
});
}
-/** Whether this build keeps workflow messages from authorizing local agent
- * requests in `community`. The build flag lists exact canonical origins; with
- * any listed, an unknown or unreadable community is restricted too. */
-export function workflowRequestsRestricted(community: string | undefined) {
- const restricted = (
- import.meta.env.VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES ?? ""
- )
- .split(" ")
- .filter(Boolean);
- if (!restricted.length) return false;
- if (!community) return true;
- try {
- return restricted.includes(communityDestination(community).url);
- } catch {
- return true;
- }
-}
-
-/** How a mention from a relay workflow is checked: the agent's community, the
- * community's workflow key, and its creators' profiles. */
+/** How a mention from a relay workflow is checked: the community's workflow
+ * key, and its creators' profiles. */
export type WorkflowCheck = {
- community?: string | undefined;
authority: string | undefined;
agent: string;
profiles?: Pick | undefined;
@@ -124,7 +104,6 @@ export function workflowOf(
): WorkflowCheck {
return {
agent,
- community: reads?.origin,
authority: reads?.workflowAuthority(),
profiles: reads && {
ensure: async (ids) => {
@@ -136,23 +115,16 @@ export function workflowOf(
}
/** Owner prompts, attested siblings, and verified relay workflow mentions
- * delegated by the owner or a profile-verified sibling to this particular agent.
- * Where the build restricts workflow requests, no workflow-marked message
- * authorizes one: its rendered text is not bound to the owner's template. */
+ * delegated by the owner or a profile-verified sibling to this particular agent. */
export async function fromOwner(
event: EventData,
owner: string,
workflow?: WorkflowCheck,
) {
- const restricted = workflowRequestsRestricted(workflow?.community);
- if (restricted && event.tags.some(([name]) => name === "buzz:workflow"))
- return false;
if (event.pubkey === owner || (await attestedOwner(event)) === owner)
return true;
const creator =
- !restricted &&
- workflow &&
- workflowMentionOwner(event, workflow.authority, workflow.agent);
+ workflow && workflowMentionOwner(event, workflow.authority, workflow.agent);
if (!creator) return false;
if (creator === owner) return true;
const profiles = workflow?.profiles;
diff --git a/src/features/agents/reads.ts b/src/features/agents/reads.ts
index 1f0858e237..955e5f63a9 100644
--- a/src/features/agents/reads.ts
+++ b/src/features/agents/reads.ts
@@ -35,8 +35,6 @@ export type AgentReads = Readonly<{
connection: object;
/** The community these reads are of; it changes when the agent's does. */
scope: string;
- /** That community's normalized origin, when known. */
- origin?: string | undefined;
/** The channel as the community lists it, or undefined when unknown. */
channel(id: string): Promise;
read(
@@ -92,7 +90,6 @@ export function ownerReads(
return Object.freeze({
connection: session,
scope: snapshot.scope ?? "",
- origin: snapshot.origin,
channel: async (id: string) => {
const summary =
session.channels.list().channels.find((item) => item.id === id) ??
diff --git a/src/features/agents/workflow-authorization.test.ts b/src/features/agents/workflow-authorization.test.ts
index 1c3c630c2b..17c39904d1 100644
--- a/src/features/agents/workflow-authorization.test.ts
+++ b/src/features/agents/workflow-authorization.test.ts
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { schnorr } from "@noble/curves/secp256k1.js";
import { bytesToHex } from "nostr-tools/utils";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
import { eventDto, type RelayEvent } from "../relay/events";
import { createProfileDirectory } from "../relay/profile-directory";
import type { RelayReader } from "../relay/reader";
@@ -153,81 +153,6 @@ it("recovers from failed or missing reads and respects a replaced profile head",
}
});
-describe("communities the build restricts", () => {
- const restricted = "wss://restricted.example.test";
- const direct = (key = owner, tags: string[][] = []) =>
- eventDto(signed(key, { kind: 9, content: "@target do it", tags }));
- beforeEach(() => {
- vi.stubEnv(
- "VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES",
- "https://restricted.example.test",
- );
- });
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it("deny every workflow mention there and keep delegation elsewhere", async () => {
- const f = directory();
- try {
- const blocked = { ...f.context, community: restricted };
- expect(await fromOwner(prompt(), owner.pubkey, blocked)).toBe(false);
- expect(await fromOwner(prompt(), creator.pubkey, blocked)).toBe(false);
- expect(f.read).not.toHaveBeenCalled();
- const open = {
- ...f.context,
- community: "wss://team.communities.buzz.xyz",
- };
- expect(await fromOwner(prompt(), owner.pubkey, open)).toBe(true);
- expect(await fromOwner(prompt(), creator.pubkey, open)).toBe(true);
- } finally {
- f.profiles.dispose();
- }
- });
-
- it("deny workflow-marked messages from the owner but keep direct requests", async () => {
- const workflow = [["buzz:workflow", "true"]];
- const blocked = { authority: relay.pubkey, agent: target.pubkey };
- expect(
- await fromOwner(direct(owner, workflow), owner.pubkey, {
- ...blocked,
- community: restricted,
- }),
- ).toBe(false);
- expect(
- await fromOwner(direct(owner, workflow), owner.pubkey, {
- ...blocked,
- community: "https://team.communities.buzz.xyz",
- }),
- ).toBe(true);
- expect(
- await fromOwner(direct(), owner.pubkey, {
- ...blocked,
- community: restricted,
- }),
- ).toBe(true);
- expect(
- await fromOwner(direct(creator, [auth()]), owner.pubkey, {
- ...blocked,
- community: restricted,
- }),
- ).toBe(true);
- });
-
- it("treat an unknown community as restricted while any is listed", async () => {
- const f = directory();
- try {
- expect(await fromOwner(prompt(), creator.pubkey, f.context)).toBe(false);
- expect(await fromOwner(prompt(), creator.pubkey)).toBe(false);
- expect(await fromOwner(direct(), owner.pubkey)).toBe(true);
- vi.stubEnv("VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES", "");
- expect(await fromOwner(prompt(), creator.pubkey, f.context)).toBe(true);
- } finally {
- f.profiles.dispose();
- }
- });
-});
-
it("denies a profile read invalidated by session disposal", async () => {
let release!: (events: RelayEvent[]) => void;
const pending = new Promise((resolve) => {
diff --git a/vite.config.ts b/vite.config.ts
index 050b33e89c..aa0d16b0d6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -108,13 +108,6 @@ export default defineConfig(async ({ command, mode }) => {
.filter(Boolean)
.map(relayOrigin)
.join(" ");
- const workflowRestrictedCommunities = (
- env.BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES ?? ""
- )
- .split(/\s+/)
- .filter(Boolean)
- .map(relayOrigin)
- .join(" ");
// Public routing configuration only; the viewer pin and credentials stay in Node.
const defaultRelay = env.BUZZ_RELAY_URL?.trim();
const defaultOrigin = defaultRelay ? relayOrigin(defaultRelay) : "";
@@ -211,8 +204,6 @@ export default defineConfig(async ({ command, mode }) => {
"import.meta.env.VITE_BUZZ_EXTERNAL_MCP_COMMUNITIES": JSON.stringify(
externalMcpCommunities,
),
- "import.meta.env.VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES":
- JSON.stringify(workflowRestrictedCommunities),
"import.meta.env.VITE_BUZZ_BUILDERLAB_URL": JSON.stringify(builderlabUrl),
"import.meta.env.VITE_BUZZ_FEDERATED_IDENTITY": JSON.stringify(
federatedUrl === undefined ? "0" : "1",
From 07c514043ff6119c49e11635201fc5ba52cbb150 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Mon, 9 Nov 2026 16:51:41 -0800
Subject: [PATCH 2/8] Show owners why an agent didn't act, and warn about
owner-only agent mentions
A mention an agent refuses for who sent it now leaves a note under the
message, on the owner's device only. The reason comes from
mentionAuthority, next to fromOwner, which every bundled harness and the
plugin harness kit report through Delivery.ignored.
The workflow editor reads the community's NIP-11
workflow_agent_mentions and warns when agents mentioned in the
workflow's messages won't act on some or all of its runs.
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
docs/unread.md | 2 +-
docs/workflows.md | 5 +-
src/bundled/claude-code/runtime.ts | 16 +--
src/bundled/codex/runtime.ts | 16 +--
src/bundled/goose/runtime.ts | 28 ++--
src/bundled/pi/runtime.ts | 16 ++-
src/bundled/workflows/WorkflowChannel.tsx | 4 +
src/bundled/workflows/WorkflowEditor.test.tsx | 37 ++++++
src/bundled/workflows/WorkflowEditor.tsx | 28 +++-
src/bundled/workflows/WorkflowsPage.tsx | 3 +
.../workflows/useWorkflowAgentMentions.ts | 28 ++++
.../workflowAgentMentionWarning.test.ts | 70 ++++++++++
.../workflows/workflowAgentMentionWarning.ts | 67 ++++++++++
src/features/agent-harness/kit.test.ts | 26 +++-
src/features/agent-harness/kit.ts | 10 +-
src/features/agents/activity.ts | 19 ++-
src/features/agents/local-activity.ts | 44 ++++++-
src/features/agents/local-decline.test.ts | 60 +++++++++
src/features/agents/mention-authority.test.ts | 122 ++++++++++++++++++
src/features/agents/owner-attestation.ts | 61 +++++++--
src/features/agents/runner.ts | 3 +-
src/features/agents/service.ts | 8 +-
.../messages/AgentDeclineNotes.test.tsx | 59 +++++++++
src/features/messages/AgentDeclineNotes.tsx | 58 +++++++++
src/features/messages/MessageRow.tsx | 8 ++
src/features/messages/Messages.module.css | 7 +
26 files changed, 732 insertions(+), 73 deletions(-)
create mode 100644 src/bundled/workflows/useWorkflowAgentMentions.ts
create mode 100644 src/bundled/workflows/workflowAgentMentionWarning.test.ts
create mode 100644 src/bundled/workflows/workflowAgentMentionWarning.ts
create mode 100644 src/features/agents/local-decline.test.ts
create mode 100644 src/features/agents/mention-authority.test.ts
create mode 100644 src/features/messages/AgentDeclineNotes.test.tsx
create mode 100644 src/features/messages/AgentDeclineNotes.tsx
diff --git a/docs/unread.md b/docs/unread.md
index df1a67646e..fee7747105 100644
--- a/docs/unread.md
+++ b/docs/unread.md
@@ -380,7 +380,7 @@ rules still apply. No message or read marker is rewritten.
The relay currently emits `buzz:workflow-mention` only for recipients named in
the stored template as well as the rendered output, and a relay running
-[secure workflows mode](https://github.com/block/buzz/blob/main/docs/operator-secure-workflows.md) may leave it off, so no agent acts on the message. If substituted input alone
+[owner-only agent mentions](https://github.com/block/buzz/blob/main/docs/operator-workflow-agent-mentions.md) may leave it off, so no agent acts on the message. If substituted input alone
names the owner, its single `p` tag cannot distinguish that mention from owner
attribution and does not create mention attention. Put the owner's explicit
`@Name` in the template when they should be alerted. Distinguishing substituted
diff --git a/docs/workflows.md b/docs/workflows.md
index 4bed1837d2..e979ad142d 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -257,8 +257,9 @@ agent; all such tags
must have exactly two fields, a lowercase-hex pubkey, and no duplicate targets.
The relay emits these tags only for targets present in the owner-authored template
and the rendered output. A relay running
-[secure workflows mode](https://github.com/block/buzz/blob/main/docs/operator-secure-workflows.md) may leave the tag off; the message still posts, but
-no agent acts on it. Legacy `p` tags route mentions but do not authorize them.
+[owner-only agent mentions](https://github.com/block/buzz/blob/main/docs/operator-workflow-agent-mentions.md) may leave the tag off; the message still posts, but
+no agent acts on it, and the owner's app notes under the message which of their
+agents didn't act. Legacy `p` tags route mentions but do not authorize them.
Workflows created and maintained under an owned agent's own key remain owned by
that agent and can wake agents with the same proven human owner. This does not
grant an agent permission to edit a workflow owned by a different signing identity.
diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts
index cc0c42f8ca..f69c5ddfa5 100644
--- a/src/bundled/claude-code/runtime.ts
+++ b/src/bundled/claude-code/runtime.ts
@@ -22,7 +22,10 @@ import {
unsupportedSynced,
unverifiedSynced,
} from "../../features/agents/agent-config";
-import { fromOwner, workflowOf } from "../../features/agents/owner-attestation";
+import {
+ mentionAuthority,
+ workflowOf,
+} from "../../features/agents/owner-attestation";
import type { TranscriptTurn } from "../../features/agents/activity-transcript";
import type { AgentReads } from "../../features/agents/reads";
import { localStore, type AgentStore } from "../../features/agents/store";
@@ -963,16 +966,13 @@ export class ClaudeRuntime {
// for the owner.
const wake = trigger.type !== "mention" ? trigger : undefined;
// Claude runs commands as you; mentions need owner or delegated authority.
- if (
- trigger.type === "mention" &&
- !(await fromOwner(
+ if (trigger.type === "mention") {
+ const authority = await mentionAuthority(
trigger.event,
agent.owner,
workflowOf(this.reads(agent.key), agent.pubkey),
- ))
- ) {
- ignored?.();
- return;
+ );
+ if (authority !== "owner") return ignored?.(authority);
}
signal.throwIfAborted();
const entry = this.entry(agent, settings);
diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts
index 98bd30b868..e530be91ad 100644
--- a/src/bundled/codex/runtime.ts
+++ b/src/bundled/codex/runtime.ts
@@ -12,7 +12,10 @@ import { isFinalMessage } from "../../buzz-mcp/session-tools";
import type { WorkOutcome } from "../../features/agents/local-activity";
import type { AgentProtection } from "../../features/agent-protection/service";
import { nativeProtectionBridge } from "../../features/agent-protection/native-bridge";
-import { fromOwner, workflowOf } from "../../features/agents/owner-attestation";
+import {
+ mentionAuthority,
+ workflowOf,
+} from "../../features/agents/owner-attestation";
import {
type ConversationWake,
wakeAnchor,
@@ -1682,16 +1685,13 @@ export class CodexRuntime {
const event =
trigger.type !== "mention" ? wakeAnchor(trigger) : trigger.event;
const reads = this.reads(agent.key);
- if (
- trigger.type === "mention" &&
- !(await fromOwner(
+ if (trigger.type === "mention") {
+ const authority = await mentionAuthority(
trigger.event,
agent.owner,
workflowOf(reads, agent.pubkey),
- ))
- ) {
- delivery.ignored?.();
- return;
+ );
+ if (authority !== "owner") return delivery.ignored?.(authority);
}
delivery.signal.throwIfAborted();
if (!reads) throw new Error("Connect to the agent's community first");
diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts
index 55f6b3323a..2964a2ef97 100644
--- a/src/bundled/goose/runtime.ts
+++ b/src/bundled/goose/runtime.ts
@@ -21,7 +21,7 @@ import {
turnNote,
} from "../../buzz-mcp/session-tools";
import {
- fromOwner,
+ mentionAuthority,
type Agent,
type AgentHandle,
type Agents,
@@ -402,22 +402,16 @@ export class GooseRuntime {
// goose runs commands as you; mentions need owner or delegated authority.
if (trigger.type === "mention") {
const snapshot = this.relay(agent.key)?.snapshot();
- if (
- !(await fromOwner(trigger.event, agent.owner, {
- agent: agent.pubkey,
- authority:
- snapshot?.status === "ready"
- ? snapshot.session.workflowAuthority
- : undefined,
- profiles:
- snapshot?.status === "ready"
- ? snapshot.session.profiles
- : undefined,
- }))
- ) {
- ignored?.();
- return;
- }
+ const authority = await mentionAuthority(trigger.event, agent.owner, {
+ agent: agent.pubkey,
+ authority:
+ snapshot?.status === "ready"
+ ? snapshot.session.workflowAuthority
+ : undefined,
+ profiles:
+ snapshot?.status === "ready" ? snapshot.session.profiles : undefined,
+ });
+ if (authority !== "owner") return ignored?.(authority);
signal.throwIfAborted();
}
const entry = this.entry(agent.key, settings);
diff --git a/src/bundled/pi/runtime.ts b/src/bundled/pi/runtime.ts
index 4e645cc94a..e45111f6ae 100644
--- a/src/bundled/pi/runtime.ts
+++ b/src/bundled/pi/runtime.ts
@@ -29,7 +29,10 @@ import {
} from "../../features/agent-harness/core-memory";
import { readAuthorNames } from "../../features/agent-harness/author-names";
import { readProject } from "../../features/agent-harness/project";
-import { fromOwner, workflowOf } from "../../features/agents/owner-attestation";
+import {
+ mentionAuthority,
+ workflowOf,
+} from "../../features/agents/owner-attestation";
import { type AgentKey, pubkeyOf } from "../../features/agents/membership";
import { sessionPrompts } from "../../features/agents/session-prompts";
import { localStore, type AgentStore } from "../../features/agents/store";
@@ -545,15 +548,14 @@ export class PiRuntime {
// it, and keeps its own destination for a failure recovered after a stop.
const attention = wake?.type === "attention";
// Pi runs commands as you; mentions need owner or delegated authority.
- if (
- trigger.type === "mention" &&
- !(await fromOwner(
+ if (trigger.type === "mention") {
+ const authority = await mentionAuthority(
trigger.event,
agent.owner,
workflowOf(this.reads(agent.key), agent.pubkey),
- ))
- )
- return ignored?.();
+ );
+ if (authority !== "owner") return ignored?.(authority);
+ }
signal.throwIfAborted();
const entry = this.entry(agent, config(raw));
entry.handle = agent;
diff --git a/src/bundled/workflows/WorkflowChannel.tsx b/src/bundled/workflows/WorkflowChannel.tsx
index 977e61cf99..1f844c1797 100644
--- a/src/bundled/workflows/WorkflowChannel.tsx
+++ b/src/bundled/workflows/WorkflowChannel.tsx
@@ -6,6 +6,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
+import type { WorkflowAgentMentions } from "./workflowAgentMentionWarning";
import type {
WorkflowCapability,
WorkflowDefinition,
@@ -65,6 +66,7 @@ export function WorkflowChannel({
onSaveReadback,
onClose,
onDelete,
+ agentMentions,
}: {
capability: WorkflowCapability;
channelId: string;
@@ -77,6 +79,7 @@ export function WorkflowChannel({
) => void;
onClose: () => void;
onDelete: (definition: WorkflowDefinition) => void;
+ agentMentions?: WorkflowAgentMentions | undefined;
}) {
const { snapshot, refresh } = useWorkflowView(
useCallback(
@@ -374,6 +377,7 @@ export function WorkflowChannel({
onChange={(yaml) => setDraft({ ...draft, yaml })}
onSave={save}
onLocalDraftRiskChange={setLocalDraftAtRisk}
+ agentMentions={agentMentions}
readOnly={readonly}
busy={busy}
locked={!!draft.operationId}
diff --git a/src/bundled/workflows/WorkflowEditor.test.tsx b/src/bundled/workflows/WorkflowEditor.test.tsx
index 14e0f69e2d..bf32a35dd3 100644
--- a/src/bundled/workflows/WorkflowEditor.test.tsx
+++ b/src/bundled/workflows/WorkflowEditor.test.tsx
@@ -5,6 +5,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, test, vi } from "vitest";
import { WorkflowEditor } from "./WorkflowEditor";
+import { WorkflowMentionMembers } from "./WorkflowMessageText";
import { fixtureYaml } from "./fixtures";
import { parse } from "yaml";
@@ -514,3 +515,39 @@ test("opening an existing workflow focuses its heading without running or saving
expect(run).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
+
+test("warns while editing when the relay limits agent mentions to the owner's runs", () => {
+ const yaml = `name: Review
+trigger:
+ on: reaction_added
+steps:
+ - id: say
+ action: send_message
+ text: "@Builder review this"
+`;
+ const editor = (agentMentions?: "any" | "owner") => (
+
+ {}}
+ onSave={() => {}}
+ agentMentions={agentMentions}
+ />
+
+ );
+ const { rerender } = render(editor("owner"));
+ expect(
+ screen.getByText(/Agents you mention act only on runs you start/),
+ ).toBeInTheDocument();
+ rerender(editor());
+ expect(screen.queryByText(/Agents you mention/)).not.toBeInTheDocument();
+});
diff --git a/src/bundled/workflows/WorkflowEditor.tsx b/src/bundled/workflows/WorkflowEditor.tsx
index 602abc8e5a..a935c9aa09 100644
--- a/src/bundled/workflows/WorkflowEditor.tsx
+++ b/src/bundled/workflows/WorkflowEditor.tsx
@@ -1,7 +1,14 @@
import { Field } from "../../shared/design-system/ui/Field";
import { Input } from "../../shared/design-system/ui/Input";
import { Textarea } from "../../shared/design-system/ui/Textarea";
-import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import {
+ useContext,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
import { DotsThreeIcon } from "../../shared/design-system/icons";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import { Button } from "../../shared/design-system/ui/Button";
@@ -18,6 +25,11 @@ import { WorkflowForm, type WorkflowSelection } from "./WorkflowForm";
import { conditionRowError } from "./workflowConditionExpression";
import { draftIssue } from "./editor-model";
import { getWorkflowActivationWarning } from "./workflowActivationWarning";
+import {
+ getAgentMentionWarning,
+ type WorkflowAgentMentions,
+} from "./workflowAgentMentionWarning";
+import { WorkflowMentionMembers } from "./WorkflowMessageText";
import {
formStateToYaml,
yamlToFormState,
@@ -50,6 +62,7 @@ export function WorkflowEditor({
children,
create = false,
chooseChannel = false,
+ agentMentions = "any",
}: {
yaml: string;
initialYaml?: string | undefined;
@@ -72,6 +85,8 @@ export function WorkflowEditor({
children?: ReactNode;
create?: boolean;
chooseChannel?: boolean;
+ /** The community's NIP-11 `workflow_agent_mentions`. */
+ agentMentions?: WorkflowAgentMentions | undefined;
}) {
const id = useId();
const heading = useRef(null);
@@ -135,6 +150,12 @@ export function WorkflowEditor({
onChange(next);
};
const warning = getWorkflowActivationWarning(yaml);
+ const members = useContext(WorkflowMentionMembers);
+ const mentionWarning = getAgentMentionWarning(
+ yaml,
+ members.filter((member) => member.agent).map((member) => member.name),
+ agentMentions,
+ );
const submit = () => {
if (readOnly || busy || locked || blocked || error) return;
const wasEnabled =
@@ -406,6 +427,11 @@ export function WorkflowEditor({
{controlReason}
)}
+ {!readOnly && mentionWarning && (
+
+ {mentionWarning}
+
+ )}
{status}
{activating && (
diff --git a/src/bundled/workflows/WorkflowsPage.tsx b/src/bundled/workflows/WorkflowsPage.tsx
index a34932e6e3..011acbfb81 100644
--- a/src/bundled/workflows/WorkflowsPage.tsx
+++ b/src/bundled/workflows/WorkflowsPage.tsx
@@ -5,6 +5,7 @@ import {
useState,
type ReactNode,
} from "react";
+import { useWorkflowAgentMentions } from "./useWorkflowAgentMentions";
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
import type { WorkflowDefinition } from "../../features/workflows/types";
@@ -131,6 +132,7 @@ export function WorkflowCommunity({
}, [open, detailTarget]);
const channel = eligible.find((item) => item.id === selected);
const mentionMembers = useWorkflowMentionMembers(session, channel);
+ const agentMentions = useWorkflowAgentMentions(session.scope);
useEffect(() => {
if (
!selected ||
@@ -340,6 +342,7 @@ export function WorkflowCommunity({
onSaveReadback={setSaveReadback}
onDelete={deleteWorkflow}
viewer={viewer}
+ agentMentions={agentMentions}
onClose={() => openChannel("")}
/>
diff --git a/src/bundled/workflows/useWorkflowAgentMentions.ts b/src/bundled/workflows/useWorkflowAgentMentions.ts
new file mode 100644
index 0000000000..1bfd4fdb56
--- /dev/null
+++ b/src/bundled/workflows/useWorkflowAgentMentions.ts
@@ -0,0 +1,28 @@
+import { useEffect, useState } from "react";
+import { nativeIdentityEnabled } from "../../features/identity/service";
+import { communityFromScope } from "../../features/relay/gifs";
+import { nativeRelayInfo } from "../../features/relay/native";
+import {
+ workflowAgentMentions,
+ type WorkflowAgentMentions,
+} from "./workflowAgentMentionWarning";
+
+/** The active community's NIP-11 mode, read once per community. `any` until
+ * read, when unreadable, and on hosts with no NIP-11 reader. */
+export function useWorkflowAgentMentions(scope: string): WorkflowAgentMentions {
+ const community = communityFromScope(scope);
+ const [mode, setMode] = useState<{
+ community: string;
+ mode: WorkflowAgentMentions;
+ }>();
+ useEffect(() => {
+ if (!community || !nativeIdentityEnabled()) return;
+ const controller = new AbortController();
+ nativeRelayInfo(community, controller.signal).then(
+ (info) => setMode({ community, mode: workflowAgentMentions(info) }),
+ () => {},
+ );
+ return () => controller.abort();
+ }, [community]);
+ return mode?.community === community ? mode.mode : "any";
+}
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.test.ts b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
new file mode 100644
index 0000000000..7d8f08978b
--- /dev/null
+++ b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
@@ -0,0 +1,70 @@
+import { expect, it } from "vitest";
+import {
+ getAgentMentionWarning,
+ NEVER_WARNING,
+ OWNER_RUNS_WARNING,
+ workflowAgentMentions,
+} from "./workflowAgentMentionWarning";
+
+const definition = (
+ trigger: string,
+ text = "@Builder review this",
+ extra = "",
+) => `name: Review
+trigger:
+ on: ${trigger}
+steps:
+ - id: say
+ action: send_message
+ text: "${text}"
+${extra}`;
+const warn = (
+ yaml: string,
+ mode = workflowAgentMentions({ workflow_agent_mentions: "owner" }),
+) => getAgentMentionWarning(yaml, ["Builder"], mode);
+
+it.each(["message_posted", "reaction_added", "diff_posted"])(
+ "warns that only your own runs direct agents on a %s trigger",
+ (trigger) => expect(warn(definition(trigger))).toBe(OWNER_RUNS_WARNING),
+);
+
+it("warns that agents never act on a webhook trigger", () => {
+ expect(warn(definition("webhook"))).toBe(NEVER_WARNING);
+});
+
+it("warns that agents never act with any step but a message or delay", () => {
+ const webhook = ` - id: fetch
+ action: call_webhook
+ url: https://example.test
+`;
+ expect(warn(definition("schedule", undefined, webhook))).toBe(NEVER_WARNING);
+ expect(warn(definition("message_posted", undefined, webhook))).toBe(
+ NEVER_WARNING,
+ );
+});
+
+it("doesn't warn a schedule with only message and delay steps", () => {
+ const delay = ` - id: wait
+ action: delay
+ duration: 5m
+`;
+ expect(warn(definition("schedule", undefined, delay))).toBeNull();
+});
+
+it("doesn't warn without an agent mention", () => {
+ expect(warn(definition("webhook", "@Someone else"))).toBeNull();
+ expect(getAgentMentionWarning(definition("webhook"), [], "owner")).toBeNull();
+});
+
+it("doesn't warn on a relay that lets any run direct agents, or an older one", () => {
+ expect(warn(definition("webhook"), workflowAgentMentions({}))).toBeNull();
+ expect(
+ warn(
+ definition("webhook"),
+ workflowAgentMentions({ workflow_agent_mentions: "any" }),
+ ),
+ ).toBeNull();
+ expect(workflowAgentMentions({ workflow_agent_mentions: "OWNER" })).toBe(
+ "any",
+ );
+});
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.ts b/src/bundled/workflows/workflowAgentMentionWarning.ts
new file mode 100644
index 0000000000..db3403f4cb
--- /dev/null
+++ b/src/bundled/workflows/workflowAgentMentionWarning.ts
@@ -0,0 +1,67 @@
+import { parse as yamlParse } from "yaml";
+
+/** NIP-11 `workflow_agent_mentions`: `owner` lets only runs the owner or a
+ * timer started, with only message and delay steps, direct agents. */
+export type WorkflowAgentMentions = "any" | "owner";
+
+/** A missing or unknown value is an older relay's: every run may. */
+export function workflowAgentMentions(
+ info: Record,
+): WorkflowAgentMentions {
+ return info.workflow_agent_mentions === "owner" ? "owner" : "any";
+}
+
+export const OWNER_RUNS_WARNING =
+ "Agents you mention act only on runs you start (this relay uses owner-only agent mentions).";
+export const NEVER_WARNING =
+ "Agents you mention won't act on this workflow's messages.";
+
+const PERSON_TRIGGERS = new Set([
+ "message_posted",
+ "reaction_added",
+ "diff_posted",
+]);
+
+function record(value: unknown): Record | undefined {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
+/** What owner-only agent mentions means for this definition's agent
+ * mentions, as the relay decides it: by trigger and every step's action. */
+export function getAgentMentionWarning(
+ yaml: string,
+ agentNames: readonly string[],
+ mode: WorkflowAgentMentions,
+): string | null {
+ if (mode !== "owner" || !agentNames.length) return null;
+ let definition: Record | undefined;
+ try {
+ definition = record(yamlParse(yaml));
+ } catch {
+ return null;
+ }
+ const steps = Array.isArray(definition?.steps)
+ ? definition.steps.map(record)
+ : [];
+ const mentions = steps.some((step) => {
+ const text = step?.action === "send_message" ? step.text : undefined;
+ return (
+ typeof text === "string" &&
+ agentNames.some((name) => text.includes(`@${name}`))
+ );
+ });
+ if (!mentions) return null;
+ const trigger = record(definition?.trigger)?.on;
+ if (
+ trigger === "webhook" ||
+ steps.some(
+ (step) => step?.action !== "send_message" && step?.action !== "delay",
+ )
+ )
+ return NEVER_WARNING;
+ return typeof trigger === "string" && PERSON_TRIGGERS.has(trigger)
+ ? OWNER_RUNS_WARNING
+ : null;
+}
diff --git a/src/features/agent-harness/kit.test.ts b/src/features/agent-harness/kit.test.ts
index 934539a5db..ed85d42bd6 100644
--- a/src/features/agent-harness/kit.test.ts
+++ b/src/features/agent-harness/kit.test.ts
@@ -176,9 +176,15 @@ describe("harnessKit.conversation", () => {
it("refuses a mention from anyone else, and triggers that are not its", async () => {
const { kit, delivery } = setup();
const event = message("2", "@Ampy rm -rf", { pubkey: mallory });
+ const ignored = vi.fn();
expect(
- await kit.conversation(delivery({ type: "mention", event }), "thread"),
+ await kit.conversation(
+ delivery({ type: "mention", event }, { ignored }),
+ "thread",
+ ),
).toBeUndefined();
+ // It tells its owner why, as every bundled harness does.
+ expect(ignored).toHaveBeenCalledExactlyOnceWith("sender");
// A relay workflow mention the bundled harnesses would run: signed by
// the community's workflow key, owned by the owner, aimed at this agent.
const workflow = eventDto(
@@ -220,17 +226,23 @@ describe("harnessKit.conversation", () => {
"thread",
),
).toBeDefined();
+ const timerIgnored = vi.fn();
expect(
await kit.conversation(
- delivery({
- type: "timer",
- slug: "t",
- timer: {} as never,
- spent: false,
- }),
+ delivery(
+ {
+ type: "timer",
+ slug: "t",
+ timer: {} as never,
+ spent: false,
+ },
+ { ignored: timerIgnored },
+ ),
"thread",
),
).toBeUndefined();
+ // Not a refusal of who sent it: no reason to show.
+ expect(timerIgnored).not.toHaveBeenCalled();
});
it("takes a plugin's wake in its thread, and drops one with nowhere to go", async () => {
diff --git a/src/features/agent-harness/kit.ts b/src/features/agent-harness/kit.ts
index 3bc4f2a9e5..a185143d27 100644
--- a/src/features/agent-harness/kit.ts
+++ b/src/features/agent-harness/kit.ts
@@ -20,7 +20,7 @@ import {
} from "../../bundled/claude-code/prompt";
import type { McpHandler } from "../host/service";
import type { AgentKey } from "../agents/membership";
-import { fromOwner } from "../agents/owner-attestation";
+import { mentionAuthority } from "../agents/owner-attestation";
import { ownerReads } from "../agents/reads";
import type {
AgentHandle,
@@ -208,7 +208,7 @@ export function harnessKit(sources: Sources): HarnessKit {
return Object.freeze({
async conversation(
- { trigger, agent, channelId, signal }: Delivery,
+ { trigger, agent, channelId, signal, ignored }: Delivery,
scope: HarnessScope,
) {
if (
@@ -224,7 +224,11 @@ export function harnessKit(sources: Sources): HarnessKit {
if (trigger.type === "mention") {
// Only the owner or an attested sibling. Unlike the bundled harnesses,
// which kept relay workflow mentions for compatibility, no workflow check.
- if (!(await fromOwner(trigger.event, agent.owner))) return undefined;
+ const authority = await mentionAuthority(trigger.event, agent.owner);
+ if (authority !== "owner") {
+ ignored?.(authority);
+ return undefined;
+ }
signal.throwIfAborted();
}
const event =
diff --git a/src/features/agents/activity.ts b/src/features/agents/activity.ts
index d7f0b74c3e..7b17c6ff7e 100644
--- a/src/features/agents/activity.ts
+++ b/src/features/agents/activity.ts
@@ -1,4 +1,8 @@
-import type { LocalAgentSource, LocalWork } from "./local-activity";
+import type {
+ LocalAgentSource,
+ LocalDecline,
+ LocalWork,
+} from "./local-activity";
import { activityPromptThread } from "./activity-transcript";
import type { EventData } from "../relay/events";
import { threadReference } from "../relay/thread-reference";
@@ -61,6 +65,8 @@ type Snapshot = Readonly<{
records: readonly RawRecord[];
turns: readonly ActivityTurn[];
local?: readonly LocalWork[];
+ /** This device's agents' mentions declined for who sent them. */
+ declined?: readonly LocalDecline[];
typing: readonly TypingEntry[];
trimmed: number;
/** Unlike raw-record trimming, losing a turn can lose request completeness. */
@@ -184,6 +190,12 @@ export function createAgentActivity(
(row) => row.scope === local.scope && canAccess(row.channelId),
)
: [];
+ const declined =
+ local && !closed
+ ? (local.source.declined?.() ?? []).filter(
+ (row) => row.scope === local.scope && canAccess(row.channelId),
+ )
+ : [];
const now = Date.now();
const fresh = records.filter(
(record) => now - record.receivedAt < ACTIVITY_HISTORY_AGE_MS,
@@ -259,6 +271,10 @@ export function createAgentActivity(
snapshot.status === status &&
(snapshot.local ?? []).length === localWork.length &&
(snapshot.local ?? []).every((row, index) => row === localWork[index]) &&
+ (snapshot.declined ?? []).length === declined.length &&
+ (snapshot.declined ?? []).every(
+ (row, index) => row === declined[index],
+ ) &&
snapshot.records === records &&
snapshot.trimmed === trimmed &&
JSON.stringify(snapshot.turns) === JSON.stringify(visible) &&
@@ -271,6 +287,7 @@ export function createAgentActivity(
records: Object.freeze(records),
turns: Object.freeze(visible),
local: Object.freeze(localWork),
+ declined: Object.freeze(declined),
typing: Object.freeze(typers),
trimmed,
turnsTrimmed,
diff --git a/src/features/agents/local-activity.ts b/src/features/agents/local-activity.ts
index 8f657c981c..93ce4bf3b4 100644
--- a/src/features/agents/local-activity.ts
+++ b/src/features/agents/local-activity.ts
@@ -1,4 +1,6 @@
import type { AgentSessionAssociation } from "./session-admission";
+import type { MentionRefusal } from "./owner-attestation";
+
export const REPLY_CORRELATION_MS = 120_000;
export type LocalWorkState =
@@ -80,6 +82,14 @@ export type LocalWork = Readonly<{
/** It can be asked again once it fails. */
retryable?: true;
}>;
+/** A mention this agent didn't act on, and why: shown only to its owner here. */
+export type LocalDecline = Readonly<{
+ scope: string;
+ agent: string;
+ channelId: string;
+ messageId: string;
+ refusal: MentionRefusal;
+}>;
const settled = (state: LocalWorkState) =>
state === "done" ||
state === "error" ||
@@ -90,6 +100,8 @@ export const localWorkRoot = (
) => (work.syntheticMessage && !work.thread ? undefined : work.threadRootId);
export type LocalAgentSource = {
work(): readonly LocalWork[];
+ /** Mentions declined for who sent them; notified with `subscribeWork`. */
+ declined?(): readonly LocalDecline[];
subscribeWork(listener: () => void): () => void;
/** Hides one already-seen error on this device; never changes its outcome. */
dismissError?(work: Pick): void;
@@ -105,12 +117,16 @@ export type LocalAgentSource = {
/** App-owned local facts. No relay publication, inference, polling or process control. */
export function createLocalAgentActivity() {
let work: readonly LocalWork[] = [];
+ let declined: readonly LocalDecline[] = [];
const workListeners = new Set<() => void>();
const entries = new Map();
const visible = new Map();
const omissions = new Map();
let revision = 0;
let order = 0;
+ const declines = new Map();
+ const declineKey = (row: Pick) =>
+ `${row.scope}\n${row.agent}\n${row.messageId}`;
/** How to stop each stoppable request's turn, until it settles. */
const stops = new Map Promise>();
/** How to ask each retryable request again, while its row is kept. */
@@ -123,6 +139,7 @@ export function createLocalAgentActivity() {
omissions.delete(id);
}
work = Object.freeze([...entries.values()]);
+ declined = Object.freeze([...declines.values()]);
for (const listener of workListeners) listener();
};
const subscribe = (listeners: Set<() => void>, listener: () => void) => {
@@ -133,6 +150,7 @@ export function createLocalAgentActivity() {
};
const reader: LocalAgentSource = {
work: () => work,
+ declined: () => declined,
subscribeWork: (listener) => subscribe(workListeners, listener),
dismissError({ scope, agent, messageId }) {
for (const [id, row] of entries)
@@ -207,6 +225,7 @@ export function createLocalAgentActivity() {
// still owns the prior attempt's fresh reply IDs, because those relay
// echoes may arrive after the activity row is replaced.
let replaced: LocalWork | undefined;
+ declines.delete(declineKey(input));
for (const [id, row] of entries)
if (
row.scope === input.scope &&
@@ -599,12 +618,25 @@ export function createLocalAgentActivity() {
entries.set(id, Object.freeze({ ...current, stoppable: true }));
publish();
},
- remove: () => {
+ /** With a `refusal`, keeps a note of why the agent didn't act. */
+ remove: (refusal?: MentionRefusal) => {
stops.delete(id);
retries.delete(id);
clearTimeout(replyTimers.get(id));
replyTimers.delete(id);
- if (entries.delete(id)) publish();
+ const row = entries.get(id);
+ if (!entries.delete(id)) return;
+ if (refusal && row) {
+ // Display budget only: the oldest note goes first.
+ const oldest = declines.size >= 512 && declines.keys().next().value;
+ if (oldest) declines.delete(oldest);
+ const { scope, agent, channelId, messageId } = row;
+ declines.set(
+ declineKey(row),
+ Object.freeze({ scope, agent, channelId, messageId, refusal }),
+ );
+ }
+ publish();
},
};
},
@@ -622,6 +654,14 @@ export function createLocalAgentActivity() {
replyTimers.delete(id);
changed = true;
}
+ for (const [key, row] of declines)
+ if (
+ (!scope || row.scope === scope) &&
+ (!agent || row.agent === agent)
+ ) {
+ declines.delete(key);
+ changed = true;
+ }
if (changed) publish();
},
};
diff --git a/src/features/agents/local-decline.test.ts b/src/features/agents/local-decline.test.ts
new file mode 100644
index 0000000000..4b25d1422a
--- /dev/null
+++ b/src/features/agents/local-decline.test.ts
@@ -0,0 +1,60 @@
+import { expect, it } from "vitest";
+import { createAgentActivity } from "./activity";
+import { createLocalAgentActivity } from "./local-activity";
+
+const scope = "https://relay.test:viewer",
+ agent = "a".repeat(64),
+ message = "b".repeat(64);
+const begin = (local: ReturnType) =>
+ local.begin({
+ scope,
+ agent,
+ channelId: "channel",
+ messageId: message,
+ threadRootId: message,
+ });
+const project = (local: ReturnType) =>
+ createAgentActivity(
+ false,
+ () => {},
+ (channel) => channel === "channel",
+ undefined,
+ undefined,
+ { source: local.reader, scope },
+ );
+
+it("keeps why an agent declined a mention, for its owner's view", () => {
+ const local = createLocalAgentActivity();
+ const activity = project(local);
+ try {
+ begin(local).remove("workflow");
+ expect(activity.queries.snapshot().local).toEqual([]);
+ expect(activity.queries.snapshot().declined).toEqual([
+ {
+ scope,
+ agent,
+ channelId: "channel",
+ messageId: message,
+ refusal: "workflow",
+ },
+ ]);
+ // Handling the message again replaces its note.
+ begin(local);
+ expect(activity.queries.snapshot().declined).toEqual([]);
+ } finally {
+ activity.dispose();
+ }
+});
+
+it("shows no note for a rejection that isn't about who sent it", () => {
+ const local = createLocalAgentActivity();
+ begin(local).remove();
+ expect(local.reader.declined?.()).toEqual([]);
+});
+
+it("forgets notes with the agent's activity", () => {
+ const local = createLocalAgentActivity();
+ begin(local).remove("sender");
+ local.clear(scope, agent);
+ expect(local.reader.declined?.()).toEqual([]);
+});
diff --git a/src/features/agents/mention-authority.test.ts b/src/features/agents/mention-authority.test.ts
new file mode 100644
index 0000000000..c3b1bb1626
--- /dev/null
+++ b/src/features/agents/mention-authority.test.ts
@@ -0,0 +1,122 @@
+import { createHash } from "node:crypto";
+import { schnorr } from "@noble/curves/secp256k1.js";
+import { bytesToHex } from "nostr-tools/utils";
+import { expect, it, vi } from "vitest";
+import { eventDto } from "../relay/events";
+import { createProfileDirectory } from "../relay/profile-directory";
+import type { RelayReader } from "../relay/reader";
+import { keypair, signed } from "../relay/testing";
+import { mentionAuthority } from "./owner-attestation";
+
+const relay = keypair(),
+ owner = keypair(),
+ sibling = keypair(),
+ agent = keypair(),
+ stranger = keypair();
+const auth = (key: typeof sibling) => [
+ "auth",
+ owner.pubkey,
+ "",
+ bytesToHex(
+ schnorr.sign(
+ createHash("sha256").update(`nostr:agent-auth:${key.pubkey}:`).digest(),
+ owner.secret,
+ ),
+ ),
+];
+const message = (key: typeof owner, tags: string[][] = []) =>
+ eventDto(signed(key, { kind: 9, content: "@agent go", tags }));
+const workflow = (creator: string, mention = true) =>
+ message(relay, [
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", creator],
+ ...(mention ? [["buzz:workflow-mention", agent.pubkey]] : []),
+ ]);
+const check = (read = vi.fn(async () => [] as never[])) => ({
+ authority: relay.pubkey,
+ agent: agent.pubkey,
+ profiles: createProfileDirectory({ read } as unknown as RelayReader).queries,
+});
+
+it("accepts the owner and an attested sibling", async () => {
+ expect(await mentionAuthority(message(owner), owner.pubkey, check())).toBe(
+ "owner",
+ );
+ expect(
+ await mentionAuthority(
+ message(sibling, [auth(sibling)]),
+ owner.pubkey,
+ check(),
+ ),
+ ).toBe("owner");
+});
+
+it("refuses someone else's message for who sent it", async () => {
+ expect(await mentionAuthority(message(stranger), owner.pubkey, check())).toBe(
+ "sender",
+ );
+ // Without a workflow check, as plugin harnesses call it.
+ expect(await mentionAuthority(workflow(owner.pubkey), owner.pubkey)).toBe(
+ "sender",
+ );
+});
+
+it("accepts the owner's workflow only when it names this agent", async () => {
+ expect(
+ await mentionAuthority(workflow(owner.pubkey), owner.pubkey, check()),
+ ).toBe("owner");
+ expect(
+ await mentionAuthority(
+ workflow(owner.pubkey, false),
+ owner.pubkey,
+ check(),
+ ),
+ ).toBe("workflow");
+});
+
+it("refuses someone else's workflow for who owns it", async () => {
+ expect(
+ await mentionAuthority(
+ workflow(stranger.pubkey, false),
+ owner.pubkey,
+ check(),
+ ),
+ ).toBe("sender");
+});
+
+it("gives no reason while the creator's profile can't be read", async () => {
+ const read = vi.fn(async () => {
+ throw new Error("offline");
+ });
+ expect(
+ await mentionAuthority(workflow(sibling.pubkey), owner.pubkey, check(read)),
+ ).toBeUndefined();
+});
+
+it("names a sibling's workflow from its already-read profile", async () => {
+ const profile = eventDto(
+ signed(sibling, {
+ kind: 0,
+ created_at: 100,
+ content: JSON.stringify({ owner: owner.pubkey, is_agent: true }),
+ tags: [auth(sibling)],
+ }),
+ );
+ const read = vi.fn(async () => [profile]);
+ const siblingCheck = check(read as never);
+ expect(
+ await mentionAuthority(
+ workflow(sibling.pubkey),
+ owner.pubkey,
+ siblingCheck,
+ ),
+ ).toBe("owner");
+ expect(
+ await mentionAuthority(
+ workflow(sibling.pubkey, false),
+ owner.pubkey,
+ siblingCheck,
+ ),
+ ).toBe("workflow");
+ expect(read).toHaveBeenCalledOnce();
+});
diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts
index 4cf2208bb1..0572fb07a3 100644
--- a/src/features/agents/owner-attestation.ts
+++ b/src/features/agents/owner-attestation.ts
@@ -2,7 +2,10 @@ import { schnorr } from "@noble/curves/secp256k1.js";
import { hexToBytes } from "nostr-tools/utils";
import type { ProfileQueries } from "../relay/profile-directory";
import { hasEventProof, type EventData } from "../relay/events";
-import { workflowMentionOwner } from "../relay/workflow-attribution";
+import {
+ workflowMentionOwner,
+ workflowOwner,
+} from "../relay/workflow-attribution";
import type { AgentReads } from "./reads";
const KEY = /^[0-9a-f]{64}$/;
@@ -114,6 +117,11 @@ export function workflowOf(
};
}
+/** Why a mention can't direct an agent, for a note only its owner sees:
+ * `sender`, someone else sent it; `workflow`, the owner's own workflow posted
+ * it without naming this agent as a step's author-written mention. */
+export type MentionRefusal = "sender" | "workflow";
+
/** Owner prompts, attested siblings, and verified relay workflow mentions
* delegated by the owner or a profile-verified sibling to this particular agent. */
export async function fromOwner(
@@ -121,28 +129,57 @@ export async function fromOwner(
owner: string,
workflow?: WorkflowCheck,
) {
+ return (await mentionAuthority(event, owner, workflow)) === "owner";
+}
+
+/** `fromOwner`'s verdict with its reason. `undefined` refuses without a
+ * reason: a creator's profile that can't be read yet is no verdict. */
+export async function mentionAuthority(
+ event: EventData,
+ owner: string,
+ workflow?: WorkflowCheck,
+): Promise<"owner" | MentionRefusal | undefined> {
if (event.pubkey === owner || (await attestedOwner(event)) === owner)
- return true;
- const creator =
- workflow && workflowMentionOwner(event, workflow.authority, workflow.agent);
- if (!creator) return false;
+ return "owner";
+ const creator = workflow && workflowOwner(event, workflow.authority);
+ if (!creator) return "sender";
+ // Only a mention that names this agent reads profiles; the reason for
+ // one that doesn't comes from those already read.
+ const named = !!workflowMentionOwner(
+ event,
+ workflow.authority,
+ workflow.agent,
+ );
+ const owned = await ownedCreator(creator, owner, workflow.profiles, named);
+ if (owned !== true) return owned;
+ return named ? "owner" : "workflow";
+}
+
+/** The workflow's creator is the owner or a profile-verified sibling:
+ * `true`, `"sender"` when it is neither, `undefined` when unreadable.
+ * Without `read`, only an already-read profile counts. */
+async function ownedCreator(
+ creator: string,
+ owner: string,
+ profiles: WorkflowCheck["profiles"],
+ read: boolean,
+): Promise {
if (creator === owner) return true;
- const profiles = workflow?.profiles;
- if (!profiles?.event) return false;
+ if (!profiles?.event) return "sender";
try {
- await profiles.ensure([creator]);
+ if (read) await profiles.ensure([creator]);
const profile = profiles.event(creator);
- return (
- !!profile &&
+ return profile &&
hasEventProof(profile) &&
profile.kind === 0 &&
profile.pubkey === creator &&
(await attestedOwner(profile)) === owner &&
profiles.event(creator) === profile
- );
+ ? true
+ : "sender";
} catch {
// Missing or failed profile reads are not ownership verdicts; a later
// mention can retry through the session's existing profile directory.
- return false;
+ return undefined;
}
}
diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts
index d540208f06..22087cf65d 100644
--- a/src/features/agents/runner.ts
+++ b/src/features/agents/runner.ts
@@ -27,6 +27,7 @@ import type {
Trigger,
} from "./service";
import type { LocalAgentActivity, WorkOutcome } from "./local-activity";
+import type { MentionRefusal } from "./owner-attestation";
import { threadReference } from "../relay/thread-reference";
import {
hasEventProof,
@@ -2784,7 +2785,7 @@ export class AgentRunners {
workingUntil: working.hold,
...(job.work
? {
- ignored: () => job.work?.remove(),
+ ignored: (refusal?: MentionRefusal) => job.work?.remove(refusal),
acknowledged: (input?: Readonly<{ steering?: true }>) => {
job.work?.started();
job.work?.acknowledged(input);
diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts
index ecc70d1e36..42e29a6acf 100644
--- a/src/features/agents/service.ts
+++ b/src/features/agents/service.ts
@@ -792,8 +792,9 @@ export type Delivery = Readonly<{
workingUntil(done: PromiseLike): void;
/** Optional presentation evidence supplied by the harness, not inferred by the UI. */
step?(tool: string, target?: string): void;
- /** The plugin declined this request; no work or successful outcome is implied. */
- ignored?(): void;
+ /** The plugin declined this request; no work or successful outcome is implied.
+ * A `refusal` shows its owner, on this device, why the agent didn't act. */
+ ignored?(refusal?: MentionRefusal): void;
/** The harness acknowledged this exact request. Not proof of model consumption. */
acknowledged?(input?: Readonly<{ steering?: true }>): void;
/** A newer acknowledged request owns this turn's activity; hide this message's
@@ -1859,7 +1860,8 @@ export function teamPrompt(
})
.join("\n\n");
}
-export { fromOwner } from "./owner-attestation";
+export { fromOwner, mentionAuthority } from "./owner-attestation";
+import type { MentionRefusal } from "./owner-attestation";
/** What Agents needs of the communities: the viewer, the selected one, and
* a connection to any joined one without selecting it. */
diff --git a/src/features/messages/AgentDeclineNotes.test.tsx b/src/features/messages/AgentDeclineNotes.test.tsx
new file mode 100644
index 0000000000..b2578e0a28
--- /dev/null
+++ b/src/features/messages/AgentDeclineNotes.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, it } from "vitest";
+import type { LocalDecline } from "../agents/local-activity";
+import { AgentDeclineNotes } from "./AgentDeclineNotes";
+
+afterEach(cleanup);
+const agent = "a".repeat(64),
+ other = "c".repeat(64),
+ message = "b".repeat(64);
+const choices = { identities: [{ pubkey: agent, name: "Builder" }] };
+const session = (declined: LocalDecline[]) =>
+ ({
+ agentActivity: {
+ snapshot: () => ({ declined }),
+ subscribe: () => () => {},
+ },
+ agentChoices: {
+ snapshot: () => choices,
+ subscribe: () => () => {},
+ },
+ }) as never;
+const decline = (refusal: LocalDecline["refusal"], who = agent) => ({
+ scope: "s",
+ agent: who,
+ channelId: "channel",
+ messageId: message,
+ refusal,
+});
+
+it("says which agent didn't act and why", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("Builder acts only on messages from you."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Your agent didn't act: workflow messages direct agents only when you or a timer start the run.",
+ ),
+ ).toBeInTheDocument();
+});
+
+it("shows nothing for another message", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+});
diff --git a/src/features/messages/AgentDeclineNotes.tsx b/src/features/messages/AgentDeclineNotes.tsx
new file mode 100644
index 0000000000..9a03e33e73
--- /dev/null
+++ b/src/features/messages/AgentDeclineNotes.tsx
@@ -0,0 +1,58 @@
+import { useSyncExternalStore } from "react";
+import type { MentionRefusal } from "../agents/owner-attestation";
+import type { RelaySession } from "../relay/session";
+import styles from "./Messages.module.css";
+
+const noSubscribe = () => () => {};
+
+export function agentDeclineText(name: string, refusal: MentionRefusal) {
+ return refusal === "sender"
+ ? `${name} acts only on messages from you.`
+ : `${name} didn't act: workflow messages direct agents only when you or a timer start the run.`;
+}
+
+/** Why this device's agents didn't act on this message. Local and owner-only:
+ * the decisions are this app's, and nothing is published. */
+export function AgentDeclineNotes({
+ session,
+ channelId,
+ messageId,
+}: {
+ session: Pick;
+ channelId: string;
+ messageId: string;
+}) {
+ const read = () =>
+ JSON.stringify(
+ (session.agentActivity?.snapshot().declined ?? [])
+ .filter(
+ (row) => row.channelId === channelId && row.messageId === messageId,
+ )
+ .map((row) => [row.agent, row.refusal]),
+ );
+ const declined = JSON.parse(
+ useSyncExternalStore(
+ session.agentActivity?.subscribe ?? noSubscribe,
+ read,
+ read,
+ ),
+ ) as [string, MentionRefusal][];
+ // Read only once there is a note: test and preview sessions may have none.
+ const readChoices = () =>
+ declined.length ? session.agentChoices.snapshot() : undefined;
+ const choices = useSyncExternalStore(
+ (declined.length && session.agentChoices.subscribe) || noSubscribe,
+ readChoices,
+ readChoices,
+ );
+ return declined.map(([agent, refusal]) => {
+ const name =
+ choices?.identities.find((choice) => choice.pubkey === agent)?.name ??
+ "Your agent";
+ return (
+
+ {agentDeclineText(name, refusal)}
+
+ );
+ });
+}
diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx
index 899275d81a..2ba2ba8b24 100644
--- a/src/features/messages/MessageRow.tsx
+++ b/src/features/messages/MessageRow.tsx
@@ -85,6 +85,7 @@ import {
} from "../conversation/ContributionBoundary";
import type { ChannelMessage, Profile } from "../relay/contracts";
import { AttachmentImage } from "./AttachmentImage";
+import { AgentDeclineNotes } from "./AgentDeclineNotes";
import { DeliveryNotice } from "./DeliveryNotice";
import { AttachmentView } from "../conversation/AttachmentView";
import { AudioAttachment } from "./AudioAttachment";
@@ -1621,6 +1622,13 @@ function MessageRowContent({
{!reactionsInBubble && reactionContent}
+ {session && (
+
+ )}
{active && session && scope && extensions?.accessories && (
div:last-child {
flex-shrink: 0;
}
+.agentNote {
+ margin: var(--space-1h) 0 0;
+ color: var(--text-secondary);
+ font-size: var(--text-caption);
+ line-height: var(--text-caption--line-height);
+ letter-spacing: var(--text-caption--letter-spacing);
+}
From 6dd2fb3c4cac3067e5e89645cb42d88c6408f680 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Mon, 9 Nov 2026 17:25:55 -0800
Subject: [PATCH 3/8] Fix workflow decline note, editor mention matching and
cold-cache refusal
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
docs/buzz-help.md | 25 +++++++
src/bundled/workflows/WorkflowEditor.tsx | 6 +-
.../workflowAgentMentionWarning.test.ts | 51 +++++++++++++-
.../workflows/workflowAgentMentionWarning.ts | 70 ++++++++++++++++++-
src/features/agents/mention-authority.test.ts | 19 +++--
src/features/agents/owner-attestation.ts | 9 +--
.../messages/AgentDeclineNotes.test.tsx | 2 +-
src/features/messages/AgentDeclineNotes.tsx | 2 +-
8 files changed, 164 insertions(+), 20 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index fa81d516d3..cf9f233cc3 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -1532,6 +1532,31 @@ matches the displayed participant name. When no participant name is available, B
public-key prefix. A direct message with yourself is labeled **Notes to self**.
These labels only help identify the conversation you select.
+## When your agent ignores a mention
+
+Your agents act only on messages from you, from your other agents, and from
+workflows that you or your agents own and that direct them. When one of your
+agents on this computer ignores a mention for one of those reasons, a short note
+under the message says so. Only you see it, on this device; nothing is posted.
+
+- **“‹Agent› acts only on messages from you.”** Someone else sent the message,
+ or someone else’s workflow posted it.
+- **“‹Agent› didn’t act: this workflow message doesn’t authorize it. The
+ workflow’s run history shows why.”** Your own workflow posted it, but the
+ message doesn’t direct this agent.
+
+Buzz shows no note when it can’t yet tell who owns the workflow.
+
+On a relay that uses owner-only agent mentions, the desktop workflow editor
+warns when a message step mentions an agent in the channel:
+
+- **“Agents you mention act only on runs you start (this relay uses owner-only
+ agent mentions).”** The workflow starts on a message, reaction or diff.
+- **“Agents you mention won’t act on this workflow’s messages.”** It starts on a
+ webhook, or has a step other than a message or a delay.
+
+A schedule with only message and delay steps shows no warning.
+
## Deleting an agent’s workflow
In **Workflows → Owner → All channel workflows**, open the workflow’s actions
diff --git a/src/bundled/workflows/WorkflowEditor.tsx b/src/bundled/workflows/WorkflowEditor.tsx
index a935c9aa09..c649759068 100644
--- a/src/bundled/workflows/WorkflowEditor.tsx
+++ b/src/bundled/workflows/WorkflowEditor.tsx
@@ -151,11 +151,7 @@ export function WorkflowEditor({
};
const warning = getWorkflowActivationWarning(yaml);
const members = useContext(WorkflowMentionMembers);
- const mentionWarning = getAgentMentionWarning(
- yaml,
- members.filter((member) => member.agent).map((member) => member.name),
- agentMentions,
- );
+ const mentionWarning = getAgentMentionWarning(yaml, members, agentMentions);
const submit = () => {
if (readOnly || busy || locked || blocked || error) return;
const wasEnabled =
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.test.ts b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
index 7d8f08978b..5ae1cf02f3 100644
--- a/src/bundled/workflows/workflowAgentMentionWarning.test.ts
+++ b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
@@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import {
getAgentMentionWarning,
+ resolveWorkflowMentions,
NEVER_WARNING,
OWNER_RUNS_WARNING,
workflowAgentMentions,
@@ -18,10 +19,18 @@ steps:
action: send_message
text: "${text}"
${extra}`;
+const member = (pubkey: string, name: string, agent = true) => ({
+ pubkey,
+ name,
+ label: name,
+ agent,
+});
+const builder = member("b", "Builder");
const warn = (
yaml: string,
mode = workflowAgentMentions({ workflow_agent_mentions: "owner" }),
-) => getAgentMentionWarning(yaml, ["Builder"], mode);
+ members = [builder],
+) => getAgentMentionWarning(yaml, members, mode);
it.each(["message_posted", "reaction_added", "diff_posted"])(
"warns that only your own runs direct agents on a %s trigger",
@@ -68,3 +77,43 @@ it("doesn't warn on a relay that lets any run direct agents, or an older one", (
"any",
);
});
+
+it.each([
+ ["@builder review this", true],
+ ["@BUILDER, review this", true],
+ ["(@Builder) review", true],
+ ["@BuilderExtra review", false],
+ ["mail@Builder review", false],
+ ["@Builder_2 review", false],
+])("matches %s as the relay does", (text, warns) => {
+ expect(warn(definition("webhook", text)) !== null).toBe(warns);
+});
+
+it("matches the longest name first, then checks it is an agent", () => {
+ const person = member("p", "Builder Bob", false);
+ expect(
+ warn(definition("webhook", "@Builder Bob review"), undefined, [
+ builder,
+ person,
+ ]),
+ ).toBeNull();
+ expect(
+ resolveWorkflowMentions("@Builder Bob and @Builder", [builder, person]),
+ ).toEqual([person, builder]);
+});
+
+it("names no one for a name two members share", () => {
+ const twin = member("t", "builder", false);
+ expect(warn(definition("webhook"), undefined, [builder, twin])).toBeNull();
+ expect(
+ resolveWorkflowMentions("@Builder", [builder, { ...builder }]),
+ ).toEqual([builder]);
+});
+
+it("folds case per code point, so later mentions keep their place", () => {
+ const dotted = member("i", "İ", false);
+ expect(resolveWorkflowMentions("@İ @Builder", [dotted, builder])).toEqual([
+ dotted,
+ builder,
+ ]);
+});
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.ts b/src/bundled/workflows/workflowAgentMentionWarning.ts
index db3403f4cb..30ce8d1328 100644
--- a/src/bundled/workflows/workflowAgentMentionWarning.ts
+++ b/src/bundled/workflows/workflowAgentMentionWarning.ts
@@ -1,4 +1,5 @@
import { parse as yamlParse } from "yaml";
+import type { WorkflowMentionMember } from "./WorkflowMessageText";
/** NIP-11 `workflow_agent_mentions`: `owner` lets only runs the owner or a
* timer started, with only message and delay steps, direct agents. */
@@ -28,14 +29,77 @@ function record(value: unknown): Record | undefined {
: undefined;
}
+const extendsName = (c: string | undefined) =>
+ c !== undefined && /[\p{L}\p{N}_]/u.test(c);
+// Rust's `char::to_lowercase`, which folds each code point on its own.
+const fold = (c: string) => [...c.toLowerCase()];
+
+/** The members `text` names, as the relay's `resolve_mention_pubkeys` matches
+ * them: an `@` at the start or after whitespace or `(`, the exact name in any
+ * case, not followed by a letter, digit or `_`; longer names first, each span
+ * once; a name two members share names no one. */
+export function resolveWorkflowMentions<
+ M extends Pick,
+>(text: string, members: readonly M[]): M[] {
+ const byName = new Map();
+ for (const member of members) {
+ if (!member.name.trim()) continue;
+ const key = member.name.toLowerCase();
+ const known = byName.get(key);
+ byName.set(
+ key,
+ known === undefined || known?.pubkey === member.pubkey ? member : null,
+ );
+ }
+ const chars = [...text];
+ const consumed = chars.map(() => false);
+ const hits: [number, M][] = [];
+ const names = [...members].sort(
+ (a, b) => [...b.name].length - [...a.name].length,
+ );
+ const matchLength = (start: number, name: readonly string[]) => {
+ let at = start;
+ let index = 0;
+ while (index < name.length) {
+ const c = chars[at];
+ if (c === undefined) return undefined;
+ for (const folded of fold(c))
+ if (name[index++] !== folded) return undefined;
+ at++;
+ }
+ return at - start;
+ };
+ for (const { name } of names) {
+ const folded = [...name].flatMap(fold);
+ if (!folded.length) continue;
+ for (let at = 0; at < chars.length; ) {
+ const left =
+ at === 0 || /\s/u.test(chars[at - 1] ?? "") || chars[at - 1] === "(";
+ const length =
+ chars[at] === "@" && left && !consumed[at]
+ ? matchLength(at + 1, folded)
+ : undefined;
+ if (length === undefined || extendsName(chars[at + 1 + length])) {
+ at++;
+ continue;
+ }
+ const member = byName.get(name.toLowerCase());
+ if (member) hits.push([at, member]);
+ consumed.fill(true, at, at + 1 + length);
+ at += 1 + length;
+ }
+ }
+ return [...new Set(hits.sort((a, b) => a[0] - b[0]).map(([, m]) => m))];
+}
+
/** What owner-only agent mentions means for this definition's agent
* mentions, as the relay decides it: by trigger and every step's action. */
export function getAgentMentionWarning(
yaml: string,
- agentNames: readonly string[],
+ members: readonly WorkflowMentionMember[],
mode: WorkflowAgentMentions,
): string | null {
- if (mode !== "owner" || !agentNames.length) return null;
+ if (mode !== "owner" || !members.some((member) => member.agent)) return null;
let definition: Record | undefined;
try {
definition = record(yamlParse(yaml));
@@ -49,7 +113,7 @@ export function getAgentMentionWarning(
const text = step?.action === "send_message" ? step.text : undefined;
return (
typeof text === "string" &&
- agentNames.some((name) => text.includes(`@${name}`))
+ resolveWorkflowMentions(text, members).some((member) => member.agent)
);
});
if (!mentions) return null;
diff --git a/src/features/agents/mention-authority.test.ts b/src/features/agents/mention-authority.test.ts
index c3b1bb1626..e1c8eaf980 100644
--- a/src/features/agents/mention-authority.test.ts
+++ b/src/features/agents/mention-authority.test.ts
@@ -76,11 +76,7 @@ it("accepts the owner's workflow only when it names this agent", async () => {
it("refuses someone else's workflow for who owns it", async () => {
expect(
- await mentionAuthority(
- workflow(stranger.pubkey, false),
- owner.pubkey,
- check(),
- ),
+ await mentionAuthority(workflow(stranger.pubkey), owner.pubkey, check()),
).toBe("sender");
});
@@ -93,6 +89,19 @@ it("gives no reason while the creator's profile can't be read", async () => {
).toBeUndefined();
});
+it("gives no reason for a workflow whose creator's profile isn't read yet", async () => {
+ const read = vi.fn(async () => [] as never[]);
+ for (const creator of [sibling.pubkey, stranger.pubkey])
+ expect(
+ await mentionAuthority(
+ workflow(creator, false),
+ owner.pubkey,
+ check(read),
+ ),
+ ).toBeUndefined();
+ expect(read).not.toHaveBeenCalled();
+});
+
it("names a sibling's workflow from its already-read profile", async () => {
const profile = eventDto(
signed(sibling, {
diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts
index 0572fb07a3..adca3d06fd 100644
--- a/src/features/agents/owner-attestation.ts
+++ b/src/features/agents/owner-attestation.ts
@@ -156,8 +156,8 @@ export async function mentionAuthority(
}
/** The workflow's creator is the owner or a profile-verified sibling:
- * `true`, `"sender"` when it is neither, `undefined` when unreadable.
- * Without `read`, only an already-read profile counts. */
+ * `true`, `"sender"` when it is neither, `undefined` when unknown.
+ * Without `read`, only an already-read profile counts; none is unknown. */
async function ownedCreator(
creator: string,
owner: string,
@@ -169,8 +169,9 @@ async function ownedCreator(
try {
if (read) await profiles.ensure([creator]);
const profile = profiles.event(creator);
- return profile &&
- hasEventProof(profile) &&
+ // Unread, ownership is unknown: deny with no reason rather than guess.
+ if (!profile) return read ? "sender" : undefined;
+ return hasEventProof(profile) &&
profile.kind === 0 &&
profile.pubkey === creator &&
(await attestedOwner(profile)) === owner &&
diff --git a/src/features/messages/AgentDeclineNotes.test.tsx b/src/features/messages/AgentDeclineNotes.test.tsx
index b2578e0a28..c413b97802 100644
--- a/src/features/messages/AgentDeclineNotes.test.tsx
+++ b/src/features/messages/AgentDeclineNotes.test.tsx
@@ -42,7 +42,7 @@ it("says which agent didn't act and why", () => {
).toBeInTheDocument();
expect(
screen.getByText(
- "Your agent didn't act: workflow messages direct agents only when you or a timer start the run.",
+ "Your agent didn't act: this workflow message doesn't authorize it. The workflow's run history shows why.",
),
).toBeInTheDocument();
});
diff --git a/src/features/messages/AgentDeclineNotes.tsx b/src/features/messages/AgentDeclineNotes.tsx
index 9a03e33e73..e9617bc4f3 100644
--- a/src/features/messages/AgentDeclineNotes.tsx
+++ b/src/features/messages/AgentDeclineNotes.tsx
@@ -8,7 +8,7 @@ const noSubscribe = () => () => {};
export function agentDeclineText(name: string, refusal: MentionRefusal) {
return refusal === "sender"
? `${name} acts only on messages from you.`
- : `${name} didn't act: workflow messages direct agents only when you or a timer start the run.`;
+ : `${name} didn't act: this workflow message doesn't authorize it. The workflow's run history shows why.`;
}
/** Why this device's agents didn't act on this message. Local and owner-only:
From 70f3de46f4ab95c1edc1b5248fa1f3f8742903c3 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Mon, 9 Nov 2026 17:43:14 -0800
Subject: [PATCH 4/8] Match the relay's Unicode rules in workflow mention
resolution
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
.../workflowAgentMentionWarning.test.ts | 16 ++++++++++++++++
.../workflows/workflowAgentMentionWarning.ts | 11 +++++++----
2 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.test.ts b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
index 5ae1cf02f3..af7aa4f738 100644
--- a/src/bundled/workflows/workflowAgentMentionWarning.test.ts
+++ b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
@@ -117,3 +117,19 @@ it("folds case per code point, so later mentions keep their place", () => {
builder,
]);
});
+
+it("folds names and text as the relay does, Unicode included", () => {
+ const sigma = member("s", "ΟΣ");
+ expect(resolveWorkflowMentions("@ος", [sigma])).toEqual([sigma]);
+ expect(resolveWorkflowMentions("@ΟΣ", [sigma])).toEqual([]);
+ for (const mark of ["\u0345", "\u05B0"])
+ expect(resolveWorkflowMentions(`@Builder${mark}`, [builder])).toEqual([]);
+ expect(resolveWorkflowMentions("\u0085@Builder", [builder])).toEqual([
+ builder,
+ ]);
+ expect(resolveWorkflowMentions("\uFEFF@Builder", [builder])).toEqual([]);
+ const blank = member("w", "\u0085");
+ expect(resolveWorkflowMentions("@\u0085 @Builder", [blank, builder])).toEqual(
+ [builder],
+ );
+});
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.ts b/src/bundled/workflows/workflowAgentMentionWarning.ts
index 30ce8d1328..05be4d7936 100644
--- a/src/bundled/workflows/workflowAgentMentionWarning.ts
+++ b/src/bundled/workflows/workflowAgentMentionWarning.ts
@@ -30,7 +30,7 @@ function record(value: unknown): Record | undefined {
}
const extendsName = (c: string | undefined) =>
- c !== undefined && /[\p{L}\p{N}_]/u.test(c);
+ c !== undefined && /[\p{Alphabetic}\p{N}_]/u.test(c);
// Rust's `char::to_lowercase`, which folds each code point on its own.
const fold = (c: string) => [...c.toLowerCase()];
@@ -43,7 +43,8 @@ export function resolveWorkflowMentions<
>(text: string, members: readonly M[]): M[] {
const byName = new Map();
for (const member of members) {
- if (!member.name.trim()) continue;
+ if (!member.name.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""))
+ continue;
const key = member.name.toLowerCase();
const known = byName.get(key);
byName.set(
@@ -70,11 +71,13 @@ export function resolveWorkflowMentions<
return at - start;
};
for (const { name } of names) {
- const folded = [...name].flatMap(fold);
+ const folded = [...name.toLowerCase()];
if (!folded.length) continue;
for (let at = 0; at < chars.length; ) {
const left =
- at === 0 || /\s/u.test(chars[at - 1] ?? "") || chars[at - 1] === "(";
+ at === 0 ||
+ /\p{White_Space}/u.test(chars[at - 1] ?? "") ||
+ chars[at - 1] === "(";
const length =
chars[at] === "@" && left && !consumed[at]
? matchLength(at + 1, folded)
From d633070e2c8c1ff9418d15ca2269a3e6c1ad07ad Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Mon, 9 Nov 2026 18:19:24 -0800
Subject: [PATCH 5/8] Plainer owner-only agent mention hints
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
docs/buzz-help.md | 22 ++++++++-----
docs/workflows.md | 2 +-
src/bundled/workflows/WorkflowEditor.test.tsx | 4 ++-
.../workflowAgentMentionWarning.test.ts | 33 +++++++++++++------
.../workflows/workflowAgentMentionWarning.ts | 14 ++++----
.../messages/AgentDeclineNotes.test.tsx | 4 +--
src/features/messages/AgentDeclineNotes.tsx | 4 +--
7 files changed, 53 insertions(+), 30 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index cf9f233cc3..b62fafba77 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -1539,21 +1539,27 @@ workflows that you or your agents own and that direct them. When one of your
agents on this computer ignores a mention for one of those reasons, a short note
under the message says so. Only you see it, on this device; nothing is posted.
-- **“‹Agent› acts only on messages from you.”** Someone else sent the message,
+- **“‹Agent› only responds to your messages.”** Someone else sent the message,
or someone else’s workflow posted it.
-- **“‹Agent› didn’t act: this workflow message doesn’t authorize it. The
- workflow’s run history shows why.”** Your own workflow posted it, but the
- message doesn’t direct this agent.
+- **“‹Agent› didn’t respond to this workflow message. This community limits
+ which workflows can use agents, so other people can’t use yours. The
+ workflow’s Runs tab shows why it didn’t respond this time.”** Your own
+ workflow posted it, but the message doesn’t direct this agent.
Buzz shows no note when it can’t yet tell who owns the workflow.
On a relay that uses owner-only agent mentions, the desktop workflow editor
warns when a message step mentions an agent in the channel:
-- **“Agents you mention act only on runs you start (this relay uses owner-only
- agent mentions).”** The workflow starts on a message, reaction or diff.
-- **“Agents you mention won’t act on this workflow’s messages.”** It starts on a
- webhook, or has a step other than a message or a delay.
+Each warning ends with “This community limits which workflows can use agents,
+so other people can’t use yours.”
+
+- **“Agents you mention respond only when you start this workflow, not when
+ someone else does.”** The workflow starts on a message, reaction or diff.
+- **“Agents won’t respond to workflows started by a webhook.”** It starts on a
+ webhook, whatever its steps.
+- **“Agents won’t respond to this workflow because it has steps other than Send
+ Message and Delay.”** It has a step other than a message or a delay.
A schedule with only message and delay steps shows no warning.
diff --git a/docs/workflows.md b/docs/workflows.md
index e979ad142d..a9af115621 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -259,7 +259,7 @@ The relay emits these tags only for targets present in the owner-authored templa
and the rendered output. A relay running
[owner-only agent mentions](https://github.com/block/buzz/blob/main/docs/operator-workflow-agent-mentions.md) may leave the tag off; the message still posts, but
no agent acts on it, and the owner's app notes under the message which of their
-agents didn't act. Legacy `p` tags route mentions but do not authorize them.
+agents didn't respond. Legacy `p` tags route mentions but do not authorize them.
Workflows created and maintained under an owned agent's own key remain owned by
that agent and can wake agents with the same proven human owner. This does not
grant an agent permission to edit a workflow owned by a different signing identity.
diff --git a/src/bundled/workflows/WorkflowEditor.test.tsx b/src/bundled/workflows/WorkflowEditor.test.tsx
index bf32a35dd3..1841ff8d2b 100644
--- a/src/bundled/workflows/WorkflowEditor.test.tsx
+++ b/src/bundled/workflows/WorkflowEditor.test.tsx
@@ -546,7 +546,9 @@ steps:
);
const { rerender } = render(editor("owner"));
expect(
- screen.getByText(/Agents you mention act only on runs you start/),
+ screen.getByText(
+ /Agents you mention respond only when you start this workflow/,
+ ),
).toBeInTheDocument();
rerender(editor());
expect(screen.queryByText(/Agents you mention/)).not.toBeInTheDocument();
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.test.ts b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
index af7aa4f738..4664d321df 100644
--- a/src/bundled/workflows/workflowAgentMentionWarning.test.ts
+++ b/src/bundled/workflows/workflowAgentMentionWarning.test.ts
@@ -2,8 +2,9 @@ import { expect, it } from "vitest";
import {
getAgentMentionWarning,
resolveWorkflowMentions,
- NEVER_WARNING,
OWNER_RUNS_WARNING,
+ STEPS_WARNING,
+ WEBHOOK_WARNING,
workflowAgentMentions,
} from "./workflowAgentMentionWarning";
@@ -37,18 +38,30 @@ it.each(["message_posted", "reaction_added", "diff_posted"])(
(trigger) => expect(warn(definition(trigger))).toBe(OWNER_RUNS_WARNING),
);
-it("warns that agents never act on a webhook trigger", () => {
- expect(warn(definition("webhook"))).toBe(NEVER_WARNING);
-});
-
-it("warns that agents never act with any step but a message or delay", () => {
- const webhook = ` - id: fetch
+const fetchStep = ` - id: fetch
action: call_webhook
url: https://example.test
`;
- expect(warn(definition("schedule", undefined, webhook))).toBe(NEVER_WARNING);
- expect(warn(definition("message_posted", undefined, webhook))).toBe(
- NEVER_WARNING,
+
+it("warns that agents never respond on a webhook trigger", () => {
+ expect(warn(definition("webhook"))).toBe(WEBHOOK_WARNING);
+});
+
+it("prefers the webhook warning when a webhook trigger has other steps", () => {
+ expect(warn(definition("webhook", undefined, fetchStep))).toBe(
+ WEBHOOK_WARNING,
+ );
+});
+
+it("warns that agents never respond with any step but a message or delay", () => {
+ expect(warn(definition("schedule", undefined, fetchStep))).toBe(
+ STEPS_WARNING,
+ );
+});
+
+it("gives a message trigger with other steps the steps warning", () => {
+ expect(warn(definition("message_posted", undefined, fetchStep))).toBe(
+ STEPS_WARNING,
);
});
diff --git a/src/bundled/workflows/workflowAgentMentionWarning.ts b/src/bundled/workflows/workflowAgentMentionWarning.ts
index 05be4d7936..4cfca11236 100644
--- a/src/bundled/workflows/workflowAgentMentionWarning.ts
+++ b/src/bundled/workflows/workflowAgentMentionWarning.ts
@@ -12,10 +12,11 @@ export function workflowAgentMentions(
return info.workflow_agent_mentions === "owner" ? "owner" : "any";
}
-export const OWNER_RUNS_WARNING =
- "Agents you mention act only on runs you start (this relay uses owner-only agent mentions).";
-export const NEVER_WARNING =
- "Agents you mention won't act on this workflow's messages.";
+const WHY =
+ "This community limits which workflows can use agents, so other people can't use yours.";
+export const OWNER_RUNS_WARNING = `Agents you mention respond only when you start this workflow, not when someone else does. ${WHY}`;
+export const WEBHOOK_WARNING = `Agents won't respond to workflows started by a webhook. ${WHY}`;
+export const STEPS_WARNING = `Agents won't respond to this workflow because it has steps other than Send Message and Delay. ${WHY}`;
const PERSON_TRIGGERS = new Set([
"message_posted",
@@ -121,13 +122,14 @@ export function getAgentMentionWarning(
});
if (!mentions) return null;
const trigger = record(definition?.trigger)?.on;
+ // A webhook run never directs agents, whatever its steps.
+ if (trigger === "webhook") return WEBHOOK_WARNING;
if (
- trigger === "webhook" ||
steps.some(
(step) => step?.action !== "send_message" && step?.action !== "delay",
)
)
- return NEVER_WARNING;
+ return STEPS_WARNING;
return typeof trigger === "string" && PERSON_TRIGGERS.has(trigger)
? OWNER_RUNS_WARNING
: null;
diff --git a/src/features/messages/AgentDeclineNotes.test.tsx b/src/features/messages/AgentDeclineNotes.test.tsx
index c413b97802..71b1a6df94 100644
--- a/src/features/messages/AgentDeclineNotes.test.tsx
+++ b/src/features/messages/AgentDeclineNotes.test.tsx
@@ -38,11 +38,11 @@ it("says which agent didn't act and why", () => {
/>,
);
expect(
- screen.getByText("Builder acts only on messages from you."),
+ screen.getByText("Builder only responds to your messages."),
).toBeInTheDocument();
expect(
screen.getByText(
- "Your agent didn't act: this workflow message doesn't authorize it. The workflow's run history shows why.",
+ "Your agent didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.",
),
).toBeInTheDocument();
});
diff --git a/src/features/messages/AgentDeclineNotes.tsx b/src/features/messages/AgentDeclineNotes.tsx
index e9617bc4f3..d39990dd9e 100644
--- a/src/features/messages/AgentDeclineNotes.tsx
+++ b/src/features/messages/AgentDeclineNotes.tsx
@@ -7,8 +7,8 @@ const noSubscribe = () => () => {};
export function agentDeclineText(name: string, refusal: MentionRefusal) {
return refusal === "sender"
- ? `${name} acts only on messages from you.`
- : `${name} didn't act: this workflow message doesn't authorize it. The workflow's run history shows why.`;
+ ? `${name} only responds to your messages.`
+ : `${name} didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.`;
}
/** Why this device's agents didn't act on this message. Local and owner-only:
From 5727698bea3b440bdb2e9bf1ed5697f8e00a30f7 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Mon, 9 Nov 2026 18:47:55 -0800
Subject: [PATCH 6/8] Show no note for ignored direct mentions
Only the owner's own workflow message that doesn't direct the agent gets a
note. Other ignored mentions stay silent, as before.
---
docs/buzz-help.md | 19 ++++-------
src/bundled/codex/runtime.test.ts | 3 +-
src/features/agent-harness/kit.test.ts | 26 ++++----------
src/features/agent-harness/kit.ts | 10 ++----
src/features/agents/local-decline.test.ts | 4 +--
src/features/agents/mention-authority.test.ts | 18 +++++-----
src/features/agents/owner-attestation.ts | 34 +++++++++----------
.../messages/AgentDeclineNotes.test.tsx | 7 ++--
src/features/messages/AgentDeclineNotes.tsx | 10 +++---
9 files changed, 52 insertions(+), 79 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index b62fafba77..c712dfcc65 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -1535,18 +1535,13 @@ These labels only help identify the conversation you select.
## When your agent ignores a mention
Your agents act only on messages from you, from your other agents, and from
-workflows that you or your agents own and that direct them. When one of your
-agents on this computer ignores a mention for one of those reasons, a short note
-under the message says so. Only you see it, on this device; nothing is posted.
-
-- **“‹Agent› only responds to your messages.”** Someone else sent the message,
- or someone else’s workflow posted it.
-- **“‹Agent› didn’t respond to this workflow message. This community limits
- which workflows can use agents, so other people can’t use yours. The
- workflow’s Runs tab shows why it didn’t respond this time.”** Your own
- workflow posted it, but the message doesn’t direct this agent.
-
-Buzz shows no note when it can’t yet tell who owns the workflow.
+workflows that you or your agents own and that direct them. When your own
+workflow posts a message that mentions one of your agents on this computer but
+doesn’t direct it, a short note under the message says so: **“‹Agent› didn’t
+respond to this workflow message. This community limits which workflows can use
+agents, so other people can’t use yours. The workflow’s Runs tab shows why it
+didn’t respond this time.”** Only you see it, on this device; nothing is posted.
+Other ignored mentions show no note.
On a relay that uses owner-only agent mentions, the desktop workflow editor
warns when a message step mentions an agent in the channel:
diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts
index fd83bb9951..d154713d60 100644
--- a/src/bundled/codex/runtime.test.ts
+++ b/src/bundled/codex/runtime.test.ts
@@ -2843,7 +2843,8 @@ it("ignores non-owner messages without spawning", async () => {
if ("event" in other.trigger) other.trigger.event.pubkey = "f".repeat(64);
const ignored = vi.fn();
await f.runtime.run({ ...other, ignored });
- expect(ignored).toHaveBeenCalledOnce();
+ // No reason, so no note for its owner.
+ expect(ignored).toHaveBeenCalledExactlyOnceWith(undefined);
expect(f.spawn).not.toHaveBeenCalled();
expect(f.publish).not.toHaveBeenCalled();
});
diff --git a/src/features/agent-harness/kit.test.ts b/src/features/agent-harness/kit.test.ts
index ed85d42bd6..934539a5db 100644
--- a/src/features/agent-harness/kit.test.ts
+++ b/src/features/agent-harness/kit.test.ts
@@ -176,15 +176,9 @@ describe("harnessKit.conversation", () => {
it("refuses a mention from anyone else, and triggers that are not its", async () => {
const { kit, delivery } = setup();
const event = message("2", "@Ampy rm -rf", { pubkey: mallory });
- const ignored = vi.fn();
expect(
- await kit.conversation(
- delivery({ type: "mention", event }, { ignored }),
- "thread",
- ),
+ await kit.conversation(delivery({ type: "mention", event }), "thread"),
).toBeUndefined();
- // It tells its owner why, as every bundled harness does.
- expect(ignored).toHaveBeenCalledExactlyOnceWith("sender");
// A relay workflow mention the bundled harnesses would run: signed by
// the community's workflow key, owned by the owner, aimed at this agent.
const workflow = eventDto(
@@ -226,23 +220,17 @@ describe("harnessKit.conversation", () => {
"thread",
),
).toBeDefined();
- const timerIgnored = vi.fn();
expect(
await kit.conversation(
- delivery(
- {
- type: "timer",
- slug: "t",
- timer: {} as never,
- spent: false,
- },
- { ignored: timerIgnored },
- ),
+ delivery({
+ type: "timer",
+ slug: "t",
+ timer: {} as never,
+ spent: false,
+ }),
"thread",
),
).toBeUndefined();
- // Not a refusal of who sent it: no reason to show.
- expect(timerIgnored).not.toHaveBeenCalled();
});
it("takes a plugin's wake in its thread, and drops one with nowhere to go", async () => {
diff --git a/src/features/agent-harness/kit.ts b/src/features/agent-harness/kit.ts
index a185143d27..3bc4f2a9e5 100644
--- a/src/features/agent-harness/kit.ts
+++ b/src/features/agent-harness/kit.ts
@@ -20,7 +20,7 @@ import {
} from "../../bundled/claude-code/prompt";
import type { McpHandler } from "../host/service";
import type { AgentKey } from "../agents/membership";
-import { mentionAuthority } from "../agents/owner-attestation";
+import { fromOwner } from "../agents/owner-attestation";
import { ownerReads } from "../agents/reads";
import type {
AgentHandle,
@@ -208,7 +208,7 @@ export function harnessKit(sources: Sources): HarnessKit {
return Object.freeze({
async conversation(
- { trigger, agent, channelId, signal, ignored }: Delivery,
+ { trigger, agent, channelId, signal }: Delivery,
scope: HarnessScope,
) {
if (
@@ -224,11 +224,7 @@ export function harnessKit(sources: Sources): HarnessKit {
if (trigger.type === "mention") {
// Only the owner or an attested sibling. Unlike the bundled harnesses,
// which kept relay workflow mentions for compatibility, no workflow check.
- const authority = await mentionAuthority(trigger.event, agent.owner);
- if (authority !== "owner") {
- ignored?.(authority);
- return undefined;
- }
+ if (!(await fromOwner(trigger.event, agent.owner))) return undefined;
signal.throwIfAborted();
}
const event =
diff --git a/src/features/agents/local-decline.test.ts b/src/features/agents/local-decline.test.ts
index 4b25d1422a..6d962b4d60 100644
--- a/src/features/agents/local-decline.test.ts
+++ b/src/features/agents/local-decline.test.ts
@@ -46,7 +46,7 @@ it("keeps why an agent declined a mention, for its owner's view", () => {
}
});
-it("shows no note for a rejection that isn't about who sent it", () => {
+it("shows no note for a rejection without a reason", () => {
const local = createLocalAgentActivity();
begin(local).remove();
expect(local.reader.declined?.()).toEqual([]);
@@ -54,7 +54,7 @@ it("shows no note for a rejection that isn't about who sent it", () => {
it("forgets notes with the agent's activity", () => {
const local = createLocalAgentActivity();
- begin(local).remove("sender");
+ begin(local).remove("workflow");
local.clear(scope, agent);
expect(local.reader.declined?.()).toEqual([]);
});
diff --git a/src/features/agents/mention-authority.test.ts b/src/features/agents/mention-authority.test.ts
index e1c8eaf980..ba4f5e1d12 100644
--- a/src/features/agents/mention-authority.test.ts
+++ b/src/features/agents/mention-authority.test.ts
@@ -51,14 +51,14 @@ it("accepts the owner and an attested sibling", async () => {
).toBe("owner");
});
-it("refuses someone else's message for who sent it", async () => {
- expect(await mentionAuthority(message(stranger), owner.pubkey, check())).toBe(
- "sender",
- );
+it("refuses someone else's message with no reason", async () => {
+ expect(
+ await mentionAuthority(message(stranger), owner.pubkey, check()),
+ ).toBeUndefined();
// Without a workflow check, as plugin harnesses call it.
- expect(await mentionAuthority(workflow(owner.pubkey), owner.pubkey)).toBe(
- "sender",
- );
+ expect(
+ await mentionAuthority(workflow(owner.pubkey), owner.pubkey),
+ ).toBeUndefined();
});
it("accepts the owner's workflow only when it names this agent", async () => {
@@ -74,10 +74,10 @@ it("accepts the owner's workflow only when it names this agent", async () => {
).toBe("workflow");
});
-it("refuses someone else's workflow for who owns it", async () => {
+it("refuses someone else's workflow with no reason", async () => {
expect(
await mentionAuthority(workflow(stranger.pubkey), owner.pubkey, check()),
- ).toBe("sender");
+ ).toBeUndefined();
});
it("gives no reason while the creator's profile can't be read", async () => {
diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts
index adca3d06fd..fac6275266 100644
--- a/src/features/agents/owner-attestation.ts
+++ b/src/features/agents/owner-attestation.ts
@@ -118,9 +118,9 @@ export function workflowOf(
}
/** Why a mention can't direct an agent, for a note only its owner sees:
- * `sender`, someone else sent it; `workflow`, the owner's own workflow posted
- * it without naming this agent as a step's author-written mention. */
-export type MentionRefusal = "sender" | "workflow";
+ * the owner's own workflow posted it without naming this agent as a step's
+ * author-written mention. */
+export type MentionRefusal = "workflow";
/** Owner prompts, attested siblings, and verified relay workflow mentions
* delegated by the owner or a profile-verified sibling to this particular agent. */
@@ -132,8 +132,8 @@ export async function fromOwner(
return (await mentionAuthority(event, owner, workflow)) === "owner";
}
-/** `fromOwner`'s verdict with its reason. `undefined` refuses without a
- * reason: a creator's profile that can't be read yet is no verdict. */
+/** `fromOwner`'s verdict with its reason. `undefined` refuses without one:
+ * anyone else's message or workflow, or a creator that can't be read yet. */
export async function mentionAuthority(
event: EventData,
owner: string,
@@ -142,7 +142,7 @@ export async function mentionAuthority(
if (event.pubkey === owner || (await attestedOwner(event)) === owner)
return "owner";
const creator = workflow && workflowOwner(event, workflow.authority);
- if (!creator) return "sender";
+ if (!creator) return undefined;
// Only a mention that names this agent reads profiles; the reason for
// one that doesn't comes from those already read.
const named = !!workflowMentionOwner(
@@ -151,36 +151,34 @@ export async function mentionAuthority(
workflow.agent,
);
const owned = await ownedCreator(creator, owner, workflow.profiles, named);
- if (owned !== true) return owned;
+ if (!owned) return undefined;
return named ? "owner" : "workflow";
}
-/** The workflow's creator is the owner or a profile-verified sibling:
- * `true`, `"sender"` when it is neither, `undefined` when unknown.
- * Without `read`, only an already-read profile counts; none is unknown. */
+/** The workflow's creator is the owner or a profile-verified sibling.
+ * Without `read`, only an already-read profile counts. */
async function ownedCreator(
creator: string,
owner: string,
profiles: WorkflowCheck["profiles"],
read: boolean,
-): Promise {
+): Promise {
if (creator === owner) return true;
- if (!profiles?.event) return "sender";
+ if (!profiles?.event) return false;
try {
if (read) await profiles.ensure([creator]);
const profile = profiles.event(creator);
- // Unread, ownership is unknown: deny with no reason rather than guess.
- if (!profile) return read ? "sender" : undefined;
- return hasEventProof(profile) &&
+ if (!profile) return false;
+ return (
+ hasEventProof(profile) &&
profile.kind === 0 &&
profile.pubkey === creator &&
(await attestedOwner(profile)) === owner &&
profiles.event(creator) === profile
- ? true
- : "sender";
+ );
} catch {
// Missing or failed profile reads are not ownership verdicts; a later
// mention can retry through the session's existing profile directory.
- return undefined;
+ return false;
}
}
diff --git a/src/features/messages/AgentDeclineNotes.test.tsx b/src/features/messages/AgentDeclineNotes.test.tsx
index 71b1a6df94..661cca1dcc 100644
--- a/src/features/messages/AgentDeclineNotes.test.tsx
+++ b/src/features/messages/AgentDeclineNotes.test.tsx
@@ -32,14 +32,11 @@ const decline = (refusal: LocalDecline["refusal"], who = agent) => ({
it("says which agent didn't act and why", () => {
render(
,
);
- expect(
- screen.getByText("Builder only responds to your messages."),
- ).toBeInTheDocument();
expect(
screen.getByText(
"Your agent didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.",
@@ -50,7 +47,7 @@ it("says which agent didn't act and why", () => {
it("shows nothing for another message", () => {
const { container } = render(
,
diff --git a/src/features/messages/AgentDeclineNotes.tsx b/src/features/messages/AgentDeclineNotes.tsx
index d39990dd9e..075f8bb396 100644
--- a/src/features/messages/AgentDeclineNotes.tsx
+++ b/src/features/messages/AgentDeclineNotes.tsx
@@ -5,10 +5,8 @@ import styles from "./Messages.module.css";
const noSubscribe = () => () => {};
-export function agentDeclineText(name: string, refusal: MentionRefusal) {
- return refusal === "sender"
- ? `${name} only responds to your messages.`
- : `${name} didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.`;
+export function agentDeclineText(name: string) {
+ return `${name} didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.`;
}
/** Why this device's agents didn't act on this message. Local and owner-only:
@@ -45,13 +43,13 @@ export function AgentDeclineNotes({
readChoices,
readChoices,
);
- return declined.map(([agent, refusal]) => {
+ return declined.map(([agent]) => {
const name =
choices?.identities.find((choice) => choice.pubkey === agent)?.name ??
"Your agent";
return (
- {agentDeclineText(name, refusal)}
+ {agentDeclineText(name)}
);
});
From 0c4a062b6659c8941261cde748d4864e4a5ab4f6 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Mon, 9 Nov 2026 19:59:18 -0800
Subject: [PATCH 7/8] Give the decline note a community reason only on
owner-only relays
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
docs/buzz-help.md | 11 +++---
.../messages/AgentDeclineNotes.test.tsx | 30 ++++++++++++++--
src/features/messages/AgentDeclineNotes.tsx | 34 +++++++++++++++----
src/features/messages/MessageRow.tsx | 3 +-
4 files changed, 64 insertions(+), 14 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index c712dfcc65..1d807e1d63 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -1537,10 +1537,13 @@ These labels only help identify the conversation you select.
Your agents act only on messages from you, from your other agents, and from
workflows that you or your agents own and that direct them. When your own
workflow posts a message that mentions one of your agents on this computer but
-doesn’t direct it, a short note under the message says so: **“‹Agent› didn’t
-respond to this workflow message. This community limits which workflows can use
-agents, so other people can’t use yours. The workflow’s Runs tab shows why it
-didn’t respond this time.”** Only you see it, on this device; nothing is posted.
+doesn’t direct it, a short note under the message says so. On a relay that uses
+owner-only agent mentions it reads: **“‹Agent› didn’t respond to this workflow
+message. This community limits which workflows can use agents, so other people
+can’t use yours. The workflow’s Runs tab shows why it didn’t respond this
+time.”** Elsewhere it reads: **“‹Agent› didn’t respond because this workflow
+message wasn’t set up to direct agents.”** Only you see it, on this device;
+nothing is posted.
Other ignored mentions show no note.
On a relay that uses owner-only agent mentions, the desktop workflow editor
diff --git a/src/features/messages/AgentDeclineNotes.test.tsx b/src/features/messages/AgentDeclineNotes.test.tsx
index 661cca1dcc..c2519f6893 100644
--- a/src/features/messages/AgentDeclineNotes.test.tsx
+++ b/src/features/messages/AgentDeclineNotes.test.tsx
@@ -1,11 +1,18 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
-import { afterEach, expect, it } from "vitest";
+import { afterEach, expect, it, vi } from "vitest";
import type { LocalDecline } from "../agents/local-activity";
import { AgentDeclineNotes } from "./AgentDeclineNotes";
-afterEach(cleanup);
+const relay = vi.hoisted(() => ({ mode: "owner" }));
+vi.mock("../../bundled/workflows/useWorkflowAgentMentions", () => ({
+ useWorkflowAgentMentions: () => relay.mode,
+}));
+afterEach(() => {
+ cleanup();
+ relay.mode = "owner";
+});
const agent = "a".repeat(64),
other = "c".repeat(64),
message = "b".repeat(64);
@@ -32,6 +39,7 @@ const decline = (refusal: LocalDecline["refusal"], who = agent) => ({
it("says which agent didn't act and why", () => {
render(
{
).toBeInTheDocument();
});
+it("gives no community reason on a relay that lets any workflow direct agents", () => {
+ relay.mode = "any";
+ render(
+ ,
+ );
+ expect(
+ screen.getByText(
+ "Builder didn't respond because this workflow message wasn't set up to direct agents.",
+ ),
+ ).toBeInTheDocument();
+});
+
it("shows nothing for another message", () => {
const { container } = render(
() => {};
-export function agentDeclineText(name: string) {
+export function agentDeclineText(name: string, ownerOnly: boolean) {
+ if (!ownerOnly)
+ return `${name} didn't respond because this workflow message wasn't set up to direct agents.`;
return `${name} didn't respond to this workflow message. This community limits which workflows can use agents, so other people can't use yours. The workflow's Runs tab shows why it didn't respond this time.`;
}
@@ -13,10 +16,12 @@ export function agentDeclineText(name: string) {
* the decisions are this app's, and nothing is published. */
export function AgentDeclineNotes({
session,
+ scope,
channelId,
messageId,
}: {
session: Pick;
+ scope: string;
channelId: string;
messageId: string;
}) {
@@ -35,21 +40,36 @@ export function AgentDeclineNotes({
read,
),
) as [string, MentionRefusal][];
- // Read only once there is a note: test and preview sessions may have none.
- const readChoices = () =>
- declined.length ? session.agentChoices.snapshot() : undefined;
+ return declined.length ? (
+ a)} />
+ ) : null;
+}
+
+/** Mounted only when there is a note, so the relay-info read happens only then. */
+function Notes({
+ session,
+ scope,
+ agents,
+}: {
+ session: Pick;
+ scope: string;
+ agents: string[];
+}) {
+ const ownerOnly = useWorkflowAgentMentions(scope) === "owner";
+ // Test and preview sessions may have no choices store.
+ const readChoices = () => session.agentChoices?.snapshot();
const choices = useSyncExternalStore(
- (declined.length && session.agentChoices.subscribe) || noSubscribe,
+ session.agentChoices?.subscribe ?? noSubscribe,
readChoices,
readChoices,
);
- return declined.map(([agent]) => {
+ return agents.map((agent) => {
const name =
choices?.identities.find((choice) => choice.pubkey === agent)?.name ??
"Your agent";
return (
- {agentDeclineText(name)}
+ {agentDeclineText(name, ownerOnly)}
);
});
diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx
index 2ba2ba8b24..76a78babe7 100644
--- a/src/features/messages/MessageRow.tsx
+++ b/src/features/messages/MessageRow.tsx
@@ -1622,9 +1622,10 @@ function MessageRowContent({
{!reactionsInBubble && reactionContent}
- {session && (
+ {session && scope && (
From 27373c861f9fd4e25c2a5311c9a33e1d7009dae0 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Thu, 12 Nov 2026 20:26:41 -0500
Subject: [PATCH 8/8] Test that a workflow refusal reaches the owner's decline
note
Each runtime gives the workflow reason for its owner's workflow mention
that doesn't name it, and the runner keeps it for the decline projection.
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
src/bundled/claude-code/runtime.test.ts | 25 ++++++++++++++++++++++
src/bundled/codex/runtime.test.ts | 22 +++++++++++++++++++
src/bundled/goose/runtime.test.ts | 28 +++++++++++++++++++++++++
src/bundled/pi/runtime.test.ts | 27 ++++++++++++++++++++++++
src/features/agents/service.test.ts | 26 +++++++++++++++++++++++
5 files changed, 128 insertions(+)
diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts
index f1decf1b42..d5d014ef7d 100644
--- a/src/bundled/claude-code/runtime.test.ts
+++ b/src/bundled/claude-code/runtime.test.ts
@@ -2246,6 +2246,31 @@ it.each([true, false])(
},
);
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const relay = keypair();
+ const event = eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", channel],
+ ["p", self],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", alice],
+ ],
+ }),
+ );
+ const f = setup([event], { workflowAuthority: relay.pubkey });
+ try {
+ const ignored = vi.fn();
+ await f.deliver({ type: "mention", event }, { ignored });
+ expect(ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(f.claudes()).toHaveLength(0);
+ } finally {
+ f.runtime.dispose();
+ }
+});
+
it.each([false, true])(
"handles sibling workflow authorization with cancellation=%s",
async (cancelled) => {
diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts
index d154713d60..3ef4421d6d 100644
--- a/src/bundled/codex/runtime.test.ts
+++ b/src/bundled/codex/runtime.test.ts
@@ -2873,6 +2873,28 @@ it("runs a verified relay workflow mention for its owner", async () => {
await f.complete();
expect(f.publish).toHaveBeenCalled();
});
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const f = fixture();
+ const relay = keypair();
+ f.snapshot.session.workflowAuthority = relay.pubkey;
+ const ask = f.delivery("@Codex workflow");
+ const event = eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", "channel"],
+ ["p", pubkey],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", owner],
+ ],
+ }),
+ );
+ const ignored = vi.fn();
+ await f.runtime.run({ ...ask, trigger: { type: "mention", event }, ignored });
+ expect(ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(f.spawn).not.toHaveBeenCalled();
+});
it.each([false, true])(
"handles sibling workflow authorization with cancellation=%s",
async (cancelled) => {
diff --git a/src/bundled/goose/runtime.test.ts b/src/bundled/goose/runtime.test.ts
index 81074f1e9a..dcac6f3f26 100644
--- a/src/bundled/goose/runtime.test.ts
+++ b/src/bundled/goose/runtime.test.ts
@@ -12,6 +12,8 @@ import type { Agent, Delivery } from "../../features/agents/service";
import type { AgentProtection } from "../../features/agent-protection/service";
import type { Host } from "../../features/host/service";
import type { EventData, RelayEvent } from "../../features/relay/events";
+import { eventDto } from "../../features/relay/events";
+import { keypair, signed } from "../../features/relay/testing";
import type { RelayData } from "../../features/relay/service";
import { fakeSpawn, flush, gated } from "./goose-testing";
import { REQUEST_TIMEOUT_MS } from "./goose";
@@ -85,6 +87,8 @@ function setup(
slug?: string,
) => Promise;
thinking?: NonNullable[0]>["thinking"];
+ /** The community's relay workflow key. */
+ workflowAuthority?: string;
/** Wraps the fake's `spawn`, as `gated(…).wrap` does. */
wrap?: (spawn: ReturnType["spawn"]) => Host["spawn"];
}> = {},
@@ -132,6 +136,7 @@ function setup(
dispose: () => {},
};
const session = {
+ workflowAuthority: options.workflowAuthority,
read,
agentMemories: {
open: vi.fn(() => memories),
@@ -1740,6 +1745,29 @@ it.each([
}
});
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const relay = keypair();
+ const f = setup({ workflowAuthority: relay.pubkey });
+ const ignored = vi.fn();
+ await f.deliver(
+ eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", channel],
+ ["p", self],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", alice],
+ ],
+ }),
+ ),
+ { ignored },
+ );
+ expect(ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(f.goose()).toBeUndefined();
+});
+
it("takes a wake with no thread only in a Solo chat", async () => {
const ignored = vi.fn();
const stream = setup();
diff --git a/src/bundled/pi/runtime.test.ts b/src/bundled/pi/runtime.test.ts
index c6ebd82e6b..070f013811 100644
--- a/src/bundled/pi/runtime.test.ts
+++ b/src/bundled/pi/runtime.test.ts
@@ -14,6 +14,8 @@ import type {
StandaloneTurn,
} from "../../features/agents/service";
import type { EventData, RelayEvent } from "../../features/relay/events";
+import { eventDto } from "../../features/relay/events";
+import { keypair, signed } from "../../features/relay/testing";
import type { RelayData } from "../../features/relay/service";
import { deferred, fakeHost, type Handler } from "./pi-testing";
import { type Config, DEFAULT_CONFIG, PiRuntime } from "./runtime";
@@ -75,6 +77,7 @@ function setup(
/** Channels that are the owner's own Solo chats; the owner is the viewer. */
const solo = new Set();
const session = {
+ workflowAuthority: undefined as string | undefined,
read: vi.fn(async () => [root]),
agentMemories: {
read: vi.fn(async () => ({
@@ -473,6 +476,30 @@ it("shows a conversation only the messages it was not shown, nor its own replies
expect(text).not.toContain("Earlier from Pi");
});
+it("gives the workflow reason for its owner's workflow mention that doesn't name it", async () => {
+ const { host, deliver, session } = setup();
+ const relay = keypair();
+ session.workflowAuthority = relay.pubkey;
+ const turn = deliver({
+ type: "mention",
+ event: eventDto(
+ signed(relay, {
+ kind: 9,
+ content: "@Agent workflow",
+ tags: [
+ ["h", channel],
+ ["p", self],
+ ["buzz:workflow", "true"],
+ ["buzz:workflow-owner", alice],
+ ],
+ }),
+ ),
+ });
+ await turn.run;
+ expect(turn.ignored).toHaveBeenCalledExactlyOnceWith("workflow");
+ expect(host.processes).toEqual([]);
+});
+
it("ignores a mention from someone who is not the owner", async () => {
const { host, deliver } = setup();
const turn = deliver({
diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts
index 9bd55274a0..a31913caa8 100644
--- a/src/features/agents/service.test.ts
+++ b/src/features/agents/service.test.ts
@@ -2318,6 +2318,32 @@ it("shows no typing for a mention its runtime ignores", async () => {
expect(await published(native, 20002)).toEqual([]);
});
+it("keeps a runtime's refusal reason for its owner's decline note", async () => {
+ const { service, run, emit, activity } = await setup();
+ await service.create({ type: "example/echo", name: "Echo" });
+ run.mockImplementationOnce(async ({ ignored }) => ignored?.("workflow"));
+ emit({
+ events: [
+ event("m", {
+ tags: [
+ ["h", channel],
+ ["p", bot],
+ ],
+ }),
+ ],
+ channelId: channel,
+ });
+ await vi.waitFor(() =>
+ expect(activity.reader.declined?.()).toEqual([
+ expect.objectContaining({
+ agent: bot,
+ channelId: channel,
+ refusal: "workflow",
+ }),
+ ]),
+ );
+});
+
it("wakes on a reply in a thread it started only by a mention or a watch", async () => {
const { service, run, emit } = await setup();
await service.create({ type: "example/echo", name: "Echo" });