Merge remote-tracking branch 'origin/main' into kennylopez-iphone-duo-devicehub

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>

# Conflicts:
#	mobile/ios/Runner/NativeProfileTextEditor.swift
This commit is contained in:
kenny lopez
2026-10-27 15:10:44 +00:00
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

@@ -37,6 +37,17 @@ final class NativeProfileTextEditorCoordinator: NSObject,
}
return
}
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
@@ -295,7 +306,7 @@ 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?
@@ -308,6 +319,8 @@ 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 }
@@ -491,13 +504,30 @@ 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
@@ -539,7 +569,7 @@ 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 ?? ""])
@@ -576,7 +606,7 @@ 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)
@@ -603,6 +633,8 @@ 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."
@@ -665,8 +697,13 @@ 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

@@ -7,6 +7,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),