Speed up mobile Solo chat creation and agent discovery (#8547)

## Summary

Solo compose now opens before agent discovery finishes, and chat
creation avoids full channel-list reloads while overlapping independent
reads. Durable retries, ownership checks, and relay confirmation remain
intact.

The agent picker uses the owner's saved inventory, excludes known
archived identities, verifies profiles off the UI thread, and reuses
avatar artwork.

### Related issue

No matching issue found. Related: #8546 adds project selection to the
same compose surface.

### Testing

- Full mobile suite: 3,755 passed, 19 skipped; four additional artifact
tests passed.
- Analysis, formatting, file-size checks, and iOS Release build passed.
- Regression tests fail when the old blocking paths are restored.
- Installed on a physical iPhone with existing profile/community
settings preserved. Owner tested the earlier creation/picker
improvements; final compose timing awaits device feedback. No timing
recording captured.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-27 15:09:22 +00:00
committed by GitHub
parent 6861e18a86
commit b14884980e
19 changed files with 1251 additions and 211 deletions

View File

@@ -6,6 +6,8 @@ struct MeAgentAccessibilityTests {
@MainActor static func main() {
for locked in [false, true] {
for empty in [false, true] {
let savedName = "Saved agent"
var submitted: [String: String]?
let controller = NativeProfileTextEditorViewController(
title: "New chat", initialValue: "Chat", placeholder: "Chat name",
multiline: false, pageBackgroundColor: .systemGroupedBackground,
@@ -14,8 +16,8 @@ struct MeAgentAccessibilityTests {
channelDescription: nil, originalName: nil, originalDescription: nil,
canEditDetails: true, canEditCanvas: false, canvasContent: "",
canvasLoaded: false,
chatOptions: ["fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]],
onCancel: {}, onSet: { _ in }
chatOptions: ["selectedAssistant": "agent", "savedAssistant": ["id": "agent", "name": savedName], "presentationId": "test", "assistantsLoading": empty, "fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]],
onCancel: {}, onSet: { submitted = $0 as? [String: String] }
)
controller.loadViewIfNeeded()
precondition(controller.numberOfSections(in: controller.tableView) == 3)
@@ -27,10 +29,45 @@ struct MeAgentAccessibilityTests {
precondition(project.isEnabled == !locked)
precondition(project.showsMenuAsPrimaryAction && project.menu != nil)
let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2))
let content = cell.contentConfiguration as! UIListContentConfiguration
precondition(content.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent"))
precondition((content.image != nil) == (locked || !empty))
let disabled = locked || empty
precondition(cell.accessibilityTraits.contains(.notEnabled) == disabled)
precondition(cell.accessibilityTraits.contains(.button) == !disabled)
precondition((cell.selectionStyle == .none) == disabled)
let canSubmit = locked || !empty
precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit)
// Invoke the action even when disabled to check its independent gate.
_ = controller.perform(NSSelectorFromString("setTapped"))
precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil))
if empty && !locked {
controller.tableView.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.tableView.reloadData()
controller.tableView.layoutIfNeeded()
precondition(controller.tableView(controller.tableView, titleForFooterInSection: 1) == nil)
precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == "Loading agents…")
controller.updateChatAssistants([
"presentationId": "test",
"assistants": [["id": "agent", "name": "Agent"]],
])
controller.tableView.layoutIfNeeded()
let updated = controller.tableView.cellForRow(at: IndexPath(row: 0, section: 2))!
precondition(updated.accessibilityTraits.contains(.button))
precondition(!updated.accessibilityTraits.contains(.notEnabled))
precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == nil)
}
submitted = nil
controller.updateChatAssistants([
"presentationId": "test",
"assistants": empty ? [] : [["id": "agent", "name": "Agent"]],
])
let refreshedCell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2))
let refreshed = refreshedCell.contentConfiguration as! UIListContentConfiguration
precondition(refreshed.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent"))
precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit)
_ = controller.perform(NSSelectorFromString("setTapped"))
precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil))
}
}
avatarTests()

View File

