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 <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-10-26 20:09:38 +00:00
parent 5e8c549339
commit ad99b13f46
6 changed files with 100 additions and 4 deletions

View File

@@ -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))

View File

@@ -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)
}

View File

@@ -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

View File

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

View File

@@ -157,6 +157,7 @@ void main() {
final draft = Completer<Map<String, String>>();
String? presentationId;
Map<Object?, Object?>? 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'),
]);

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);
}
}