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
52 changes: 52 additions & 0 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,25 @@ fn append_reply_instruction(s: &mut String, event_id: &str) {
));
}

/// Append the delivery contract, unconditionally, to every turn's prompt.
///
/// Plain session/assistant text is never auto-published to a Buzz channel —
/// only an explicit `buzz messages send` call delivers a reply. Stronger
/// models tend to infer this convention from the surrounding CLI-oriented
/// context; others don't, and a turn can complete cleanly (tokens billed, no
/// error logged) while the reply is silently dropped because the model never
/// called `buzz messages send`. Stating the contract explicitly, every turn,
/// removes the need for the model to infer it. See block/buzz#2698.
fn append_delivery_contract(s: &mut String) {
s.push_str(
"\nIMPORTANT: Your plain text output in this session is NOT delivered \
to the user — it is never read by them. The only way a reply reaches \
them is an explicit `buzz messages send` (or equivalent CLI) call. If \
this turn is meant to produce a reply, you MUST call it before ending \
the turn.",
);
}

/// Append a new-thread reply instruction for a human-facing top-level mention.
///
/// The triggering mention has no thread tags, so the agent's reply becomes the
Expand Down Expand Up @@ -1708,6 +1727,12 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
sections.push(framing.closing_note.to_string());
}

// 5. Delivery contract — always present, regardless of scope or whether a
// reply anchor was resolved. See block/buzz#2698.
let mut delivery_contract = String::new();
append_delivery_contract(&mut delivery_contract);
sections.push(delivery_contract);

sections
}

Expand Down Expand Up @@ -2040,6 +2065,33 @@ mod tests {
assert!(!prompt.contains("--- Event 1 ---"));
}

/// The delivery contract (block/buzz#2698) must be stated on every turn,
/// regardless of scope — a model that never infers the "plain text isn't
/// delivered" convention should still be told explicitly every time.
#[test]
fn test_format_prompt_always_states_delivery_contract() {
let ch = Uuid::new_v4();
let event = make_event("Hello @agent");
let batch = FlushBatch {
channel_id: ch,
events: vec![BatchEvent {
event,
prompt_tag: "@mention".into(),
received_at: Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};

let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");

assert!(
prompt.contains("Your plain text output in this session is NOT delivered"),
"expected the delivery contract to be present in every prompt, got:\n{prompt}"
);
assert!(prompt.contains("buzz messages send"));
}

/// Helper: build a merged (cancel + re-prompt) batch with one cancelled
/// event and one new event, framed by `reason`.
fn make_merged_batch(reason: Option<CancelReason>) -> FlushBatch {
Expand Down
36 changes: 27 additions & 9 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,26 @@ class SendMessage {
Channel channel,
String? authorPubkey,
) async {
List<ChannelMember>? members;
try {
members = await _fetchMembers(channelId);
} catch (_) {
// Fall back to metadata below so an unavailable membership query does
// not block ordinary DM sends.
}

var members = await _tryFetchMembers(channelId);
final author = authorPubkey?.toLowerCase();
final participants = members != null && members.isNotEmpty
var participants = members != null && members.isNotEmpty
? members.map((member) => member.pubkey)
: channel.participantPubkeys;

// A DM always has at least one other participant besides the sender, so
// both the live membership query and the channel-metadata fallback
// coming back empty at once means the data isn't available right now —
// e.g. a relay reconnect in progress (common on mobile after
// backgrounding) — not that the DM genuinely has nobody else in it.
// Retry briefly instead of silently sending with zero recipients.
for (var attempt = 0; participants.isEmpty && attempt < 3; attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 400));
members = await _tryFetchMembers(channelId);
if (members != null && members.isNotEmpty) {
participants = members.map((member) => member.pubkey);
}
}

return {
for (final participant in participants)
if (participant.trim().isNotEmpty &&
Expand All @@ -138,6 +146,16 @@ class SendMessage {
};
}

Future<List<ChannelMember>?> _tryFetchMembers(String channelId) async {
try {
return await _fetchMembers(channelId);
} catch (_) {
// Fall back to metadata below so an unavailable membership query does
// not block ordinary DM sends.
return null;
}
}

void _ensureDeliveryValid() {
if (_isDeliveryValid?.call() == false) {
throw StateError(
Expand Down
86 changes: 85 additions & 1 deletion mobile/test/features/channels/send_message_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,90 @@ void main() {
await result;
});

test(
'retries membership fetch when both membership and metadata are empty',
() async {
final session = _PendingPublishRelaySession();
final signingKey = nostr.Keys.generate().nsec;
final sender = nostr.Keys(
nostr.Nip19.decode(payload: signingKey).data,
).public;
final recipient = 'b' * 64;
var fetchCount = 0;
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
fetchMembers: (_) async {
fetchCount++;
// Simulate a relay reconnect resolving after a couple of attempts.
if (fetchCount < 3) return const [];
return [_member(sender), _member(recipient)];
},
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

final result = send(
channelId: _channelId,
content: 'hello after a flaky membership fetch',
// Metadata fallback is empty too, so the first attempt alone can't
// resolve a recipient — only the retry can.
channel: _dmChannel(const []),
mentionPubkeys: const [],
);
await session.published;

expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [
['p', recipient],
]);
expect(fetchCount, greaterThanOrEqualTo(3));

session.accept();
await result;
},
timeout: const Timeout(Duration(seconds: 5)),
);

test(
'gives up after bounded retries and sends without recipients rather than hanging',
() async {
final session = _PendingPublishRelaySession();
final signingKey = nostr.Keys.generate().nsec;
var fetchCount = 0;
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: signingKey),
fetchMembers: (_) async {
fetchCount++;
return const [];
},
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

final result = send(
channelId: _channelId,
content: 'hello into the void',
channel: _dmChannel(const []),
mentionPubkeys: const [],
);
await session.published;

expect(
session.event.tags.where((tag) => tag.first == 'p').toList(),
isEmpty,
);
// Initial attempt + 3 retries, not unbounded.
expect(fetchCount, 4);

session.accept();
await result;
},
timeout: const Timeout(Duration(seconds: 5)),
);

test('cancels delivery after the active community changes', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
Expand Down Expand Up @@ -246,7 +330,7 @@ Channel _dmChannel(List<String> participantPubkeys) => Channel(
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: participantPubkeys.first,
createdBy: participantPubkeys.firstOrNull ?? 'unknown',
createdAt: DateTime(2025),
memberCount: participantPubkeys.length,
participantPubkeys: participantPubkeys,
Expand Down