@@ -28,6 +28,17 @@ final class NativeProfileTextEditorCoordinator: NSObject,
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
if call.method == "updateChatAssistants" {
DispatchQueue.main.async { [weak self] in
if let arguments = call.arguments as? [String: Any],
let navigation = self?.presentedController as? UINavigationController,
let editor = navigation.viewControllers.first as? NativeProfileTextEditorViewController {
editor.updateChatAssistants(arguments)
}
result(nil)
}
return
}
guard call.method == "present" || call.method == "presentChannel" || call.method == "presentChat" || call.method == "presentProject" else {
result(FlutterMethodNotImplemented)
return
@@ -241,7 +252,7 @@ private final class NativeProfileTextEditorViewController:
private var hasCanvas: Bool {
canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private let chatOptions: [String: Any]?
private var chatOptions: [String: Any]?
private var hasFocusedEditor = false
private var selectedAssistantAvatar: UIImage?
private var selectedAssistant: String?
@@ -254,6 +265,8 @@ private final class NativeProfileTextEditorViewController:
private var assistants: [[String: String]] {
chatOptions?["assistants"] as? [[String: String]] ?? []
}
// Keep decoded artwork while switching between the form and its agent list.
private let assistantAvatars = MeAvatarLoader()
private var fieldsLocked: Bool { chatOptions?["fieldsLocked"] as? Bool ?? false }
private var isChannelEditor: Bool { channelDescription != nil }
@@ -426,13 +439,30 @@ private final class NativeProfileTextEditorViewController:
updateNavigation()
}
func updateChatAssistants(_ arguments: [String: Any]) {
guard let token = arguments["presentationId"] as? String,
token == chatOptions?["presentationId"] as? String else { return }
chatOptions?["assistants"] = arguments["assistants"]
chatOptions?["assistantsLoading"] = false
chatOptions?["assistantError"] = arguments["assistantError"]
tableView.reloadSections(IndexSet(integer: 2), with: .none)
updateNavigation()
}
// A durable retry keeps its saved recipient even if discovery hides it.
// Flutter revalidates ownership before any creation writes.
private var canSubmitAssistant: Bool {
guard let selectedAssistant else { return false }
return fieldsLocked || assistants.contains { $0["id"] == selectedAssistant }
}
private func updateNavigation() {
navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges
&& (!isChannelEditor || !canonicalChannelName.isEmpty)
&& (!isChatEditor || (
!currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& currentValue.count <= 120
&& (isProjectEditor || assistants.contains { $0["id"] == selectedAssistant })
&& (isProjectEditor || canSubmitAssistant)
))
navigationController?.isModalInPresentation = hasUnsavedChanges
placeholderLabel.isHidden = !textView.text.isEmpty
@@ -474,7 +504,7 @@ private final class NativeProfileTextEditorViewController:
onSet(name)
} else if isChatEditor {
guard let selectedAssistant,
assistants.contains(where: { $0["id"] == selectedAssistant }),
canSubmitAssistant,
!currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
currentValue.count <= 120 else { return }
onSet(["name": currentValue, "assistant": selectedAssistant, "project": selectedProject ?? ""])
@@ -511,7 +541,7 @@ private final class NativeProfileTextEditorViewController:
if isChatEditor && indexPath.section == 2 {
guard !fieldsLocked && !assistants.isEmpty else { return }
view.endEditing(true)
let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor) { [weak self] id, avatar in
let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor, avatars: assistantAvatars) { [weak self] id, avatar in
self?.selectedAssistantAvatar = avatar
self?.selectedAssistant = id
self?.tableView.reloadSections(IndexSet(integer: 2), with: .none)
@@ -538,6 +568,8 @@ private final class NativeProfileTextEditorViewController:
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
guard isChatEditor && section == 2 else { return nil }
if chatOptions?["assistantsLoading"] as? Bool == true { return "Loading agents…" }
if let error = chatOptions?["assistantError"] as? String { return error }
if let error = chatOptions?["error"] as? String { return error }
return assistants.isEmpty
? "No agents are available for this account. Connect an agent on desktop, then try again."
@@ -600,8 +632,13 @@ private final class NativeProfileTextEditorViewController:
cell.accessoryView = button
} else if isChatEditor && indexPath.section == 2 {
var content = cell.defaultContentConfiguration()
let selected = assistants.first { $0["id"] == selectedAssistant }
let saved = chatOptions?["savedAssistant"] as? [String: String]
let fallback = fieldsLocked ? selectedAssistant.map {
saved.flatMap { $0["id"] == selectedAssistant ? $0 : nil } ?? ["id": $0, "name": $0]
} : nil
let selected = assistants.first { $0["id"] == selectedAssistant } ?? fallback
content.text = selected?["name"] ?? "Choose an agent"
content.textProperties.numberOfLines = 0
if let selected {
content.image = selectedAssistantAvatar ?? MeAssistantListController.avatarImage(selected, photo: nil)
}

View File

@@ -41,6 +41,7 @@ class ChannelActions {
List<String> labels = const [],
String? description,
int? ttlSeconds,
bool waitForChannelList = true,
}) async {
final resolvedChannelId = channelId ?? _newUuidV4();
final tags = buildCreateChannelTags(
@@ -55,9 +56,58 @@ class ChannelActions {
_ensureCommunityValid();
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
_ensureCommunityValid();
if (!waitForChannelList) {
// Solo can open from its own confirmed metadata and roster. Reconcile
// the sidebar independently of unrelated channels' message history.
_ref.invalidate(channelsProvider);
return await findJoinedChannel(resolvedChannelId) ??
(throw StateError('Channel was created but is not visible yet'));
}
return _refreshChannelsAndRead(resolvedChannelId);
}
/// Read one channel for creation recovery without reloading every membership
/// and every channel's recent messages. Missing membership fails closed.
Future<Channel?> findJoinedChannel(String channelId) async {
_ensureCommunityValid();
final events = await _session.fetchHistory(
NostrFilter(
kinds: const [39000, 39002],
tags: {
'#d': [channelId],
},
limit: 2,
),
);
_ensureCommunityValid();
final metadata = events
.where((e) => e.kind == 39000 && e.getTagValue('d') == channelId)
.firstOrNull;
final roster = events
.where((e) => e.kind == 39002 && e.getTagValue('d') == channelId)
.firstOrNull;
if (metadata == null || roster == null) return null;
final members = membersFromEvent(roster);
if (!members.any((m) => m.pubkey == _currentPubkey)) return null;
final details = channelDetailsFromEvent(metadata);
return Channel(
id: channelId,
name: details.name,
channelType: details.channelType,
labels: details.labels,
visibility: details.visibility,
description: details.description,
topic: details.topic,
createdBy: details.createdBy,
createdAt: details.createdAt,
memberCount: members.length,
isMember: true,
archivedAt: details.archivedAt,
ttlSeconds: details.ttlSeconds,
ttlDeadline: details.ttlDeadline,
);
}
/// Open (or create) a DM channel with the given pubkeys.
///
/// This submits a kind:41010 command event; the relay responds with an OK

View File

@@ -45,6 +45,7 @@ class MeAssistantList extends ConsumerWidget {
child: AppListRowRaw(
leading: AvatarImage(
imageUrl:
agent.avatarUrl ??
(profiles[agent.pubkey] ??
ref
.read(userCacheProvider.notifier)

View File

@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../../shared/profile/user_cache_provider.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../../shared/animated_avatar.dart';
import '../../../shared/widgets/app_list.dart';
import '../../../shared/widgets/concentric_sheet_surface.dart';

View File

@@ -48,16 +48,23 @@ Future<(Channel, String)?> _showNewMeChat(
var retry = pending != null;
while (context.mounted) {
sync.check();
final directory = await ref.read(meAssistantsProvider.future);
final directory = ref.read(meAssistantsProvider).asData?.value;
final directoryRequest = ref.read(meAssistantsProvider).hasError
? ref.refresh(meAssistantsProvider.future)
: ref.read(meAssistantsProvider.future);
final presentationId = const Uuid().v4();
var active = true;
if (!context.mounted) return null;
sync.check();
if (!context.mounted) return null;
final theme = utilitySurfaceThemeData(Theme.of(context));
Map<Object?, Object?>? draft;
try {
draft = await const MethodChannel('buzz/profile_text_editor')
final presentation = const MethodChannel('buzz/profile_text_editor')
.invokeMethod<Map<Object?, Object?>>('presentChat', {
'title': 'New chat',
'presentationId': presentationId,
'assistantsLoading': directory == null,
'initialValue': name,
'placeholder': 'Chat name',
'multiline': false,
@@ -69,6 +76,19 @@ Future<(Channel, String)?> _showNewMeChat(
.toARGB32(),
'containerCornerRadius': Radii.container,
'selectedAssistant': assistant,
if (retry && assistant != null)
'savedAssistant': {
'id': assistant,
'name':
ref.read(userCacheProvider)[assistant]?.label ??
directory
?.where((a) => a.pubkey == assistant)
.firstOrNull
?.displayName ??
assistant,
'avatarUrl':
ref.read(userCacheProvider)[assistant]?.avatarUrl ?? '',
},
'selectedProject': project,
'projects': [
for (final group
@@ -77,31 +97,45 @@ Future<(Channel, String)?> _showNewMeChat(
],
'fieldsLocked': retry,
'error': error,
'assistants': [
for (final agent in directory)
{
'id': agent.pubkey,
'name': agent.displayName ?? 'Agent',
'artworkScale':
usesInsetAgentArtwork(
ref
.read(userCacheProvider.notifier)
.get(agent.pubkey)
?.avatarUrl,
)
? '$animatedAvatarArtworkScale'
: '1',
'avatarUrl':
ref
.read(userCacheProvider.notifier)
.get(agent.pubkey)
?.avatarUrl ??
'',
},
],
'assistants': _nativeMeAssistants(directory ?? []),
});
// Send updates only to this presentation; late results cannot populate
// a reopened sheet or a different community's editor.
Future<void> loadAssistants() async {
try {
final agents = await directoryRequest;
if (!active || !context.mounted || sync.disposed) return;
await const MethodChannel(
'buzz/profile_text_editor',
).invokeMethod<void>('updateChatAssistants', {
'presentationId': presentationId,
'assistants': _nativeMeAssistants(agents),
});
} catch (_) {
if (!active || !context.mounted || sync.disposed) return;
try {
await const MethodChannel(
'buzz/profile_text_editor',
).invokeMethod<void>('updateChatAssistants', {
'presentationId': presentationId,
'assistants': <Object>[],
'assistantError':
'Couldn’t load agents. Close this sheet and try again.',
});
} on PlatformException {
// The native editor may already be dismissed.
} on MissingPluginException {
// Flutter fallback has no native editor.
}
}
}
loadAssistants();
draft = await presentation;
} on MissingPluginException {
break;
} finally {
active = false;
}
if (draft == null || !context.mounted) return null;
name = (draft['name'] as String? ?? '').trim();
@@ -150,28 +184,31 @@ Future<Channel> _createMeChat(
sync = _currentMeSync(ref, sync);
final intent = MeChatCreation(channelId, name, assistant, project: project);
await intent.save(sync);
final owned = await isOwnedMeAssistant(sync, assistant);
final actions = ref.read(channelActionsProvider);
final preflight = await Future.wait<Object?>([
isOwnedMeAssistant(sync, assistant),
sync.head(),
actions.findJoinedChannel(channelId),
]);
final owned = preflight[0] as bool;
sync.check();
if (!owned) {
throw StateError('This agent is no longer available.');
}
final head = await sync.head();
final head = preflight[1] as NostrEvent?;
(head == null ? const MeGroups() : sync.decode(head))
.place(channelId, group: project)
.encode(sync.community);
final actions = ref.read(channelActionsProvider);
return intent.complete<Channel>(
sync,
findChannel: () async {
final all = await ref.read(channelsProvider.notifier).fetchFresh();
return all.where((c) => c.id == channelId).firstOrNull;
},
findChannel: () async => preflight[2] as Channel?,
createChannel: () => actions.createChannel(
channelId: channelId,
name: name,
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
waitForChannelList: false,
),
hasAgent: () async {
ref.invalidate(channelMembersProvider(channelId));
@@ -191,3 +228,17 @@ Future<Channel> _createMeChat(
sync.update((current) => current.place(channelId, group: project)),
);
}
List<Map<String, String>> _nativeMeAssistants(
List<AgentDirectoryEntry> agents,
) => [
for (final agent in agents)
{
'id': agent.pubkey,
'name': agent.displayName ?? 'Agent',
'artworkScale': usesInsetAgentArtwork(agent.avatarUrl)
? '$animatedAvatarArtworkScale'
: '1',
'avatarUrl': agent.avatarUrl ?? '',
},
];

View File

@@ -28,10 +28,15 @@ class _NewMeChat extends HookConsumerWidget {
);
}
final choices = directory.asData?.value ?? [];
final chosen = choices
.where((agent) => agent.pubkey == selected.value)
.firstOrNull;
final profile = ref.watch(userCacheProvider)[selected.value];
final chosen =
choices.where((agent) => agent.pubkey == selected.value).firstOrNull ??
(frozenAgent.value == null
? null
: AgentDirectoryEntry(
pubkey: frozenAgent.value!,
displayName: profile?.label ?? frozenAgent.value!,
));
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
Grid.gutter,

View File

@@ -0,0 +1,89 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:nostr/nostr.dart' as nostr;
import '../relay/relay.dart';
import 'me_sync.dart';
/// Desktop's owner-authored agent inventory. A historical kind:0 ownership
/// attestation alone does not mean an agent is still in this inventory.
Future<Map<String, String>> loadMeAgentInventory(MeSync sync) async {
const pageSize = 200;
final events = <NostrEvent>[];
int? until;
String? beforeId;
for (var page = 0; page < 50; page++) {
sync.check();
final batch = await sync.session.queryRelay([
NostrFilter(
kinds: const [30177],
authors: [sync.pubkey],
limit: pageSize,
until: until,
extensions: {'before_id': ?beforeId},
),
]);
sync.check();
if (batch.length > pageSize ||
batch.any((e) => e.kind != 30177 || e.pubkey != sync.pubkey)) {
throw StateError('Agent inventory response is outside its owner scope.');
}
events.addAll(batch);
if (batch.length < pageSize) {
final owner = sync.pubkey;
return _parseInventory(events, owner);
}
final ordered = [...batch]
..sort((a, b) {
final time = b.createdAt.compareTo(a.createdAt);
return time != 0 ? time : a.id.compareTo(b.id);
});
final last = ordered.last;
if (until != null &&
(last.createdAt > until ||
(last.createdAt == until && last.id.compareTo(beforeId!) <= 0))) {
throw StateError('Agent inventory pagination did not advance.');
}
until = last.createdAt;
beforeId = last.id;
}
throw StateError('Agent inventory exceeds the read budget.');
}
Future<Map<String, String>> _parseInventory(
List<NostrEvent> events,
String owner,
) => Isolate.run(() => _inventoryNames(events, owner));
Map<String, String> _inventoryNames(List<NostrEvent> events, String owner) {
final heads = <String, NostrEvent>{};
for (final event in events) {
if (event.pubkey != owner) continue;
final id = event.getTagValue('d');
if (id == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(id)) continue;
// Verify before allowing a newer tombstone or record to replace a head.
try {
nostr.Event.fromJson(jsonEncode(event.toJson()));
} catch (_) {
// A corrupt record must not hide unrelated verified agents.
continue;
}
final previous = heads[id];
if (previous == null || meEventIsNewer(event, previous)) heads[id] = event;
}
final names = <String, String>{};
for (final entry in heads.entries) {
try {
final body = jsonDecode(entry.value.content);
if (body is! Map<String, dynamic>) continue;
final name = body['name'];
if (name is String && name.trim().isNotEmpty) {
names[entry.key] = name.trim();
}
} on FormatException {
// Empty/deleted and malformed heads never resurrect an earlier listing.
}
}
return names;
}

View File

@@ -0,0 +1,94 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import '../relay/relay.dart';
import 'me_sync.dart';
final meRelayInformationClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// Matches desktop's known-archived filtering. Unknown archive evidence does
/// not hide identities; this is presentation, never ownership or admission.
final meArchivedAgentsProvider = FutureProvider<Set<String>>((ref) async {
final sync = ref.watch(meSyncProvider);
final client = ref.watch(meRelayInformationClientProvider);
try {
final uri = Uri.parse(sync.community);
final response = await client
.send(
http.Request('GET', uri)
..headers['Accept'] = 'application/nostr+json',
)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 200) return {};
final chunks = StreamIterator(
response.stream.timeout(const Duration(seconds: 3)),
);
final bytes = <int>[];
try {
await (() async {
while (await chunks.moveNext()) {
final chunk = chunks.current;
if (bytes.length + chunk.length > 256 * 1024) {
throw const FormatException(
'Relay information exceeds size limit.',
);
}
bytes.addAll(chunk);
}
})().timeout(const Duration(seconds: 3));
} finally {
await chunks.cancel();
}
sync.check();
final info = jsonDecode(utf8.decode(bytes));
// NIP-11 self is the signing authority; pubkey is only a contact key.
final authority = info is Map<String, dynamic> ? info['self'] : null;
if (authority is! String ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(authority)) {
return {};
}
final events = await sync.session.fetchHistory(
NostrFilter(kinds: const [13535], authors: [authority], limit: 1),
timeout: const Duration(seconds: 3),
);
sync.check();
return await _parseArchives(events, authority);
} catch (_) {
return {};
}
});
Future<Set<String>> _parseArchives(List<NostrEvent> events, String authority) =>
Isolate.run(() => _archived(events, authority));
Set<String> _archived(List<NostrEvent> events, String authority) {
if (events.length != 1) return {};
final event = events.single;
final markers = event.tags.where((tag) => tag.isNotEmpty && tag.first == '-');
if (event.kind != 13535 ||
event.pubkey != authority ||
event.content.isNotEmpty ||
markers.length != 1 ||
markers.single.length != 1) {
return {};
}
final encoded = jsonEncode(event.toJson());
if (utf8.encode(encoded).length > 2 * 1024 * 1024) return {};
nostr.Event.fromJson(encoded);
return {
for (final tag in event.tags)
if (tag.length >= 2 &&
tag[0] == 'p' &&
RegExp(r'^[0-9a-f]{64}$').hasMatch(tag[1]))
tag[1],
};
}

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:nostr/nostr.dart' as nostr;
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -6,11 +7,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../crypto/nip_oa.dart';
import '../mentions/agent_identity_provider.dart';
import '../relay/relay.dart';
import 'me_agent_inventory.dart';
import 'me_archived_agents.dart';
import 'me_sync.dart';
/// Owned assistants from verified profiles, including agents without kind 10100.
/// Agents inherit their owner's community access and need not be listed in the
/// community's human membership snapshot.
/// The owner's current inventory, with verified ownership and desktop's
/// known-archived exclusion. Never scan the community's historical profiles.
final meAssistantsProvider = FutureProvider<List<AgentDirectoryEntry>>((
ref,
) async {
@@ -18,8 +20,32 @@ final meAssistantsProvider = FutureProvider<List<AgentDirectoryEntry>>((
if (sync.connectionState != null) {
ref.watch(relaySessionProvider.select((state) => state.status));
}
final session = ref.watch(relaySessionProvider.notifier);
return _loadAssistants(sync, session);
// Refresh archive evidence with the inventory, including explicit retries.
// Ordinary sheet openings reuse this provider's completed directory.
final archivedRequest = ref.refresh(meArchivedAgentsProvider.future);
final inventory = await loadMeAgentInventory(sync);
sync.check();
final profiles = await _loadProfiles(sync, inventory.keys.toList());
final archived = await archivedRequest;
sync.check();
final choices = [
for (final profile in profiles)
if (!archived.contains(profile.pubkey))
AgentDirectoryEntry(
pubkey: profile.pubkey,
displayName: inventory[profile.pubkey],
avatarUrl: profile.avatarUrl,
respondTo: profile.respondTo,
respondToAllowlist: profile.respondToAllowlist,
channelIds: profile.channelIds,
),
];
choices.sort(
(a, b) => (a.displayName ?? '').toLowerCase().compareTo(
(b.displayName ?? '').toLowerCase(),
),
);
return choices;
}, retry: (_, _) => null);
/// Freshness-controlled ownership check for one personal-chat recipient.
@@ -34,76 +60,63 @@ final meAssistantOwnershipProvider = FutureProvider.family<bool, String>((
return isOwnedMeAssistant(sync, pubkey);
});
/// Revalidates only the chosen assistant before creating a chat.
/// Revalidate the selected recipient, including existing chats whose agent may
/// no longer be offered in the new-chat inventory. Discovery is not admission.
Future<bool> isOwnedMeAssistant(MeSync sync, String pubkey) async =>
(await _loadAssistants(
sync,
sync.session,
pubkey: pubkey,
)).any((agent) => agent.pubkey == pubkey);
(await _loadProfiles(sync, [
pubkey,
])).any((agent) => agent.pubkey == pubkey);
Future<List<AgentDirectoryEntry>> _loadAssistants(
Future<List<AgentDirectoryEntry>> _loadProfiles(
MeSync sync,
RelaySessionNotifier session, {
String? pubkey,
}) async {
final profiles = <String, NostrEvent>{};
final seen = <String>{};
const pageSize = 100;
// The current relay only indexes single-letter NIP-01 tag filters. It
// silently drops #auth, so owner-scoped discovery needs relay support.
// Until then fail visibly at this bound rather than drain the directory.
const maxPages = 10;
for (var page = 1; page <= maxPages; page++) {
List<String> pubkeys,
) async {
final profiles = <NostrEvent>[];
const batchSize = 100;
for (var start = 0; start < pubkeys.length; start += batchSize) {
sync.check();
final events = await session.queryRelay([
NostrFilter(
kinds: const [0],
authors: pubkey == null ? null : [pubkey],
limit: pageSize,
extensions: {'page': page},
),
final batch = pubkeys.skip(start).take(batchSize).toList();
final events = await sync.session.queryRelay([
NostrFilter(kinds: const [0], authors: batch, limit: batchSize),
]);
sync.check();
if (events.length > pageSize) {
throw StateError('Agent directory exceeded the requested page size.');
}
var added = false;
for (final event in events) {
if (event.kind != 0 || (pubkey != null && event.pubkey != pubkey)) {
continue;
}
if (seen.contains(event.id)) continue;
try {
nostr.Event.fromJson(jsonEncode(event.toJson()));
} catch (_) {
continue;
}
if (!seen.add(event.id)) continue;
added = true;
final previous = profiles[event.pubkey];
if (previous == null ||
event.createdAt > previous.createdAt ||
(event.createdAt == previous.createdAt &&
event.id.compareTo(previous.id) < 0)) {
profiles[event.pubkey] = event;
}
}
if (events.length < pageSize) break;
if (!added || page == maxPages) {
throw StateError('Could not finish loading assistants. Try again.');
if (events.length > batchSize) {
throw StateError('Agent profiles exceeded the requested batch size.');
}
profiles.addAll(
events.where((e) => e.kind == 0 && batch.contains(e.pubkey)),
);
}
final agents = <AgentDirectoryEntry>[];
for (final event in profiles.values) {
final owner = sync.pubkey;
final result = await _parseProfiles(profiles, owner);
sync.check();
return result;
}
Future<List<AgentDirectoryEntry>> _parseProfiles(
List<NostrEvent> events,
String owner,
) => Isolate.run(() => _verifiedProfiles(events, owner));
List<AgentDirectoryEntry> _verifiedProfiles(
List<NostrEvent> events,
String owner,
) {
final latest = <String, NostrEvent>{};
for (final event in events) {
try {
if (verifiedProfileOaOwnerPubkey(event) == sync.pubkey) {
agents.add(AgentDirectoryEntry.fromEvent(event));
}
nostr.Event.fromJson(jsonEncode(event.toJson()));
} catch (_) {
// A malformed profile must not hide unrelated verified agents.
continue;
}
final previous = latest[event.pubkey];
if (previous == null || meEventIsNewer(event, previous)) {
latest[event.pubkey] = event;
}
}
return agents;
return [
for (final event in latest.values)
if (verifiedProfileOaOwnerPubkey(event) == owner)
AgentDirectoryEntry.fromEvent(event),
];
}

View File

@@ -50,6 +50,7 @@ class MeChatCreation {
previous.project != project)) {
throw StateError('Resume the pending chat before starting another.');
}
// A matching cached value does not prove a previous disk write succeeded.
if (sync.prefs == null ||
!await sync.prefs!.setString(
storageKey(sync),
@@ -108,11 +109,12 @@ class MeChatCreation {
sync.check();
final result = existing ?? await createChannel();
sync.check();
final member = await hasAgent();
final status = await Future.wait([hasAgent(), isPlaced()]);
final member = status[0];
sync.check();
if (!member) await addAgent();
sync.check();
final placed = await isPlaced();
final placed = status[1];
sync.check();
// Placement may be confirmed while its inner journal cleanup failed.
if (!placed || sync.prefs?.getString(sync.pendingKey) != null) {

View File

@@ -16,6 +16,7 @@ import '../../shared/utils/string_utils.dart';
class AgentDirectoryEntry {
final String pubkey;
final String? displayName;
final String? avatarUrl;
final String? respondTo;
final List<String> respondToAllowlist;
final List<String> channelIds;
@@ -23,6 +24,7 @@ class AgentDirectoryEntry {
const AgentDirectoryEntry({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.respondTo,
this.respondToAllowlist = const [],
this.channelIds = const [],
@@ -38,6 +40,7 @@ class AgentDirectoryEntry {
return AgentDirectoryEntry(
pubkey: event.pubkey.toLowerCase(),
displayName: text('display_name') ?? text('name'),
avatarUrl: text('picture'),
respondTo: text('respond_to'),
respondToAllowlist: strings(
'respond_to_allowlist',

View File

@@ -1,4 +1,5 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/me/me_assistants.dart';
@@ -23,6 +24,12 @@ class _Channels extends ChannelsNotifier {
class _Relay extends RelaySessionNotifier {
VoidCallback? beforeRead;
int reads = 0;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
@@ -58,6 +65,7 @@ void main() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
call,
) async {
if (call.method == 'updateChatAssistants') return null;
forms++;
if (forms > 1) return null;
if (disconnect) {
@@ -86,6 +94,17 @@ void main() {
),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(_Channels.new),
channelActionsProvider.overrideWith(
(ref) => ChannelActions(
ref: ref,
session: relay,
signedEventRelay: SignedEventRelay(
session: relay,
nsec: config.nsec,
),
currentPubkey: sync.pubkey,
),
),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/channels/channel.dart';
@@ -6,9 +7,14 @@ import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/widgets/app_list.dart';
import 'package:flutter/foundation.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:flutter/material.dart';
@@ -20,48 +26,27 @@ import 'package:pointycastle/digests/sha256.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _EmptyChannels extends ChannelsNotifier {
_EmptyChannels(this.existing);
final bool existing;
int freshReads = 0;
@override
Future<List<Channel>> build() async => [];
@override
Future<List<Channel>> fetchFresh({bool fetchDirectory = false}) async {
freshReads++;
return [
if (existing)
Channel(
id: '10000000-0000-4000-8000-000000000001',
name: 'Mobile Solo',
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'me',
createdAt: DateTime.utc(2026),
memberCount: 1,
isMember: true,
),
];
}
}
class _Relay extends RelaySessionNotifier {
final NostrEvent profile;
final submitted = <NostrEvent>[];
_Relay(this.profile);
_Relay(this.profile, this.existing);
final List<NostrEvent> existing;
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async =>
filters.any((filter) => filter.kinds.contains(0)) ? [profile] : [];
}) async => [profile];
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
}) async => filter.kinds.contains(39000) ? existing : [];
@override
Future<NostrEvent> publish(
@@ -82,6 +67,7 @@ void main() {
? 'interrupted Solo recovery uses fresh membership before creating'
: 'New Solo chat submits its discovery label through the production action',
(tester) async {
final flutterSheet = defaultTargetPlatform == TargetPlatform.android;
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final owner = nostr.Keys.generate(), agent = nostr.Keys.generate();
@@ -108,29 +94,89 @@ void main() {
],
).toMap(),
);
final relay = _Relay(profile);
const channelId = '10000000-0000-4000-8000-000000000001';
final relay = _Relay(profile, [
if (recoverExisting)
for (final kind in [39000, 39002])
NostrEvent.fromJson(
nostr.Event.from(
kind: kind,
content: '',
secretKey: owner.secret,
tags: [
['d', channelId],
if (kind == 39000) ['name', 'Mobile Solo'],
if (kind == 39002) ['p', owner.public],
],
).toMap(),
),
]);
final config = RelayConfig(
baseUrl: 'https://relay.test',
nsec: owner.nsec,
);
final sync = MeSync(relay, config, prefs: prefs);
final channels = _EmptyChannels(recoverExisting);
if (recoverExisting) {
await MeChatCreation(
'10000000-0000-4000-8000-000000000001',
channelId,
'Mobile Solo',
agent.public,
).save(sync);
}
const bridge = MethodChannel('buzz/profile_text_editor');
// Let native chrome mount while the real verification isolate runs.
const platformViews = MethodChannel('flutter/platform_views');
final nativeViews = <MethodChannel>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
platformViews,
(call) async {
if (call.method == 'create') {
final args = call.arguments as Map<Object?, Object?>;
final channel = MethodChannel(
'${args['viewType']}/${args['id']}',
);
nativeViews.add(channel);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(_) async => null,
);
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
platformViews,
null,
),
);
addTearDown(() {
for (final channel in nativeViews) {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
);
}
});
final directory = Completer<List<AgentDirectoryEntry>>();
if (flutterSheet) directory.complete([]);
final draft = Completer<Map<String, String>>();
String? presentationId;
Map<Object?, Object?>? agentUpdate;
Object? savedAssistant;
var forms = 0;
final formErrors = <Object?>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
call,
) async {
formErrors.add((call.arguments as Map)['error']);
if (call.method == 'updateChatAssistants') {
agentUpdate = call.arguments as Map<Object?, Object?>;
return null;
}
if (++forms > 1) return null;
return {'name': 'Mobile Solo', 'assistant': agent.public};
final arguments = call.arguments as Map;
presentationId = arguments['presentationId'] as String;
savedAssistant = arguments['savedAssistant'];
return draft.future;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
@@ -142,14 +188,13 @@ void main() {
ProviderScope(
overrides: [
meSyncProvider.overrideWithValue(sync),
relaySessionProvider.overrideWith(() => relay),
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(() => channels),
channelMembersProvider(
'10000000-0000-4000-8000-000000000001',
).overrideWith((ref) async => []),
meAssistantsProvider.overrideWith((ref) => directory.future),
channelsProvider.overrideWith(_EmptyChannels.new),
channelMembersProvider(channelId).overrideWith((ref) async => []),
channelActionsProvider.overrideWith(
(ref) => ChannelActions(
ref: ref,
@@ -166,30 +211,120 @@ void main() {
),
);
await tester.pumpAndSettle();
tester
final start = tester
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
.nativeActions!
.firstWhere((action) => action.label == 'New chat')
.onPressed!();
.onPressed!;
// Ownership verification runs in a real isolate; pumpAndSettle alone
// only drains the widget test's fake clock, not that computation.
start();
await tester.pumpAndSettle();
if (recoverExisting) {
await tester.tap(find.text('Resume'));
await tester.pumpAndSettle();
// Continue to member setup using the existing channel. A stale lookup
// would instead submit a second kind:9007 creation command.
expect(channels.freshReads, 1, reason: formErrors.toString());
expect(relay.submitted.where((e) => e.kind == 9007), isEmpty);
return;
}
if (flutterSheet) {
final row = find.byKey(const ValueKey('new-session-assistant'));
if (!recoverExisting) {
expect(find.text('Choose an agent'), findsOneWidget);
await tester.enterText(
find.byKey(const ValueKey('new-session-name')),
'Mobile Solo',
);
await tester.tap(find.text('Create chat'));
await tester.pumpAndSettle();
expect(
find.text('Enter a name and choose an agent.'),
findsOneWidget,
);
expect(relay.submitted, isEmpty);
expect(MeChatCreation.pending(sync), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
return;
}
final semantics = tester.ensureSemantics();
await tester.pump();
expect(find.text('Choose an agent'), findsNothing);
expect(find.text(agent.public), findsOneWidget);
expect(find.bySemanticsLabel(agent.public), findsOneWidget);
expect(tester.widget<AppListRowRaw>(row).onTap, isNull);
final container = ProviderScope.containerOf(tester.element(row));
container
.read(userCacheProvider.notifier)
.put(
UserProfile(pubkey: agent.public, displayName: 'Saved agent'),
);
await tester.pumpAndSettle();
expect(find.text('Saved agent'), findsOneWidget);
expect(find.bySemanticsLabel('Saved agent'), findsOneWidget);
expect(tester.widget<AppListRowRaw>(row).onTap, isNull);
semantics.dispose();
await tester.tap(find.text('Retry'));
} else {
expect(
forms,
1,
reason: 'Compose opens even while agents are loading',
);
if (recoverExisting) {
expect(savedAssistant, {
'id': agent.public,
'name': agent.public,
'avatarUrl': '',
});
}
directory.complete([
AgentDirectoryEntry(
pubkey: agent.public,
displayName: 'Owned agent',
),
]);
await tester.pumpAndSettle();
expect(agentUpdate?['presentationId'], presentationId);
expect(
(agentUpdate?['assistants'] as List).single['id'],
agent.public,
);
draft.complete({'name': 'Mobile Solo', 'assistant': agent.public});
}
for (
var attempts = 0;
attempts < 100 && relay.submitted.isEmpty;
attempts++
) {
await tester.pumpAndSettle();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
}
await tester.pumpAndSettle();
final command = relay.submitted.single;
expect(command.kind, 9007);
expect(command.pubkey, owner.public);
expect(command.tags, contains(equals(['t', 'buzz.solo'])));
expect(command.tags, contains(equals(['channel_type', 'stream'])));
expect(command.tags, contains(equals(['visibility', 'private'])));
expect(command.tags.where((tag) => tag.first == 'P'), isEmpty);
if (recoverExisting) {
expect(relay.submitted.where((event) => event.kind == 9007), isEmpty);
expect(
command.kind,
9000,
reason: 'Recovery continues with member setup',
);
expect(MeChatCreation.pending(sync)?.id, channelId);
expect(command.tags, contains(equals(['p', agent.public])));
} else {
expect(command.kind, 9007);
expect(command.pubkey, owner.public);
expect(command.tags, contains(equals(['t', 'buzz.solo'])));
expect(command.tags, contains(equals(['channel_type', 'stream'])));
expect(command.tags, contains(equals(['visibility', 'private'])));
expect(command.tags.where((tag) => tag.first == 'P'), isEmpty);
}
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
variant: TargetPlatformVariant({
TargetPlatform.iOS,
TargetPlatform.android,
}),
);
}
}

View File

@@ -0,0 +1,187 @@
import 'dart:async';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'Solo creation finishes while the full channel list is stalled',
() async {
final keys = nostr.Keys.generate();
final session = _SoloSession(keys.public);
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue(keys.public),
],
);
addTearDown(container.dispose);
container.listen(channelsProvider, (_, _) {});
await session.listStarted.future;
final actions = container.read(
Provider(
(ref) => ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: keys.nsec,
),
currentPubkey: keys.public,
),
),
);
expect(await actions.findJoinedChannel('solo-id'), isNull);
final created = await actions
.createChannel(
channelId: 'solo-id',
name: 'New chat',
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
waitForChannelList: false,
)
.timeout(const Duration(seconds: 2));
expect(session.stalledList.isCompleted, isFalse);
expect(container.read(channelsProvider).isLoading, isTrue);
expect(created.isPersonalChat(keys.public, const []), isTrue);
expect(created.isMember, isTrue);
expect(created.memberCount, 1);
expect(session.reads, 2);
expect(session.published.single.kind, 9007);
expect(
session.published.single.tags,
contains(equals(['t', 'buzz.solo'])),
);
expect(session.published.single.tags.any((t) => t.first == 'P'), isFalse);
// A retry reads the same channel, without a broad membership query.
expect((await actions.findJoinedChannel('solo-id'))?.id, created.id);
expect(session.published, hasLength(1));
},
);
test(
'targeted recovery requires membership and preserves failures',
() async {
final keys = nostr.Keys.generate();
final session = _SoloSession('another-owner')..exists = true;
var valid = true;
final container = ProviderContainer();
addTearDown(container.dispose);
final actions = container.read(
Provider(
(ref) => ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: keys.nsec,
),
currentPubkey: keys.public,
isCommunityValid: () => valid,
),
),
);
expect(await actions.findJoinedChannel('solo-id'), isNull);
session.failure = TimeoutException('relay unavailable');
await expectLater(
actions.findJoinedChannel('solo-id'),
throwsA(isA<TimeoutException>()),
);
session.failure = null;
session.onRead = () => valid = false;
await expectLater(actions.findJoinedChannel('solo-id'), throwsStateError);
expect(session.published, isEmpty);
},
);
}
class _SoloSession extends RelaySessionNotifier {
_SoloSession(this.owner);
final String owner;
bool exists = false;
int reads = 0;
Object? failure;
void Function()? onRead;
final published = <NostrEvent>[];
final stalledList = Completer<List<NostrEvent>>();
final listStarted = Completer<void>();
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
expect(filters.single.tags, {
'#p': [owner],
});
if (!listStarted.isCompleted) listStarted.complete();
return stalledList.future;
}
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
published.add(event);
exists = true;
return event;
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
reads++;
expect(filter.kinds, [39000, 39002]);
expect(filter.tags, {
'#d': ['solo-id'],
});
expect(filter.limit, 2);
onRead?.call();
if (failure != null) throw failure!;
if (!exists) return [];
return [
NostrEvent(
id: 'metadata',
pubkey: 'relay',
createdAt: 1,
kind: 39000,
tags: [
['d', 'solo-id'],
['name', 'New chat'],
['t', 'stream'],
['t', 'buzz.solo'],
['private'],
['P', owner],
],
content: '',
sig: '',
),
NostrEvent(
id: 'roster',
pubkey: 'relay',
createdAt: 1,
kind: 39002,
tags: [
['d', 'solo-id'],
['p', owner, '', 'owner'],
],
content: '',
sig: '',
),
];
}
}

View File

@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/me/me_archived_agents.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
test(
'only a verified snapshot from NIP-11 self hides archived agents',
() async {
final owner = nostr.Keys.generate();
final authority = nostr.Keys.generate();
final impostor = nostr.Keys.generate();
final target = 'a' * 64;
NostrEvent snapshot(nostr.Keys signer, {bool marker = true}) =>
NostrEvent.fromJson(
nostr.Event.from(
kind: 13535,
content: '',
secretKey: signer.secret,
tags: [
if (marker) ['-'],
['p', target],
],
).toMap(),
);
final relay = _ArchiveRelay(snapshot(authority));
final client = MockClient((request) async {
expect(request.url.origin, 'https://relay.test');
expect(request.headers['Accept'], 'application/nostr+json');
return http.Response(
jsonEncode({'self': authority.public, 'pubkey': impostor.public}),
200,
);
});
final container = ProviderContainer(
overrides: [
meSyncProvider.overrideWithValue(
MeSync(
relay,
RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec),
),
),
meRelayInformationClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
addTearDown(client.close);
expect(await container.read(meArchivedAgentsProvider.future), {target});
expect(relay.author, authority.public);
for (final invalid in [
snapshot(impostor),
snapshot(authority, marker: false),
NostrEvent.fromJson(snapshot(authority).toJson()..['sig'] = '0' * 128),
]) {
relay.event = invalid;
container.invalidate(meArchivedAgentsProvider);
expect(await container.read(meArchivedAgentsProvider.future), isEmpty);
}
relay.event = snapshot(authority);
container.invalidate(meArchivedAgentsProvider);
expect(await container.read(meArchivedAgentsProvider.future), {target});
},
);
test('slow metadata has a total deadline and cancels its stream', () async {
final owner = nostr.Keys.generate();
final relay = _ArchiveRelay(null);
var cancelled = false;
final body = StreamController<List<int>>(onCancel: () => cancelled = true);
final client = MockClient.streaming(
(_, _) async => http.StreamedResponse(body.stream, 200),
);
final container = ProviderContainer(
overrides: [
meSyncProvider.overrideWithValue(
MeSync(
relay,
RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec),
),
),
meRelayInformationClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
addTearDown(client.close);
addTearDown(body.close);
final trickle = Timer.periodic(const Duration(milliseconds: 100), (_) {
body.add([32]);
});
addTearDown(trickle.cancel);
final result = await container
.read(meArchivedAgentsProvider.future)
.timeout(const Duration(seconds: 5));
expect(result, isEmpty);
expect(cancelled, isTrue);
expect(relay.author, isNull);
});
test(
'contact keys and unavailable metadata are not archive authority',
() async {
final owner = nostr.Keys.generate();
final relay = _ArchiveRelay(null);
final client = MockClient(
(_) async => http.Response(jsonEncode({'pubkey': owner.public}), 200),
);
final container = ProviderContainer(
overrides: [
meSyncProvider.overrideWithValue(
MeSync(
relay,
RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec),
),
),
meRelayInformationClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
addTearDown(client.close);
expect(await container.read(meArchivedAgentsProvider.future), isEmpty);
expect(relay.author, isNull);
},
);
}
class _ArchiveRelay extends RelaySessionNotifier {
_ArchiveRelay(this.event);
NostrEvent? event;
String? author;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
expect(filter.kinds, [13535]);
author = filter.authors!.single;
return [?event];
}
}

View File

@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_archived_agents.dart';
import 'package:buzz/shared/me/me_agent_inventory.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -10,10 +12,12 @@ import 'package:pointycastle/digests/sha256.dart';
class _Relay extends RelaySessionNotifier {
final List<List<NostrEvent>> pages;
final requestedPages = <int>[];
List<NostrEvent>? inventory;
List<List<NostrEvent>>? inventoryPages;
int inventoryReads = 0;
final requestedTags = <Map<String, dynamic>>[];
final requestedAuthors = <List<String>?>[];
_Relay(this.pages);
_Relay(this.pages, {this.inventory});
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
@@ -21,12 +25,19 @@ class _Relay extends RelaySessionNotifier {
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
expect(filters.single.kinds, [0]);
requestedTags.add(filters.single.toJson());
requestedAuthors.add(filters.single.authors);
final page = filters.single.toJson()['page'] as int;
requestedPages.add(page);
return pages[page - 1];
final filter = filters.single;
requestedTags.add(filter.toJson());
if (filter.kinds.single == 30177) {
return inventoryPages?[inventoryReads++] ?? inventory ?? const [];
}
expect(filter.kinds, [0]);
expect(
filter.authors,
isNotEmpty,
reason: 'Never scan all community profiles',
);
requestedAuthors.add(filter.authors);
return pages.expand((p) => p).toList();
}
}
@@ -68,7 +79,25 @@ void main() {
);
}
ProviderContainer setup(_Relay relay) {
NostrEvent listing(
String pubkey, {
String name = 'Saved agent',
int time = 1,
String? content,
}) => NostrEvent.fromJson(
nostr.Event.from(
kind: 30177,
content: content ?? jsonEncode({'name': name}),
secretKey: owner.secret,
createdAt: time,
tags: [
['d', pubkey],
],
).toMap(),
);
ProviderContainer setup(_Relay relay, {Set<String> archived = const {}}) {
relay.inventory ??= [listing(agent.public)];
final sync = MeSync(
relay,
RelayConfig(
@@ -83,29 +112,41 @@ void main() {
overrides: [
relaySessionProvider.overrideWith(() => relay),
meSyncProvider.overrideWithValue(sync),
meArchivedAgentsProvider.overrideWith((ref) async => archived),
],
);
addTearDown(container.dispose);
return container;
}
test('finds owned kind 0 agents without legacy directory entries', () async {
final valid = profile(agent, ownedBy: owner);
final forged = valid.toJson()..['pubkey'] = other.public;
final container = setup(
_Relay([
[
profile(owner),
profile(nostr.Keys.generate(), ownedBy: other),
NostrEvent.fromJson(forged),
valid,
],
]),
);
final choices = await container.read(meAssistantsProvider.future);
expect(choices.map((a) => a.pubkey), [agent.public]);
expect(choices.single.displayName, 'My assistant');
});
test(
'lists current inventory with verified ownership, name and artwork',
() async {
final valid = profile(
agent,
ownedBy: owner,
content: {
'display_name': 'Profile name',
'picture': 'https://example.com/agent.png',
},
);
final forged = valid.toJson()..['pubkey'] = other.public;
final container = setup(
_Relay([
[
profile(owner),
profile(nostr.Keys.generate(), ownedBy: other),
NostrEvent.fromJson(forged),
valid,
],
]),
);
final choices = await container.read(meAssistantsProvider.future);
expect(choices.map((a) => a.pubkey), [agent.public]);
expect(choices.single.displayName, 'Saved agent');
expect(choices.single.avatarUrl, 'https://example.com/agent.png');
},
);
for (final malformed in [7, true, 'wrong', <String, Object>{}, <Object>[]]) {
test(
'malformed optional profile fields do not poison directory: $malformed',
@@ -135,7 +176,7 @@ void main() {
final choices = await container.read(meAssistantsProvider.future);
expect(
choices.singleWhere((a) => a.pubkey == agent.public).displayName,
'My assistant',
'Saved agent',
);
expect(
await isOwnedMeAssistant(
@@ -147,6 +188,32 @@ void main() {
},
);
}
test(
'inventory pagination advances and rejects a repeated full page',
() async {
final first = listing(agent.public, time: 2);
final last = listing(other.public, time: 1);
final page = List.filled(200, first);
final relay = _Relay([])
..inventoryPages = [
page,
[last],
];
final sync = setup(relay).read(meSyncProvider);
expect(
(await loadMeAgentInventory(sync)).keys,
unorderedEquals([agent.public, other.public]),
);
expect(relay.requestedTags.last['until'], 2);
expect(relay.requestedTags.last['before_id'], first.id);
expect(relay.requestedTags.first['authors'], [owner.public]);
relay.inventoryReads = 0;
relay.inventoryPages = [page, page];
await expectLater(loadMeAgentInventory(sync), throwsStateError);
expect(relay.inventoryReads, 2);
},
);
test('latest profile removes obsolete ownership', () async {
final container = setup(
_Relay([
@@ -155,38 +222,78 @@ void main() {
);
expect(await container.read(meAssistantsProvider.future), isEmpty);
});
test('reads beyond the first directory page', () async {
final relay = _Relay([
[for (var i = 0; i < 100; i++) profile(owner, time: i + 1)],
[profile(agent, ownedBy: owner)],
]);
expect(
(await setup(relay).read(meAssistantsProvider.future)).single.pubkey,
agent.public,
);
expect(relay.requestedPages, [1, 2]);
});
test(
'directory discovery is bounded even when the relay keeps paging',
'omits historical, removed and archived agents without losing ownership checks',
() async {
final repeated = [
for (var i = 0; i < 99; i++) profile(agent, ownedBy: owner, time: i),
];
final relay = _Relay([
for (var page = 0; page < 11; page++)
[...repeated, profile(agent, ownedBy: owner, time: 100 + page)],
]);
await expectLater(
setup(relay).read(meAssistantsProvider.future),
throwsStateError,
final archivedAgent = nostr.Keys.generate();
final removed = nostr.Keys.generate();
final historical = nostr.Keys.generate();
final relay = _Relay(
[
[
for (final key in [agent, archivedAgent, removed, historical])
profile(key, ownedBy: owner),
],
],
inventory: [
listing(agent.public),
for (final field in ['id', 'sig'])
NostrEvent.fromJson(
listing(agent.public, time: 3, content: '{}').toJson()
..[field] = '0' * (field == 'id' ? 64 : 128),
),
listing(archivedAgent.public),
listing(removed.public),
listing(removed.public, time: 2, content: '{}'),
],
);
final container = setup(relay, archived: {archivedAgent.public});
expect(
(await container.read(
meAssistantsProvider.future,
)).map((a) => a.pubkey),
[agent.public],
);
expect(
relay.requestedAuthors.single,
unorderedEquals([agent.public, archivedAgent.public]),
);
expect(
await isOwnedMeAssistant(
container.read(meSyncProvider),
historical.public,
),
isTrue,
);
expect(relay.requestedPages.length, 10);
for (final filter in relay.requestedTags) {
expect(filter.containsKey('#auth'), isFalse);
expect(filter['limit'], 100);
}
},
);
test(
'empty inventory is authoritative and does not scan historical profiles',
() async {
final relay = _Relay([
[profile(agent, ownedBy: owner)],
], inventory: []);
expect(await setup(relay).read(meAssistantsProvider.future), isEmpty);
expect(relay.requestedAuthors, isEmpty);
},
);
test('inventory rejects records outside owner scope', () async {
final foreign = NostrEvent.fromJson(
nostr.Event.from(
kind: 30177,
content: '{"name":"Foreign"}',
secretKey: other.secret,
tags: [
['d', agent.public],
],
).toMap(),
);
final container = setup(_Relay([], inventory: [foreign]));
await expectLater(
loadMeAgentInventory(container.read(meSyncProvider)),
throwsStateError,
);
});
for (final conditions in ['kind=1', 'created_at>1', 'created_at<1']) {
test(
'discovery and send revalidation reject inapplicable $conditions',

View File

@@ -1,11 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test(
'retry confirms persistence after a failed cached journal write',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingStore();
final previousStore = SharedPreferencesStorePlatform.instance;
SharedPreferencesStorePlatform.instance = store;
addTearDown(
() => SharedPreferencesStorePlatform.instance = previousStore,
);
final prefs = await SharedPreferences.getInstance();
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Keys.generate().nsec,
),
prefs: prefs,
);
const intent = MeChatCreation('id', 'Name', 'agent');
var reachedRelay = false;
Future<String> run() => intent.complete<String>(
sync,
findChannel: () async {
reachedRelay = true;
await prefs.reload();
expect(MeChatCreation.pending(sync)?.id, intent.id);
return null;
},
createChannel: () async => throw StateError('Simulated process stop'),
hasAgent: () async => false,
addAgent: () async {},
isPlaced: () async => false,
place: () async {},
);
await expectLater(run(), throwsStateError);
expect(reachedRelay, isFalse);
expect(
MeChatCreation.pending(sync)?.id,
intent.id,
); // Failed write remains cached.
await expectLater(run(), throwsStateError);
expect(reachedRelay, isTrue);
await prefs.reload();
expect(MeChatCreation.pending(sync)?.id, intent.id);
},
);
test('missing durable storage blocks every relay side effect', () async {
final sync = MeSync(
RelaySessionNotifier(),
@@ -150,3 +198,16 @@ void main() {
});
}
}
class _FailingStore extends InMemorySharedPreferencesStore {
_FailingStore() : super.empty();
bool failNext = true;
@override
Future<bool> setValue(String valueType, String key, Object value) async {
if (failNext && key.startsWith('flutter.me-chat-creation:')) {
failNext = false;
return false;
}
return super.setValue(valueType, key, value);
}
}

View File

@@ -9,6 +9,7 @@ import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_archived_agents.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/me/me_recovery.dart';
import 'package:buzz/shared/relay/relay.dart';
@@ -203,6 +204,7 @@ void main() {
final config = _Config(RelayConfig(baseUrl: origin, nsec: nsec));
final container = ProviderContainer(
overrides: [
meArchivedAgentsProvider.overrideWith((ref) async => {}),
relayConfigProvider.overrideWith(() => config),
relaySessionProvider.overrideWith(() => relay),
savedPrefsProvider.overrideWithValue(prefs),