fix(desktop): scope agent sends to their captured tenant; thread follow-ups onto the opener

Two fifth-round review findings on the Projects agent send path.

Cross-tenant in-flight sends (P1): the submit flow suspends across
managed-agent startup, DM open, and the send itself. A community switch
during any suspension does not cancel the callback — remounting only
removes the UI — so a stale callback could open a DM or publish the
captured tenant's content on the NEW tenant's relay. Fix: the caller
captures the community relay scope before its first await and passes it
as expected_relay_url through open_dm and send_channel_message. Each
command resolves its relay base exactly once, asserts the captured scope
against it (assert_expected_relay_scope, ws(s)→http(s) normalized), and
uses that same base for every side effect — thread-ref read, submit, and
metadata read. A mismatch fails closed ("active community changed before
the message was submitted; not sent") rather than publishing to the
wrong tenant. Absent scope preserves unscoped behavior for callers with
no tenant boundary. submit_event_with_created_at becomes
submit_event_at_created_at (explicit base) so the scope-checked base is
the one used at submit time — re-resolving there would reopen the race.

Same-second follow-up hiding (P2 of the pair): follow-up sends carried
no parentEventId, so a follow-up signed within the opener's second got a
random event id and roughly half sorted on the rejected side of the
same-second id tiebreak in isAtOrAfterConversationOpener — an immediate
follow-up could vanish. Fix: follow-ups now reply to the opener
(parentEventId = opener.eventId); the comparator always admits causal
e-tag replies, so visibility no longer depends on id luck.

Both submit handlers (ProjectAgentChatPanel, ProjectsAgentPromptPage)
route through a new pure orchestration function,
submitProjectAgentMessage, which never re-reads the scope after capture
and is unit-tested without React: switch-during-startup publishes
nothing to either tenant, switch-during-DM-open fails closed, the
captured scope rides every relay side effect, and follow-ups reference
the opener. The e2e bridge mirrors the backend check after its injected
delays so specs can drive a mid-flight community switch
deterministically.

File-size ratchet keeps its discipline: the scope check lives in
relay/scope.rs, resolve_thread_ref moves to
commands/messages/thread_ref.rs, sendChannelMessage moves to
shared/api/tauriMessages.ts (re-exported from tauri.ts), and OpenDmInput
moves next to openDm in tauriChannels.ts.

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Wintermute
2026-08-17 00:44:54 -04:00
co-authored by Thomas Petersen
parent bbbb3564b6
commit a86f9e2d85
16 changed files with 593 additions and 138 deletions
+19 -4
View File
@@ -6,7 +6,10 @@ use crate::{
events,
models::ChannelInfo,
nostr_convert,
relay::{parse_command_response, query_relay, submit_event},
relay::{
assert_expected_relay_scope, parse_command_response, query_relay_at, submit_event,
submit_event_at_with_keys,
},
};
#[derive(Deserialize)]
@@ -17,18 +20,30 @@ struct OpenDmAck {
#[tauri::command]
pub async fn open_dm(
pubkeys: Vec<String>,
expected_relay_url: Option<String>,
state: State<'_, AppState>,
) -> Result<ChannelInfo, String> {
// Resolve the relay once for the open + metadata read pair. Callers with
// a captured tenant scope (Projects agent sends) pass
// `expected_relay_url`; a mismatch means the active community changed
// while their callback was suspended, and opening a DM on the new tenant
// would hand the stale callback a channel in the wrong community — fail
// closed instead.
let api_base_url = crate::relay::relay_api_base_url_with_override(&state);
assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?;
// Submit a kind:41010 dm-open event; the relay replies with the channel id
// in its OK message payload.
let builder = events::build_dm_open(&pubkeys)?;
let result = submit_event(builder, &state).await?;
let keys = state.signing_keys()?;
let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?;
let ack: OpenDmAck = parse_command_response(&result.message)?;
// Re-fetch the channel metadata so the frontend gets the same `ChannelInfo`
// shape as `get_channel_details`.
let metadata = query_relay(
// shape as `get_channel_details` — through the same scope-checked base.
let metadata = query_relay_at(
&state,
&api_base_url,
&[serde_json::json!({
"kinds": [39000],
"#d": [ack.channel_id],
+27 -53
View File
@@ -18,7 +18,8 @@ use crate::{
},
nostr_convert,
relay::{
query_relay, submit_event, submit_event_with_created_at, submit_event_with_keys_created_at,
assert_expected_relay_scope, query_relay, submit_event, submit_event_at_created_at,
submit_event_with_keys_created_at,
},
};
@@ -433,54 +434,8 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<S
// ── Writes ──────────────────────────────────────────────────────────────────
/// Fetch a parent event and extract the thread root from its NIP-10 e-tags.
async fn resolve_thread_ref(
parent_event_id: &str,
state: &AppState,
) -> Result<events::ThreadRef, String> {
let parent_eid =
EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
let evs = query_relay(
state,
&[serde_json::json!({
"ids": [parent_event_id],
"kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
"limit": 1
})],
)
.await?;
let parent = evs
.first()
.ok_or_else(|| "parent event not found".to_string())?;
// Walk tags looking for NIP-10 root/reply markers.
let (mut root, mut reply) = (None, None);
for tag in parent.tags.iter() {
let s = tag.as_slice();
if s.len() >= 4 && s[0] == "e" {
match s[3].as_str() {
"root" => root = Some(s[1].clone()),
"reply" => reply = Some(s[1].clone()),
_ => {}
}
}
}
let root_hex = root.or(reply);
let root_eid = match root_hex {
Some(hex) if hex != parent_event_id => {
EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))?
}
_ => parent_eid,
};
Ok(events::ThreadRef {
root_event_id: root_eid,
parent_event_id: parent_eid,
})
}
mod thread_ref;
use thread_ref::resolve_thread_ref;
#[tauri::command]
#[allow(clippy::too_many_arguments)]
@@ -495,6 +450,7 @@ pub async fn send_channel_message(
sent_from_thread_tag: Option<Vec<String>>,
mention_pubkeys: Option<Vec<String>>,
kind: Option<u32>,
expected_relay_url: Option<String>,
state: State<'_, AppState>,
) -> Result<SendChannelMessageResponse, String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
@@ -505,7 +461,13 @@ pub async fn send_channel_message(
let emoji = emoji_tags.unwrap_or_default();
let mention_refs_only = mention_tags.unwrap_or_default();
let link_previews = link_preview_tags.unwrap_or_default();
// Resolve the relay once and use it for every read and the submission.
// Callers that captured a tenant scope before an await (Projects agent
// sends) pass `expected_relay_url`; a mismatch means the active community
// changed mid-flight and the send must fail closed rather than publish
// the captured tenant's content to the new tenant's relay.
let relay_base = crate::relay::relay_api_base_url_with_override(&state);
assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?;
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE {
return Err("sent-from-thread provenance requires a stream message".into());
@@ -525,7 +487,7 @@ pub async fn send_channel_message(
let parent_id = parent_event_id
.as_deref()
.ok_or("forum comment requires parent_event_id")?;
let thread_ref = resolve_thread_ref(parent_id, &state).await?;
let thread_ref = resolve_thread_ref(parent_id, &state, &relay_base).await?;
resolved_root = Some(thread_ref.root_event_id.to_hex());
events::build_forum_comment(
channel_uuid,
@@ -539,7 +501,7 @@ pub async fn send_channel_message(
_ => {
let thread_ref = match parent_event_id.as_deref() {
Some(pid) => {
let tr = resolve_thread_ref(pid, &state).await?;
let tr = resolve_thread_ref(pid, &state, &relay_base).await?;
resolved_root = Some(tr.root_event_id.to_hex());
Some(tr)
}
@@ -562,7 +524,9 @@ pub async fn send_channel_message(
// `created_at` is the signed event's own second, not a post-publication
// clock read — persisted as an event cursor by the Projects opener.
let (result, created_at) = submit_event_with_created_at(builder, &state).await?;
// Submit through the base resolved (and scope-checked) above — a
// re-resolve here would reopen the mid-command switch window.
let (result, created_at) = submit_event_at_created_at(builder, &state, &relay_base).await?;
let depth = match (&parent_event_id, &resolved_root) {
(None, _) => 0,
@@ -779,7 +743,17 @@ pub async fn send_managed_agent_channel_message(
let submission_auth_tag =
managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?;
let thread_ref = match parent_event_id.as_deref() {
Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?),
Some(parent_id) => Some(
// Same active-relay resolution as before — this path has no
// caller-captured tenant scope (yet), so resolve the override
// here and read through it.
resolve_thread_ref(
parent_id,
&state,
&crate::relay::relay_api_base_url_with_override(&state),
)
.await?,
),
None => None,
};
@@ -0,0 +1,58 @@
use nostr::EventId;
use crate::{app_state::AppState, events, relay::query_relay_at};
/// Fetch a parent event and extract the thread root from its NIP-10 e-tags.
///
/// Reads through the explicit `api_base_url` the calling command resolved —
/// never re-resolving the workspace override — so a mid-command community
/// switch cannot split one logical send across two relays.
pub(super) async fn resolve_thread_ref(
parent_event_id: &str,
state: &AppState,
api_base_url: &str,
) -> Result<events::ThreadRef, String> {
let parent_eid =
EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
let evs = query_relay_at(
state,
api_base_url,
&[serde_json::json!({
"ids": [parent_event_id],
"kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
"limit": 1
})],
)
.await?;
let parent = evs
.first()
.ok_or_else(|| "parent event not found".to_string())?;
// Walk tags looking for NIP-10 root/reply markers.
let (mut root, mut reply) = (None, None);
for tag in parent.tags.iter() {
let s = tag.as_slice();
if s.len() >= 4 && s[0] == "e" {
match s[3].as_str() {
"root" => root = Some(s[1].clone()),
"reply" => reply = Some(s[1].clone()),
_ => {}
}
}
}
let root_hex = root.or(reply);
let root_eid = match root_hex {
Some(hex) if hex != parent_event_id => {
EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))?
}
_ => parent_eid,
};
Ok(events::ThreadRef {
root_event_id: root_eid,
parent_event_id: parent_eid,
})
}
+4 -1
View File
@@ -84,6 +84,9 @@ pub fn relay_http_base_url(relay_url: &str) -> String {
trimmed.to_string()
}
mod scope;
pub use scope::assert_expected_relay_scope;
pub fn relay_api_base_url() -> String {
if let Some(base) = configured_env_var("BUZZ_RELAY_HTTP") {
return base.trim_end_matches('/').to_string();
@@ -537,7 +540,7 @@ pub use get::get_relay_json;
mod submit;
pub use submit::{
submit_event, submit_event_at_with_keys, submit_event_with_created_at,
submit_event, submit_event_at_created_at, submit_event_at_with_keys,
submit_event_with_keys_created_at, submit_signed_event_at_with_keys, SubmitEventResponse,
};
+60
View File
@@ -0,0 +1,60 @@
use super::relay_http_base_url;
/// Fail closed when a caller-captured relay scope no longer matches the
/// relay a command actually resolved.
///
/// Long-lived UI callbacks (e.g. the Projects agent submit flow) capture the
/// community relay before their first await; a workspace switch during that
/// await would otherwise retarget the eventual publication to the new
/// tenant's relay. Callers pass the captured scope as a ws(s) URL; it is
/// normalized through [`relay_http_base_url`] and compared against the base
/// the command resolved once and uses for every side effect. `None` preserves
/// the unscoped behavior for callers without a tenant boundary.
pub fn assert_expected_relay_scope(
expected_relay_url: Option<&str>,
resolved_api_base_url: &str,
) -> Result<(), String> {
let Some(expected) = expected_relay_url.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(());
};
let expected_base = relay_http_base_url(expected);
if expected_base != resolved_api_base_url.trim().trim_end_matches('/') {
return Err(
"active community changed before the message was submitted; not sent".to_string(),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::assert_expected_relay_scope;
#[test]
fn matching_scope_passes_across_ws_http_normalization() {
assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-a.example")
.unwrap();
assert_expected_relay_scope(Some("ws://localhost:3000"), "http://localhost:3000").unwrap();
// Trailing-slash and whitespace tolerance mirrors relay_http_base_url.
assert_expected_relay_scope(
Some(" wss://tenant-a.example/ "),
"https://tenant-a.example/",
)
.unwrap();
}
#[test]
fn changed_scope_fails_closed() {
let error =
assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-b.example")
.unwrap_err();
assert!(error.contains("active community changed"), "{error}");
}
#[test]
fn absent_scope_preserves_unscoped_sends() {
assert_expected_relay_scope(None, "https://anything.example").unwrap();
assert_expected_relay_scope(Some(""), "https://anything.example").unwrap();
assert_expected_relay_scope(Some(" "), "https://anything.example").unwrap();
}
}
+12 -5
View File
@@ -77,28 +77,35 @@ pub async fn submit_event(
submit_event_at_with_keys(builder, state, &api_base_url, &keys).await
}
/// Like [`submit_event`], but also returns the signed event's `created_at`.
/// Sign, submit to an explicit HTTP API base URL, and also return the signed
/// event's `created_at`.
///
/// Callers that persist a timestamp as an event cursor (e.g. the Projects
/// conversation opener) need the signed event's own second — a
/// post-publication clock read can land a second later and permanently
/// exclude other events stamped in the event's real second.
pub async fn submit_event_with_created_at(
///
/// The explicit base (rather than a re-read of the workspace override at
/// submit time) matters for the same callers: they validated a tenant scope
/// against the resolved base earlier in the same command, and re-resolving
/// here would reopen the window where a workspace switch retargets the event
/// after the check passed.
pub async fn submit_event_at_created_at(
builder: nostr::EventBuilder,
state: &AppState,
api_base_url: &str,
) -> Result<(SubmitEventResponse, i64), String> {
let api_base_url = relay_api_base_url_with_override(state);
let keys = state.signing_keys()?;
let event = builder
.sign_with_keys(&keys)
.map_err(|e| format!("failed to sign event: {e}"))?;
let created_at = event.created_at.as_secs() as i64;
let result = submit_signed_event_at_with_keys(&event, state, &api_base_url, &keys).await?;
let result = submit_signed_event_at_with_keys(&event, state, api_base_url, &keys).await?;
Ok((result, created_at))
}
/// Like `submit_event_with_keys`, but also returns the signed event's
/// `created_at` — same cursor rationale as [`submit_event_with_created_at`].
/// `created_at` — same cursor rationale as [`submit_event_at_created_at`].
pub async fn submit_event_with_keys_created_at(
builder: nostr::EventBuilder,
state: &AppState,
+1 -1
View File
@@ -27,11 +27,11 @@ import type {
Channel,
ChannelDetail,
CreateChannelInput,
OpenDmInput,
SetChannelPurposeInput,
SetChannelTopicInput,
UpdateChannelInput,
} from "@/shared/api/types";
import type { OpenDmInput } from "@/shared/api/tauriChannels";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible";
import { useCommunities } from "@/features/communities/useCommunities";
@@ -5,6 +5,7 @@ import {
isAtOrAfterConversationOpener,
mergeProjectAgentConversationEvents,
restoreProjectsAgentConversation,
submitProjectAgentMessage,
visibleConversationMessages,
} from "./projectAgentConversation.ts";
import {
@@ -287,3 +288,157 @@ test("storage round-trips opener-anchored pointers and clears them", () => {
clearStoredProjectsAgentConversation(WORKSPACE_ID);
assert.equal(readStoredProjectsAgentConversation(WORKSPACE_ID), null);
});
// ── submitProjectAgentMessage ───────────────────────────────────────────────
/** Models the backend's fail-closed scope check: commands resolve the active
* relay when they run and reject when a caller-captured scope no longer
* matches. `active` is mutable so tests can switch communities mid-flight. */
function makeScopedBackend(active) {
const state = { active, dmOpens: [], sends: [] };
const assertScope = (expectedRelayUrl) => {
if (expectedRelayUrl !== undefined && expectedRelayUrl !== state.active) {
throw new Error(
"active community changed before the message was submitted; not sent",
);
}
};
return {
state,
openDm: async (input) => {
assertScope(input.expectedRelayUrl);
state.dmOpens.push({ relay: state.active, input });
return { id: `dm-on-${state.active}` };
},
send: async (request) => {
assertScope(request.expectedRelayUrl);
state.sends.push({ relay: state.active, request });
return { eventId: `f${"0".repeat(63)}`, createdAt: PROMPT_AT };
},
};
}
test("a community switch during agent startup publishes nothing to either tenant", async () => {
const backend = makeScopedBackend("wss://tenant-a.example");
let releaseStart;
const startGate = new Promise((resolve) => {
releaseStart = resolve;
});
const pending = submitProjectAgentMessage({
agent: { pubkey: AGENT_PUBKEY, isManaged: true, isActive: false },
conversation: null,
content: "tenant A repo context",
mentionPubkeys: [AGENT_PUBKEY],
relayScope: "wss://tenant-a.example",
startAgent: () => startGate,
openDm: backend.openDm,
send: backend.send,
});
// The user switches communities while the callback is suspended on the
// managed-agent startup await. Remounting removed the panel, but this
// callback keeps running.
backend.state.active = "wss://tenant-b.example";
releaseStart();
await assert.rejects(pending, /active community changed/);
assert.deepEqual(backend.state.dmOpens, []);
assert.deepEqual(backend.state.sends, []);
});
test("a community switch during the DM open fails the send closed", async () => {
const backend = makeScopedBackend("wss://tenant-a.example");
const scopedOpenDm = backend.openDm;
const pending = submitProjectAgentMessage({
agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true },
conversation: null,
content: "tenant A repo context",
mentionPubkeys: [AGENT_PUBKEY],
relayScope: "wss://tenant-a.example",
startAgent: () => {
throw new Error("inactive relay agents are not startable");
},
openDm: async (input) => {
const channel = await scopedOpenDm(input);
// The switch lands after the DM was opened on tenant A but before the
// message submit — the narrowest window Carl's finding names.
backend.state.active = "wss://tenant-b.example";
return channel;
},
send: backend.send,
});
await assert.rejects(pending, /active community changed/);
// The DM was legitimately opened while tenant A was still active…
assert.equal(backend.state.dmOpens.length, 1);
assert.equal(backend.state.dmOpens[0].relay, "wss://tenant-a.example");
// …but nothing was ever published anywhere.
assert.deepEqual(backend.state.sends, []);
});
test("the captured scope rides every relay side effect of a first send", async () => {
const backend = makeScopedBackend("wss://tenant-a.example");
const result = await submitProjectAgentMessage({
agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true },
conversation: null,
content: "opener",
mentionPubkeys: [AGENT_PUBKEY],
relayScope: "wss://tenant-a.example",
startAgent: async () => {},
openDm: backend.openDm,
send: backend.send,
});
assert.equal(
backend.state.dmOpens[0].input.expectedRelayUrl,
"wss://tenant-a.example",
);
assert.equal(
backend.state.sends[0].request.expectedRelayUrl,
"wss://tenant-a.example",
);
// The opener is a thread root: no parent reference.
assert.equal(backend.state.sends[0].request.parentEventId, undefined);
assert.equal(result.channel.id, "dm-on-wss://tenant-a.example");
});
test("follow-ups reply to the opener so same-second id ordering cannot hide them", async () => {
const backend = makeScopedBackend("wss://tenant-a.example");
await submitProjectAgentMessage({
agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true },
conversation: { channel: EXISTING_DM, opener: OPENER },
content: "follow-up in the opener's second",
mentionPubkeys: [AGENT_PUBKEY],
relayScope: "wss://tenant-a.example",
startAgent: async () => {},
openDm: () => {
throw new Error("an existing conversation must reuse its channel");
},
send: backend.send,
});
const request = backend.state.sends[0].request;
assert.equal(request.channelId, EXISTING_DM.id);
assert.equal(request.parentEventId, OPENER.eventId);
// Carl's exact-head probe: a same-second follow-up whose random id lands on
// the rejected side of the id tiebreak (`e… > d…`). As an unreferenced root
// it would vanish; as the reply the submit path now sends, it is admitted.
const rejectedSideId = `e${"0".repeat(63)}`;
const asUnreferencedRoot = {
created_at: OPENER.createdAt,
id: rejectedSideId,
tags: [],
};
assert.equal(
isAtOrAfterConversationOpener(asUnreferencedRoot, OPENER),
false,
);
const asSentReply = {
created_at: OPENER.createdAt,
id: rejectedSideId,
tags: [["e", OPENER.eventId, "", "reply"]],
};
assert.equal(isAtOrAfterConversationOpener(asSentReply, OPENER), true);
});
@@ -118,3 +118,75 @@ export function mergeProjectAgentConversationEvents<
).values(),
].sort((left, right) => left.created_at - right.created_at);
}
/**
* Runs the Projects agent submit sequence with every relay side effect bound
* to the tenant scope the caller captured before the first await.
*
* The sequence suspends twice (managed-agent startup, DM open) and a
* community switch during either suspension does not cancel this callback
* remounting only removes the UI. Binding is therefore delegated to the
* scoped APIs themselves: `openDm` and `send` receive `expectedRelayUrl`
* and the backing commands fail closed when the active community no longer
* matches, so a stale callback can neither open a DM in the new tenant nor
* publish the captured tenant's context to it. This function never re-reads
* the active scope after capture doing so would race the very switch it
* guards against.
*
* Follow-up sends carry `parentEventId = opener.eventId`: a follow-up signed
* in the opener's own second gets a random event id, and roughly half of
* those sort on the rejected side of the same-second id tiebreak in
* `isAtOrAfterConversationOpener`. The causal reply reference which the
* comparator always admits is what keeps an immediate follow-up visible;
* id luck cannot.
*/
export async function submitProjectAgentMessage<Ch extends { id: string }>({
agent,
conversation,
content,
mentionPubkeys,
mediaTags,
relayScope,
startAgent,
openDm,
send,
}: {
agent: { pubkey: string; isManaged: boolean; isActive: boolean };
conversation: { channel: Ch; opener: ProjectsConversationOpener } | null;
content: string;
mentionPubkeys: string[];
mediaTags?: string[][];
/** Community relay captured before the first await; null when the
* community has no relay identity (no tenant boundary to protect). */
relayScope: string | null;
startAgent: (agentPubkey: string) => Promise<unknown>;
openDm: (input: {
pubkeys: string[];
expectedRelayUrl?: string;
}) => Promise<Ch>;
send: (request: {
channelId: string;
content: string;
mentionPubkeys: string[];
mediaTags?: string[][];
parentEventId?: string;
expectedRelayUrl?: string;
}) => Promise<{ eventId: string; createdAt: number }>;
}): Promise<{ channel: Ch; sent: { eventId: string; createdAt: number } }> {
const expectedRelayUrl = relayScope ?? undefined;
if (agent.isManaged && !agent.isActive) {
await startAgent(agent.pubkey);
}
const channel =
conversation?.channel ??
(await openDm({ pubkeys: [agent.pubkey], expectedRelayUrl }));
const sent = await send({
channelId: channel.id,
content,
mentionPubkeys,
mediaTags,
parentEventId: conversation?.opener.eventId,
expectedRelayUrl,
});
return { channel, sent };
}
@@ -9,7 +9,10 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage";
import { useCommunities } from "@/features/communities/useCommunities";
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext";
import { restoreProjectsAgentConversation } from "@/features/projects/lib/projectAgentConversation";
import {
restoreProjectsAgentConversation,
submitProjectAgentMessage,
} from "@/features/projects/lib/projectAgentConversation";
import {
clearStoredProjectsAgentConversation,
type ProjectsConversationOpener,
@@ -20,7 +23,7 @@ import {
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { sendChannelMessage } from "@/shared/api/tauri";
import { sendChannelMessage } from "@/shared/api/tauriMessages";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
@@ -126,21 +129,37 @@ export function ProjectAgentChatPanel({
if (!trimmed || !selectedAgent || isSending) return;
setIsSending(true);
try {
if (selectedAgent.isManaged && !selectedAgent.isActive) {
await startAgentMutation.mutateAsync(selectedAgent.pubkey);
}
const channel =
conversation?.channel ??
(await openDmMutation.mutateAsync({
pubkeys: [selectedAgent.pubkey],
}));
const sent = await sendChannelMessage(
channel.id,
`${trimmed}${contextPayload}`,
undefined,
// The awaits below suspend across a possible community switch;
// `submitProjectAgentMessage` binds every relay side effect to the
// scope captured here (fail closed), and threads follow-ups onto the
// opener so a same-second follow-up cannot be hidden by id ordering.
const { channel, sent } = await submitProjectAgentMessage({
agent: selectedAgent,
conversation,
content: `${trimmed}${contextPayload}`,
mentionPubkeys: [
...new Set([...mentionPubkeys, selectedAgent.pubkey]),
],
mediaTags,
[...new Set([...mentionPubkeys, selectedAgent.pubkey])],
);
relayScope,
startAgent: (agentPubkey) =>
startAgentMutation.mutateAsync(agentPubkey),
openDm: (input) => openDmMutation.mutateAsync(input),
send: (request) =>
sendChannelMessage(
request.channelId,
request.content,
request.parentEventId,
request.mediaTags,
request.mentionPubkeys,
undefined,
undefined,
undefined,
undefined,
undefined,
request.expectedRelayUrl,
),
});
if (!conversation) {
// Anchor the conversation to the exact accepted opener event: a
// bare timestamp cannot isolate it from unrelated same-second DM
@@ -176,6 +195,7 @@ export function ProjectAgentChatPanel({
conversation,
isSending,
openDmMutation,
relayScope,
selectedAgent,
startAgentMutation,
storageScope,
@@ -21,6 +21,8 @@ import {
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
import { useCommunities } from "@/features/communities/useCommunities";
import {
useChannelMessagesQuery,
useChannelSubscription,
@@ -48,6 +50,7 @@ import {
isAtOrAfterConversationOpener,
mergeProjectAgentConversationEvents,
restoreProjectsAgentConversation,
submitProjectAgentMessage,
} from "@/features/projects/lib/projectAgentConversation";
import {
clearStoredProjectsAgentConversation,
@@ -57,7 +60,7 @@ import {
writeStoredProjectsAgentConversation,
} from "@/features/projects/lib/projectAgentConversationStorage";
import { useIdentityQuery } from "@/shared/api/hooks";
import { sendChannelMessage } from "@/shared/api/tauri";
import { sendChannelMessage } from "@/shared/api/tauriMessages";
import type { Channel } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
@@ -385,6 +388,14 @@ export function ProjectsAgentPromptPage({
const channelsQuery = useChannelsQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();
const { activeCommunity } = useCommunities();
// Tenant scope for the submit sequence below: captured per render, so the
// value the callback closes over is the community that was active when the
// user pressed Ask — the backing commands fail closed if it changes while
// the callback is suspended.
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
: null;
const candidatePubkeys = React.useMemo(
() => candidates.map((candidate) => candidate.pubkey),
@@ -468,25 +479,39 @@ export function ProjectsAgentPromptPage({
setIsSending(true);
try {
if (selectedAgent.isManaged && !selectedAgent.isActive) {
await startAgentMutation.mutateAsync(selectedAgent.pubkey);
}
const channel =
conversation?.channel ??
(await openDmMutation.mutateAsync({
pubkeys: [selectedAgent.pubkey],
}));
// Repo context rides only on the conversation opener.
const content = conversation
? trimmed
: `${trimmed}${repoContextPayload}`;
const sent = await sendChannelMessage(
channel.id,
// The awaits inside suspend across a possible community switch;
// `submitProjectAgentMessage` binds every relay side effect to the
// scope captured at render (fail closed) and threads follow-ups onto
// the opener so a same-second follow-up cannot be hidden by id
// ordering.
const { channel, sent } = await submitProjectAgentMessage({
agent: selectedAgent,
conversation,
content,
undefined,
undefined,
[selectedAgent.pubkey],
);
mentionPubkeys: [selectedAgent.pubkey],
relayScope,
startAgent: (agentPubkey) =>
startAgentMutation.mutateAsync(agentPubkey),
openDm: (input) => openDmMutation.mutateAsync(input),
send: (request) =>
sendChannelMessage(
request.channelId,
request.content,
request.parentEventId,
undefined,
request.mentionPubkeys,
undefined,
undefined,
undefined,
undefined,
undefined,
request.expectedRelayUrl,
),
});
if (!conversation) {
// Anchor the conversation to the exact accepted opener event: a bare
// timestamp cannot isolate it from unrelated same-second DM history.
@@ -521,6 +546,7 @@ export function ProjectsAgentPromptPage({
conversation,
isSending,
openDmMutation,
relayScope,
repoContextPayload,
richText.clearContent,
richText.getMarkdown,
+1 -38
View File
@@ -7,7 +7,6 @@ import {
fromRawInstallRuntimeResult,
type RawInstallRuntimeResult,
} from "@/shared/api/installTypes";
import type { RawSendChannelMessageResult } from "@/shared/api/tauriMessageTypes";
import type {
AddChannelMembersInput,
AddChannelMembersResult,
@@ -26,7 +25,6 @@ import type {
RelayEvent,
SearchMessagesInput,
SearchMessagesResponse,
SendChannelMessageResult,
SetCanvasInput,
SetCanvasResult,
ThreadCursor,
@@ -44,6 +42,7 @@ import type {
} from "@/shared/api/types";
export * from "@/shared/api/tauriChannels";
export { sendChannelMessage } from "@/shared/api/tauriMessages";
type RawPresenceLookup = Record<string, PresenceStatus>;
@@ -533,42 +532,6 @@ export async function getThreadReplies(
};
}
export async function sendChannelMessage(
channelId: string,
content: string,
parentEventId?: string | null,
mediaTags?: string[][],
mentionPubkeys?: string[],
kind?: number,
emojiTags?: string[][],
mentionTags?: string[][],
linkPreviewTags?: string[][],
sentFromThreadTag?: string[],
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
{
channelId,
content,
parentEventId,
mediaTags: mediaTags ?? null,
emojiTags: emojiTags ?? null,
mentionTags: mentionTags ?? null,
linkPreviewTags,
sentFromThreadTag: sentFromThreadTag ?? null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
},
);
return {
eventId: response.event_id,
parentEventId: response.parent_event_id,
rootEventId: response.root_event_id,
depth: response.depth,
createdAt: response.created_at,
};
}
export type BlobDescriptor = {
url: string;
sha256: string;
+11 -1
View File
@@ -6,7 +6,6 @@ import type {
ChannelPageCursor,
ChannelType,
CreateChannelInput,
OpenDmInput,
SetChannelPurposeInput,
SetChannelTopicInput,
UpdateChannelInput,
@@ -156,6 +155,17 @@ export async function ensureStarterChannels(): Promise<Channel[]> {
);
}
export type OpenDmInput = {
pubkeys: string[];
/**
* Tenant scope captured by the caller before its first await (community
* relay URL). The backend fails closed when the active community no longer
* matches, so a suspended callback can never open a DM in the wrong
* community. Omit for callers without a tenant boundary.
*/
expectedRelayUrl?: string;
};
export async function openDm(input: OpenDmInput): Promise<Channel> {
return fromRawChannel(await invokeTauri<RawChannel>("open_dm", input));
}
+43
View File
@@ -0,0 +1,43 @@
import { invokeTauri } from "@/shared/api/tauri";
import type { RawSendChannelMessageResult } from "@/shared/api/tauriMessageTypes";
import type { SendChannelMessageResult } from "@/shared/api/types";
export async function sendChannelMessage(
channelId: string,
content: string,
parentEventId?: string | null,
mediaTags?: string[][],
mentionPubkeys?: string[],
kind?: number,
emojiTags?: string[][],
mentionTags?: string[][],
linkPreviewTags?: string[][],
sentFromThreadTag?: string[],
expectedRelayUrl?: string,
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
{
channelId,
content,
parentEventId,
mediaTags: mediaTags ?? null,
emojiTags: emojiTags ?? null,
mentionTags: mentionTags ?? null,
linkPreviewTags,
sentFromThreadTag: sentFromThreadTag ?? null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
// Tenant scope captured by the caller before its first await; the
// backend fails closed when the active community no longer matches.
expectedRelayUrl: expectedRelayUrl ?? null,
},
);
return {
eventId: response.event_id,
parentEventId: response.parent_event_id,
rootEventId: response.root_event_id,
depth: response.depth,
createdAt: response.created_at,
};
}
-4
View File
@@ -50,10 +50,6 @@ export type CreateChannelInput = {
ttlSeconds?: number;
};
export type OpenDmInput = {
pubkeys: string[];
};
export type UpdateChannelInput = {
channelId: string;
name?: string;
+53
View File
@@ -3786,6 +3786,51 @@ function getRelayWsUrl(config: E2eConfig | undefined): string {
return config?.relayWsUrl ?? DEFAULT_RELAY_WS_URL;
}
/**
* Mirror of the backend's `assert_expected_relay_scope`: a caller-captured
* tenant scope must still match the active community when the command runs.
* The mock's "active relay" is the active community's relayUrl in
* localStorage (specs switch communities by rewriting it), falling back to
* the configured mock relay. Lets specs drive the mid-flight community
* switch with `openDmDelayMs` / `sendMessageDelayMs` and prove the send
* fails closed.
*/
function assertExpectedRelayScope(
expectedRelayUrl: string | null | undefined,
config: E2eConfig | undefined,
): void {
const expected = expectedRelayUrl?.trim();
if (!expected) return;
let active: string | null = null;
try {
const activeId = window.localStorage.getItem("buzz-active-community-id");
const communities = JSON.parse(
window.localStorage.getItem("buzz-communities") ?? "[]",
) as { id: string; relayUrl: string }[];
active =
communities.find((community) => community.id === activeId)?.relayUrl ??
null;
} catch {
active = null;
}
if (
normalizeMockRelayUrl(active ?? getRelayWsUrl(config)) !==
normalizeMockRelayUrl(expected)
) {
throw new Error(
"active community changed before the message was submitted; not sent",
);
}
}
/** Same ws(s) normalization the app applies to community relay URLs. */
function normalizeMockRelayUrl(url: string): string {
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
return `wss://${url}`;
}
return url;
}
function getIdentity(config: E2eConfig | undefined): TestIdentity | undefined {
if (!isRelayMode(config)) {
return undefined;
@@ -6492,6 +6537,7 @@ async function handleCreateChannel(
async function handleOpenDm(
args: {
pubkeys: string[];
expectedRelayUrl?: string | null;
},
config: E2eConfig | undefined,
) {
@@ -6499,6 +6545,9 @@ async function handleOpenDm(
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
// After the injected delay, like the real command's post-await check: a
// caller-captured tenant scope must still match the active community.
assertExpectedRelayScope(args.expectedRelayUrl, config);
const normalizedPubkeys = normalizeParticipantPubkeys(args.pubkeys);
if (normalizedPubkeys.length === 0) {
@@ -9160,6 +9209,7 @@ async function handleSendChannelMessage(
linkPreviewTags?: string[][] | null;
sentFromThreadTag?: string[] | null;
suppressLinkPreviews?: boolean;
expectedRelayUrl?: string | null;
},
config: E2eConfig | undefined,
): Promise<RawSendChannelMessageResponse> {
@@ -9170,6 +9220,9 @@ async function handleSendChannelMessage(
window.setTimeout(resolve, sendMessageDelayMs),
);
}
// After the injected delay, like the real command's post-await check: a
// caller-captured tenant scope must still match the active community.
assertExpectedRelayScope(args.expectedRelayUrl, config);
// Mirror the WebSocket send path's failure injection so specs that route
// the first message through the acknowledged HTTP transport still exercise