diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index add9f7a69..49379eef6 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -9,6 +9,8 @@ PODS: - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -24,6 +26,7 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) @@ -39,6 +42,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" url_launcher_ios: @@ -52,6 +57,7 @@ SPEC CHECKSUMS: flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index ee255bff3..ccd9fc913 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'features/home/home_page.dart'; import 'features/pairing/pairing_page.dart'; +import 'features/channels/agent_activity/observer_subscription.dart'; import 'shared/auth/auth.dart'; import 'shared/relay/relay.dart'; import 'shared/theme/theme.dart'; @@ -30,6 +31,7 @@ class App extends HookConsumerWidget { // authenticated. These providers connect and manage the websocket. if (authState.value?.status == AuthStatus.authenticated) { ref.watch(relaySessionProvider); + ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); } diff --git a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart new file mode 100644 index 000000000..340444c9b --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../shared/theme/theme.dart'; +import '../../profile/user_cache_provider.dart'; +import '../date_formatters.dart'; +import 'observer_models.dart'; +import 'observer_subscription.dart'; +import 'transcript_item_widget.dart'; + +/// Full-screen modal bottom sheet showing the live agent activity transcript. +class AgentActivitySheet extends HookConsumerWidget { + final String channelId; + final String agentPubkey; + + const AgentActivitySheet({ + super.key, + required this.channelId, + required this.agentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final observerState = ref.watch( + observerSubscriptionProvider(( + channelId: channelId, + agentPubkey: agentPubkey, + )), + ); + final transcript = observerState.transcript; + final connection = observerState.connection; + + // Resolve bot name. + final profile = ref.watch( + userCacheProvider.select((cache) => cache[agentPubkey.toLowerCase()]), + ); + final botName = profile?.label ?? shortPubkey(agentPubkey); + + // Auto-scroll to bottom on new items. + final sheetControllerRef = useRef(null); + final previousLength = useRef(0); + + useEffect(() { + final sc = sheetControllerRef.value; + if (transcript.length > previousLength.value && + sc != null && + sc.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (sc.hasClients) { + sc.animateTo( + sc.position.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } + }); + } + previousLength.value = transcript.length; + return null; + }, [transcript.length]); + + // Preload the bot profile. + useEffect(() { + ref.read(userCacheProvider.notifier).preload([agentPubkey]); + return null; + }, [agentPubkey]); + + return DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, sheetScrollController) { + sheetControllerRef.value = sheetScrollController; + final bottomPadding = + MediaQuery.viewPaddingOf(context).bottom + Grid.sm; + return Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.bot, + size: 18, + color: context.colors.onSurface, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + botName, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + _ConnectionBadge(connection: connection), + ], + ), + const SizedBox(height: Grid.half), + Text( + 'Showing live activity from this point.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xxs), + Divider(color: context.colors.outlineVariant), + ], + ), + ), + // Transcript list + Expanded( + child: transcript.isEmpty + ? Padding( + padding: EdgeInsets.only(bottom: bottomPadding), + child: _EmptyState( + connection: connection, + errorMessage: observerState.errorMessage, + ), + ) + : ListView.builder( + controller: sheetScrollController, + padding: EdgeInsets.fromLTRB( + Grid.xs, + Grid.xxs, + Grid.xs, + bottomPadding, + ), + itemCount: transcript.length, + itemBuilder: (context, index) { + return TranscriptItemWidget(item: transcript[index]); + }, + ), + ), + ], + ); + }, + ); + } +} + +class _EmptyState extends StatelessWidget { + final ObserverConnectionState connection; + final String? errorMessage; + + const _EmptyState({required this.connection, this.errorMessage}); + + @override + Widget build(BuildContext context) { + if (connection == ObserverConnectionState.error) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.circleX, size: 24, color: context.colors.error), + const SizedBox(height: Grid.xxs), + Text( + 'Error: ${errorMessage ?? 'Unknown error'}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + if (connection == ObserverConnectionState.idle) { + return Center( + child: Text( + 'Not connected', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + ); + } + + // connecting or open — show spinner + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.outline, + ), + ), + const SizedBox(height: Grid.xxs), + Text( + 'Waiting for activity\u2026', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ), + ); + } +} + +class _ConnectionBadge extends StatelessWidget { + final ObserverConnectionState connection; + + const _ConnectionBadge({required this.connection}); + + @override + Widget build(BuildContext context) { + final (color, label) = switch (connection) { + ObserverConnectionState.idle => (context.colors.outline, 'Idle'), + ObserverConnectionState.connecting => ( + context.appColors.warning, + 'Connecting', + ), + ObserverConnectionState.open => (context.appColors.success, 'Live'), + ObserverConnectionState.error => (context.colors.error, 'Error'), + }; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.quarter, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Grid.half), + Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/agent_activity/observer_models.dart b/mobile/lib/features/channels/agent_activity/observer_models.dart new file mode 100644 index 000000000..9294a382b --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/observer_models.dart @@ -0,0 +1,149 @@ +import 'package:flutter/foundation.dart'; + +/// Connection state for the observer relay subscription. +enum ObserverConnectionState { idle, connecting, open, error } + +/// Status of a tool execution. +enum ToolStatus { executing, completed, failed, pending } + +/// A decrypted observer frame from a kind:24200 event. +@immutable +class ObserverFrame { + final int seq; + final String timestamp; + final String kind; + final int? agentIndex; + final String? channelId; + final String? sessionId; + final String? turnId; + final dynamic payload; + + const ObserverFrame({ + required this.seq, + required this.timestamp, + required this.kind, + this.agentIndex, + this.channelId, + this.sessionId, + this.turnId, + this.payload, + }); + + factory ObserverFrame.fromJson(Map json) => ObserverFrame( + seq: json['seq'] as int? ?? 0, + timestamp: json['timestamp'] as String? ?? '', + kind: json['kind'] as String? ?? '', + agentIndex: json['agentIndex'] as int?, + channelId: json['channelId'] as String?, + sessionId: json['sessionId'] as String?, + turnId: json['turnId'] as String?, + payload: json['payload'], + ); +} + +/// A section within prompt context metadata. +@immutable +class PromptSection { + final String title; + final String body; + + const PromptSection({required this.title, required this.body}); +} + +/// A single item in the agent activity transcript. +sealed class TranscriptItem { + String get id; + String get timestamp; +} + +class MessageItem extends TranscriptItem { + @override + final String id; + final String role; + final String title; + String text; + @override + final String timestamp; + + MessageItem({ + required this.id, + required this.role, + required this.title, + required this.text, + required this.timestamp, + }); +} + +class ThoughtItem extends TranscriptItem { + @override + final String id; + final String title; + String text; + @override + final String timestamp; + + ThoughtItem({ + required this.id, + required this.title, + required this.text, + required this.timestamp, + }); +} + +class LifecycleItem extends TranscriptItem { + @override + final String id; + final String title; + String text; + @override + final String timestamp; + + LifecycleItem({ + required this.id, + required this.title, + required this.text, + required this.timestamp, + }); +} + +class MetadataItem extends TranscriptItem { + @override + final String id; + final String title; + List sections; + @override + final String timestamp; + + MetadataItem({ + required this.id, + required this.title, + required this.sections, + required this.timestamp, + }); +} + +class ToolItem extends TranscriptItem { + @override + final String id; + String title; + String toolName; + String? sproutToolName; + ToolStatus status; + Map args; + String result; + bool isError; + @override + final String timestamp; + + ToolItem({ + required this.id, + required this.title, + required this.toolName, + this.sproutToolName, + required this.status, + required this.args, + required this.result, + required this.isError, + required this.timestamp, + }); +} diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart new file mode 100644 index 000000000..aa8c25cc8 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -0,0 +1,343 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../../shared/crypto/nip44.dart'; +import '../../../shared/relay/relay.dart'; +import 'observer_models.dart'; +import 'transcript_builder.dart'; + +/// Maximum observer events to keep per agent. +const _maxObserverEvents = 800; + +/// Key for channel-scoped transcript reads. +typedef ObserverKey = ({String channelId, String agentPubkey}); + +/// State emitted by the channel-scoped observer transcript provider. +@immutable +class ObserverState { + final ObserverConnectionState connection; + final List transcript; + final String? errorMessage; + + const ObserverState({ + required this.connection, + required this.transcript, + this.errorMessage, + }); + + const ObserverState.initial() + : connection = ObserverConnectionState.idle, + transcript = const [], + errorMessage = null; +} + +@immutable +class ObserverRelayState { + final ObserverConnectionState connection; + final Map> framesByAgent; + final String? errorMessage; + + const ObserverRelayState({ + required this.connection, + required this.framesByAgent, + this.errorMessage, + }); + + const ObserverRelayState.initial() + : connection = ObserverConnectionState.idle, + framesByAgent = const {}, + errorMessage = null; +} + +class ObserverRelayNotifier extends Notifier { + final Map> _framesByAgent = {}; + final Map> _dedupeKeysByAgent = {}; + final Map _conversationKeysByAgent = {}; + + void Function()? _unsubscribe; + Future? _startFuture; + String? _privHex; + String? _ownerPubkey; + String? _identityKey; + String? _errorMessage; + int _subscriptionEpoch = 0; + bool _disposed = false; + + @override + ObserverRelayState build() { + final config = ref.watch(relayConfigProvider); + final sessionState = ref.watch(relaySessionProvider); + final identityKey = '${config.baseUrl}|${config.nsec ?? ''}'; + + _disposed = false; + if (_identityKey != null && _identityKey != identityKey) { + _reset(); + } + _identityKey = identityKey; + + ref.onDispose(() { + _disposed = true; + _reset(); + }); + + if (sessionState.status == SessionStatus.connected) { + Future.microtask(_ensureSubscribed); + } + + final hasSigningKey = config.nsec?.isNotEmpty == true; + return ObserverRelayState( + connection: hasSigningKey + ? _connectionForSession(sessionState.status) + : ObserverConnectionState.idle, + framesByAgent: _snapshotFrames(), + errorMessage: _errorMessage, + ); + } + + Future _ensureSubscribed() { + if (_disposed) return Future.value(); + if (_unsubscribe != null) return Future.value(); + if (_startFuture != null) return _startFuture!; + + _startFuture = _subscribe(_subscriptionEpoch); + return _startFuture!; + } + + Future _subscribe(int epoch) async { + try { + if (_disposed || epoch != _subscriptionEpoch) { + return; + } + + final config = ref.read(relayConfigProvider); + final nsec = config.nsec; + if (nsec == null || nsec.isEmpty) { + _errorMessage = null; + _emit(connection: ObserverConnectionState.idle); + return; + } + + final privHex = _decodePrivkey(nsec); + final ownerPubkey = _derivePubkey(privHex); + if (_disposed || epoch != _subscriptionEpoch) { + return; + } + _privHex = privHex; + _ownerPubkey = ownerPubkey; + _errorMessage = null; + _emit(connection: ObserverConnectionState.connecting); + + final session = ref.read(relaySessionProvider.notifier); + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: [EventKind.agentObserverFrame], + tags: { + '#p': [ownerPubkey], + }, + limit: 0, + ), + _handleEvent, + onClosed: (message) { + _unsubscribe = null; + _errorMessage = 'Observer subscription closed: $message'; + _emit(connection: ObserverConnectionState.error); + }, + ); + + if (_disposed || epoch != _subscriptionEpoch) { + unsubscribe(); + return; + } + + _unsubscribe = unsubscribe; + _emit(connection: ObserverConnectionState.open); + } catch (error) { + if (_disposed || epoch != _subscriptionEpoch) { + return; + } + _errorMessage = _observerErrorMessage(error); + _emit(connection: ObserverConnectionState.error); + } finally { + if (epoch == _subscriptionEpoch) { + _startFuture = null; + } + } + } + + void _handleEvent(NostrEvent event) { + final agentPubkey = event.getTagValue('agent'); + if (agentPubkey == null || event.getTagValue('frame') != 'telemetry') { + return; + } + + final normalizedAgent = agentPubkey.toLowerCase(); + if (event.pubkey.toLowerCase() != normalizedAgent) { + return; + } + + final ownerPubkey = _ownerPubkey; + final privHex = _privHex; + if (ownerPubkey == null || privHex == null) { + return; + } + + final pTag = event.getTagValue('p'); + if (pTag?.toLowerCase() != ownerPubkey.toLowerCase()) { + return; + } + + final frame = _decryptFrame(event, normalizedAgent, privHex); + if (frame == null) return; + + final dedupeKey = '${frame.seq}:${frame.timestamp}'; + final dedupeKeys = _dedupeKeysByAgent.putIfAbsent( + normalizedAgent, + () => {}, + ); + if (!dedupeKeys.add(dedupeKey)) { + return; + } + + final frames = _framesByAgent.putIfAbsent( + normalizedAgent, + () => [], + ); + frames.add(frame); + frames.sort(_compareObserverFrames); + + if (frames.length > _maxObserverEvents) { + final removeCount = frames.length - _maxObserverEvents; + for (final removed in frames.take(removeCount)) { + dedupeKeys.remove('${removed.seq}:${removed.timestamp}'); + } + frames.removeRange(0, removeCount); + } + + _errorMessage = null; + _emit(connection: ObserverConnectionState.open); + } + + ObserverFrame? _decryptFrame( + NostrEvent event, + String normalizedAgent, + String privHex, + ) { + try { + final conversationKey = _conversationKeysByAgent.putIfAbsent( + normalizedAgent, + () => getConversationKey(privHex, normalizedAgent), + ); + final plaintext = nip44Decrypt(conversationKey, event.content); + final json = jsonDecode(plaintext) as Map; + return ObserverFrame.fromJson(json); + } catch (error) { + _errorMessage = 'Observer event decrypt failed: $error'; + _emit(connection: ObserverConnectionState.error); + return null; + } + } + + void _emit({required ObserverConnectionState connection}) { + if (_disposed) return; + state = ObserverRelayState( + connection: connection, + framesByAgent: _snapshotFrames(), + errorMessage: _errorMessage, + ); + } + + Map> _snapshotFrames() { + return Map>.unmodifiable({ + for (final entry in _framesByAgent.entries) + entry.key: List.unmodifiable(entry.value), + }); + } + + void _reset() { + _subscriptionEpoch += 1; + _unsubscribe?.call(); + _unsubscribe = null; + _startFuture = null; + _privHex = null; + _ownerPubkey = null; + _errorMessage = null; + _framesByAgent.clear(); + _dedupeKeysByAgent.clear(); + _conversationKeysByAgent.clear(); + } + + static String _decodePrivkey(String nsec) { + try { + final privHex = nostr.Nip19.decodePrivkey(nsec); + if (privHex.isEmpty) { + throw const FormatException('empty private key'); + } + return privHex; + } catch (_) { + throw const FormatException('failed to decode private key'); + } + } + + static String _derivePubkey(String privHex) { + try { + return nostr.Keychain(privHex).public; + } catch (_) { + throw const FormatException('failed to derive pubkey'); + } + } + + ObserverConnectionState _connectionForSession(SessionStatus status) { + if (_errorMessage != null && _unsubscribe == null && _startFuture == null) { + return ObserverConnectionState.error; + } + return switch (status) { + SessionStatus.connected => + _unsubscribe == null + ? ObserverConnectionState.connecting + : ObserverConnectionState.open, + SessionStatus.connecting || + SessionStatus.reconnecting => ObserverConnectionState.connecting, + SessionStatus.disconnected => ObserverConnectionState.idle, + }; + } + + static String _observerErrorMessage(Object error) { + if (error is FormatException) { + return error.message; + } + return 'Observer subscription failed: $error'; + } + + static int _compareObserverFrames(ObserverFrame a, ObserverFrame b) { + final tsA = DateTime.tryParse(a.timestamp)?.millisecondsSinceEpoch ?? 0; + final tsB = DateTime.tryParse(b.timestamp)?.millisecondsSinceEpoch ?? 0; + if (tsA != tsB) return tsA.compareTo(tsB); + return a.seq.compareTo(b.seq); + } +} + +final observerRelayProvider = + NotifierProvider( + ObserverRelayNotifier.new, + ); + +final observerSubscriptionProvider = + Provider.family((ref, key) { + final relayState = ref.watch(observerRelayProvider); + final normalizedAgent = key.agentPubkey.toLowerCase(); + final frames = relayState.framesByAgent[normalizedAgent] ?? const []; + final channelFrames = [ + for (final frame in frames) + if (frame.channelId == null || frame.channelId == key.channelId) + frame, + ]; + + return ObserverState( + connection: relayState.connection, + transcript: buildTranscript(channelFrames), + errorMessage: relayState.errorMessage, + ); + }); diff --git a/mobile/lib/features/channels/agent_activity/transcript_builder.dart b/mobile/lib/features/channels/agent_activity/transcript_builder.dart new file mode 100644 index 000000000..84ee60d3e --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/transcript_builder.dart @@ -0,0 +1,734 @@ +import 'dart:convert'; + +import 'observer_models.dart'; + +// --------------------------------------------------------------------------- +// Sprout tool catalogs +// --------------------------------------------------------------------------- + +const _sproutReadTools = { + 'get_messages', + 'get_channel_history', + 'get_thread', + 'search', + 'get_feed', + 'get_reactions', + 'list_channels', + 'get_channel', + 'get_users', + 'get_presence', + 'list_channel_members', + 'list_dms', + 'get_canvas', + 'list_workflows', + 'get_workflow_runs', + 'get_event', + 'get_user_notes', + 'get_contact_list', +}; + +const _sproutWriteTools = { + 'send_message', + 'send_diff_message', + 'edit_message', + 'delete_message', + 'add_reaction', + 'remove_reaction', + 'join_channel', + 'leave_channel', + 'update_channel', + 'set_channel_topic', + 'set_channel_purpose', + 'open_dm', + 'set_profile', + 'set_presence', + 'trigger_workflow', + 'approve_step', + 'create_channel', + 'archive_channel', + 'unarchive_channel', + 'add_channel_member', + 'remove_channel_member', + 'add_dm_member', + 'hide_dm', + 'set_canvas', + 'create_workflow', + 'update_workflow', + 'delete_workflow', + 'set_channel_add_policy', + 'vote_on_post', + 'publish_note', + 'set_contact_list', +}; + +final _sproutToolNames = {..._sproutReadTools, ..._sproutWriteTools}; + +final _sproutToolNamesByLength = _sproutToolNames.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + +final _sproutToolTitleAliases = <(RegExp, String)>[ + (RegExp(r'\bsending message to channel\b'), 'send_message'), + (RegExp(r'\bretrieving recent messages from channel\b'), 'get_messages'), + (RegExp(r'\bgetting channel details\b'), 'get_channel'), + (RegExp(r'\bgetting user information\b'), 'get_users'), + (RegExp(r'\bsearching relay history\b'), 'search'), + (RegExp(r'\bgetting thread\b'), 'get_thread'), + (RegExp(r'\badding reaction\b'), 'add_reaction'), + (RegExp(r'\bremoving reaction\b'), 'remove_reaction'), +]; + +// --------------------------------------------------------------------------- +// Utility helpers (port of agentSessionUtils.ts) +// --------------------------------------------------------------------------- + +Map _asRecord(dynamic value) { + if (value is Map) return value; + if (value is Map) return value.cast(); + return const {}; +} + +String? _asString(dynamic value) { + return value is String ? value : null; +} + +String _shorten(String value) { + if (value.length > 14) { + return '${value.substring(0, 8)}...${value.substring(value.length - 4)}'; + } + return value; +} + +String _titleCase(String value) { + return value + .replaceAll(RegExp(r'[_-]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim() + .replaceAllMapped(RegExp(r'\b\w'), (m) => m[0]!.toUpperCase()); +} + +// --------------------------------------------------------------------------- +// Tool name helpers (port of agentSessionToolCatalog.ts) +// --------------------------------------------------------------------------- + +String _normalizeToolNameText(String value) { + return value + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9_]+'), '_') + .replaceAll(RegExp(r'_+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); +} + +String? _findSproutToolName(String value, bool includeShortNames) { + final alias = _findSproutToolAlias(value); + if (alias != null) return alias; + + final normalized = _normalizeToolNameText(value); + for (final name in _sproutToolNamesByLength) { + if ((!includeShortNames && name.length < 8) || !normalized.contains(name)) { + continue; + } + return name; + } + return null; +} + +String? _findSproutToolAlias(String value) { + final normalizedPhrase = value + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[_-]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' '); + for (final (pattern, name) in _sproutToolTitleAliases) { + if (pattern.hasMatch(normalizedPhrase)) return name; + } + return null; +} + +bool _isGenericToolTitle(String value) { + final normalized = _normalizeToolNameText(value); + return normalized.isEmpty || + normalized == 'tool' || + normalized == 'tool_call' || + normalized == 'mcp_tool_call' || + normalized == 'unknown' || + normalized == 'read' || + normalized == 'write' || + normalized == 'execute' || + normalized == 'completed'; +} + +String _normalizeToolName(String title) { + final knownName = _findSproutToolName(title, true); + if (knownName != null) return knownName; + + final normalized = _normalizeToolNameText( + title, + ).replaceAll(RegExp(r'^sprout_mcp_'), '').replaceAll(RegExp(r'^sprout_'), ''); + return RegExp(r'[a-z][a-z0-9_]+').firstMatch(normalized)?[0] ?? normalized; +} + +ToolStatus _normalizeToolStatus(String status) { + final normalized = status.toLowerCase(); + if (normalized.contains('complete') || + normalized.contains('success') || + normalized == 'done') { + return ToolStatus.completed; + } + if (normalized.contains('fail') || normalized.contains('error')) { + return ToolStatus.failed; + } + if (normalized.contains('pending')) { + return ToolStatus.pending; + } + return ToolStatus.executing; +} + +// --------------------------------------------------------------------------- +// Transcript helpers (port of agentSessionTranscriptHelpers.ts) +// --------------------------------------------------------------------------- + +String _extractContentText(dynamic value) { + if (value is String) return value; + if (value is List) return value.map(_extractBlockText).join('\n'); + return _extractBlockText(value); +} + +String _extractBlockText(dynamic value) { + if (value is String) return value; + if (value is List) return value.map(_extractBlockText).join('\n'); + final record = _asRecord(value); + final nestedContent = record['content']; + final rawOutput = record['rawOutput']; + final nestedText = nestedContent != null && nestedContent is! String + ? _extractBlockText(nestedContent) + : ''; + final rawOutputText = rawOutput == null + ? '' + : rawOutput is String + ? rawOutput + : _safeJsonEncode(rawOutput); + final directText = _asString(record['text']) ?? _asString(record['content']); + if (directText != null && directText.isNotEmpty) return directText; + if (nestedText.isNotEmpty) return nestedText; + if (rawOutputText.isNotEmpty) return rawOutputText; + return ''; +} + +String _extractPromptText(Map payload) { + final params = _asRecord(payload['params']); + final prompt = params['prompt']; + if (prompt is! List) return ''; + return (prompt).map(_extractBlockText).where((s) => s.isNotEmpty).join('\n'); +} + +({List sections, String userText, String userTitle}) +_parsePromptText(String text) { + final sections = _parsePromptSections(text); + if (sections.isEmpty) { + return ( + sections: [], + userText: text.trim(), + userTitle: 'Prompt', + ); + } + + PromptSection? eventSection; + for (final section in sections) { + if (section.title.toLowerCase().startsWith('sprout event')) { + eventSection = section; + break; + } + } + final eventContent = eventSection != null + ? _extractEventContent(eventSection.body) + : ''; + final eventKind = eventSection?.title.split(':').skip(1).join(':').trim(); + + return ( + sections: sections, + userText: eventContent, + userTitle: eventKind != null && eventKind.isNotEmpty + ? _titleCase(eventKind) + : 'Sprout event', + ); +} + +List _parsePromptSections(String text) { + final sections = []; + final headerPattern = RegExp(r'^\[([^\]]+)]\s*$'); + String? currentTitle; + final currentBody = StringBuffer(); + final preamble = []; + + for (final line in text.split(RegExp(r'\r?\n'))) { + final header = headerPattern.firstMatch(line); + if (header != null) { + if (currentTitle != null) { + sections.add( + PromptSection( + title: currentTitle, + body: currentBody.toString().trim(), + ), + ); + } else { + final pre = preamble.join('\n').trim(); + if (pre.isNotEmpty) { + sections.add(PromptSection(title: 'Prompt', body: pre)); + } + } + currentTitle = header.group(1)!; + currentBody.clear(); + continue; + } + + if (currentTitle != null) { + if (currentBody.isNotEmpty) currentBody.write('\n'); + currentBody.write(line); + } else { + preamble.add(line); + } + } + + if (currentTitle != null) { + sections.add( + PromptSection(title: currentTitle, body: currentBody.toString().trim()), + ); + } else { + final pre = preamble.join('\n').trim(); + if (pre.isNotEmpty) { + sections.add(PromptSection(title: 'Prompt', body: pre)); + } + } + + return sections; +} + +String _extractEventContent(String body) { + final match = RegExp(r'^Content:\s*(.*)$', multiLine: true).firstMatch(body); + return match?.group(1)?.trim() ?? ''; +} + +Map _extractToolArgs(Map update) { + final candidates = [ + update['args'], + update['arguments'], + update['input'], + update['rawInput'], + ]; + for (final candidate in candidates) { + if (candidate is Map && candidate is! List) { + return Map.from(candidate); + } + } + return const {}; +} + +({String title, String toolName, String? sproutToolName}) _extractToolIdentity( + Map update, +) { + final candidates = _collectToolNameCandidates(update); + String? knownName; + for (final c in candidates) { + knownName = _findSproutToolName(c, true); + if (knownName != null) break; + } + knownName ??= _findSproutToolName(_safeJsonEncode(update), false); + String? firstSpecific; + for (final candidate in candidates) { + if (!_isGenericToolTitle(candidate)) { + firstSpecific = candidate; + break; + } + } + final title = + _asString(update['title']) ?? knownName ?? firstSpecific ?? 'Tool call'; + return ( + title: title, + toolName: knownName ?? _normalizeToolName(firstSpecific ?? title), + sproutToolName: knownName, + ); +} + +List _collectToolNameCandidates(Map update) { + final args = _extractToolArgs(update); + final tool = _asRecord(update['tool']); + final input = _asRecord(update['input']); + final rawInput = _asRecord(update['rawInput']); + final sources = [ + update['toolName'], + update['tool_name'], + update['name'], + update['title'], + update['kind'], + tool['name'], + tool['toolName'], + args['toolName'], + args['tool_name'], + args['name'], + args['method'], + input['toolName'], + input['tool_name'], + input['name'], + rawInput['toolName'], + rawInput['tool_name'], + rawInput['name'], + ]; + return [ + for (final s in sources) + if (s is String && s.isNotEmpty) s, + ]; +} + +String _extractToolResult(Map update) { + final contentText = _extractContentText(update['content']); + if (contentText.isNotEmpty) return contentText; + return _extractBlockText(update['rawOutput']); +} + +String _describeTurnStarted(dynamic payload) { + final record = _asRecord(payload); + final ids = record['triggeringEventIds']; + if (ids is List) { + final stringIds = ids.whereType().toList(); + if (stringIds.isNotEmpty) { + return 'Triggered by ${stringIds.map(_shorten).join(', ')}.'; + } + } + return 'Heartbeat or internal turn.'; +} + +String _describeSessionResolved(dynamic payload) { + final record = _asRecord(payload); + final sessionId = _asString(record['sessionId']); + final isNewSession = record['isNewSession'] == true; + if (sessionId == null) return 'Using existing ACP session.'; + return '${isNewSession ? 'Created' : 'Using'} session ${_shorten(sessionId)}.'; +} + +String _safeJsonEncode(dynamic value) { + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } +} + +// --------------------------------------------------------------------------- +// buildTranscript (port of agentSessionTranscript.ts) +// --------------------------------------------------------------------------- + +List buildTranscript(List events) { + final items = []; + final itemsById = {}; + + // Maps a logical message ID to the actual key currently being appended to. + final activeMessageKey = {}; + final sealedKeys = {}; + var continuationSeq = 0; + + void sealOpenMessages() { + for (final currentKey in activeMessageKey.values) { + sealedKeys.add(currentKey); + } + } + + void upsertMessage( + String id, + String role, + String title, + String text, + String timestamp, + ) { + final currentKey = activeMessageKey[id]; + + if (currentKey != null && !sealedKeys.contains(currentKey)) { + final existing = itemsById[currentKey]; + if (existing is MessageItem) { + existing.text += text; + return; + } + } + + continuationSeq += 1; + final newKey = currentKey != null ? '$id:c$continuationSeq' : id; + final item = MessageItem( + id: newKey, + role: role, + title: title, + text: text, + timestamp: timestamp, + ); + items.add(item); + itemsById[newKey] = item; + activeMessageKey[id] = newKey; + } + + void upsertTextItem( + String id, + String type, + String title, + String text, + String timestamp, + ) { + final existing = itemsById[id]; + if (existing != null) { + if (type == 'thought' && existing is ThoughtItem) { + existing.text += text; + return; + } + if (type == 'lifecycle' && existing is LifecycleItem) { + existing.text += text; + return; + } + } + sealOpenMessages(); + final TranscriptItem item; + if (type == 'thought') { + item = ThoughtItem( + id: id, + title: title, + text: text, + timestamp: timestamp, + ); + } else { + item = LifecycleItem( + id: id, + title: title, + text: text, + timestamp: timestamp, + ); + } + items.add(item); + itemsById[id] = item; + } + + void upsertMetadata( + String id, + String title, + List sections, + String timestamp, + ) { + final existing = itemsById[id]; + if (existing is MetadataItem) { + existing.sections = sections; + return; + } + sealOpenMessages(); + final item = MetadataItem( + id: id, + title: title, + sections: sections, + timestamp: timestamp, + ); + items.add(item); + itemsById[id] = item; + } + + void upsertTool( + String id, + String title, + String toolName, + String? sproutToolName, + ToolStatus status, + Map args, + String result, + bool isError, + String timestamp, + ) { + final existing = itemsById[id]; + final canonicalSproutToolName = + sproutToolName ?? _findSproutToolName(toolName, true); + if (existing is ToolItem) { + if (!_isGenericToolTitle(title)) { + existing.title = title; + } + if (canonicalSproutToolName != null) { + existing.sproutToolName = canonicalSproutToolName; + existing.toolName = canonicalSproutToolName; + } else if (existing.sproutToolName == null && + !_isGenericToolTitle(toolName)) { + existing.toolName = toolName; + } + existing.status = status; + existing.args = args.isNotEmpty ? args : existing.args; + if (result.isNotEmpty) existing.result = result; + existing.isError = isError || existing.isError; + return; + } + sealOpenMessages(); + final item = ToolItem( + id: id, + title: title, + toolName: canonicalSproutToolName ?? toolName, + sproutToolName: canonicalSproutToolName, + status: status, + args: args, + result: result, + isError: isError, + timestamp: timestamp, + ); + items.add(item); + itemsById[id] = item; + } + + for (final event in events) { + if (event.kind == 'turn_started') { + upsertTextItem( + 'turn:${event.turnId ?? '${event.seq}'}', + 'lifecycle', + 'Turn started', + _describeTurnStarted(event.payload), + event.timestamp, + ); + continue; + } + + if (event.kind == 'session_resolved') { + upsertTextItem( + 'session:${event.turnId ?? '${event.seq}'}', + 'lifecycle', + 'Session ready', + _describeSessionResolved(event.payload), + event.timestamp, + ); + continue; + } + + if (event.kind == 'acp_parse_error') { + upsertTextItem( + 'parse-error:${event.seq}', + 'lifecycle', + 'Wire parse error', + _extractBlockText(event.payload), + event.timestamp, + ); + continue; + } + + if (event.kind != 'acp_read' && event.kind != 'acp_write') { + continue; + } + + final payload = _asRecord(event.payload); + final method = _asString(payload['method']); + + if (event.kind == 'acp_write' && method == 'session/prompt') { + final promptText = _extractPromptText(payload); + if (promptText.isNotEmpty) { + final parsedPrompt = _parsePromptText(promptText); + if (parsedPrompt.userText.isNotEmpty) { + upsertMessage( + 'prompt:${event.turnId ?? '${event.seq}'}', + 'user', + parsedPrompt.userTitle, + parsedPrompt.userText, + event.timestamp, + ); + } + if (parsedPrompt.sections.isNotEmpty) { + upsertMetadata( + 'prompt-context:${event.turnId ?? '${event.seq}'}', + 'Prompt context', + parsedPrompt.sections, + event.timestamp, + ); + } + } + continue; + } + + if (event.kind != 'acp_read' || method != 'session/update') { + continue; + } + + final params = _asRecord(payload['params']); + final update = _asRecord(params['update']); + final updateType = _asString(update['sessionUpdate']) ?? 'unknown'; + final turnKey = event.turnId ?? event.sessionId ?? 'unknown'; + final messageId = _asString(update['messageId']); + + if (updateType == 'agent_message_chunk') { + upsertMessage( + 'assistant:${messageId ?? turnKey}', + 'assistant', + 'Assistant', + _extractContentText(update['content']), + event.timestamp, + ); + continue; + } + + if (updateType == 'user_message_chunk') { + upsertMessage( + 'user:${messageId ?? turnKey}', + 'user', + 'User', + _extractContentText(update['content']), + event.timestamp, + ); + continue; + } + + if (updateType == 'agent_thought_chunk') { + upsertTextItem( + 'thinking:${messageId ?? turnKey}', + 'thought', + 'Thinking', + _extractContentText(update['content']), + event.timestamp, + ); + continue; + } + + if (updateType == 'tool_call') { + final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}'; + final identity = _extractToolIdentity(update); + upsertTool( + 'tool:$toolId', + identity.title, + identity.toolName, + identity.sproutToolName, + _normalizeToolStatus(_asString(update['status']) ?? 'executing'), + _extractToolArgs(update), + _extractToolResult(update), + false, + event.timestamp, + ); + continue; + } + + if (updateType == 'tool_call_update') { + final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}'; + final status = _normalizeToolStatus( + _asString(update['status']) ?? 'completed', + ); + final identity = _extractToolIdentity(update); + upsertTool( + 'tool:$toolId', + identity.title, + identity.toolName, + identity.sproutToolName, + status, + _extractToolArgs(update), + _extractToolResult(update), + status == ToolStatus.failed, + event.timestamp, + ); + continue; + } + + if (updateType == 'plan') { + final content = _extractContentText(update['content']); + upsertTextItem( + 'plan:$turnKey', + 'thought', + 'Plan', + content.isNotEmpty ? content : _safeJsonEncode(update), + event.timestamp, + ); + } + } + + return items; +} diff --git a/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart new file mode 100644 index 000000000..8f3914c81 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart @@ -0,0 +1,510 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../shared/theme/theme.dart'; +import 'observer_models.dart'; + +/// Renders a single [TranscriptItem] in the agent activity transcript. +class TranscriptItemWidget extends StatelessWidget { + final TranscriptItem item; + + const TranscriptItemWidget({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + return switch (item) { + final MessageItem i => _MessageItemWidget(item: i), + final ThoughtItem i => _ThoughtItemWidget(item: i), + final LifecycleItem i => _LifecycleItemWidget(item: i), + final MetadataItem i => _MetadataItemWidget(item: i), + final ToolItem i => _ToolItemWidget(item: i), + }; + } +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +class _MessageItemWidget extends StatelessWidget { + final MessageItem item; + + const _MessageItemWidget({required this.item}); + + @override + Widget build(BuildContext context) { + final isAssistant = item.role == 'assistant'; + final badgeColor = isAssistant + ? context.colors.primary + : context.colors.outline; + final badgeLabel = isAssistant ? 'Assistant' : 'User'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.quarter, + ), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badgeLabel, + style: context.textTheme.labelSmall?.copyWith( + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: Grid.half), + if (item.text.isNotEmpty) + GptMarkdown( + item.text, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Thought +// --------------------------------------------------------------------------- + +class _ThoughtItemWidget extends HookWidget { + final ThoughtItem item; + + const _ThoughtItemWidget({required this.item}); + + @override + Widget build(BuildContext context) { + final expanded = useState(item.text.length <= 200); + + useEffect(() { + expanded.value = item.text.length <= 200; + return null; + }, [item.id]); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half), + child: GestureDetector( + onTap: () => expanded.value = !expanded.value, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xxs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.brain, + size: 14, + color: context.colors.outline, + ), + const SizedBox(width: Grid.half), + Expanded( + child: Text( + item.title, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + expanded.value + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, + size: 14, + color: context.colors.outline, + ), + ], + ), + if (expanded.value && item.text.isNotEmpty) ...[ + const SizedBox(height: Grid.half), + GptMarkdown( + item.text, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +class _LifecycleItemWidget extends StatelessWidget { + final LifecycleItem item; + + const _LifecycleItemWidget({required this.item}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half), + child: Center( + child: Text( + '${item.title}${item.text.isNotEmpty ? ' \u2014 ${item.text}' : ''}', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + textAlign: TextAlign.center, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +class _MetadataItemWidget extends HookWidget { + final MetadataItem item; + + const _MetadataItemWidget({required this.item}); + + @override + Widget build(BuildContext context) { + final expanded = useState(false); + + useEffect(() { + expanded.value = false; + return null; + }, [item.id]); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half), + child: GestureDetector( + onTap: () => expanded.value = !expanded.value, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xxs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.fileText, + size: 14, + color: context.colors.outline, + ), + const SizedBox(width: Grid.half), + Expanded( + child: Text( + '${item.title} (${item.sections.length} sections)', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + expanded.value + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, + size: 14, + color: context.colors.outline, + ), + ], + ), + if (expanded.value) + for (final section in item.sections) ...[ + const SizedBox(height: Grid.xxs), + Text( + section.title, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + if (section.body.isNotEmpty) ...[ + const SizedBox(height: Grid.quarter), + Text( + section.body.length > 500 + ? '${section.body.substring(0, 500)}\u2026' + : section.body, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ], + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Tool +// --------------------------------------------------------------------------- + +class _ToolItemWidget extends HookWidget { + final ToolItem item; + + const _ToolItemWidget({required this.item}); + + @override + Widget build(BuildContext context) { + final argsExpanded = useState(false); + final resultExpanded = useState(false); + + useEffect(() { + argsExpanded.value = false; + resultExpanded.value = false; + return null; + }, [item.id]); + + final (statusColor, statusLabel, statusIcon) = _toolStatusDisplay( + item.status, + item.isError, + context, + ); + + final displayName = _formatToolName(item.toolName); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xxs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(8), + border: item.isError + ? Border.all(color: context.colors.error.withValues(alpha: 0.3)) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: tool name + status badge + Row( + children: [ + Icon(LucideIcons.wrench, size: 14, color: statusColor), + const SizedBox(width: Grid.half), + Expanded( + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.quarter, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 10, color: statusColor), + const SizedBox(width: Grid.quarter), + Text( + statusLabel, + style: context.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + // Args section + if (item.args.isNotEmpty) ...[ + const SizedBox(height: Grid.half), + GestureDetector( + onTap: () => argsExpanded.value = !argsExpanded.value, + child: Row( + children: [ + Text( + 'Arguments', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + const SizedBox(width: Grid.half), + Icon( + argsExpanded.value + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, + size: 12, + color: context.colors.outline, + ), + ], + ), + ), + if (argsExpanded.value) ...[ + const SizedBox(height: Grid.quarter), + _CodeBlock(text: _prettyJson(item.args)), + ], + ], + // Result section + if (item.result.isNotEmpty) ...[ + const SizedBox(height: Grid.half), + GestureDetector( + onTap: () => resultExpanded.value = !resultExpanded.value, + child: Row( + children: [ + Text( + 'Result', + style: context.textTheme.labelSmall?.copyWith( + color: item.isError + ? context.colors.error + : context.colors.outline, + ), + ), + const SizedBox(width: Grid.half), + Icon( + resultExpanded.value + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, + size: 12, + color: item.isError + ? context.colors.error + : context.colors.outline, + ), + ], + ), + ), + if (resultExpanded.value) ...[ + const SizedBox(height: Grid.quarter), + _CodeBlock( + text: item.result.length > 2000 + ? '${item.result.substring(0, 2000)}\n\n\u2026 (truncated)' + : item.result, + isError: item.isError, + ), + ], + ], + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +(Color, String, IconData) _toolStatusDisplay( + ToolStatus status, + bool isError, + BuildContext context, +) { + if (isError || status == ToolStatus.failed) { + return (context.colors.error, 'Error', LucideIcons.circleX); + } + if (status == ToolStatus.completed) { + return (context.appColors.success, 'Done', LucideIcons.circleCheck); + } + if (status == ToolStatus.pending) { + return (context.colors.outline, 'Pending', LucideIcons.circleDot); + } + return (context.appColors.warning, 'Running', LucideIcons.clock3); +} + +String _formatToolName(String toolName) { + return toolName + .split('_') + .map( + (part) => + part.isEmpty ? '' : '${part[0].toUpperCase()}${part.substring(1)}', + ) + .join(' '); +} + +String _prettyJson(Map value) { + try { + return const JsonEncoder.withIndent(' ').convert(value); + } catch (_) { + return value.toString(); + } +} + +class _CodeBlock extends StatelessWidget { + final String text; + final bool isError; + + const _CodeBlock({required this.text, this.isError = false}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xxs), + decoration: BoxDecoration( + color: isError + ? context.colors.error.withValues(alpha: 0.06) + : context.colors.surface, + borderRadius: BorderRadius.circular(6), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + text, + softWrap: false, + style: context.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + color: isError + ? context.colors.error + : context.colors.onSurfaceVariant, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart new file mode 100644 index 000000000..179ea0d4f --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../channel_management_provider.dart'; +import '../channel_typing_provider.dart'; + +/// Derived provider that computes which bot members in a channel are currently +/// typing (i.e. "working"). Returns a set of lowercase pubkeys. +/// +/// Used by both the members button badge and the members sheet to avoid +/// duplicating the bot-typing cross-reference logic. +final workingBotPubkeysProvider = Provider.family, String>(( + ref, + channelId, +) { + final typingEntries = ref.watch(channelTypingProvider(channelId)); + final membersAsync = ref.watch(channelMembersProvider(channelId)); + final allMembers = membersAsync.asData?.value ?? const []; + + final botPubkeys = { + for (final m in allMembers) + if (m.isBot) m.pubkey.toLowerCase(), + }; + + return { + for (final e in typingEntries) + if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(), + }; +}); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 0312a0c70..6144e8f1f 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -15,6 +15,7 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../forum/forum_posts_view.dart'; import 'channel.dart'; +import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; @@ -182,20 +183,10 @@ class ChannelDetailPage extends HookConsumerWidget { ], ), actions: [ - IconButton( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (_) => MembersSheet( - channel: resolvedChannel, - currentPubkey: currentPubkey, - ), - ); - }, - tooltip: 'View members', - icon: const Icon(LucideIcons.users), + _MembersButton( + channelId: resolvedChannel.id, + channel: resolvedChannel, + currentPubkey: currentPubkey, ), if (!resolvedChannel.isDm) IconButton( @@ -1048,6 +1039,62 @@ class _TypingIndicator extends ConsumerWidget { } } +// --------------------------------------------------------------------------- +// Members button with activity dot badge +// --------------------------------------------------------------------------- + +class _MembersButton extends ConsumerWidget { + final String channelId; + final Channel channel; + final String? currentPubkey; + + const _MembersButton({ + required this.channelId, + required this.channel, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasWorkingBot = ref + .watch(workingBotPubkeysProvider(channelId)) + .isNotEmpty; + + return IconButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => + MembersSheet(channel: channel, currentPubkey: currentPubkey), + ); + }, + tooltip: 'View members', + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(LucideIcons.users), + if (hasWorkingBot) + Positioned( + top: -2, + right: -2, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: context.appColors.success, + shape: BoxShape.circle, + border: Border.all(color: context.colors.surface, width: 1.5), + ), + ), + ), + ], + ), + ); + } +} + class _DmAppBarTitle extends ConsumerWidget { final Channel channel; final String? currentPubkey; diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 8ec29a0ff..55eb51f63 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -6,6 +6,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'agent_activity/agent_activity_sheet.dart'; +import 'agent_activity/working_bots_provider.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; @@ -26,6 +28,7 @@ class MembersSheet extends HookConsumerWidget { final people = allMembers.where((member) => !member.isBot).toList(); final bots = allMembers.where((member) => member.isBot).toList(); final userCache = ref.watch(userCacheProvider); + final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); // Determine if the current user can manage members. final currentMember = allMembers.cast().firstWhere( @@ -37,6 +40,23 @@ class MembersSheet extends HookConsumerWidget { currentMember.isElevated && !channel.isArchived; + void openActivity(ChannelMember bot) { + final navigator = Navigator.of(context); + navigator.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!navigator.mounted) return; + showModalBottomSheet( + context: navigator.context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => AgentActivitySheet( + channelId: channel.id, + agentPubkey: bot.pubkey, + ), + ); + }); + } + // Preload profiles for all members so avatars appear. useEffect(() { if (allMembers.isNotEmpty) { @@ -101,6 +121,16 @@ class MembersSheet extends HookConsumerWidget { canManage: canManage, isSelf: false, channelId: channel.id, + isWorking: typingBotPubkeys.contains( + bot.pubkey.toLowerCase(), + ), + onViewActivity: () => openActivity(bot), + onActivityTap: + typingBotPubkeys.contains( + bot.pubkey.toLowerCase(), + ) + ? () => openActivity(bot) + : null, ), ], if (people.isEmpty && bots.isEmpty) @@ -157,6 +187,11 @@ class _SectionLabel extends StatelessWidget { const _changeableRoles = ['admin', 'member', 'guest']; +String _roleLabel(String role) { + if (role.isEmpty) return 'Member'; + return '${role[0].toUpperCase()}${role.substring(1)}'; +} + class _MemberTile extends ConsumerWidget { final ChannelMember member; final String? currentPubkey; @@ -164,6 +199,9 @@ class _MemberTile extends ConsumerWidget { final bool canManage; final bool isSelf; final String channelId; + final bool isWorking; + final VoidCallback? onActivityTap; + final VoidCallback? onViewActivity; const _MemberTile({ required this.member, @@ -172,45 +210,76 @@ class _MemberTile extends ConsumerWidget { required this.canManage, required this.isSelf, required this.channelId, + this.isWorking = false, + this.onActivityTap, + this.onViewActivity, }); @override Widget build(BuildContext context, WidgetRef ref) { final label = member.labelFor(currentPubkey); final initial = label.substring(0, 1).toUpperCase(); - final showMenu = canManage && !isSelf && !member.isOwner; + final showManagementActions = canManage && !isSelf && !member.isOwner; + final showMenu = showManagementActions || onViewActivity != null; return ListTile( contentPadding: EdgeInsets.zero, leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial), title: Text(label), - subtitle: Text( - _roleLabel(member.role), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, - ), - ), + subtitle: isWorking + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: context.appColors.success, + ), + ), + const SizedBox(width: Grid.half), + Text( + 'Working\u2026', + style: context.textTheme.bodySmall?.copyWith( + color: context.appColors.success, + fontWeight: FontWeight.w600, + ), + ), + ], + ) + : Text( + _roleLabel(member.role), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), trailing: showMenu ? IconButton( icon: const Icon(LucideIcons.ellipsis, size: 18), - onPressed: () => _showMemberActions(context, ref), + onPressed: () => _showMemberActions( + context, + ref, + showManagementActions: showManagementActions, + ), visualDensity: VisualDensity.compact, ) : null, + onTap: onActivityTap, ); } - String _roleLabel(String role) { - if (role.isEmpty) return 'Member'; - return '${role[0].toUpperCase()}${role.substring(1)}'; - } - - void _showMemberActions(BuildContext context, WidgetRef ref) { + void _showMemberActions( + BuildContext context, + WidgetRef ref, { + required bool showManagementActions, + }) { final label = member.labelFor(currentPubkey); + final canChangeRole = showManagementActions && !member.isBot; showModalBottomSheet( context: context, showDragHandle: true, - builder: (_) => SafeArea( + builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -220,83 +289,82 @@ class _MemberTile extends ConsumerWidget { child: Text(label, style: context.textTheme.titleSmall), ), const SizedBox(height: Grid.xxs), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Text( - 'Change role', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.outline, - ), - ), - ), - const SizedBox(height: Grid.half), - for (final role in _changeableRoles) + if (onViewActivity != null) ListTile( - title: Text(_roleLabel(role)), - trailing: role == member.role - ? Icon( - LucideIcons.check, - size: 16, - color: context.colors.primary, - ) - : null, - enabled: role != member.role, - onTap: role == member.role - ? null - : () async { - Navigator.of(context).pop(); - await ref - .read(channelActionsProvider) - .changeMemberRole( - channelId: channelId, - pubkey: member.pubkey, - role: role, - ); - }, + leading: Icon( + LucideIcons.activity, + size: 18, + color: context.colors.primary, + ), + title: const Text('View activity'), + onTap: () { + Navigator.of(sheetContext).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + onViewActivity?.call(); + }); + }, ), - const Divider(), - ListTile( - leading: Icon( - LucideIcons.userMinus, - size: 18, - color: context.colors.error, - ), - title: Text( - 'Remove from channel', - style: TextStyle(color: context.colors.error), - ), - onTap: () async { - Navigator.of(context).pop(); - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove member'), - content: Text('Remove $label from this channel?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text( - 'Remove', - style: TextStyle(color: context.colors.error), + if (showManagementActions) ...[ + if (canChangeRole) ...[ + const SizedBox(height: Grid.xxs), + _RoleSelector( + selectedRole: member.role, + onChanged: (role) async { + Navigator.of(sheetContext).pop(); + await ref + .read(channelActionsProvider) + .changeMemberRole( + channelId: channelId, + pubkey: member.pubkey, + role: role, + ); + }, + ), + const SizedBox(height: Grid.xs), + ], + ListTile( + leading: Icon( + LucideIcons.userMinus, + size: 18, + color: context.colors.error, + ), + title: Text( + 'Remove from channel', + style: TextStyle(color: context.colors.error), + ), + onTap: () async { + Navigator.of(context).pop(); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove member'), + content: Text('Remove $label from this channel?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), ), - ), - ], - ), - ); - if (confirmed == true) { - await ref - .read(channelActionsProvider) - .removeMember( - channelId: channelId, - pubkey: member.pubkey, - ); - } - }, - ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + 'Remove', + style: TextStyle(color: context.colors.error), + ), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read(channelActionsProvider) + .removeMember( + channelId: channelId, + pubkey: member.pubkey, + ); + } + }, + ), + ], const SizedBox(height: Grid.xxs), ], ), @@ -305,6 +373,60 @@ class _MemberTile extends ConsumerWidget { } } +class _RoleSelector extends StatelessWidget { + final String selectedRole; + final ValueChanged onChanged; + + const _RoleSelector({required this.selectedRole, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final hasKnownRole = _changeableRoles.contains(selectedRole); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Role', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.outline, + ), + ), + const SizedBox(height: Grid.xxs), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: [ + for (final role in _changeableRoles) + ButtonSegment( + value: role, + label: Text(_roleLabel(role)), + ), + ], + selected: hasKnownRole ? {selectedRole} : const {}, + emptySelectionAllowed: !hasKnownRole, + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: WidgetStatePropertyAll(context.textTheme.labelSmall), + ), + onSelectionChanged: (roles) { + if (roles.isEmpty) return; + final role = roles.single; + if (role == selectedRole) return; + onChanged(role); + }, + ), + ), + ], + ), + ); + } +} + class _MemberAvatar extends HookWidget { final String? avatarUrl; final String initial; diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 59c0aaf44..563bca3c4 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -9,6 +9,7 @@ abstract final class EventKind { static const streamMessage = 9; static const typingIndicator = 20002; static const auth = 22242; + static const agentObserverFrame = 24200; static const readState = 30078; static const streamMessageV2 = 40002; static const streamMessageEdit = 40003; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 5f61afb90..71ce94227 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:math'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth.dart'; @@ -38,12 +38,14 @@ class _HistorySubscription { class _LiveSubscription { final NostrFilter filter; final void Function(NostrEvent) onEvent; + final void Function(String message)? onClosed; Completer? readyCompleter; int? lastSeenCreatedAt; _LiveSubscription({ required this.filter, required this.onEvent, + this.onClosed, this.readyCompleter, }); } @@ -145,24 +147,36 @@ class RelaySessionNotifier extends Notifier { /// `since: lastSeenCreatedAt - 5s` on reconnect. Future subscribe( NostrFilter filter, - void Function(NostrEvent) onEvent, - ) async { + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { final subId = _nextSubId('l'); final readyCompleter = Completer(); _liveSubscriptions[subId] = _LiveSubscription( filter: filter, onEvent: onEvent, + onClosed: onClosed, readyCompleter: readyCompleter, ); _sendReq(subId, filter); // Wait for EOSE or a short fallback timeout. - await readyCompleter.future.timeout( - const Duration(milliseconds: 500), - onTimeout: () {}, - ); + try { + await readyCompleter.future.timeout( + const Duration(milliseconds: 500), + onTimeout: () {}, + ); + } catch (_) { + _liveSubscriptions.remove(subId); + _recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:')); + rethrow; + } + final liveSub = _liveSubscriptions[subId]; + if (liveSub != null && liveSub.readyCompleter == readyCompleter) { + liveSub.readyCompleter = null; + } return () => _unsubscribe(subId); } @@ -329,6 +343,8 @@ class RelaySessionNotifier extends Notifier { _handleEvent(data); case 'EOSE': _handleEose(data); + case 'CLOSED': + _handleClosed(data); case 'OK': _handleOk(data); } @@ -385,6 +401,35 @@ class RelaySessionNotifier extends Notifier { } } + void _handleClosed(List data) { + if (data.length < 2) return; + final subId = data[1] as String; + final message = data.length >= 3 && data[2] is String + ? data[2] as String + : 'subscription closed by relay'; + + final historySub = _historySubscriptions.remove(subId); + if (historySub != null) { + historySub.timeout.cancel(); + if (!historySub.completer.isCompleted) { + historySub.completer.completeError(Exception(message)); + } + return; + } + + final liveSub = _liveSubscriptions.remove(subId); + if (liveSub == null) return; + _recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:')); + + final readyCompleter = liveSub.readyCompleter; + if (readyCompleter != null && !readyCompleter.isCompleted) { + readyCompleter.completeError(Exception(message)); + return; + } + + liveSub.onClosed?.call(message); + } + void _handleOk(List data) { if (data.length < 3) return; final eventId = data[1] as String; diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart new file mode 100644 index 000000000..90adcd3a1 --- /dev/null +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -0,0 +1,375 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:sprout_mobile/features/channels/agent_activity/observer_models.dart'; +import 'package:sprout_mobile/features/channels/agent_activity/observer_subscription.dart'; +import 'package:sprout_mobile/shared/crypto/nip44.dart'; +import 'package:sprout_mobile/shared/relay/relay.dart'; + +void main() { + test('provider initializes without circular dependency error', () { + // Regression test: reading the provider should NOT throw + // "Bad state: Tried to read the state of an uninitialized provider". + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => _RecordingRelaySession()), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: null), + ), + ], + ); + addTearDown(container.dispose); + + const key = (channelId: 'test-channel', agentPubkey: 'deadbeef'); + // This line threw before the fix. + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.idle); + expect(state.transcript, isEmpty); + }); + + test('transitions to error when nsec is invalid', () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => _RecordingRelaySession()), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: 'nsec1invalid'), + ), + ], + ); + addTearDown(container.dispose); + + const key = (channelId: 'test-channel', agentPubkey: 'deadbeef'); + container.read(observerSubscriptionProvider(key)); + + // Let the subscription microtask run. + await Future.delayed(Duration.zero); + + final state = container.read(observerSubscriptionProvider(key)); + // With invalid nsec, it should be in error state, NOT throw. + expect(state.connection, ObserverConnectionState.error); + expect(state.errorMessage, isNotNull); + }); + + test('stays idle when nsec is null', () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => _RecordingRelaySession()), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: null), + ), + ], + ); + addTearDown(container.dispose); + + const key = (channelId: 'test-channel', agentPubkey: 'deadbeef'); + container.read(observerSubscriptionProvider(key)); + + // Let the subscription microtask run. Without an nsec, it should no-op. + await Future.delayed(Duration.zero); + + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.idle); + expect(state.transcript, isEmpty); + }); + + test( + 'subscribes with correct filter shape and transitions to open', + () async { + final userKeychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(userKeychain.private); + final myPubkey = userKeychain.public; + // Agent needs a valid 64-char hex pubkey for getConversationKey. + final agentKeychain = nostr.Keychain.generate(); + final agentPubkey = agentKeychain.public; + + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: nsec), + ), + ], + ); + addTearDown(container.dispose); + + final key = (channelId: 'test-channel', agentPubkey: agentPubkey); + container.read(observerSubscriptionProvider(key)); + + // Let the subscription microtask run. + await Future.delayed(Duration.zero); + + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.open); + expect(state.transcript, isEmpty); + + // Verify the subscription filter shape. + expect(relaySession.filters, hasLength(1)); + final filter = relaySession.filters.first; + expect(filter.kinds, [EventKind.agentObserverFrame]); + expect(filter.limit, 0); + expect(filter.tags['#p'], contains(myPubkey)); + expect(filter.since, isNull); + }, + ); + + test( + 'uses one shared relay subscription for channel-scoped readers', + () async { + final userKeychain = nostr.Keychain.generate(); + final agentKeychain = nostr.Keychain.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + nsec: nostr.Nip19.encodePrivkey(userKeychain.private), + ), + ), + ], + ); + addTearDown(container.dispose); + + container.read( + observerSubscriptionProvider(( + channelId: 'first-channel', + agentPubkey: agentKeychain.public, + )), + ); + container.read( + observerSubscriptionProvider(( + channelId: 'second-channel', + agentPubkey: agentKeychain.public, + )), + ); + + await Future.delayed(Duration.zero); + + expect(relaySession.filters, hasLength(1)); + }, + ); + + test('surfaces relay CLOSED messages through observer state', () async { + final userKeychain = nostr.Keychain.generate(); + final agentKeychain = nostr.Keychain.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + nsec: nostr.Nip19.encodePrivkey(userKeychain.private), + ), + ), + ], + ); + addTearDown(container.dispose); + + final key = (channelId: 'test-channel', agentPubkey: agentKeychain.public); + container.read(observerSubscriptionProvider(key)); + await Future.delayed(Duration.zero); + + relaySession.closeAll('restricted: p-gated events require #p'); + + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.error); + expect(state.errorMessage, contains('p-gated events require #p')); + }); + + test('ignores stale subscribe completion after identity changes', () async { + final firstUser = nostr.Keychain.generate(); + final secondUser = nostr.Keychain.generate(); + final agentKeychain = nostr.Keychain.generate(); + final relaySession = _RecordingRelaySession()..delaySubscribes = true; + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + nsec: nostr.Nip19.encodePrivkey(firstUser.private), + ), + ), + ], + ); + addTearDown(container.dispose); + + final key = (channelId: 'test-channel', agentPubkey: agentKeychain.public); + container.read(observerSubscriptionProvider(key)); + await Future.delayed(Duration.zero); + + expect(relaySession.filters, hasLength(1)); + expect(relaySession.filters.single.tags['#p'], [firstUser.public]); + + (container.read(relayConfigProvider.notifier) as _FakeRelayConfigNotifier) + .setNsec(nostr.Nip19.encodePrivkey(secondUser.private)); + container.read(observerSubscriptionProvider(key)); + await Future.delayed(Duration.zero); + + expect(relaySession.filters, hasLength(2)); + expect(relaySession.filters.last.tags['#p'], [secondUser.public]); + + relaySession.releaseSubscribe(0); + await Future.delayed(Duration.zero); + + expect(relaySession.filters, hasLength(1)); + expect(relaySession.filters.single.tags['#p'], [secondUser.public]); + + relaySession.releaseSubscribe(1); + await Future.delayed(Duration.zero); + + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.open); + expect(relaySession.filters, hasLength(1)); + }); + + test( + 'decrypts observer frames and exposes channel-scoped transcript', + () async { + final ownerKeychain = nostr.Keychain.generate(); + final agentKeychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(ownerKeychain.private); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: nsec), + ), + ], + ); + addTearDown(container.dispose); + + const channelId = 'test-channel'; + final key = (channelId: channelId, agentPubkey: agentKeychain.public); + container.read(observerSubscriptionProvider(key)); + await Future.delayed(Duration.zero); + + final conversationKey = getConversationKey( + agentKeychain.private, + ownerKeychain.public, + ); + final encrypted = nip44Encrypt( + conversationKey, + jsonEncode({ + 'seq': 1, + 'timestamp': '2026-04-30T12:00:00.000Z', + 'kind': 'turn_started', + 'channelId': channelId, + 'turnId': 'turn-1', + 'payload': { + 'triggeringEventIds': ['0123456789abcdef'], + }, + }), + ); + final event = nostr.Event.from( + kind: EventKind.agentObserverFrame, + content: encrypted, + tags: [ + ['p', ownerKeychain.public], + ['agent', agentKeychain.public], + ['frame', 'telemetry'], + ], + privkey: agentKeychain.private, + verify: false, + ); + + relaySession.emit(NostrEvent.fromJson(event.toJson())); + + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.open); + expect(state.transcript, hasLength(1)); + final item = state.transcript.single; + expect(item, isA()); + expect((item as LifecycleItem).title, 'Turn started'); + + final otherChannelState = container.read( + observerSubscriptionProvider(( + channelId: 'other-channel', + agentPubkey: agentKeychain.public, + )), + ); + expect(otherChannelState.transcript, isEmpty); + }, + ); +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +class _RecordingRelaySession extends RelaySessionNotifier { + final List filters = []; + final List _listeners = []; + final List _closedListeners = []; + final List> _subscribeGates = []; + bool delaySubscribes = false; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + filters.add(filter); + _listeners.add(onEvent); + if (onClosed != null) { + _closedListeners.add(onClosed); + } + if (delaySubscribes) { + final gate = Completer(); + _subscribeGates.add(gate); + await gate.future; + } + return () { + filters.remove(filter); + _listeners.remove(onEvent); + if (onClosed != null) { + _closedListeners.remove(onClosed); + } + }; + } + + void emit(NostrEvent event) { + for (final listener in List.of(_listeners)) { + listener(event); + } + } + + void closeAll(String message) { + for (final listener in List.of(_closedListeners)) { + listener(message); + } + filters.clear(); + _listeners.clear(); + _closedListeners.clear(); + } + + void releaseSubscribe(int index) { + final gate = _subscribeGates[index]; + if (!gate.isCompleted) { + gate.complete(); + } + } +} + +class _FakeRelayConfigNotifier extends RelayConfigNotifier { + String? _nsec; + + _FakeRelayConfigNotifier({required String? nsec}) : _nsec = nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec); + + void setNsec(String? nsec) { + _nsec = nsec; + state = RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec); + } +} diff --git a/mobile/test/features/channels/agent_activity/transcript_builder_test.dart b/mobile/test/features/channels/agent_activity/transcript_builder_test.dart new file mode 100644 index 000000000..c71176e99 --- /dev/null +++ b/mobile/test/features/channels/agent_activity/transcript_builder_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/features/channels/agent_activity/observer_models.dart'; +import 'package:sprout_mobile/features/channels/agent_activity/transcript_builder.dart'; + +void main() { + test('aggregates assistant chunks until another item seals the message', () { + final items = buildTranscript([ + _updateFrame( + seq: 1, + update: { + 'sessionUpdate': 'agent_message_chunk', + 'messageId': 'm1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + }, + ), + _updateFrame( + seq: 2, + update: { + 'sessionUpdate': 'agent_message_chunk', + 'messageId': 'm1', + 'content': [ + {'type': 'text', 'text': ' world'}, + ], + }, + ), + _updateFrame( + seq: 3, + update: { + 'sessionUpdate': 'tool_call', + 'toolCallId': 'tool-1', + 'title': 'sleep', + 'args': {'seconds': 5}, + }, + ), + _updateFrame( + seq: 4, + update: { + 'sessionUpdate': 'agent_message_chunk', + 'messageId': 'm1', + 'content': [ + {'type': 'text', 'text': 'Done'}, + ], + }, + ), + ]); + + expect(items, hasLength(3)); + expect(items[0], isA()); + expect((items[0] as MessageItem).text, 'Hello world'); + expect(items[1], isA()); + expect(items[2], isA()); + expect((items[2] as MessageItem).text, 'Done'); + }); + + test('normalizes sprout tool calls and applies result updates', () { + final items = buildTranscript([ + _updateFrame( + seq: 1, + update: { + 'sessionUpdate': 'tool_call', + 'toolCallId': 'send-1', + 'title': 'Sending message to channel', + 'status': 'executing', + 'args': {'content': 'hi'}, + }, + ), + _updateFrame( + seq: 2, + update: { + 'sessionUpdate': 'tool_call_update', + 'toolCallId': 'send-1', + 'status': 'completed', + 'content': [ + {'type': 'text', 'text': 'posted #activity-test-channel'}, + ], + }, + ), + ]); + + expect(items, hasLength(1)); + expect(items.single, isA()); + final tool = items.single as ToolItem; + expect(tool.sproutToolName, 'send_message'); + expect(tool.toolName, 'send_message'); + expect(tool.status, ToolStatus.completed); + expect(tool.args, {'content': 'hi'}); + expect(tool.result, 'posted #activity-test-channel'); + }); + + test('parses sprout prompt text into user message and metadata', () { + final items = buildTranscript([ + ObserverFrame( + seq: 1, + timestamp: _timestamp(1), + kind: 'acp_write', + turnId: 'turn-1', + payload: { + 'method': 'session/prompt', + 'params': { + 'prompt': [ + { + 'content': + '[Sprout event: stream message]\n' + 'Content: @claude can you do that again?\n\n' + '[Channel]\n' + '#activity-test-channel', + }, + ], + }, + }, + ), + ]); + + expect(items, hasLength(2)); + expect(items[0], isA()); + final message = items[0] as MessageItem; + expect(message.role, 'user'); + expect(message.title, 'Stream Message'); + expect(message.text, '@claude can you do that again?'); + expect(items[1], isA()); + expect((items[1] as MetadataItem).sections, hasLength(2)); + }); +} + +ObserverFrame _updateFrame({ + required int seq, + required Map update, +}) { + return ObserverFrame( + seq: seq, + timestamp: _timestamp(seq), + kind: 'acp_read', + turnId: 'turn-1', + payload: { + 'method': 'session/update', + 'params': {'update': update}, + }, + ); +} + +String _timestamp(int seq) => + DateTime.utc(2026, 4, 30, 12, 0, seq).toIso8601String(); diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index c6b646e5d..78b81a715 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -144,8 +144,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { @override Future subscribe( NostrFilter filter, - void Function(NostrEvent) onEvent, - ) async { + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { operations.add('subscribe'); liveFilters.add(filter); if (!_subscribed.isCompleted) { diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 4de88a5be..50407a225 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -133,8 +133,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { @override Future subscribe( NostrFilter filter, - void Function(NostrEvent) onEvent, - ) async { + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { filters.add(filter); _listeners.add(onEvent); return () { diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 60ed0c0d9..d5d9e9f3d 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -39,6 +39,57 @@ void main() { unsubscribeFirst(); unsubscribeSecond(); }); + + test('live subscribe fails when relay closes before ready', () async { + final session = RelaySessionNotifier(); + const filter = NostrFilter(kinds: [EventKind.agentObserverFrame], limit: 0); + + final subscribe = session.subscribe(filter, (_) {}); + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'restricted: p-gated events require #p matching your pubkey', + ]); + + await expectLater( + subscribe, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('p-gated events require #p'), + ), + ), + ); + }); + + test( + 'live onClosed callback runs when relay closes an open subscription', + () async { + final session = RelaySessionNotifier(); + final closedMessages = []; + const filter = NostrFilter( + kinds: [EventKind.agentObserverFrame], + limit: 0, + ); + + final subscribe = session.subscribe( + filter, + (_) {}, + onClosed: closedMessages.add, + ); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'restricted: no longer valid', + ]); + + expect(closedMessages, ['restricted: no longer valid']); + unsubscribe(); + }, + ); } const _channelId = '11111111-1111-4111-8111-111111111111';