Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ reconnects preserve pending avatar verification work):
- `resetRenderScopedReactionHydration()` — reaction hydration cache
- `clearSearchHitEventCache()` — search result event cache
- `clearMarkdownNodeCache()` — markdown parse-node cache
- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events)

**If you add a new module-level cache, Map, or class instance that holds
community-scoped data, you must add its reset to `resetCommunityState()`.**
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
| `buzz issues` | `create`, `get`, `list`, `status` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |

Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.

When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation.

`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references.

## Conversational Agent Creation

When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous.
Expand Down
40 changes: 31 additions & 9 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1387,19 +1387,25 @@ pub fn extract_p_tags(event: &serde_json::Value) -> Vec<serde_json::Value> {
.unwrap_or_default()
}

/// Return a create-command response with an entity ID injected.
pub fn create_response_with_id(resp: &str, id_key: &str, id_val: &str) -> String {
/// Return a create-command response, injecting the entity ID **only** when the
/// relay accepted the event (`"accepted": true`). When the relay rejected the
/// event, emitting the locally-computed link would be misleading — callers
/// that copy or share the link would reference an event that was never stored.
pub fn create_response_with_id_if_accepted(resp: &str, id_key: &str, id_val: &str) -> String {
let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({}));
v[id_key] = serde_json::json!(id_val);
if v.get("accepted").is_none() {
v["accepted"] = serde_json::json!(true);
let accepted = v.get("accepted").and_then(|a| a.as_bool()).unwrap_or(false);
if accepted {
v[id_key] = serde_json::json!(id_val);
}
v.to_string()
}

/// Print a create-command response, injecting the generated entity ID.
pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) {
println!("{}", create_response_with_id(resp, id_key, id_val));
println!(
"{}",
create_response_with_id_if_accepted(resp, id_key, id_val)
);
}

/// Extract a JSON field from relay write response messages shaped as
Expand Down Expand Up @@ -2297,7 +2303,8 @@ mod retry_policy_tests {
#[cfg(test)]
mod tests {
use super::{
advance_query_cursor, create_response_with_id, extract_relay_response_field, BuzzClient,
advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field,
BuzzClient,
};
use nostr::{EventBuilder, Keys, Kind, Tag};

Expand Down Expand Up @@ -2345,15 +2352,30 @@ mod tests {
}

#[test]
fn create_response_with_id_overrides_local_id_with_relay_id() {
fn create_response_with_id_if_accepted_injects_id_when_accepted() {
let raw = r#"{"event_id":"abc","accepted":true,"message":"response:{\"workflow_id\":\"relay-id\"}"}"#;
let out = create_response_with_id(raw, "workflow_id", "relay-id");
let out = create_response_with_id_if_accepted(raw, "workflow_id", "relay-id");
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
// ID injected and original fields preserved when accepted.
assert_eq!(v["workflow_id"].as_str(), Some("relay-id"));
assert_eq!(v["event_id"].as_str(), Some("abc"));
assert_eq!(v["accepted"].as_bool(), Some(true));
}

#[test]
fn create_response_with_id_if_accepted_omits_id_when_rejected() {
let raw = r#"{"event_id":"abc","accepted":false,"message":"duplicate"}"#;
let out = create_response_with_id_if_accepted(raw, "workflow_id", "local-id");
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
// ID must not be present when relay rejected the event; emitting a
// link to an event that was never stored would mislead callers.
assert!(
v.get("workflow_id").is_none(),
"link field must be absent on rejected create"
);
assert_eq!(v["accepted"].as_bool(), Some(false));
}

// --- (a) auth-suppression regression pair ---

fn make_auth_tag() -> (Tag, String) {
Expand Down
6 changes: 5 additions & 1 deletion crates/buzz-cli/src/commands/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ pub async fn cmd_create_issue(

let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let event_id = event.id.to_hex();
let resp = client.submit_event(event).await?;
println!("{resp}");
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce issues with it (see base_prompt.md).
let link = crate::links::issue_link(&event_id, repo_owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}

Expand Down
6 changes: 5 additions & 1 deletion crates/buzz-cli/src/commands/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,12 @@ pub async fn cmd_open_pr(

let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let event_id = event.id.to_hex();
let resp = client.submit_event(event).await?;
println!("{resp}");
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce PRs with it (see base_prompt.md).
let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}

Expand Down
6 changes: 5 additions & 1 deletion crates/buzz-cli/src/commands/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,12 @@ pub async fn cmd_create_repo(
channel,
)?;
let event = client.sign_event(builder)?;
let owner = event.pubkey.to_hex();
let resp = client.submit_event(event).await?;
println!("{resp}");
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce repos with it (see base_prompt.md).
let link = crate::links::repo_link(&owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod agent_management;
mod client;
mod commands;
mod error;
mod links;
mod validate;

use clap::{Parser, Subcommand};
Expand Down
51 changes: 51 additions & 0 deletions crates/buzz-cli/src/links.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Canonical `buzz://` deep links for Buzz-hosted git entities.
//!
//! Buzz Desktop renders these links as rich preview cards in chat and
//! navigates in-app when they are clicked. The desktop parser lives in
//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must
//! stay format-compatible (see `golden_format_matches_desktop` below and
//! the mirror test in `entityLink.test.mjs`).
//!
//! Callers are expected to validate inputs first (`validate_hex64`,
//! `validate_repo_id`); the identifier charsets need no URL encoding.

