[codex] Add mobile agent activity viewer (#436)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-30 12:28:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f23560d3d4
commit 755cab45f6
17 changed files with 2933 additions and 114 deletions
+6
View File
@@ -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
+2
View File
@@ -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);
}
@@ -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<ScrollController?>(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,
),
),
],
),
);
}
}
@@ -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<String, dynamic> 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<PromptSection> 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<String, dynamic> 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,
});
}
@@ -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<TranscriptItem> 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<String, List<ObserverFrame>> 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<ObserverRelayState> {
final Map<String, List<ObserverFrame>> _framesByAgent = {};
final Map<String, Set<String>> _dedupeKeysByAgent = {};
final Map<String, Uint8List> _conversationKeysByAgent = {};
void Function()? _unsubscribe;
Future<void>? _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<void> _ensureSubscribed() {
if (_disposed) return Future.value();
if (_unsubscribe != null) return Future.value();
if (_startFuture != null) return _startFuture!;
_startFuture = _subscribe(_subscriptionEpoch);
return _startFuture!;
}
Future<void> _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,
() => <String>{},
);
if (!dedupeKeys.add(dedupeKey)) {
return;
}
final frames = _framesByAgent.putIfAbsent(
normalizedAgent,
() => <ObserverFrame>[],
);
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<String, dynamic>;
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<String, List<ObserverFrame>> _snapshotFrames() {
return Map<String, List<ObserverFrame>>.unmodifiable({
for (final entry in _framesByAgent.entries)
entry.key: List<ObserverFrame>.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, ObserverRelayState>(
ObserverRelayNotifier.new,
);
final observerSubscriptionProvider =
Provider.family<ObserverState, ObserverKey>((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,
);
});
@@ -0,0 +1,734 @@
import 'dart:convert';
import 'observer_models.dart';
// ---------------------------------------------------------------------------
// Sprout tool catalogs
// ---------------------------------------------------------------------------
const _sproutReadTools = <String>{
'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 = <String>{
'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 = <String>{..._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<String, dynamic> _asRecord(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
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<String, dynamic> 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<PromptSection> sections, String userText, String userTitle})
_parsePromptText(String text) {
final sections = _parsePromptSections(text);
if (sections.isEmpty) {
return (
sections: <PromptSection>[],
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<PromptSection> _parsePromptSections(String text) {
final sections = <PromptSection>[];
final headerPattern = RegExp(r'^\[([^\]]+)]\s*$');
String? currentTitle;
final currentBody = StringBuffer();
final preamble = <String>[];
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<String, dynamic> _extractToolArgs(Map<String, dynamic> 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<String, dynamic>.from(candidate);
}
}
return const {};
}
({String title, String toolName, String? sproutToolName}) _extractToolIdentity(
Map<String, dynamic> 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<String> _collectToolNameCandidates(Map<String, dynamic> update) {
final args = _extractToolArgs(update);
final tool = _asRecord(update['tool']);
final input = _asRecord(update['input']);
final rawInput = _asRecord(update['rawInput']);
final sources = <dynamic>[
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<String, dynamic> 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<String>().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<TranscriptItem> buildTranscript(List<ObserverFrame> events) {
final items = <TranscriptItem>[];
final itemsById = <String, TranscriptItem>{};
// Maps a logical message ID to the actual key currently being appended to.
final activeMessageKey = <String, String>{};
final sealedKeys = <String>{};
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<PromptSection> 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<String, dynamic> 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;
}
@@ -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<String, dynamic> 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,
),
),
),
);
}
}
@@ -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<Set<String>, String>((
ref,
channelId,
) {
final typingEntries = ref.watch(channelTypingProvider(channelId));
final membersAsync = ref.watch(channelMembersProvider(channelId));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final botPubkeys = <String>{
for (final m in allMembers)
if (m.isBot) m.pubkey.toLowerCase(),
};
return <String>{
for (final e in typingEntries)
if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(),
};
});
@@ -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<void>(
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<void>(
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;
+211 -89
View File
@@ -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<ChannelMember?>().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<void>(
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<void>(
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<bool>(
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<bool>(
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<String> 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<String>(
segments: [
for (final role in _changeableRoles)
ButtonSegment<String>(
value: role,
label: Text(_roleLabel(role)),
),
],
selected: hasKnownRole ? {selectedRole} : const <String>{},
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;
@@ -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;
+52 -7
View File
@@ -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<void>? readyCompleter;
int? lastSeenCreatedAt;
_LiveSubscription({
required this.filter,
required this.onEvent,
this.onClosed,
this.readyCompleter,
});
}
@@ -145,24 +147,36 @@ class RelaySessionNotifier extends Notifier<SessionState> {
/// `since: lastSeenCreatedAt - 5s` on reconnect.
Future<void Function()> 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<void>();
_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<SessionState> {
_handleEvent(data);
case 'EOSE':
_handleEose(data);
case 'CLOSED':
_handleClosed(data);
case 'OK':
_handleOk(data);
}
@@ -385,6 +401,35 @@ class RelaySessionNotifier extends Notifier<SessionState> {
}
}
void _handleClosed(List<dynamic> 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<dynamic> data) {
if (data.length < 3) return;
final eventId = data[1] as String;
@@ -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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(Duration.zero);
expect(relaySession.filters, hasLength(2));
expect(relaySession.filters.last.tags['#p'], [secondUser.public]);
relaySession.releaseSubscribe(0);
await Future<void>.delayed(Duration.zero);
expect(relaySession.filters, hasLength(1));
expect(relaySession.filters.single.tags['#p'], [secondUser.public]);
relaySession.releaseSubscribe(1);
await Future<void>.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<void>.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<LifecycleItem>());
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<NostrFilter> filters = [];
final List<void Function(NostrEvent)> _listeners = [];
final List<void Function(String message)> _closedListeners = [];
final List<Completer<void>> _subscribeGates = [];
bool delaySubscribes = false;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<void Function()> 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<void>();
_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);
}
}
@@ -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<MessageItem>());
expect((items[0] as MessageItem).text, 'Hello world');
expect(items[1], isA<ToolItem>());
expect(items[2], isA<MessageItem>());
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<ToolItem>());
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<MessageItem>());
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<MetadataItem>());
expect((items[1] as MetadataItem).sections, hasLength(2));
});
}
ObserverFrame _updateFrame({
required int seq,
required Map<String, dynamic> 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();
@@ -144,8 +144,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
@override
Future<void Function()> 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) {
@@ -133,8 +133,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
@override
Future<void Function()> 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 () {
@@ -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<Exception>().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 = <String>[];
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';