From ad99b13f46cebfcf6ee6603fd8a6810a43db7c22 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 26 Oct 2026 20:09:38 +0000 Subject: [PATCH] fix(mobile): persist Solo retries and show their saved recipient Rewrite the recovery journal after failed cached preference writes. Render the saved agent identity in locked native retries even when discovery has not returned it. Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- .../MeAgentAccessibilityTests.swift | 9 ++- .../ios/Runner/NativeProfileTextEditor.swift | 7 ++- .../channels/me/native_new_me_chat.dart | 13 ++++ mobile/lib/shared/me/me_chat_creation.dart | 2 +- .../channels/me/solo_creation_tags_test.dart | 12 +++- .../test/shared/me/me_chat_creation_test.dart | 61 +++++++++++++++++++ 6 files changed, 100 insertions(+), 4 deletions(-) diff --git a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift index a21e4260a5..3261ba27f8 100644 --- a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift +++ b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift @@ -6,6 +6,7 @@ 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", @@ -15,7 +16,7 @@ struct MeAgentAccessibilityTests { channelDescription: nil, originalName: nil, originalDescription: nil, canEditDetails: true, canEditCanvas: false, canvasContent: "", canvasLoaded: false, - chatOptions: ["selectedAssistant": "agent", "presentationId": "test", "assistantsLoading": empty, "fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]], + 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() @@ -28,6 +29,9 @@ 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) @@ -58,6 +62,9 @@ struct MeAgentAccessibilityTests { "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)) diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 317f64563b..c8544b926f 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -632,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) } diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index 1b9a69f1ea..9bf932e38c 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -76,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 diff --git a/mobile/lib/shared/me/me_chat_creation.dart b/mobile/lib/shared/me/me_chat_creation.dart index 4fd92da73a..f366fb0df5 100644 --- a/mobile/lib/shared/me/me_chat_creation.dart +++ b/mobile/lib/shared/me/me_chat_creation.dart @@ -50,7 +50,7 @@ class MeChatCreation { previous.project != project)) { throw StateError('Resume the pending chat before starting another.'); } - if (previous != null) return; + // A matching cached value does not prove a previous disk write succeeded. if (sync.prefs == null || !await sync.prefs!.setString( storageKey(sync), diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index 181bd9c819..1c3fa33708 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -157,6 +157,7 @@ void main() { final draft = Completer>(); String? presentationId; Map? agentUpdate; + Object? savedAssistant; var forms = 0; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( call, @@ -166,7 +167,9 @@ void main() { return null; } if (++forms > 1) return null; - presentationId = (call.arguments as Map)['presentationId'] as String; + final arguments = call.arguments as Map; + presentationId = arguments['presentationId'] as String; + savedAssistant = arguments['savedAssistant']; return draft.future; }); addTearDown( @@ -216,6 +219,13 @@ void main() { await tester.pumpAndSettle(); } 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'), ]); diff --git a/mobile/test/shared/me/me_chat_creation_test.dart b/mobile/test/shared/me/me_chat_creation_test.dart index 7aac3f116f..a7e79ccdc6 100644 --- a/mobile/test/shared/me/me_chat_creation_test.dart +++ b/mobile/test/shared/me/me_chat_creation_test.dart @@ -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 run() => intent.complete( + 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 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); + } +}