/// Build a `buzz://repo` link for a repository announcement (kind 30617).
pub fn repo_link(owner: &str, repo_id: &str) -> String {
format!("buzz://repo?owner={owner}&d={repo_id}")
}

/// Build a `buzz://pr` link for a pull request event (kind 1618).
pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String {
format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}")
}

/// Build a `buzz://issue` link for an issue event (kind 1621).
pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String {
format!("buzz://issue?id={event_id}&owner={owner}&d={repo_id}")
}

#[cfg(test)]
mod tests {
use super::*;

const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";

// Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs
// ("builders emit the canonical cross-language link format").
#[test]
fn golden_format_matches_desktop() {
assert_eq!(
pull_request_link(EVENT_ID, OWNER, "buzz-world"),
format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world")
);
assert_eq!(
issue_link(EVENT_ID, OWNER, "buzz-world"),
format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world")
);
assert_eq!(
repo_link(OWNER, "buzz-world"),
format!("buzz://repo?owner={OWNER}&d=buzz-world")
);
}
}
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getIdentity } from "@/shared/api/tauriIdentity";
import { clearTrayAgentActivity } from "@/shared/api/trayMenu";
import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { resetLinkPreviewTitleCache } from "@/shared/lib/useResolvedLinkPreviews";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import {
clearAllDrafts,
Expand Down Expand Up @@ -71,6 +72,7 @@ function resetCommunityState({
resetBackgroundMediaUploads();
clearSearchHitEventCache();
clearMarkdownNodeCache();
resetLinkPreviewTitleCache();
}

type CommunityInitResult =
Expand Down
135 changes: 135 additions & 0 deletions desktop/src/shared/lib/entityLink.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
buildIssueLink,
buildPullRequestLink,
buildRepoLink,
entityLinkProjectRouteId,
isEntityLink,
parseEntityLink,
} from "./entityLink.ts";

const OWNER =
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
const EVENT_ID =
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";

// Golden format strings — must match the Rust builder in
// crates/buzz-cli/src/links.rs (`golden_format_matches_desktop` test).
test("builders emit the canonical cross-language link format", () => {
assert.equal(
buildPullRequestLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }),
`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
);
assert.equal(
buildIssueLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }),
`buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
);
assert.equal(
buildRepoLink({ owner: OWNER, dtag: "buzz-world" }),
`buzz://repo?owner=${OWNER}&d=buzz-world`,
);
});

test("builders reject invalid identifiers", () => {
assert.throws(() =>
buildRepoLink({ owner: "not-a-pubkey", dtag: "buzz-world" }),
);
assert.throws(() => buildRepoLink({ owner: OWNER, dtag: ".hidden" }));
assert.throws(() => buildRepoLink({ owner: OWNER, dtag: "a..b" }));
assert.throws(() =>
buildPullRequestLink({ id: "short", owner: OWNER, dtag: "buzz-world" }),
);
});

test("parseEntityLink round-trips built links", () => {
const link = buildPullRequestLink({
id: EVENT_ID,
owner: OWNER,
dtag: "buzz-world",
});
assert.deepEqual(parseEntityLink(link), {
ok: true,
value: { type: "pr", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" },
});

const repoLink = buildRepoLink({ owner: OWNER, dtag: "buzz-world" });
assert.deepEqual(parseEntityLink(repoLink), {
ok: true,
value: { type: "repo", owner: OWNER, dtag: "buzz-world" },
});
});

test("parseEntityLink lowercase-normalizes hex identifiers", () => {
const parsed = parseEntityLink(
`buzz://issue?id=${EVENT_ID.toUpperCase()}&owner=${OWNER.toUpperCase()}&d=buzz-world`,
);
assert.deepEqual(parsed, {
ok: true,
value: { type: "issue", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" },
});
});

test("parseEntityLink rejects malformed links", () => {
const cases = [
["not a url at all", "invalid-url"],
[`https://pr?id=${EVENT_ID}&owner=${OWNER}&d=repo`, "wrong-scheme"],
[`buzz://message?channel=x&id=${EVENT_ID}`, "wrong-host"],
[`buzz://pr?id=${EVENT_ID}&owner=nope&d=repo`, "invalid-owner"],
[`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=.hidden`, "invalid-dtag"],
[`buzz://pr?id=${EVENT_ID}&owner=${OWNER}`, "invalid-dtag"],
[`buzz://pr?owner=${OWNER}&d=repo`, "invalid-id"],
[`buzz://issue?id=short&owner=${OWNER}&d=repo`, "invalid-id"],
];
for (const [href, reason] of cases) {
assert.deepEqual(parseEntityLink(href), { ok: false, reason }, href);
}
});

test("isEntityLink matches entity hosts and excludes message links", () => {
assert.equal(isEntityLink(`buzz://pr?id=${EVENT_ID}`), true);
assert.equal(isEntityLink(`buzz://issue?id=${EVENT_ID}`), true);
assert.equal(isEntityLink(`buzz://repo?owner=${OWNER}`), true);
assert.equal(isEntityLink("buzz://message?channel=x&id=y"), false);
assert.equal(isEntityLink("https://github.com/block/buzz"), false);
assert.equal(isEntityLink(null), false);
});

test("entityLinkProjectRouteId emits the canonical 30617 coordinate route id", () => {
const parsed = parseEntityLink(
buildRepoLink({ owner: OWNER, dtag: "buzz-world" }),
);
assert.ok(parsed.ok);
assert.equal(
entityLinkProjectRouteId(parsed.value),
`30617:${OWNER}:buzz-world`,
);
});

test("parseEntityLink rejects noncanonical extras", () => {
// Unexpected path segments — reserved for future versioning.
assert.deepEqual(
parseEntityLink(
`buzz://pr/ignored?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
),
{ ok: false, reason: "unexpected-path" },
);
// Fragment — not part of the canonical format.
assert.deepEqual(
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world#section`),
{ ok: false, reason: "unexpected-fragment" },
);
// Unknown query parameter — reject to preserve forward-compat posture.
assert.deepEqual(
parseEntityLink(
`buzz://repo?owner=${OWNER}&d=buzz-world&relay=wss%3A%2F%2Frelay.example`,
),
{ ok: false, reason: "unknown-param" },
);
// Duplicate required parameter — reject.
assert.deepEqual(
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world&owner=${OWNER}`),
{ ok: false, reason: "duplicate-param" },
);
});
Loading
Loading